Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/extractor/parser/extractTranslateAliases.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
lineAliasMap: Map<number, string>;
};

function resolveArg(
rawArg: string,
constants: Map<string, string>
): 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<string, string>
): TranslateAliases {
const aliasMap = new Map<string, string>();
const lineAliasMap = new Map<number, string>();

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 };
}
49 changes: 33 additions & 16 deletions src/extractor/parser/generateReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ function keyInfoFromComment(context: Context, info: MagicKeyComment) {
function reportKey(
context: Context,
node: KeyInfoNode,
contextNs: NamespaceInfoNode | undefined
contextNs: NamespaceInfoNode | undefined,
nsByAlias: Map<string, NamespaceInfoNode>
) {
const { strictNamespace, defaultNamespace } = context.options;
const { keys, warnings } = context;
Expand All @@ -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 };
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, NamespaceInfoNode>
) {
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);
}
}
}
Expand Down Expand Up @@ -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') {
Expand Down
44 changes: 42 additions & 2 deletions src/extractor/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -118,15 +122,51 @@ export const Parser = <T extends string = GeneralTokenType>({
return true;
});

iterator = createIterator(filteredIgnored);
const constants = code
? extractConstants(code)
: new Map<string, string>();
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<any>[] = [];
for (let i = 0; i < filteredIgnored.length; i++) {
const tok = filteredIgnored[i] as Token<any>;
const nextTok = filteredIgnored[i + 1] as Token<any> | 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<T>[]);

const context: ParserContext<T> = {
tokens: iterator,
getCurrentLine,
withLabel,
ruleMap,
blocks,
constants: code ? extractConstants(code) : new Map(),
constants,
translateAliases,
};

let depth = 0;
Expand Down
7 changes: 7 additions & 0 deletions src/extractor/parser/rules/tFunctionGeneral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand All @@ -28,6 +34,7 @@ export const tFunctionGeneral = (
dependsOnContext,
values: [],
optionsDynamic,
alias,
};

// read props
Expand Down
2 changes: 2 additions & 0 deletions src/extractor/parser/rules/tNsSourceGeneral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { NamespaceInfoNode, ParserContext } from '../types.js';
*/
export const tNsSourceGeneral = (context: ParserContext<any>) => {
const line = context.getCurrentLine();
const alias = context.translateAliases.lineAliasMap.get(line);
const args = parseList(context, 'expression.end');

if (args.type !== 'array') {
Expand All @@ -23,6 +24,7 @@ export const tNsSourceGeneral = (context: ParserContext<any>) => {
type: 'nsInfo',
line,
values: [],
alias,
};

const [firstArg, ...otherArgs] = args.values;
Expand Down
8 changes: 8 additions & 0 deletions src/extractor/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<T extends string = GeneralTokenType> = {
Expand Down Expand Up @@ -90,6 +94,10 @@ export type ParserContext<T extends string = GeneralTokenType> = {
ruleMap: RuleMap<T>;
blocks: BlocksType;
constants: Map<string, string>;
translateAliases: {
aliasMap: Map<string, string>;
lineAliasMap: Map<number, string>;
};
};

export type ExtractorInternal = (
Expand Down
Loading
Loading