From 3679d00cb40736c2f7e8c836d9a35128598b59b2 Mon Sep 17 00:00:00 2001 From: Dmitrii Bocharov Date: Fri, 22 May 2026 11:00:50 +0200 Subject: [PATCH] fix(extractor): route each t alias to its own useTranslate namespace A file with multiple useTranslate calls in the same scope used to attribute every t() call to whichever useTranslate appeared last, and silently dropped any aliased call (`const { t: tCommon } = ...`). The pre-pass now records each destructured alias and the namespace it came from. Aliased call sites are retagged as trigger.t.function so the existing rule pipeline handles them; the resulting keyInfo and nsInfo carry the alias, and the reporter matches them up instead of relying on "latest nsInfo wins." Resolves the customer report on bdshadow/resolve-const-namespace about multi-namespace components. --- .../parser/extractTranslateAliases.ts | 113 ++++++++++++++++++ src/extractor/parser/generateReport.ts | 49 +++++--- src/extractor/parser/parser.ts | 44 ++++++- .../parser/rules/tFunctionGeneral.ts | 7 ++ .../parser/rules/tNsSourceGeneral.ts | 2 + src/extractor/parser/types.ts | 8 ++ .../unit/extractor/react/useTranslate.test.ts | 83 +++++++++++++ 7 files changed, 288 insertions(+), 18 deletions(-) create mode 100644 src/extractor/parser/extractTranslateAliases.ts diff --git a/src/extractor/parser/extractTranslateAliases.ts b/src/extractor/parser/extractTranslateAliases.ts new file mode 100644 index 00000000..05c7eb14 --- /dev/null +++ b/src/extractor/parser/extractTranslateAliases.ts @@ -0,0 +1,113 @@ +/** + * Pre-pass that detects which destructured names are bound to which + * `useTranslate` / `getTranslate` namespace within a file. + * + * Recognises: + * + * const { t } = useTranslate('ns') + * const { t, isLoading } = useTranslate('ns') + * const { t: tCommon } = useTranslate('ns') + * const t = await getTranslate('ns') // unnamed binding + * const { t: tCommon } = useTranslate(NS.K) // namespace resolved via constants + * + * Returns: + * - aliasMap: alias-name -> resolved namespace literal + * - lineAliasMap: line number of the useTranslate call -> alias name, + * used by the parser to tag the emitted nsInfo node so + * the reporter can match it to the corresponding keyInfo. + * + * Multi-line declarations are supported. Property entries that aren't `t` + * (or `t:` rename) are ignored, so a `{ isLoading, t }` pattern still + * resolves correctly. + * + * If the namespace argument is an identifier or `NAME.PROP` member access, + * it is resolved against the constants map produced by extractConstants. + * Unresolvable namespaces are intentionally skipped, so the existing + * dynamic-namespace warning still fires. + */ +const TRANSLATE_DESTRUCTURE_RE = + /const\s+\{\s*([^}]*?)\s*\}\s*=\s*(?:await\s+)?(?:useTranslate|getTranslate)\s*\(\s*([\s\S]*?)\s*\)/g; + +// `const t = await getTranslate('ns')` style — single binding, no destructure. +const TRANSLATE_SINGLE_RE = + /const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:await\s+)?(?:useTranslate|getTranslate)\s*\(\s*([\s\S]*?)\s*\)/g; + +const T_ENTRY_RE = /(?:^|,)\s*t(?:\s*:\s*([A-Za-z_$][\w$]*))?\s*(?=,|$)/; + +const STRING_ARG_RE = /^(['"`])((?:\\.|(?!\1)[^\\])*)\1$/; +const IDENTIFIER_ARG_RE = /^([A-Za-z_$][\w$]*)$/; +const MEMBER_ARG_RE = /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/; + +export type TranslateAliases = { + aliasMap: Map; + lineAliasMap: Map; +}; + +function resolveArg( + rawArg: string, + constants: Map +): string | undefined { + const trimmed = rawArg.trim(); + let m = STRING_ARG_RE.exec(trimmed); + if (m) return m[2]; + m = MEMBER_ARG_RE.exec(trimmed); + if (m) { + const key = `${m[1]}.${m[2]}`; + return constants.get(key); + } + m = IDENTIFIER_ARG_RE.exec(trimmed); + if (m) { + return constants.get(m[1]); + } + return undefined; +} + +function lineNumberOf(code: string, index: number): number { + let line = 1; + for (let i = 0; i < index; i++) { + if (code.charCodeAt(i) === 10 /* \n */) line += 1; + } + return line; +} + +export function extractTranslateAliases( + code: string, + constants: Map +): TranslateAliases { + const aliasMap = new Map(); + const lineAliasMap = new Map(); + + let match: RegExpExecArray | null; + + TRANSLATE_DESTRUCTURE_RE.lastIndex = 0; + while ((match = TRANSLATE_DESTRUCTURE_RE.exec(code))) { + const [, body, rawArg] = match; + const ns = resolveArg(rawArg, constants); + if (ns === undefined) continue; + + const entry = T_ENTRY_RE.exec(`,${body},`); + if (!entry) continue; + + const alias = entry[1] ?? 't'; + aliasMap.set(alias, ns); + // `useTranslate(` opens on the same line as the const declaration in + // the vast majority of real code; align to the call-site line so the + // parser's nsInfo (emitted at trigger.use.translate) matches. + const callIndex = match.index + match[0].lastIndexOf('('); + lineAliasMap.set(lineNumberOf(code, callIndex), alias); + } + + TRANSLATE_SINGLE_RE.lastIndex = 0; + while ((match = TRANSLATE_SINGLE_RE.exec(code))) { + const [, alias, rawArg] = match; + const ns = resolveArg(rawArg, constants); + if (ns === undefined) continue; + if (!aliasMap.has(alias)) { + aliasMap.set(alias, ns); + const callIndex = match.index + match[0].lastIndexOf('('); + lineAliasMap.set(lineNumberOf(code, callIndex), alias); + } + } + + return { aliasMap, lineAliasMap }; +} diff --git a/src/extractor/parser/generateReport.ts b/src/extractor/parser/generateReport.ts index da3ecdf3..d3544da3 100644 --- a/src/extractor/parser/generateReport.ts +++ b/src/extractor/parser/generateReport.ts @@ -52,7 +52,8 @@ function keyInfoFromComment(context: Context, info: MagicKeyComment) { function reportKey( context: Context, node: KeyInfoNode, - contextNs: NamespaceInfoNode | undefined + contextNs: NamespaceInfoNode | undefined, + nsByAlias: Map ) { const { strictNamespace, defaultNamespace } = context.options; const { keys, warnings } = context; @@ -63,7 +64,14 @@ function reportKey( line, dependsOnContext, optionsDynamic, + alias, } = node; + // Prefer the nsInfo bound to the same destructured alias as the call + // site, so multiple `useTranslate(...)` declarations in one scope route + // their keys to the right namespace instead of all collapsing onto the + // most recently emitted nsInfo. + const aliasContextNs = alias ? nsByAlias.get(alias) : undefined; + const effectiveContextNs = aliasContextNs ?? contextNs; if (shouldBeIgnored(context, line)) { return { keys, warnings }; @@ -83,7 +91,7 @@ function reportKey( return; } - if (dependsOnContext && !contextNs && !keyNs && strictNamespace) { + if (dependsOnContext && !effectiveContextNs && !keyNs && strictNamespace) { // there is no namespace source so namespace is ambiguous warnings.push({ line, warning: 'W_MISSING_T_SOURCE' }); return; @@ -95,10 +103,11 @@ function reportKey( return; } - const namespace = keyNs ?? (dependsOnContext ? contextNs?.name : undefined); + const namespace = + keyNs ?? (dependsOnContext ? effectiveContextNs?.name : undefined); if (namespace && !isString(namespace)) { // namespace is dynamic - if (namespace === contextNs?.name) { + if (namespace === effectiveContextNs?.name) { // namespace coming from context warnings.push({ line, warning: 'W_UNRESOLVABLE_NAMESPACE' }); } else { @@ -136,48 +145,56 @@ function reportNs(context: Context, node: NamespaceInfoNode) { function reportGeneral( context: Context, node: GeneralNode | undefined, - contextNs: NamespaceInfoNode | undefined + contextNs: NamespaceInfoNode | undefined, + nsByAlias: Map ) { if (!node) { return; } if (node.type === 'expr' || node.type === 'array') { let namespace = contextNs; + // Per-alias tracking lives in a child scope so cousin branches don't + // see each other's bindings, but later siblings within the same expr + // do — same shape as the existing `namespace` walker. + const childNsByAlias = new Map(nsByAlias); for (const item of node.values) { if (item.type === 'nsInfo') { const oldNamespace = namespace; if (!shouldBeIgnored(context, item.line)) { reportNs(context, item); namespace = item; + if (item.alias) { + childNsByAlias.set(item.alias, item); + } } // there might be nested stuff - reportGeneral(context, item.name, oldNamespace); + reportGeneral(context, item.name, oldNamespace, childNsByAlias); for (const i of item.values) { - reportGeneral(context, i, oldNamespace); + reportGeneral(context, i, oldNamespace, childNsByAlias); } } else { - reportGeneral(context, item, namespace); + reportGeneral(context, item, namespace, childNsByAlias); } } } else if (node.type === 'keyInfo') { - reportKey(context, node, contextNs); + reportKey(context, node, contextNs, nsByAlias); // there might be nested stuff - reportGeneral(context, node.keyName, contextNs); - reportGeneral(context, node.namespace, contextNs); - reportGeneral(context, node.defaultValue, contextNs); + reportGeneral(context, node.keyName, contextNs, nsByAlias); + reportGeneral(context, node.namespace, contextNs, nsByAlias); + reportGeneral(context, node.defaultValue, contextNs, nsByAlias); for (const i of node.values) { - reportGeneral(context, i, contextNs); + reportGeneral(context, i, contextNs, nsByAlias); } } else if (node.type === 'dict') { for (const item of Object.values(node.value)) { - reportGeneral(context, item, contextNs); + reportGeneral(context, item, contextNs, nsByAlias); } // go through values with unknown keynames for (const item of node.unknown) { - reportGeneral(context, item, contextNs); + reportGeneral(context, item, contextNs, nsByAlias); } } } @@ -208,7 +225,7 @@ export function generateReport({ warnings: [], }; - reportGeneral(context, node, contextNs); + reportGeneral(context, node, contextNs, new Map()); unusedComments.forEach((value) => { if (value.type === 'WARNING') { diff --git a/src/extractor/parser/parser.ts b/src/extractor/parser/parser.ts index 9d7859d9..ac4eb87d 100644 --- a/src/extractor/parser/parser.ts +++ b/src/extractor/parser/parser.ts @@ -25,6 +25,10 @@ import { typesAsMerger } from './tokenMergers/typesAsMerger.js'; import { typesCastMerger } from './tokenMergers/typesCastMerger.js'; import { customTCallMerger } from './tokenMergers/customTCallMerger.js'; import { extractConstants } from './extractConstants.js'; +import { + extractTranslateAliases, + TranslateAliases, +} from './extractTranslateAliases.js'; export const DEFAULT_BLOCKS = { 'block.begin': ['block.end'], @@ -118,7 +122,42 @@ export const Parser = ({ return true; }); - iterator = createIterator(filteredIgnored); + const constants = code + ? extractConstants(code) + : new Map(); + const translateAliases: TranslateAliases = code + ? extractTranslateAliases(code, constants) + : { aliasMap: new Map(), lineAliasMap: new Map() }; + + // Retag aliased function calls (e.g. `tCommon(`) as trigger.t.function. + // The original tFunctionMerger only matches the literal name `t`; once + // we know the alias set, we can retroactively promote those calls so + // the existing rule pipeline processes them like a plain `t(...)`. + // The merged-token `.token` carries the alias name (e.g. `tCommon(`), + // which the tFunction rule strips to attribute the call. + const aliasedTokens: Token[] = []; + for (let i = 0; i < filteredIgnored.length; i++) { + const tok = filteredIgnored[i] as Token; + const nextTok = filteredIgnored[i + 1] as Token | undefined; + const isPotentialAliasCall = + tok.customType === 'function.call' && + tok.token !== 't' && + translateAliases.aliasMap.has(tok.token) && + nextTok?.customType === 'expression.begin'; + if (isPotentialAliasCall) { + aliasedTokens.push({ + ...tok, + customType: 'trigger.t.function' as any, + token: `${tok.token}(`, + endIndex: nextTok!.endIndex, + }); + i += 1; // consumed the `(` token + } else { + aliasedTokens.push(tok); + } + } + + iterator = createIterator(aliasedTokens as Token[]); const context: ParserContext = { tokens: iterator, @@ -126,7 +165,8 @@ export const Parser = ({ withLabel, ruleMap, blocks, - constants: code ? extractConstants(code) : new Map(), + constants, + translateAliases, }; let depth = 0; diff --git a/src/extractor/parser/rules/tFunctionGeneral.ts b/src/extractor/parser/rules/tFunctionGeneral.ts index 3e090e63..e4086100 100644 --- a/src/extractor/parser/rules/tFunctionGeneral.ts +++ b/src/extractor/parser/rules/tFunctionGeneral.ts @@ -9,6 +9,12 @@ export const tFunctionGeneral = ( dependsOnContext: boolean ) => { const line = context.getCurrentLine(); + // The merged trigger token text is the alias name plus `(`, e.g. `t(` or + // `tCommon(`. Strip the paren to recover the alias used at the call site. + const triggerToken = context.tokens.current()?.token ?? 't('; + const alias = triggerToken.endsWith('(') + ? triggerToken.slice(0, -1) + : triggerToken; const args = parseList(context, 'expression.end'); if (args.type !== 'array') { @@ -28,6 +34,7 @@ export const tFunctionGeneral = ( dependsOnContext, values: [], optionsDynamic, + alias, }; // read props diff --git a/src/extractor/parser/rules/tNsSourceGeneral.ts b/src/extractor/parser/rules/tNsSourceGeneral.ts index f41f05d4..ac5f950e 100644 --- a/src/extractor/parser/rules/tNsSourceGeneral.ts +++ b/src/extractor/parser/rules/tNsSourceGeneral.ts @@ -12,6 +12,7 @@ import { NamespaceInfoNode, ParserContext } from '../types.js'; */ export const tNsSourceGeneral = (context: ParserContext) => { const line = context.getCurrentLine(); + const alias = context.translateAliases.lineAliasMap.get(line); const args = parseList(context, 'expression.end'); if (args.type !== 'array') { @@ -23,6 +24,7 @@ export const tNsSourceGeneral = (context: ParserContext) => { type: 'nsInfo', line, values: [], + alias, }; const [firstArg, ...otherArgs] = args.values; diff --git a/src/extractor/parser/types.ts b/src/extractor/parser/types.ts index bfcb7a28..0e7a7b60 100644 --- a/src/extractor/parser/types.ts +++ b/src/extractor/parser/types.ts @@ -43,6 +43,8 @@ export type NamespaceInfoNode = NodeCommon & { type: 'nsInfo'; name?: GeneralNode; values: GeneralNode[]; + /** Destructured binding bound to this useTranslate, e.g. `t` or `tCommon`. */ + alias?: string; }; export type KeyInfoNode = NodeCommon & { type: 'keyInfo'; @@ -52,6 +54,8 @@ export type KeyInfoNode = NodeCommon & { defaultValue?: GeneralNode; values: GeneralNode[]; optionsDynamic?: boolean; + /** Function alias used at the call site, e.g. `t` or `tCommon`. */ + alias?: string; }; export type ExtractGeneralOptions = { @@ -90,6 +94,10 @@ export type ParserContext = { ruleMap: RuleMap; blocks: BlocksType; constants: Map; + translateAliases: { + aliasMap: Map; + lineAliasMap: Map; + }; }; export type ExtractorInternal = ( diff --git a/test/unit/extractor/react/useTranslate.test.ts b/test/unit/extractor/react/useTranslate.test.ts index d0022ac9..9966ed7d 100644 --- a/test/unit/extractor/react/useTranslate.test.ts +++ b/test/unit/extractor/react/useTranslate.test.ts @@ -537,6 +537,89 @@ describe.each(['js', 'ts', 'jsx', 'tsx'])('useTranslate (.%s)', (ext) => { }); }); + describe('multiple useTranslate aliases in the same scope', () => { + it('routes each alias to its own namespace (rename pattern)', async () => { + const code = ` + import '@tolgee/react' + function Example () { + const { t } = useTranslate('defaultNamespace') + const { t: tCommon } = useTranslate('common') + t('some.key.from.default') + tCommon('some.shared.label') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { + keyName: 'some.key.from.default', + namespace: 'defaultNamespace', + line: 6, + }, + { keyName: 'some.shared.label', namespace: 'common', line: 7 }, + ]); + }); + + it('handles three aliases with mixed rename / no-rename', async () => { + const code = ` + import '@tolgee/react' + function Example () { + const { t } = useTranslate('feature') + const { t: tCommon } = useTranslate('common') + const { t: tShared } = useTranslate('shared') + t('feature.key') + tCommon('common.key') + tShared('shared.key') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'feature.key', namespace: 'feature', line: 7 }, + { keyName: 'common.key', namespace: 'common', line: 8 }, + { keyName: 'shared.key', namespace: 'shared', line: 9 }, + ]); + }); + + it('resolves aliases when the namespace is a top-level const', async () => { + const code = ` + import '@tolgee/react' + const NS = { FEATURE: 'feature', COMMON: 'common' } as const + function Example () { + const { t } = useTranslate(NS.FEATURE) + const { t: tCommon } = useTranslate(NS.COMMON) + t('feature.key') + tCommon('common.key') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'feature.key', namespace: 'feature', line: 7 }, + { keyName: 'common.key', namespace: 'common', line: 8 }, + ]); + }); + + it('handles multi-property destructure { t, isLoading }', async () => { + const code = ` + import '@tolgee/react' + function Example () { + const { isLoading, t } = useTranslate('feature') + t('feature.key') + } + `; + + const extracted = await extractReactKeys(code, FILE_NAME); + expect(extracted.warnings).toEqual([]); + expect(extracted.keys).toEqual([ + { keyName: 'feature.key', namespace: 'feature', line: 5 }, + ]); + }); + }); + describe('global tolgee.t function', () => { it('detects global tolgee.t function', async () => { const code = `