diff --git a/docs/rules/no-nested-trans.md b/docs/rules/no-nested-trans.md
new file mode 100644
index 0000000..7c37c44
--- /dev/null
+++ b/docs/rules/no-nested-trans.md
@@ -0,0 +1,77 @@
+# no-nested-trans
+
+Disallow nested translation functions and components.
+
+Translation functions and components should not be nested inside each other. This includes:
+
+- Tagged template expressions: `t```, `msg``, `defineMessage``
+- Function calls: `t()`, `msg()`, `defineMessage()`, `plural()`, `select()`, `selectOrdinal()`
+- JSX components: ``, ``, `} other="many" />
+
+// Function calls inside function calls
+plural(count, {
+ one: "one book",
+ other: t`There are ${count} books`
+})
+
+select(gender, {
+ male: plural(count, { one: "one", other: "many" }),
+ other: "items"
+})
+
+// Nested tagged templates
+t`Hello ${t`world`}`
+msg`Hello ${plural(count, { one: "one", other: "many" })}`
+```
+
+✅ Examples of **correct** code for this rule:
+
+```tsx
+// Standalone usage
+const message = t`Hello`
+const books = plural(count, { one: "one book", other: "many books" })
+const greeting = select(gender, { male: "He", female: "She", other: "They" })
+
+// Components with static content
+There are many books
+
+
+
+// Components with variables and expressions (non-translation)
+{userName} has {bookCount} books
+
+
+// Adjacent usage (not nested)
+
+```
+
+## When Not To Use It
+
+If you need to compose translations in complex ways, you might want to disable this rule. However, it's generally recommended to keep translations simple and avoid nesting.
+
+## Further Reading
+
+- [LinguiJS Translation Components](https://lingui.js.org/tutorials/react.html#rendering-translations)
+- [LinguiJS Functions](https://lingui.js.org/ref/macro.html)
diff --git a/src/helpers.ts b/src/helpers.ts
index 1336f1b..e9d26fd 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -39,6 +39,27 @@ export const LinguiCallExpressionPluralQuery = 'CallExpression[callee.name=plura
export const LinguiPluralComponentQuery = 'JSXElement[openingElement.name.name=Plural]'
+/**
+ * Queries for select and selectOrdinal CallExpression expressions
+ *
+ * CallExpression: select(value, { one: "# item", other: "# items" });
+ * CallExpression: selectOrdinal(value, { one: "1st", other: "#th" });
+ */
+export const LinguiCallExpressionSelectQuery = 'CallExpression[callee.name=select]'
+
+export const LinguiCallExpressionSelectOrdinalQuery = 'CallExpression[callee.name=selectOrdinal]'
+
+/**
+ * Queries for Select and SelectOrdinal JSX components
+ *
+ *
+ *
+ */
+export const LinguiSelectComponentQuery = 'JSXElement[openingElement.name.name=Select]'
+
+export const LinguiSelectOrdinalComponentQuery =
+ 'JSXElement[openingElement.name.name=SelectOrdinal]'
+
export function isNativeDOMTag(str: string) {
return DOM_TAGS.includes(str)
}
diff --git a/src/index.ts b/src/index.ts
index 6e19e62..f365332 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,7 +6,7 @@ import * as tCallInFunctionRule from './rules/t-call-in-function'
import * as textRestrictionsRule from './rules/text-restrictions'
import * as noTransInsideTransRule from './rules/no-trans-inside-trans'
import * as consistentPluralFormatRule from './rules/consistent-plural-format'
-
+import * as noNestedTransRule from './rules/no-nested-trans'
import { ESLint, Linter } from 'eslint'
import { FlatConfig, RuleModule } from '@typescript-eslint/utils/ts-eslint'
@@ -19,6 +19,7 @@ const rules = {
[textRestrictionsRule.name]: textRestrictionsRule.rule,
[noTransInsideTransRule.name]: noTransInsideTransRule.rule,
[consistentPluralFormatRule.name]: consistentPluralFormatRule.rule,
+ [noNestedTransRule.name]: noNestedTransRule.rule,
}
type RuleKey = keyof typeof rules
diff --git a/src/rules/no-nested-trans.ts b/src/rules/no-nested-trans.ts
new file mode 100644
index 0000000..0b53210
--- /dev/null
+++ b/src/rules/no-nested-trans.ts
@@ -0,0 +1,139 @@
+import { TSESTree } from '@typescript-eslint/utils'
+import { createRule } from '../create-rule'
+import {
+ LinguiTransQuery,
+ LinguiCallExpressionPluralQuery,
+ LinguiPluralComponentQuery,
+ LinguiCallExpressionSelectQuery,
+ LinguiCallExpressionSelectOrdinalQuery,
+ LinguiSelectComponentQuery,
+ LinguiSelectOrdinalComponentQuery,
+} from '../helpers'
+
+export const name = 'no-nested-trans'
+export const rule = createRule({
+ name,
+ meta: {
+ docs: {
+ description: 'Disallow nested translation functions and components',
+ recommended: 'error',
+ },
+ messages: {
+ default:
+ 'Translation functions and components cannot be nested inside each other. Found {{childType}} inside {{parentType}}.',
+ },
+ schema: [
+ {
+ type: 'object',
+ properties: {},
+ additionalProperties: false,
+ },
+ ],
+ type: 'problem' as const,
+ },
+ defaultOptions: [],
+
+ create: (context) => {
+ // All Lingui translation functions and components
+ const allLinguiQueries = [
+ LinguiTransQuery,
+ LinguiPluralComponentQuery,
+ LinguiSelectComponentQuery,
+ LinguiSelectOrdinalComponentQuery,
+ LinguiCallExpressionPluralQuery,
+ LinguiCallExpressionSelectQuery,
+ LinguiCallExpressionSelectOrdinalQuery,
+ 'TaggedTemplateExpression[tag.name=t]',
+ 'TaggedTemplateExpression[tag.name=msg]',
+ 'TaggedTemplateExpression[tag.name=defineMessage]',
+ 'CallExpression[callee.name=t]',
+ 'CallExpression[callee.name=msg]',
+ 'CallExpression[callee.name=defineMessage]',
+ ].join(', ')
+
+ function getNodeType(node: TSESTree.Node): string {
+ if (node.type === 'JSXElement') {
+ const jsxNode = node as TSESTree.JSXElement
+ if (jsxNode.openingElement.name.type === 'JSXIdentifier') {
+ return `<${jsxNode.openingElement.name.name}>`
+ }
+ } else if (node.type === 'TaggedTemplateExpression') {
+ const taggedNode = node as TSESTree.TaggedTemplateExpression
+ if (taggedNode.tag.type === 'Identifier') {
+ return `${taggedNode.tag.name}\`\``
+ }
+ } else if (node.type === 'CallExpression') {
+ const callNode = node as TSESTree.CallExpression
+ if (callNode.callee.type === 'Identifier') {
+ return `${callNode.callee.name}()`
+ }
+ }
+ return 'translation function'
+ }
+
+ function findParentTranslationFunction(node: TSESTree.Node): TSESTree.Node | null {
+ let parent = node.parent
+ while (parent) {
+ // Check for JSX elements (Trans, Plural, Select, SelectOrdinal)
+ if (parent.type === 'JSXElement') {
+ const jsxParent = parent as TSESTree.JSXElement
+ if (jsxParent.openingElement.name.type === 'JSXIdentifier') {
+ const tagName = jsxParent.openingElement.name.name
+ if (['Trans', 'Plural', 'Select', 'SelectOrdinal'].includes(tagName)) {
+ return parent
+ }
+ }
+ }
+
+ // Check for function calls (plural, select, selectOrdinal, t, msg, defineMessage)
+ if (parent.type === 'CallExpression') {
+ const callParent = parent as TSESTree.CallExpression
+ if (callParent.callee.type === 'Identifier') {
+ const funcName = callParent.callee.name
+ if (
+ ['plural', 'select', 'selectOrdinal', 't', 'msg', 'defineMessage'].includes(funcName)
+ ) {
+ return parent
+ }
+ }
+ }
+
+ // Check for tagged template expressions (t``, msg``, defineMessage``)
+ if (parent.type === 'TaggedTemplateExpression') {
+ const taggedParent = parent as TSESTree.TaggedTemplateExpression
+ if (taggedParent.tag.type === 'Identifier') {
+ const tagName = taggedParent.tag.name
+ if (['t', 'msg', 'defineMessage'].includes(tagName)) {
+ return parent
+ }
+ }
+ }
+
+ parent = parent.parent
+ }
+ return null
+ }
+
+ return {
+ [`${allLinguiQueries}`](node: TSESTree.Node) {
+ const parentTranslationFunction = findParentTranslationFunction(node)
+ if (parentTranslationFunction) {
+ const childType = getNodeType(node)
+ const parentType = getNodeType(parentTranslationFunction)
+
+ context.report({
+ node,
+ messageId: 'default',
+ data: {
+ childType,
+ parentType,
+ },
+ })
+ }
+ },
+ }
+ },
+})
+
+// Export as default for compatibility with test
+export default rule
diff --git a/tests/src/rules/no-nested-trans.test.ts b/tests/src/rules/no-nested-trans.test.ts
new file mode 100644
index 0000000..7071284
--- /dev/null
+++ b/tests/src/rules/no-nested-trans.test.ts
@@ -0,0 +1,175 @@
+import { RuleTester } from '@typescript-eslint/rule-tester'
+import { rule, name } from '../../../src/rules/no-nested-trans'
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+ },
+})
+
+ruleTester.run(name, rule, {
+ valid: [
+ // Valid standalone usage
+ {
+ code: `const message = t\`Hello\``,
+ },
+ {
+ code: `const message = t({ message: "Hello" })`,
+ },
+ {
+ code: `const message = msg\`Hello\``,
+ },
+ {
+ code: `const message = defineMessage\`Hello\``,
+ },
+ {
+ code: `plural(count, { one: "one book", other: "There are many books" });`,
+ },
+ {
+ code: `select(value, { male: "He", female: "She", other: "They" });`,
+ },
+ {
+ code: `selectOrdinal(value, { one: "1st", two: "2nd", other: "#th" });`,
+ },
+ {
+ code: `There are many books`,
+ },
+ {
+ code: ``,
+ },
+ {
+ code: ``,
+ },
+ {
+ code: ``,
+ },
+ // Variables and expressions are okay
+ {
+ code: `{variable}`,
+ },
+ {
+ code: `{some.nested.variable}`,
+ },
+ {
+ code: ``,
+ },
+ // Adjacent usage is fine
+ {
+ code: `
+
+ `,
+ },
+ ],
+
+ invalid: [
+ // t`` inside components
+ {
+ code: `{t\`Hello\`}`,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: '' } }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: '' } }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: '' } }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: '' } }],
+ },
+
+ // t() inside components
+ {
+ code: `{t({ message: "Hello" })}`,
+ errors: [{ messageId: 'default', data: { childType: 't()', parentType: '' } }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: 'default', data: { childType: 't()', parentType: '' } }],
+ },
+
+ // Components inside other components
+ {
+ code: ``,
+ errors: [{ messageId: 'default', data: { childType: '', parentType: '' } }],
+ },
+ {
+ code: `one} other="many" />`,
+ errors: [{ messageId: 'default', data: { childType: '', parentType: '' } }],
+ },
+ {
+ code: `He} other="They" />`,
+ errors: [{ messageId: 'default', data: { childType: '', parentType: '' } }],
+ },
+
+ // Function calls inside components
+ {
+ code: `{plural(count, { one: "one", other: "many" })}`,
+ errors: [{ messageId: 'default', data: { childType: 'plural()', parentType: '' } }],
+ },
+ {
+ code: ``,
+ errors: [{ messageId: 'default', data: { childType: 'select()', parentType: '' } }],
+ },
+
+ // Function calls inside function calls
+ {
+ code: `plural(count, { one: "one book", other: t\`There are \${count} books\` });`,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: 'plural()' } }],
+ },
+ {
+ code: `plural(count, { one: "one book", other: t({ message: "books" }) });`,
+ errors: [{ messageId: 'default', data: { childType: 't()', parentType: 'plural()' } }],
+ },
+ {
+ code: `select(gender, { male: t\`He is here\`, female: "She is here", other: "They are here" });`,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: 'select()' } }],
+ },
+ {
+ code: `plural(count, { one: plural(1, { one: "nested", other: "error" }), other: "many" })`,
+ errors: [{ messageId: 'default', data: { childType: 'plural()', parentType: 'plural()' } }],
+ },
+
+ // Tagged templates inside tagged templates
+ {
+ code: `t\`some text \${t\`some other text\`}\``,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: 't``' } }],
+ },
+ {
+ code: `msg\`Hello \${t\`world\`}\``,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: 'msg``' } }],
+ },
+ {
+ code: `defineMessage\`Hello \${t\`world\`}\``,
+ errors: [{ messageId: 'default', data: { childType: 't``', parentType: 'defineMessage``' } }],
+ },
+
+ // Call expressions inside tagged templates
+ {
+ code: `t\`Hello \${plural(count, { one: "one", other: "many" })}\``,
+ errors: [{ messageId: 'default', data: { childType: 'plural()', parentType: 't``' } }],
+ },
+
+ // Multiple nested violations
+ {
+ code: `
+
+
+
+ `,
+ errors: [
+ { messageId: 'default', data: { childType: '', parentType: '' } },
+ { messageId: 'default', data: { childType: 't``', parentType: '' } },
+ ],
+ },
+ ],
+})