Skip to content

Commit f9a7793

Browse files
committed
refactor(no-unlocalized-strings): type-based Lingui macro detection
- Added isLinguiSymbol() to verify macros come from @lingui/* packages - Uses TypeScript symbol resolution to check module origin - Falls back to name-based matching when type info unavailable - Prevents false positives from unrelated t/Trans/msg functions
1 parent 43ce3fb commit f9a7793

File tree

1 file changed

+110
-8
lines changed

1 file changed

+110
-8
lines changed

src/rules/no-unlocalized-strings.ts

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ const DEFAULT_IGNORE_NAMES = ["__DEV__", "NODE_ENV"]
5353
// Lingui Context Detection
5454
// ============================================================================
5555

56+
/** Known Lingui package prefixes */
57+
const LINGUI_PACKAGES = ["@lingui/macro", "@lingui/react", "@lingui/core"]
58+
5659
/** Tagged template macros from Lingui */
5760
const LINGUI_TAGGED_TEMPLATES = new Set(["t"])
5861

@@ -62,16 +65,90 @@ const LINGUI_JSX_COMPONENTS = new Set(["Trans", "Plural", "Select", "SelectOrdin
6265
/** Function-style macros from Lingui */
6366
const LINGUI_FUNCTION_MACROS = new Set(["msg", "defineMessage", "plural", "select", "selectOrdinal"])
6467

68+
/**
69+
* Checks if a symbol originates from a Lingui package using TypeScript's type system.
70+
*
71+
* This is more reliable than just checking the name because:
72+
* - It works with re-exports
73+
* - It doesn't match unrelated functions with the same name
74+
* - It handles aliased imports
75+
*
76+
* Returns:
77+
* - true: Definitely from Lingui
78+
* - false: Definitely NOT from Lingui (different module) or unknown
79+
* - null: No type info available (use name-based fallback)
80+
*/
81+
function isLinguiSymbol(
82+
node: TSESTree.Node,
83+
typeChecker: ts.TypeChecker,
84+
parserServices: ReturnType<typeof ESLintUtils.getParserServices>
85+
): boolean | null {
86+
try {
87+
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node)
88+
const symbol = typeChecker.getSymbolAtLocation(tsNode)
89+
90+
if (symbol === undefined) {
91+
// No symbol info - use name-based fallback
92+
return null
93+
}
94+
95+
// Follow aliases to get the original declaration
96+
const resolvedSymbol = symbol.flags & 2097152 ? typeChecker.getAliasedSymbol(symbol) : symbol
97+
98+
const declarations = resolvedSymbol.getDeclarations()
99+
if (declarations === undefined || declarations.length === 0) {
100+
// No declarations - use name-based fallback
101+
return null
102+
}
103+
104+
// Check if any declaration comes from a Lingui package
105+
for (const decl of declarations) {
106+
const sourceFile = decl.getSourceFile()
107+
const fileName = sourceFile.fileName
108+
109+
if (LINGUI_PACKAGES.some((pkg) => fileName.includes(`node_modules/${pkg}/`))) {
110+
return true
111+
}
112+
}
113+
114+
// Has declarations but none from Lingui - this is NOT a Lingui symbol
115+
// However, if declarations are only from the current file (no module info),
116+
// we should fallback to name matching
117+
const hasExternalDeclaration = declarations.some((decl) => {
118+
const fileName = decl.getSourceFile().fileName
119+
return fileName.includes("node_modules/")
120+
})
121+
122+
if (hasExternalDeclaration) {
123+
// Definitely from a different package
124+
return false
125+
}
126+
127+
// Local declaration or ambient - use name-based fallback
128+
return null
129+
} catch {
130+
// Type checking can fail, fall back to name-based matching
131+
return null
132+
}
133+
}
134+
65135
/**
66136
* Checks if a node is inside any Lingui localization context.
67137
*
138+
* Uses TypeScript type information to verify that macros actually come from Lingui,
139+
* with a fallback to name-based matching for edge cases.
140+
*
68141
* This includes:
69142
* - Tagged templates: t`Hello`
70143
* - JSX components: <Trans>, <Plural>, <Select>, <SelectOrdinal>
71144
* - Function macros: msg(), defineMessage(), plural(), select(), selectOrdinal()
72145
* - Runtime API: i18n.t(), i18n._()
73146
*/
74-
function isInsideLinguiContext(node: TSESTree.Node): boolean {
147+
function isInsideLinguiContext(
148+
node: TSESTree.Node,
149+
typeChecker: ts.TypeChecker,
150+
parserServices: ReturnType<typeof ESLintUtils.getParserServices>
151+
): boolean {
75152
let current: TSESTree.Node | undefined = node.parent ?? undefined
76153

77154
while (current !== undefined) {
@@ -81,7 +158,11 @@ function isInsideLinguiContext(node: TSESTree.Node): boolean {
81158
current.tag.type === AST_NODE_TYPES.Identifier &&
82159
LINGUI_TAGGED_TEMPLATES.has(current.tag.name)
83160
) {
84-
return true
161+
// Verify it's actually from Lingui (or use name-based fallback)
162+
const isLingui = isLinguiSymbol(current.tag, typeChecker, parserServices)
163+
if (isLingui === true || isLingui === null) {
164+
return true
165+
}
85166
}
86167

87168
// JSX components: <Trans>Hello</Trans>, <Plural value={n} ... />
@@ -90,7 +171,11 @@ function isInsideLinguiContext(node: TSESTree.Node): boolean {
90171
current.openingElement.name.type === AST_NODE_TYPES.JSXIdentifier &&
91172
LINGUI_JSX_COMPONENTS.has(current.openingElement.name.name)
92173
) {
93-
return true
174+
// Verify it's actually from Lingui (or use name-based fallback)
175+
const isLingui = isLinguiSymbol(current.openingElement.name, typeChecker, parserServices)
176+
if (isLingui === true || isLingui === null) {
177+
return true
178+
}
94179
}
95180

96181
// Function macros: msg({ message: "Hello" }), plural(n, {...})
@@ -99,19 +184,36 @@ function isInsideLinguiContext(node: TSESTree.Node): boolean {
99184
current.callee.type === AST_NODE_TYPES.Identifier &&
100185
LINGUI_FUNCTION_MACROS.has(current.callee.name)
101186
) {
102-
return true
187+
// Verify it's actually from Lingui (or use name-based fallback)
188+
const isLingui = isLinguiSymbol(current.callee, typeChecker, parserServices)
189+
if (isLingui === true || isLingui === null) {
190+
return true
191+
}
103192
}
104193

105194
// Runtime API: i18n.t({ message: "Hello" }), i18n._("Hello")
106195
if (
107196
current.type === AST_NODE_TYPES.CallExpression &&
108197
current.callee.type === AST_NODE_TYPES.MemberExpression &&
109198
current.callee.object.type === AST_NODE_TYPES.Identifier &&
110-
current.callee.object.name === "i18n" &&
111199
current.callee.property.type === AST_NODE_TYPES.Identifier &&
112200
(current.callee.property.name === "t" || current.callee.property.name === "_")
113201
) {
114-
return true
202+
// Check if the object is of type I18n from Lingui
203+
try {
204+
const objectTsNode = parserServices.esTreeNodeToTSNodeMap.get(current.callee.object)
205+
const objectType = typeChecker.getTypeAtLocation(objectTsNode)
206+
const typeName = typeChecker.typeToString(objectType)
207+
if (typeName === "I18n") {
208+
return true
209+
}
210+
} catch {
211+
// Type info not available
212+
}
213+
// Fallback: check if variable is named "i18n"
214+
if (current.callee.object.name === "i18n") {
215+
return true
216+
}
115217
}
116218

117219
current = current.parent ?? undefined
@@ -678,7 +780,7 @@ export const noUnlocalizedStrings = createRule<[Options], MessageId>({
678780
}
679781

680782
// Already inside Lingui localization
681-
if (isInsideLinguiContext(node)) {
783+
if (isInsideLinguiContext(node, typeChecker, parserServices)) {
682784
return
683785
}
684786

@@ -765,7 +867,7 @@ export const noUnlocalizedStrings = createRule<[Options], MessageId>({
765867
return
766868
}
767869

768-
if (isInsideLinguiContext(node)) {
870+
if (isInsideLinguiContext(node, typeChecker, parserServices)) {
769871
return
770872
}
771873

0 commit comments

Comments
 (0)