diff --git a/src/parsers/function.parser.ts b/src/parsers/function.parser.ts index cfee515..85b2c52 100644 --- a/src/parsers/function.parser.ts +++ b/src/parsers/function.parser.ts @@ -1,8 +1,6 @@ -import { tsquery } from '@phenomnomnominal/tsquery'; - import { ParserInterface } from './parser.interface.js'; import { TranslationCollection } from '../utils/translation.collection.js'; -import { getStringsFromExpression, findSimpleCallExpressions } from '../utils/ast-helpers.js'; +import { getStringsFromExpression, findSimpleCallExpressions, getAST } from '../utils/ast-helpers.js'; import pkg from 'typescript'; const { isIdentifier } = pkg; @@ -10,7 +8,7 @@ export class FunctionParser implements ParserInterface { constructor(private fnName: string) {} public extract(source: string, filePath: string): TranslationCollection | null { - const sourceFile = tsquery.ast(source, filePath); + const sourceFile = getAST(source, filePath); let collection: TranslationCollection = new TranslationCollection(); diff --git a/src/parsers/marker.parser.ts b/src/parsers/marker.parser.ts index c9812da..0d1e339 100644 --- a/src/parsers/marker.parser.ts +++ b/src/parsers/marker.parser.ts @@ -1,25 +1,13 @@ -import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery'; -import { extname } from 'path'; - import { ParserInterface } from './parser.interface.js'; import { TranslationCollection } from '../utils/translation.collection.js'; -import { getNamedImportAlias, findFunctionCallExpressions, getStringsFromExpression } from '../utils/ast-helpers.js'; +import { getNamedImportAlias, findFunctionCallExpressions, getStringsFromExpression, getAST } from '../utils/ast-helpers.js'; const MARKER_MODULE_NAME = 'ngx-translate-extract-marker'; const MARKER_IMPORT_NAME = 'marker'; export class MarkerParser implements ParserInterface { public extract(source: string, filePath: string): TranslationCollection | null { - const supportedScriptTypes: Record = { - '.js': ScriptKind.JS, - '.jsx': ScriptKind.JSX, - '.ts': ScriptKind.TS, - '.tsx': ScriptKind.TSX - }; - - const scriptKind = supportedScriptTypes[extname(filePath)] ?? ScriptKind.TS; - - const sourceFile = tsquery.ast(source, filePath, scriptKind); + const sourceFile = getAST(source, filePath); const markerImportName = getNamedImportAlias(sourceFile, MARKER_MODULE_NAME, MARKER_IMPORT_NAME); if (!markerImportName) { diff --git a/src/parsers/service.parser.ts b/src/parsers/service.parser.ts index d2cbb21..349488f 100644 --- a/src/parsers/service.parser.ts +++ b/src/parsers/service.parser.ts @@ -4,7 +4,6 @@ import fs from 'node:fs'; import { ClassDeclaration, CallExpression, SourceFile } from 'typescript'; import { resolveSync } from 'tsconfig'; import JSON5 from 'json5'; -import { tsquery } from '@phenomnomnominal/tsquery'; import { ParserInterface } from './parser.interface.js'; import { TranslationCollection } from '../utils/translation.collection.js'; @@ -19,7 +18,8 @@ import { getSuperClassName, getImportPath, findFunctionExpressions, - findVariableNameByInjectType + findVariableNameByInjectType, + getAST } from '../utils/ast-helpers.js'; const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService'; @@ -29,7 +29,7 @@ export class ServiceParser implements ParserInterface { private static propertyMap = new Map(); public extract(source: string, filePath: string): TranslationCollection | null { - const sourceFile = tsquery.ast(source, filePath); + const sourceFile = getAST(source, filePath); const classDeclarations = findClassDeclarations(sourceFile); const functionDeclarations = findFunctionExpressions(sourceFile); @@ -141,7 +141,7 @@ export class ServiceParser implements ParserInterface { const allSuperClassPropertyNames: string[] = []; potentialSuperFiles.forEach((file) => { const superClassFileContent = fs.readFileSync(file, 'utf8'); - const superClassAst = tsquery.ast(superClassFileContent, file); + const superClassAst = getAST(superClassFileContent, file); const superClassDeclarations = findClassDeclarations(superClassAst, superClassName); const superClassPropertyNames = superClassDeclarations .flatMap((superClassDeclaration) => findClassPropertiesByType(superClassDeclaration, TRANSLATE_SERVICE_TYPE_REFERENCE)); diff --git a/src/utils/ast-helpers.ts b/src/utils/ast-helpers.ts index 6c9e6ee..9df125f 100644 --- a/src/utils/ast-helpers.ts +++ b/src/utils/ast-helpers.ts @@ -1,4 +1,5 @@ -import { tsquery } from '@phenomnomnominal/tsquery'; +import { extname } from 'node:path'; +import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery'; import pkg, { Node, NamedImports, @@ -7,11 +8,24 @@ import pkg, { ConstructorDeclaration, CallExpression, Expression, - PropertyAccessExpression, - StringLiteral + StringLiteral, + SourceFile } from 'typescript'; const { SyntaxKind, isStringLiteralLike, isArrayLiteralExpression, isBinaryExpression, isConditionalExpression } = pkg; +export function getAST(source: string, fileName = ''): SourceFile { + const supportedScriptTypes: Record = { + '.js': ScriptKind.JS, + '.jsx': ScriptKind.JSX, + '.ts': ScriptKind.TS, + '.tsx': ScriptKind.TSX + }; + + const scriptKind = supportedScriptTypes[extname(fileName)] ?? ScriptKind.TS; + + return tsquery.ast(source, fileName, scriptKind); +} + export function getNamedImports(node: Node, moduleName: string): NamedImports[] { const query = `ImportDeclaration[moduleSpecifier.text=/${moduleName}/] NamedImports`; return tsquery(node, query); diff --git a/tests/parsers/function.parser.spec.ts b/tests/parsers/function.parser.spec.ts index eb4bc66..c87050f 100644 --- a/tests/parsers/function.parser.spec.ts +++ b/tests/parsers/function.parser.spec.ts @@ -58,4 +58,20 @@ describe('FunctionParser', () => { expect(keys).to.deep.equal(['DYNAMIC_TRAD.val1', 'DYNAMIC_TRAD.val2']); }); + it('should not break after bracket syntax casting', () => { + const contents = ` + export class AppModule { + constructor() { + const input: unknown = 'hello'; + const myNiceVar1 = input as string; + MK('hello.after.as.syntax'); + + const myNiceVar2 = input; + MK('hello.after.bracket.syntax'); + } + } + `; + const keys = parser.extract(contents, componentFilename).keys(); + expect(keys).to.deep.equal([ 'hello.after.as.syntax', 'hello.after.bracket.syntax']); + }); }); diff --git a/tests/parsers/service.parser.spec.ts b/tests/parsers/service.parser.spec.ts index 66bcc86..ea7ee3e 100644 --- a/tests/parsers/service.parser.spec.ts +++ b/tests/parsers/service.parser.spec.ts @@ -548,6 +548,29 @@ describe('ServiceParser', () => { expect(keys).to.deep.equal([]); }); + it('should not break after bracket syntax casting', () => { + const contents = ` + @Component({ }) + export class AppComponent { + @Input() + set color(value: unknown) { + const newValue = value; + this._color = value; + + this._translateService.instant('hello.from.input.setter'); + } + _color: unknown; + + constructor(protected _translateService: TranslateService) { } + + method() { + this._translateService.instant('hello.from.method'); + } + `; + const keys = parser.extract(contents, componentFilename)?.keys(); + expect(keys).to.deep.equal(['hello.from.input.setter', 'hello.from.method']); + }); + describe('function expressions', () => { it('should extract from arrow function expression', () => { const contents = ` diff --git a/tests/utils/ast-helpers.spec.ts b/tests/utils/ast-helpers.spec.ts new file mode 100644 index 0000000..053214a --- /dev/null +++ b/tests/utils/ast-helpers.spec.ts @@ -0,0 +1,73 @@ +import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { LanguageVariant } from 'typescript'; + +import { getAST } from '../../src/utils/ast-helpers'; + +describe('getAST()', () => { + const tsqueryAstSpy = vi.spyOn(tsquery, 'ast'); + + beforeEach(() => { + tsqueryAstSpy.mockClear(); + }); + + it('should return the AST for a TypeScript source with a .ts file extension', () => { + const source = 'const x: number = 42;'; + const fileName = 'example.ts'; + + const result = getAST(source, fileName); + + expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TS); + expect(result.languageVariant).toBe(LanguageVariant.Standard); + }); + + it('should return the AST for a TypeScript source with a .tsx file extension', () => { + const source = 'const x: number = 42;'; + const fileName = 'example.tsx'; + + const result = getAST(source, fileName); + + expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TSX); + expect(result.languageVariant).toBe(LanguageVariant.JSX); + }); + + it('should return the AST for a JavaScript source with a .js file extension', () => { + const source = 'const x = 42;'; + const fileName = 'example.js'; + + const result = getAST(source, fileName); + + expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.JS); + // JS files also return JSX language variant. + expect(result.languageVariant).toBe(LanguageVariant.JSX); + }); + + it('should return the AST for a JavaScript source with a .jsx file extension', () => { + const source = 'const x = 42;'; + const fileName = 'example.jsx'; + + const result = getAST(source, fileName); + + expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.JSX); + expect(result.languageVariant).toBe(LanguageVariant.JSX); + }); + + it('should use ScriptKind.TS if the file extension is unsupported', () => { + const source = 'const x: number = 42;'; + const fileName = 'example.unknown'; + + const result = getAST(source, fileName); + + expect(tsqueryAstSpy).toHaveBeenCalledWith(source, fileName, ScriptKind.TS); + expect(result.languageVariant).toBe(LanguageVariant.Standard); + }); + + it('should use ScriptKind.TS if no file name is provided', () => { + const source = 'const x: number = 42;'; + + const result = getAST(source); + + expect(tsqueryAstSpy).toHaveBeenCalledWith(source, '', ScriptKind.TS); + expect(result.languageVariant).toBe(LanguageVariant.Standard); + }); +});