import fs from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' import * as ts from 'typescript' type TextEdit = { end: number replacement: string start: number } export type TransformSourceResult = { changes: number output: string } const I18N_MODULES = new Set(['#i18n', 'i18next', 'react-i18next']) const MOCK_PROVIDER_METHODS = new Set([ 'mockImplementation', 'mockImplementationOnce', 'mockReturnValue', 'mockReturnValueOnce', ]) const SKIPPED_DIRECTORIES = new Set([ '.next', '.turbo', '.vinext', 'coverage', 'dist', 'i18n', 'node_modules', 'public', ]) const SKIPPED_FILES = new Set(['migrate-i18n-selectors.spec.ts']) const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']) const TRANSLATION_FACTORIES = new Set(['getTranslation', 'useTranslation']) function unwrapExpression(expression: ts.Expression): ts.Expression { let current = expression while ( ts.isAsExpression(current) || ts.isNonNullExpression(current) || ts.isParenthesizedExpression(current) || ts.isSatisfiesExpression(current) || ts.isTypeAssertionExpression(current) ) { current = current.expression } return current } type ImportBinding = { importedName: string moduleName: string } function createSourceAnalysis(source: string, fileName: string, scriptKind: ts.ScriptKind) { const resolvedFileName = path.resolve(fileName) const sourceFile = ts.createSourceFile( resolvedFileName, source, ts.ScriptTarget.Latest, true, scriptKind, ) const compilerOptions: ts.CompilerOptions = { allowJs: true, jsx: ts.JsxEmit.Preserve, noLib: true, noResolve: true, target: ts.ScriptTarget.Latest, } const defaultHost = ts.createCompilerHost(compilerOptions) const host: ts.CompilerHost = { ...defaultHost, fileExists: (candidate) => path.resolve(candidate) === resolvedFileName, getSourceFile: (candidate) => path.resolve(candidate) === resolvedFileName ? sourceFile : undefined, readFile: (candidate) => (path.resolve(candidate) === resolvedFileName ? source : undefined), } const program = ts.createProgram([resolvedFileName], compilerOptions, host) return { checker: program.getTypeChecker(), sourceFile } } function getImportBinding(declaration: ts.Declaration): ImportBinding | undefined { let current: ts.Node | undefined = declaration while (current && !ts.isImportDeclaration(current)) current = current.parent if (!current || !ts.isStringLiteral(current.moduleSpecifier)) return undefined if (ts.isImportSpecifier(declaration)) { return { importedName: declaration.propertyName?.text ?? declaration.name.text, moduleName: current.moduleSpecifier.text, } } if (ts.isImportClause(declaration)) { return { importedName: 'default', moduleName: current.moduleSpecifier.text, } } if (ts.isNamespaceImport(declaration)) { return { importedName: '*', moduleName: current.moduleSpecifier.text, } } return undefined } function getDeclarations(identifier: ts.Identifier, checker: ts.TypeChecker) { return checker.getSymbolAtLocation(identifier)?.declarations ?? [] } function isImportedBinding( identifier: ts.Identifier, checker: ts.TypeChecker, importedName: string, isAllowedModule: (moduleName: string) => boolean, ) { return getDeclarations(identifier, checker).some((declaration) => { const binding = getImportBinding(declaration) return binding?.importedName === importedName && isAllowedModule(binding.moduleName) }) } function isTranslationFactoryIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) { return getDeclarations(identifier, checker).some((declaration) => { const binding = getImportBinding(declaration) return Boolean( binding && TRANSLATION_FACTORIES.has(binding.importedName) && (I18N_MODULES.has(binding.moduleName) || binding.moduleName.toLowerCase().includes('i18n')), ) }) } function isGetI18nIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) { return isImportedBinding(identifier, checker, 'getI18n', (moduleName) => I18N_MODULES.has(moduleName), ) } function isTranslationFactoryCall(expression: ts.Expression, checker: ts.TypeChecker) { const unwrapped = ts.isAwaitExpression(expression) ? unwrapExpression(expression.expression) : unwrapExpression(expression) return ( ts.isCallExpression(unwrapped) && ts.isIdentifier(unwrapped.expression) && isTranslationFactoryIdentifier(unwrapped.expression, checker) ) } function getBindingElementPropertyName(declaration: ts.BindingElement) { if ( declaration.propertyName && (ts.isIdentifier(declaration.propertyName) || ts.isStringLiteral(declaration.propertyName)) ) return declaration.propertyName.text if (ts.isIdentifier(declaration.name)) return declaration.name.text return undefined } function findAncestor( node: ts.Node, predicate: (candidate: ts.Node) => candidate is T, ): T | undefined { let current: ts.Node | undefined = node.parent while (current) { if (predicate(current)) return current current = current.parent } return undefined } function hasTranslationFunctionType(parameter: ts.ParameterDeclaration, sourceFile: ts.SourceFile) { const typeText = parameter.type?.getText(sourceFile) ?? '' return ( /\b(?:TFunction|useTranslation)\b|(?:Translate|Translator)\b/.test(typeText) || typeText.trim() === 'any' ) } function isTranslationFunctionDeclaration( declaration: ts.Declaration, checker: ts.TypeChecker, sourceFile: ts.SourceFile, ) { const importBinding = getImportBinding(declaration) if (importBinding) return importBinding.importedName === 't' && I18N_MODULES.has(importBinding.moduleName) if (ts.isBindingElement(declaration)) { if (getBindingElementPropertyName(declaration) !== 't') return false const variableDeclaration = findAncestor(declaration, ts.isVariableDeclaration) if (variableDeclaration?.initializer) return isTranslationFactoryCall(variableDeclaration.initializer, checker) const parameter = findAncestor(declaration, ts.isParameter) return Boolean(parameter && hasTranslationFunctionType(parameter, sourceFile)) } if (ts.isVariableDeclaration(declaration) && declaration.initializer) { const initializer = unwrapExpression(declaration.initializer) return ( ts.isPropertyAccessExpression(initializer) && initializer.name.text === 't' && isTranslationFactoryCall(initializer.expression, checker) ) } return ( ts.isParameter(declaration) && ts.isIdentifier(declaration.name) && declaration.name.text === 't' && hasTranslationFunctionType(declaration, sourceFile) ) } function isTranslationFunctionIdentifier( identifier: ts.Identifier, checker: ts.TypeChecker, sourceFile: ts.SourceFile, ) { return getDeclarations(identifier, checker).some((declaration) => isTranslationFunctionDeclaration(declaration, checker, sourceFile), ) } function isI18nInstanceIdentifier(identifier: ts.Identifier, checker: ts.TypeChecker) { const declarations = getDeclarations(identifier, checker) if (!declarations.length) return identifier.text === 'i18n' || identifier.text === 'i18next' return declarations.some((declaration) => { const importBinding = getImportBinding(declaration) if (importBinding) return ( I18N_MODULES.has(importBinding.moduleName) && ['*', 'default', 'i18n', 'i18next'].includes(importBinding.importedName) ) if (ts.isBindingElement(declaration)) { const variableDeclaration = findAncestor(declaration, ts.isVariableDeclaration) return ( getBindingElementPropertyName(declaration) === 'i18n' && Boolean( variableDeclaration?.initializer && isTranslationFactoryCall(variableDeclaration.initializer, checker), ) ) } if (!ts.isVariableDeclaration(declaration) || !declaration.initializer) return false const initializer = unwrapExpression(declaration.initializer) return ( ts.isCallExpression(initializer) && ts.isIdentifier(initializer.expression) && isGetI18nIdentifier(initializer.expression, checker) ) }) } function getTypeReferenceName(typeName: ts.EntityName): string { return ts.isIdentifier(typeName) ? typeName.text : typeName.right.text } function hasSelectorType(typeNode: ts.TypeNode | undefined): boolean { if (!typeNode) return false if (ts.isParenthesizedTypeNode(typeNode) || ts.isTypeOperatorNode(typeNode)) return hasSelectorType(typeNode.type) if (ts.isTypeReferenceNode(typeNode)) return ['SelectorKey', 'SelectorParam'].includes(getTypeReferenceName(typeNode.typeName)) if (ts.isUnionTypeNode(typeNode)) { const meaningfulTypes = typeNode.types.filter( (type) => type.kind !== ts.SyntaxKind.UndefinedKeyword && type.kind !== ts.SyntaxKind.NeverKeyword && !(ts.isLiteralTypeNode(type) && type.literal.kind === ts.SyntaxKind.NullKeyword), ) return meaningfulTypes.length > 0 && meaningfulTypes.every(hasSelectorType) } return false } function hasSelectorCollectionType(typeNode: ts.TypeNode | undefined): boolean { if (!typeNode) return false if (ts.isParenthesizedTypeNode(typeNode) || ts.isTypeOperatorNode(typeNode)) return hasSelectorCollectionType(typeNode.type) if (ts.isUnionTypeNode(typeNode)) return typeNode.types.some(hasSelectorCollectionType) if (ts.isArrayTypeNode(typeNode)) return hasSelectorType(typeNode.elementType) if (!ts.isTypeReferenceNode(typeNode)) return false const typeName = getTypeReferenceName(typeNode.typeName) const typeArguments = typeNode.typeArguments ?? [] if (typeName === 'Record') return hasSelectorType(typeArguments[1]) if (typeName === 'Array' || typeName === 'ReadonlyArray') return hasSelectorType(typeArguments[0]) return false } function hasCallableType(expression: ts.Expression, checker: ts.TypeChecker) { const type = checker.getTypeAtLocation(expression) if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false return checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0 } function isSelectorCollectionExpression( expression: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile, seenSymbols: Set, ): boolean { if (ts.isParenthesizedExpression(expression) || ts.isNonNullExpression(expression)) return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols) if ( ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isTypeAssertionExpression(expression) ) { if (hasSelectorCollectionType(expression.type)) return true return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols) } if (ts.isObjectLiteralExpression(expression)) { const values = expression.properties.flatMap((property): ts.Expression[] => { if (ts.isPropertyAssignment(property)) return [property.initializer] if (ts.isShorthandPropertyAssignment(property)) return [property.name] return [] }) return ( values.length > 0 && values.every((value) => isSelectorCompatibleExpression(value, checker, sourceFile, new Set(seenSymbols)), ) ) } if (ts.isArrayLiteralExpression(expression)) { return ( expression.elements.length > 0 && expression.elements.every( (element) => !ts.isSpreadElement(element) && isSelectorCompatibleExpression(element, checker, sourceFile, new Set(seenSymbols)), ) ) } if (!ts.isIdentifier(expression)) return false const symbol = checker.getSymbolAtLocation(expression) if (!symbol || seenSymbols.has(symbol)) return false seenSymbols.add(symbol) return (symbol.declarations ?? []).some((declaration) => { if (ts.isVariableDeclaration(declaration)) { return ( hasSelectorCollectionType(declaration.type) || Boolean( declaration.initializer && isSelectorCollectionExpression(declaration.initializer, checker, sourceFile, seenSymbols), ) ) } if ( ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration) || ts.isPropertySignature(declaration) ) return hasSelectorCollectionType(declaration.type) return false }) } function isSelectorCompatibleExpression( expression: ts.Expression, checker: ts.TypeChecker, sourceFile: ts.SourceFile, seenSymbols = new Set(), ): boolean { if (ts.isParenthesizedExpression(expression) || ts.isNonNullExpression(expression)) return isSelectorCompatibleExpression(expression.expression, checker, sourceFile, seenSymbols) if ( ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isTypeAssertionExpression(expression) ) { if (hasSelectorType(expression.type)) return true return isSelectorCompatibleExpression(expression.expression, checker, sourceFile, seenSymbols) } if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression)) return true if (hasCallableType(expression, checker)) return true if (ts.isConditionalExpression(expression)) { return ( isSelectorCompatibleExpression( expression.whenTrue, checker, sourceFile, new Set(seenSymbols), ) && isSelectorCompatibleExpression( expression.whenFalse, checker, sourceFile, new Set(seenSymbols), ) ) } if (ts.isElementAccessExpression(expression) || ts.isPropertyAccessExpression(expression)) { return isSelectorCollectionExpression(expression.expression, checker, sourceFile, seenSymbols) } if (!ts.isIdentifier(expression)) return false const symbol = checker.getSymbolAtLocation(expression) if (!symbol || seenSymbols.has(symbol)) return false seenSymbols.add(symbol) return (symbol.declarations ?? []).some((declaration) => { if (ts.isVariableDeclaration(declaration)) { return ( hasSelectorType(declaration.type) || Boolean( declaration.initializer && isSelectorCompatibleExpression(declaration.initializer, checker, sourceFile, seenSymbols), ) ) } if ( ts.isParameter(declaration) || ts.isPropertyDeclaration(declaration) || ts.isPropertySignature(declaration) ) return hasSelectorType(declaration.type) return false }) } function quoteSelectorKey(key: string) { return `'${key.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'` } function isSelectorPropertyName(key: string) { return /^[A-Z_$][\w$]*$/i.test(key) } function selectorAccessFor(expression: ts.Expression, sourceFile: ts.SourceFile) { if (ts.isStringLiteral(expression)) { return isSelectorPropertyName(expression.text) ? `$.${expression.text}` : `$[${quoteSelectorKey(expression.text)}]` } return `$[${expression.getText(sourceFile)}]` } function selectorFor(expression: ts.Expression, sourceFile: ts.SourceFile): string { if (ts.isArrayLiteralExpression(expression)) { const selectors = expression.elements.map((element) => { if (ts.isSpreadElement(element)) return element.getText(sourceFile) return `$ => ${selectorAccessFor(element, sourceFile)}` }) return `[${selectors.join(', ')}]` } return `$ => ${selectorAccessFor(expression, sourceFile)}` } function isSelectorAccessRoot(expression: ts.Expression, parameterName: string): boolean { if (ts.isIdentifier(expression)) return expression.text === parameterName if (ts.isElementAccessExpression(expression) || ts.isPropertyAccessExpression(expression)) return isSelectorAccessRoot(expression.expression, parameterName) return false } function isStringExpression( node: ts.Node, ): node is ts.StringLiteral | ts.NoSubstitutionTemplateLiteral { return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) } function isTranslationCall( node: ts.CallExpression, checker: ts.TypeChecker, sourceFile: ts.SourceFile, ) { if (ts.isIdentifier(node.expression)) return isTranslationFunctionIdentifier(node.expression, checker, sourceFile) if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 't') return false const receiver = unwrapExpression(node.expression.expression) if (ts.isIdentifier(receiver)) return isI18nInstanceIdentifier(receiver, checker) return ( ts.isCallExpression(receiver) && ts.isIdentifier(receiver.expression) && isGetI18nIdentifier(receiver.expression, checker) ) } function isTransComponent(identifier: ts.Identifier, checker: ts.TypeChecker) { return isImportedBinding(identifier, checker, 'Trans', (moduleName) => I18N_MODULES.has(moduleName), ) } function getJsxAttribute(node: ts.JsxOpeningLikeElement, name: string) { return node.attributes.properties.find((property): property is ts.JsxAttribute => { return ( ts.isJsxAttribute(property) && ts.isIdentifier(property.name) && property.name.text === name ) }) } function getPropertyName(node: ts.ObjectLiteralElementLike) { if (!('name' in node) || !node.name) return undefined if (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) return node.name.text return undefined } function isI18nMock(node: ts.CallExpression) { if ( !ts.isPropertyAccessExpression(node.expression) || !ts.isIdentifier(node.expression.expression) || node.expression.expression.text !== 'vi' || node.expression.name.text !== 'mock' ) { return false } const moduleName = node.arguments[0] return Boolean(moduleName && ts.isStringLiteral(moduleName) && I18N_MODULES.has(moduleName.text)) } function isVitestValueWrapper(node: ts.CallExpression) { return ( ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'vi' && (node.expression.name.text === 'fn' || node.expression.name.text === 'hoisted') ) } function applyEdits(source: string, edits: TextEdit[]) { let output = source for (const edit of [...edits].sort((left, right) => right.start - left.start)) output = `${output.slice(0, edit.start)}${edit.replacement}${output.slice(edit.end)}` return output } export function transformSource(source: string, fileName: string): TransformSourceResult { const scriptKind = fileName.endsWith('.tsx') || fileName.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS const { checker, sourceFile } = createSourceAnalysis(source, fileName, scriptKind) return transformAnalyzedSource(source, sourceFile, checker) } function transformAnalyzedSource( source: string, sourceFile: ts.SourceFile, checker: ts.TypeChecker, ): TransformSourceResult { const edits: TextEdit[] = [] const consumedEdits = new Set() const mockEditStarts = new Set() const mockProviderSymbols = new Set() const visitedMockModuleSymbols = new Set() const visitedMockProviderSymbols = new Set() const neededSelectorMockImports = new Set() const mockFactoryImports = new Map>() let currentMockFactory: ts.ArrowFunction | ts.FunctionExpression | undefined function requireMockHelper(name: 'withSelectorKey' | 'withSelectorKeyProps') { if (!currentMockFactory) { neededSelectorMockImports.add(name) return } const imports = mockFactoryImports.get(currentMockFactory) ?? new Set() imports.add(name) mockFactoryImports.set(currentMockFactory, imports) } function isSelectorMockAdapter(expression: ts.Expression) { const adapter = unwrapExpression(expression) if (!ts.isArrowFunction(adapter) && !ts.isFunctionExpression(adapter)) return false const selectorParameter = adapter.parameters[0] return ( Boolean(selectorParameter?.type && hasSelectorType(selectorParameter.type)) || /\b(?:keyFromSelector|resolveI18nKey)\s*\(/.test(adapter.body.getText(sourceFile)) ) } function addMockTEdit(node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment) { if (mockEditStarts.has(node.getStart(sourceFile))) return if (ts.isPropertyAssignment(node)) { const initializer = unwrapExpression(node.initializer) if (isSelectorMockAdapter(initializer)) return const alreadyWrapped = ts.isCallExpression(initializer) && ts.isIdentifier(initializer.expression) && initializer.expression.text === 'withSelectorKey' if (alreadyWrapped) return const translate = currentMockFactory && ts.isIdentifier(initializer) ? `(...args: Parameters) => ${initializer.text}(...args)` : node.initializer.getText(sourceFile) edits.push({ end: node.initializer.end, replacement: `withSelectorKey(${translate})`, start: node.initializer.getStart(sourceFile), }) } else { const translate = currentMockFactory ? `(...args: Parameters) => ${node.name.text}(...args)` : node.name.text edits.push({ end: node.end, replacement: `t: withSelectorKey(${translate})`, start: node.getStart(sourceFile), }) } mockEditStarts.add(node.getStart(sourceFile)) requireMockHelper('withSelectorKey') } function addMockTransEdit(node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment) { if (mockEditStarts.has(node.getStart(sourceFile))) return if (ts.isPropertyAssignment(node)) { const initializer = unwrapExpression(node.initializer) const alreadyWrapped = ts.isCallExpression(initializer) && ts.isIdentifier(initializer.expression) && initializer.expression.text === 'withSelectorKeyProps' if (alreadyWrapped) return const render = currentMockFactory && ts.isIdentifier(initializer) ? `(props: Parameters[0]) => withSelectorKeyProps(${initializer.text})(props)` : `withSelectorKeyProps(${node.initializer.getText(sourceFile)})` edits.push({ end: node.initializer.end, replacement: render, start: node.initializer.getStart(sourceFile), }) } else { const render = currentMockFactory ? `(props: Parameters[0]) => withSelectorKeyProps(${node.name.text})(props)` : `withSelectorKeyProps(${node.name.text})` edits.push({ end: node.end, replacement: `Trans: ${render}`, start: node.getStart(sourceFile), }) } mockEditStarts.add(node.getStart(sourceFile)) requireMockHelper('withSelectorKeyProps') } function collectReturnedExpressions( node: ts.FunctionLikeDeclaration, collect: (expression: ts.Expression) => void, ) { if (ts.isArrowFunction(node) && !ts.isBlock(node.body)) { collect(node.body) return } if (!node.body) return function visitReturn(candidate: ts.Node) { if (candidate !== node && ts.isFunctionLike(candidate)) return if (ts.isReturnStatement(candidate) && candidate.expression) { collect(candidate.expression) return } ts.forEachChild(candidate, visitReturn) } visitReturn(node.body) } function followLocalIdentifier( identifier: ts.Identifier, visitedSymbols: Set, collect: (node: ts.Node) => void, ) { const symbol = checker.getSymbolAtLocation(identifier) if (!symbol || visitedSymbols.has(symbol)) return symbol visitedSymbols.add(symbol) for (const declaration of symbol.declarations ?? []) { if (ts.isVariableDeclaration(declaration) && declaration.initializer) collect(declaration.initializer) else if (ts.isFunctionDeclaration(declaration)) collect(declaration) } return symbol } function collectMockProviderValue(node: ts.Node) { if (ts.isIdentifier(node)) { const symbol = followLocalIdentifier( node, visitedMockProviderSymbols, collectMockProviderValue, ) if (symbol) mockProviderSymbols.add(symbol) return } if ( ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node) || ts.isTypeAssertionExpression(node) ) { collectMockProviderValue(node.expression) return } if ( ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) ) { collectReturnedExpressions(node, collectMockProviderValue) return } if (ts.isObjectLiteralExpression(node)) { for (const property of node.properties) { if (ts.isPropertyAssignment(property) && getPropertyName(property) === 't') addMockTEdit(property) else if (ts.isShorthandPropertyAssignment(property) && property.name.text === 't') addMockTEdit(property) else if (ts.isSpreadAssignment(property)) collectMockProviderValue(property.expression) } return } if (ts.isCallExpression(node)) { if (ts.isIdentifier(node.expression)) { const symbol = followLocalIdentifier( node.expression, visitedMockProviderSymbols, collectMockProviderValue, ) if (symbol) mockProviderSymbols.add(symbol) } if (isVitestValueWrapper(node)) { for (const argument of node.arguments) collectMockProviderValue(argument) } return } if (ts.isConditionalExpression(node)) { collectMockProviderValue(node.whenTrue) collectMockProviderValue(node.whenFalse) return } if (ts.isAwaitExpression(node)) collectMockProviderValue(node.expression) } function collectMockModuleValue(node: ts.Node) { if (ts.isIdentifier(node)) { followLocalIdentifier(node, visitedMockModuleSymbols, collectMockModuleValue) return } if ( ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node) || ts.isTypeAssertionExpression(node) ) { collectMockModuleValue(node.expression) return } if ( ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) ) { collectReturnedExpressions(node, collectMockModuleValue) return } if (ts.isObjectLiteralExpression(node)) { for (const property of node.properties) { const propertyName = getPropertyName(property) if ( (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && propertyName === 't' ) { addMockTEdit(property) } else if ( (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && propertyName === 'Trans' ) { addMockTransEdit(property) } else if (propertyName === 'useTranslation' || propertyName === 'getI18n') { if (ts.isPropertyAssignment(property)) collectMockProviderValue(property.initializer) else if (ts.isShorthandPropertyAssignment(property)) collectMockProviderValue(property.name) else if (ts.isMethodDeclaration(property)) collectMockProviderValue(property) } else if (ts.isSpreadAssignment(property)) { collectMockModuleValue(property.expression) } } return } if (ts.isCallExpression(node)) { if (isVitestValueWrapper(node)) { for (const argument of node.arguments) collectMockModuleValue(argument) } return } if (ts.isConditionalExpression(node)) { collectMockModuleValue(node.whenTrue) collectMockModuleValue(node.whenFalse) return } if (ts.isAwaitExpression(node)) collectMockModuleValue(node.expression) } function collectConfiguredMockProviderValues(node: ts.Node) { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const receiver = unwrapExpression(node.expression.expression) const methodName = node.expression.name.text const receiverSymbol = ts.isIdentifier(receiver) ? checker.getSymbolAtLocation(receiver) : undefined if ( ts.isIdentifier(receiver) && MOCK_PROVIDER_METHODS.has(methodName) && receiverSymbol && mockProviderSymbols.has(receiverSymbol) ) { const value = node.arguments[0] if (value) collectMockProviderValue(value) } } ts.forEachChild(node, collectConfiguredMockProviderValues) } function visit(node: ts.Node) { if ( ts.isArrowFunction(node) && node.parameters.length === 1 && ts.isIdentifier(node.parameters[0]!.name) && ts.isElementAccessExpression(node.body) && isSelectorAccessRoot(node.body.expression, node.parameters[0]!.name.text) && ts.isStringLiteral(node.body.argumentExpression) ) { const argument = node.body.argumentExpression if (isSelectorPropertyName(argument.text)) { edits.push({ end: node.body.end, replacement: `${node.body.expression.getText(sourceFile)}.${argument.text}`, start: node.body.getStart(sourceFile), }) } else if (argument.getText(sourceFile).startsWith('"')) { edits.push({ end: argument.end, replacement: quoteSelectorKey(argument.text), start: argument.getStart(sourceFile), }) } } if (ts.isCallExpression(node) && isI18nMock(node)) { const factory = node.arguments[1] if (factory && (ts.isArrowFunction(factory) || ts.isFunctionExpression(factory))) { const previousMockFactory = currentMockFactory currentMockFactory = factory collectMockModuleValue(factory) currentMockFactory = previousMockFactory } else if (factory) { collectMockModuleValue(factory) } } if ( ts.isCallExpression(node) && isTranslationCall(node, checker, sourceFile) && node.arguments.length ) { const keyExpression = node.arguments[0]! as ts.Expression if (!isSelectorCompatibleExpression(keyExpression, checker, sourceFile)) { edits.push({ end: keyExpression.end, replacement: selectorFor(keyExpression, sourceFile), start: keyExpression.getStart(sourceFile), }) } const fallback = node.arguments[1] if (fallback && isStringExpression(fallback)) { const options = node.arguments[2] if (options && ts.isObjectLiteralExpression(unwrapExpression(options as ts.Expression))) { const optionsText = options.getText(sourceFile) const properties = optionsText.slice(1, -1).trim() edits.push({ end: options.end, replacement: `{ defaultValue: ${fallback.getText(sourceFile)}${properties ? `, ${properties}` : ''} }`, start: fallback.getStart(sourceFile), }) } else if (!options) { edits.push({ end: fallback.end, replacement: `{ defaultValue: ${fallback.getText(sourceFile)} }`, start: fallback.getStart(sourceFile), }) } } } if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { if (ts.isIdentifier(node.tagName) && isTransComponent(node.tagName, checker)) { const attribute = getJsxAttribute(node, 'i18nKey') const initializer = attribute?.initializer if (initializer && ts.isStringLiteral(initializer)) { edits.push({ end: initializer.end, replacement: `{${selectorFor(initializer, sourceFile)}}`, start: initializer.getStart(sourceFile), }) } else if (initializer && ts.isJsxExpression(initializer) && initializer.expression) { if (!isSelectorCompatibleExpression(initializer.expression, checker, sourceFile)) { edits.push({ end: initializer.expression.end, replacement: selectorFor(initializer.expression, sourceFile), start: initializer.expression.getStart(sourceFile), }) } } } } ts.forEachChild(node, visit) } visit(sourceFile) collectConfiguredMockProviderValues(sourceFile) for (const [factory, helperNames] of mockFactoryImports) { const helpers = Array.from(helperNames).sort() const body = factory.body const alreadyLoadsHelpers = ts.isBlock(body) && body.statements.some((statement) => { return ( ts.isVariableStatement(statement) && statement.getText(sourceFile).includes(`import('@/test/i18n-mock')`) ) }) if (alreadyLoadsHelpers) continue const isAsync = factory.modifiers?.some( (modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword, ) if (!isAsync) { edits.push({ end: factory.getStart(sourceFile), replacement: 'async ', start: factory.getStart(sourceFile), }) } const { character: factoryColumn } = sourceFile.getLineAndCharacterOfPosition( factory.getStart(sourceFile), ) const factoryLineStart = factory.getStart(sourceFile) - factoryColumn const leadingWhitespace = source.slice(factoryLineStart, factory.getStart(sourceFile)).match(/^\s*/)?.[0].length ?? 0 const bodyIndent = ' '.repeat(leadingWhitespace + 2) const closingIndent = ' '.repeat(leadingWhitespace) const helperImport = `const { ${helpers.join(', ')} } = await import('@/test/i18n-mock')` if (ts.isBlock(body)) { edits.push({ end: body.getStart(sourceFile) + 1, replacement: `\n${bodyIndent}${helperImport}`, start: body.getStart(sourceFile) + 1, }) } else { const bodyStart = body.getStart(sourceFile) const nestedEdits = edits.filter((edit) => edit.start >= bodyStart && edit.end <= body.end) const transformedBody = applyEdits( source.slice(bodyStart, body.end), nestedEdits.map((edit) => ({ ...edit, end: edit.end - bodyStart, start: edit.start - bodyStart, })), ) for (const edit of nestedEdits) consumedEdits.add(edit) const indentedBody = transformedBody .split('\n') .map((line, index) => (index === 0 || line.trim() === '' ? line.trimEnd() : ` ${line}`)) .join('\n') edits.push({ end: body.end, replacement: `{\n${bodyIndent}${helperImport}\n${bodyIndent}return ${indentedBody}\n${closingIndent}}`, start: bodyStart, }) } } if (neededSelectorMockImports.size) { const imports = sourceFile.statements.filter(ts.isImportDeclaration) const helperImport = imports.find((node) => { return ( ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === '@/test/i18n-mock' ) }) const namedBindings = helperImport?.importClause?.namedBindings const existingNames = namedBindings && ts.isNamedImports(namedBindings) ? namedBindings.elements.map((element) => element.name.text) : [] const missingNames = Array.from(neededSelectorMockImports).filter( (name) => !existingNames.includes(name), ) if (missingNames.length && namedBindings && ts.isNamedImports(namedBindings)) { edits.push({ end: namedBindings.end, replacement: `{ ${[...existingNames, ...missingNames].join(', ')} }`, start: namedBindings.getStart(sourceFile), }) } else if (missingNames.length) { const lastImport = imports.at(-1) const position = lastImport?.end ?? 0 const prefix = position ? '\n' : '' edits.push({ end: position, replacement: `${prefix}import { ${missingNames.join(', ')} } from '@/test/i18n-mock'`, start: position, }) } } return { changes: edits.length, output: applyEdits( source, edits.filter((edit) => !consumedEdits.has(edit)), ), } } async function listSourceFiles(root: string) { const files: string[] = [] async function walk(directory: string) { const entries = await fs.readdir(directory, { withFileTypes: true }) for (const entry of entries) { if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) continue const entryPath = path.join(directory, entry.name) if (entry.isDirectory()) await walk(entryPath) else if ( entry.isFile() && !SKIPPED_FILES.has(entry.name) && SOURCE_EXTENSIONS.has(path.extname(entry.name)) ) files.push(entryPath) } } await walk(root) return files.sort() } function createProjectProgram(root: string) { const configPath = ts.findConfigFile(root, ts.sys.fileExists, 'tsconfig.json') if (!configPath) return undefined const config = ts.readConfigFile(configPath, ts.sys.readFile) if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, '\n')) const parsed = ts.parseJsonConfigFileContent( config.config, ts.sys, path.dirname(configPath), { incremental: false, noEmit: true, }, configPath, ) if (parsed.errors.length) throw new Error(ts.flattenDiagnosticMessageText(parsed.errors[0]!.messageText, '\n')) return ts.createProgram({ options: parsed.options, rootNames: parsed.fileNames, }) } export async function migrateSelectors(root: string, write: boolean) { const files = await listSourceFiles(root) const projectProgram = createProjectProgram(root) const projectChecker = projectProgram?.getTypeChecker() const changedFilePaths: string[] = [] let changedFiles = 0 let changes = 0 for (const file of files) { const source = await fs.readFile(file, 'utf8') const projectSourceFile = projectProgram?.getSourceFile(path.resolve(file)) const result = projectSourceFile && projectChecker && projectSourceFile.text === source ? transformAnalyzedSource(source, projectSourceFile, projectChecker) : transformSource(source, file) if (!result.changes) continue changedFiles++ changedFilePaths.push(file) changes += result.changes if (write) await fs.writeFile(file, result.output, 'utf8') } return { changedFilePaths, changedFiles, changes } } async function runCli() { const write = process.argv.includes('--write') const verbose = process.argv.includes('--verbose') const unknownArgs = process.argv .slice(2) .filter((arg) => arg !== '--verbose' && arg !== '--write') if (unknownArgs.length) throw new Error(`Unknown arguments: ${unknownArgs.join(', ')}`) const result = await migrateSelectors(process.cwd(), write) const action = write ? 'Migrated' : 'Found' console.log( `${action} ${result.changes} i18n selector call sites across ${result.changedFiles} files.`, ) if (verbose) { for (const file of result.changedFilePaths) console.log(path.relative(process.cwd(), file)) } if (!write && result.changes) process.exitCode = 1 } const entryPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : '' if (import.meta.url === entryPath) { runCli().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)) process.exit(1) }) }