Skip to content

feat: add no-macro-inside-macro rule, narrow no-expression-in-message#132

Open
edzis wants to merge 1 commit intolingui:mainfrom
edzis:feat/no-macro-inside-macro
Open

feat: add no-macro-inside-macro rule, narrow no-expression-in-message#132
edzis wants to merge 1 commit intolingui:mainfrom
edzis:feat/no-macro-inside-macro

Conversation

@edzis
Copy link
Copy Markdown

@edzis edzis commented Apr 24, 2026

Closes #90.

Adds a new no-macro-inside-macro rule and narrows no-expression-in-message to avoid duplicate diagnostics on the overlapping cases.

Motivation

Lingui's extractor silently produces broken .po output when a translation macro is nested inside another — the inner macro becomes an opaque expression, the outer message falls back to positional placeholders ({0}, {1}), and placeholder comments can balloon with source fragments (see lingui/js-lingui#2260). No existing rule caught all the ways this can happen, and the partial coverage in no-expression-in-message was reported with a misleading "Should be ${variable}, not ${object.property}" message.

New rule: no-macro-inside-macro

Forbids translation macros from being nested inside another translation macro. Scope:

  • Message macros (t / msg / defineMessage, tagged-template or call form) inside another message macro's template literal or message-option body
  • Any translation macro — message or component — inside a <Plural> / <Select> / <SelectOrdinal> branch JSXAttribute (except value / offset)
  • Any translation macro inside a plural() / select() / selectOrdinal() option value (except value / offset)
  • Component macros (<Trans> / <Plural> / <Select> / <SelectOrdinal>) interpolated into a message macro template

Two exceptions are legitimate composition, not nesting:

  1. Choice calls as template interpolation. t`${plural(n, { one: '…', other: '…' })}` composes the ICU plural into the outer message.
  2. Descriptor passthrough. t(msg`…`) passes a lazy MessageDescriptor as a direct argument for translation. Allowed only when the lazy macro is a direct argument to a message macro call; interpolating it into a template literal still stringifies as [object Object] at runtime and is flagged.

Name follows the existing convention (no-trans-inside-trans, no-plural-inside-trans).

Files

  • src/rules/no-macro-inside-macro.ts — rule (229 lines)
  • tests/src/rules/no-macro-inside-macro.test.ts — 26 valid + 23 invalid cases, all named, grouped by // ==================== Section ==================== headers matching no-unlocalized-strings.test.ts conventions
  • docs/rules/no-macro-inside-macro.md — user-facing docs with scope, examples, and a performance note
  • src/index.ts — register rule; add to recommendedRules at 'warn' (matching peer structural rules like no-trans-inside-trans)
  • README.md — rule index entry

Change: narrow no-expression-in-message

The rule's docs describe its purpose as "member or function expressions in templates," but its checkExpression fall-through branch was also firing on any other expression type (nested TaggedTemplateExpression, CallExpression, JSXElement) with a misleading generic message. That behaviour duplicated coverage the new rule now owns with a targeted diagnostic.

This PR adds an isNestedLinguiMacro helper and skips nested macro interpolations in the message-template handler only. The Trans-children handler is untouched, so <Trans>Hello {func()}</Trans> and <Trans>Hello {obj.prop}</Trans> continue to fire as before.

No existing test case regresses — all existing invalid cases are ${obj.prop} / ${func()} / {obj.prop} shaped. Added 8 new valid cases documenting that nested macros in templates are now deferred.

Clean split of responsibility

Shape Rule
t`${obj.prop}` no-expression-in-message
t`${func()}` no-expression-in-message
<Trans>{obj.prop}</Trans> no-expression-in-message
<Trans>{func()}</Trans> no-expression-in-message
t`${t`x`}` no-macro-inside-macro
t`${msg`x`}` no-macro-inside-macro
t`${<Trans/>}` no-macro-inside-macro
<Plural one={t`x`}/> no-macro-inside-macro
plural(n, { one: t`x` }) no-macro-inside-macro
<Trans>{<Plural/>}</Trans> no-plural-inside-trans (pre-existing; not touched here)
<Trans>{<Trans/>}</Trans> no-trans-inside-trans (pre-existing; not touched here)

Pre-existing overlap not addressed

no-expression-in-message's Trans-children handler still overlaps with no-plural-inside-trans and no-trans-inside-trans — all three fire on <Trans>{<Plural/>}</Trans> and <Trans>{<Trans/>}</Trans>. That's pre-existing and out of scope here; a follow-up could narrow the Trans-children fall-through the same way (skip when the expression is itself a component macro).

Performance

The new rule fires only on nodes named t / msg / defineMessage or JSX elements named Trans / Plural / Select / SelectOrdinal (name-filtered at the selector level). Per match it walks up to the nearest containing Lingui macro — typically 1–5 hops, or to the program root if none is found. Linear in AST depth, no memoization, no quadratic paths. Comparable to the existing descendant-combinator rules (no-plural-inside-trans, no-trans-inside-trans). IIFE-bridged nesting is intentionally detected (the walk does not stop at function boundaries).

The narrowing in no-expression-in-message adds one O(1) name-set check per template interpolation, skipping a context.report call when it matches — net-positive on codebases that nest macros.

Tests

yarn test — 361 tests pass across 12 suites (existing 312 + new 49 from no-macro-inside-macro.test.ts + 8 new valid cases in no-expression-in-message.test.ts). No regressions.

Closes lingui#90.

Forbids nesting Lingui translation macros inside each other. Covers:
- message macro (t/msg/defineMessage) inside another message macro
- any translation macro inside a <Plural>/<Select>/<SelectOrdinal>
  branch attribute or a plural()/select()/selectOrdinal() option value
- component macro (Trans/Plural/Select/SelectOrdinal) interpolated
  into a message macro template

Legitimate composition patterns are preserved:
- choice calls as template interpolation (compose into ICU)
- descriptor passthrough (t(msg`...`))

Also narrows no-expression-in-message to skip nested Lingui macros in
message templates so users get one targeted diagnostic instead of a
misleading generic one. Its Trans-children handler is untouched.

Added to the recommended config at 'warn', matching the level of the
peer structural rules (no-trans-inside-trans, no-plural-inside-trans).
@edzis edzis marked this pull request as ready for review April 24, 2026 18:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New Rule: Disallow t inside t

1 participant