Skip to content

Commit 737c6f8

Browse files
committed
feat(rules): add no-nested-macros rule
Disallows nesting of Lingui macros (t, Trans, msg, defineMessage) inside other Lingui macros. Configurable via: - macros: array of macro names to check - allowDifferentMacros: allow mixing different macro types
1 parent 888c972 commit 737c6f8

File tree

5 files changed

+329
-1
lines changed

5 files changed

+329
-1
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,13 @@ export default [
5252

5353
| Rule | Description | Recommended |
5454
|------|-------------|:-----------:|
55+
| [no-nested-macros](docs/rules/no-nested-macros.md) | Disallow nesting Lingui macros ||
5556
| [no-single-tag-message](docs/rules/no-single-tag-message.md) | Disallow messages with only a single markup tag ||
5657
| [no-single-variable-message](docs/rules/no-single-variable-message.md) | Disallow messages that consist only of a single variable ||
5758

5859
### Planned Rules
5960

6061
- `no-complex-expressions-in-message` — Restrict complexity of expressions in messages
61-
- `no-nested-macros` — Disallow nesting Lingui macros
6262
- `valid-t-call-location` — Enforce valid locations for `t` macro calls
6363
- `no-unlocalized-strings` — Detect user-visible strings not wrapped in Lingui
6464
- `text-restrictions` — Enforce project-specific text restrictions

docs/rules/no-nested-macros.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# no-nested-macros
2+
3+
Disallow nesting of Lingui macros inside other Lingui macros.
4+
5+
## Why?
6+
7+
Nested macros can lead to:
8+
- Confusing message extraction results
9+
- Incorrect translation context
10+
- Unexpected runtime behavior
11+
12+
Each translation unit should be independent and self-contained.
13+
14+
## Rule Details
15+
16+
This rule reports when a Lingui macro (`t`, `Trans`, `msg`, `defineMessage`) is used inside another Lingui macro.
17+
18+
### ❌ Invalid
19+
20+
```tsx
21+
// t inside t
22+
t`foo ${t`bar`}`
23+
24+
// Trans inside Trans
25+
<Trans><Trans>Inner</Trans></Trans>
26+
27+
// t inside Trans
28+
<Trans>{t`Hello`}</Trans>
29+
30+
// msg inside t
31+
t`foo ${msg({ message: 'bar' })}`
32+
```
33+
34+
### ✅ Valid
35+
36+
```tsx
37+
// Separate, non-nested usage
38+
t`Hello`
39+
t`World`
40+
41+
// Normal interpolation
42+
t`Hello ${name}`
43+
<Trans>Hello {name}</Trans>
44+
45+
// Static message helpers
46+
msg({ message: 'Hello' })
47+
defineMessage({ message: 'Hello' })
48+
```
49+
50+
## Options
51+
52+
### `macros`
53+
54+
An array of macro names to check. Default: `["t", "Trans", "msg", "defineMessage"]`
55+
56+
```ts
57+
{
58+
"lingui-ts/no-nested-macros": ["error", {
59+
"macros": ["t", "Trans", "msg", "defineMessage", "plural", "select"]
60+
}]
61+
}
62+
```
63+
64+
### `allowDifferentMacros`
65+
66+
When `true`, only reports nesting of the *same* macro type. Default: `false`
67+
68+
```ts
69+
// With allowDifferentMacros: true
70+
<Trans>{t`Hello`}</Trans> // ✅ Allowed (different macros)
71+
<Trans><Trans>x</Trans></Trans> // ❌ Still error (same macro)
72+
```
73+
74+
```ts
75+
{
76+
"lingui-ts/no-nested-macros": ["error", {
77+
"allowDifferentMacros": true
78+
}]
79+
}
80+
```
81+
82+
## When Not To Use It
83+
84+
If you have intentional patterns that require macro nesting (uncommon), you can disable this rule for specific lines or files.
85+

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* @packageDocumentation
55
*/
66

7+
import { noNestedMacros } from "./rules/no-nested-macros.js"
78
import { noSingleTagMessage } from "./rules/no-single-tag-message.js"
89
import { noSingleVariableMessage } from "./rules/no-single-variable-message.js"
910

@@ -13,6 +14,7 @@ const plugin = {
1314
version: "1.0.0"
1415
},
1516
rules: {
17+
"no-nested-macros": noNestedMacros,
1618
"no-single-tag-message": noSingleTagMessage,
1719
"no-single-variable-message": noSingleVariableMessage
1820
},
@@ -26,6 +28,7 @@ plugin.configs = {
2628
"lingui-ts": plugin
2729
},
2830
rules: {
31+
"lingui-ts/no-nested-macros": "error",
2932
"lingui-ts/no-single-tag-message": "error",
3033
"lingui-ts/no-single-variable-message": "error"
3134
}

src/rules/no-nested-macros.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { RuleTester } from "@typescript-eslint/rule-tester"
2+
import { afterAll, describe, it } from "vitest"
3+
4+
import { noNestedMacros } from "./no-nested-macros.js"
5+
6+
RuleTester.afterAll = afterAll
7+
RuleTester.describe = describe
8+
RuleTester.it = it
9+
10+
const ruleTester = new RuleTester({
11+
languageOptions: {
12+
parserOptions: {
13+
ecmaVersion: 2022,
14+
sourceType: "module",
15+
ecmaFeatures: {
16+
jsx: true
17+
}
18+
}
19+
}
20+
})
21+
22+
ruleTester.run("no-nested-macros", noNestedMacros, {
23+
valid: [
24+
// Separate, non-nested usage
25+
"t`Hello`; t`World`",
26+
"<Trans>Hello</Trans>; <Trans>World</Trans>",
27+
28+
// Normal usage without nesting
29+
"t`Hello ${name}`",
30+
"<Trans>Hello {name}</Trans>",
31+
"msg({ message: 'Hello' })",
32+
"defineMessage({ message: 'Hello' })",
33+
34+
// Non-Lingui nested templates
35+
"css`color: ${theme.primary}`",
36+
"html`<div>${content}</div>`",
37+
38+
// Different macros allowed when allowDifferentMacros is true
39+
{
40+
code: "<Trans>{t`Hello`}</Trans>",
41+
options: [{ macros: ["t", "Trans"], allowDifferentMacros: true }]
42+
},
43+
{
44+
code: "t`foo ${msg({ message: 'bar' })}`",
45+
options: [{ macros: ["t", "msg"], allowDifferentMacros: true }]
46+
}
47+
],
48+
invalid: [
49+
// t inside t
50+
{
51+
code: "t`foo ${t`bar`}`",
52+
errors: [{ messageId: "nestedMacro", data: { macro: "t", parent: "t" } }]
53+
},
54+
55+
// Trans inside Trans
56+
{
57+
code: "<Trans><Trans>Inner</Trans></Trans>",
58+
errors: [{ messageId: "nestedMacro", data: { macro: "Trans", parent: "Trans" } }]
59+
},
60+
61+
// t inside Trans
62+
{
63+
code: "<Trans>{t`Hello`}</Trans>",
64+
errors: [{ messageId: "nestedMacro", data: { macro: "t", parent: "Trans" } }]
65+
},
66+
67+
// Trans inside t
68+
{
69+
code: "t`foo ${<Trans>bar</Trans>}`",
70+
errors: [{ messageId: "nestedMacro", data: { macro: "Trans", parent: "t" } }]
71+
},
72+
73+
// msg inside t
74+
{
75+
code: "t`foo ${msg({ message: 'bar' })}`",
76+
errors: [{ messageId: "nestedMacro", data: { macro: "msg", parent: "t" } }]
77+
},
78+
79+
// defineMessage inside Trans
80+
{
81+
code: "<Trans>{defineMessage({ message: 'test' })}</Trans>",
82+
errors: [{ messageId: "nestedMacro", data: { macro: "defineMessage", parent: "Trans" } }]
83+
},
84+
85+
// Multiple nested macros
86+
{
87+
code: "t`${t`a`} and ${t`b`}`",
88+
errors: [
89+
{ messageId: "nestedMacro", data: { macro: "t", parent: "t" } },
90+
{ messageId: "nestedMacro", data: { macro: "t", parent: "t" } }
91+
]
92+
},
93+
94+
// Same macro nested when allowDifferentMacros is true
95+
{
96+
code: "<Trans><Trans>Inner</Trans></Trans>",
97+
options: [{ macros: ["t", "Trans"], allowDifferentMacros: true }],
98+
errors: [{ messageId: "nestedMacro", data: { macro: "Trans", parent: "Trans" } }]
99+
},
100+
{
101+
code: "t`${t`inner`}`",
102+
options: [{ macros: ["t", "Trans"], allowDifferentMacros: true }],
103+
errors: [{ messageId: "nestedMacro", data: { macro: "t", parent: "t" } }]
104+
}
105+
]
106+
})
107+

src/rules/no-nested-macros.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils"
2+
3+
import { createRule } from "../utils/create-rule.js"
4+
5+
type MessageId = "nestedMacro"
6+
7+
interface Options {
8+
macros: string[]
9+
allowDifferentMacros: boolean
10+
}
11+
12+
const DEFAULT_MACROS = ["t", "Trans", "msg", "defineMessage"]
13+
14+
/**
15+
* Gets the name of a macro from various node types.
16+
*/
17+
function getMacroName(node: TSESTree.Node): string | null {
18+
// Tagged template: t`...`
19+
if (node.type === AST_NODE_TYPES.TaggedTemplateExpression) {
20+
if (node.tag.type === AST_NODE_TYPES.Identifier) {
21+
return node.tag.name
22+
}
23+
}
24+
25+
// JSX element: <Trans>...</Trans>
26+
if (node.type === AST_NODE_TYPES.JSXElement) {
27+
const name = node.openingElement.name
28+
if (name.type === AST_NODE_TYPES.JSXIdentifier) {
29+
return name.name
30+
}
31+
}
32+
33+
// Call expression: msg({...}), defineMessage({...})
34+
if (node.type === AST_NODE_TYPES.CallExpression) {
35+
if (node.callee.type === AST_NODE_TYPES.Identifier) {
36+
return node.callee.name
37+
}
38+
}
39+
40+
return null
41+
}
42+
43+
/**
44+
* Finds the nearest ancestor that is a Lingui macro.
45+
*/
46+
function findParentMacro(
47+
node: TSESTree.Node,
48+
macros: string[]
49+
): { node: TSESTree.Node; name: string } | null {
50+
let current = node.parent
51+
52+
while (current) {
53+
const name = getMacroName(current)
54+
if (name !== null && macros.includes(name)) {
55+
return { node: current, name }
56+
}
57+
current = current.parent
58+
}
59+
60+
return null
61+
}
62+
63+
export const noNestedMacros = createRule<[Options], MessageId>({
64+
name: "no-nested-macros",
65+
meta: {
66+
type: "problem",
67+
docs: {
68+
description: "Disallow nesting of Lingui macros inside other Lingui macros"
69+
},
70+
messages: {
71+
nestedMacro: "Nested Lingui macro '{{macro}}' is not allowed inside '{{parent}}'"
72+
},
73+
schema: [
74+
{
75+
type: "object",
76+
properties: {
77+
macros: {
78+
type: "array",
79+
items: { type: "string" },
80+
default: DEFAULT_MACROS
81+
},
82+
allowDifferentMacros: {
83+
type: "boolean",
84+
default: false
85+
}
86+
},
87+
additionalProperties: false
88+
}
89+
]
90+
},
91+
defaultOptions: [
92+
{
93+
macros: DEFAULT_MACROS,
94+
allowDifferentMacros: false
95+
}
96+
],
97+
create(context, [options]) {
98+
const macros = options.macros
99+
const allowDifferentMacros = options.allowDifferentMacros
100+
101+
function checkNode(node: TSESTree.Node): void {
102+
const macroName = getMacroName(node)
103+
if (macroName === null || !macros.includes(macroName)) {
104+
return
105+
}
106+
107+
const parentMacro = findParentMacro(node, macros)
108+
if (parentMacro === null) {
109+
return
110+
}
111+
112+
// If allowDifferentMacros is true, only report if same macro type
113+
if (allowDifferentMacros && parentMacro.name !== macroName) {
114+
return
115+
}
116+
117+
context.report({
118+
node,
119+
messageId: "nestedMacro",
120+
data: {
121+
macro: macroName,
122+
parent: parentMacro.name
123+
}
124+
})
125+
}
126+
127+
return {
128+
TaggedTemplateExpression: checkNode,
129+
JSXElement: checkNode,
130+
CallExpression: checkNode
131+
}
132+
}
133+
})

0 commit comments

Comments
 (0)