diff --git a/src/parsers/marker.parser.ts b/src/parsers/marker.parser.ts index 0d1e339..64dccfe 100644 --- a/src/parsers/marker.parser.ts +++ b/src/parsers/marker.parser.ts @@ -9,7 +9,7 @@ export class MarkerParser implements ParserInterface { public extract(source: string, filePath: string): TranslationCollection | null { const sourceFile = getAST(source, filePath); - const markerImportName = getNamedImportAlias(sourceFile, MARKER_MODULE_NAME, MARKER_IMPORT_NAME); + const markerImportName = getNamedImportAlias(sourceFile, MARKER_IMPORT_NAME, new RegExp(MARKER_MODULE_NAME)); if (!markerImportName) { return null; } diff --git a/src/parsers/service.parser.ts b/src/parsers/service.parser.ts index 349488f..20521df 100644 --- a/src/parsers/service.parser.ts +++ b/src/parsers/service.parser.ts @@ -19,7 +19,8 @@ import { getImportPath, findFunctionExpressions, findVariableNameByInjectType, - getAST + getAST, + getNamedImport } from '../utils/ast-helpers.js'; const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService'; @@ -86,17 +87,20 @@ export class ServiceParser implements ParserInterface { } private findParentClassProperties(classDeclaration: ClassDeclaration, ast: SourceFile): string[] { - const superClassName = getSuperClassName(classDeclaration); - if (!superClassName) { + const superClassNameOrAlias = getSuperClassName(classDeclaration); + if (!superClassNameOrAlias) { return []; } - const importPath = getImportPath(ast, superClassName); + + const importPath = getImportPath(ast, superClassNameOrAlias); if (!importPath) { // parent class must be in the same file and will be handled automatically, so we can // skip it here return []; } + // Resolve the actual name of the superclass from the named import + const superClassName = getNamedImport(ast, superClassNameOrAlias, importPath); const currDir = path.join(path.dirname(ast.fileName), '/'); const key = `${currDir}|${importPath}`; diff --git a/src/utils/ast-helpers.ts b/src/utils/ast-helpers.ts index 9df125f..0309f1f 100644 --- a/src/utils/ast-helpers.ts +++ b/src/utils/ast-helpers.ts @@ -2,7 +2,6 @@ import { extname } from 'node:path'; import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery'; import pkg, { Node, - NamedImports, Identifier, ClassDeclaration, ConstructorDeclaration, @@ -26,26 +25,47 @@ export function getAST(source: string, fileName = ''): SourceFile { 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); +/** + * Retrieves the identifiers for the given module name from import statements within the provided AST node. + */ +export function getNamedImportIdentifiers(node: Node, moduleName: string, importPath: string | RegExp): Identifier[] { + const importStringLiteralValue = importPath instanceof RegExp ? `value=${importPath.toString()}` : `value="${importPath}"`; + + const query = `ImportDeclaration:has(StringLiteral[${importStringLiteralValue}]) ImportSpecifier:has(Identifier[name="${moduleName}"]) > Identifier`; + + return tsquery(node, query); } -export function getNamedImportAlias(node: Node, moduleName: string, importName: string): string | null { - const [namedImportNode] = getNamedImports(node, moduleName); - if (!namedImportNode) { - return null; - } +/** + * Retrieves the original named import from a given node, import name, and import path. + * + * @example + * // Example import statement within a file + * import { Base as CoreBase } from './src/base'; + * + * getNamedImport(node, 'Base', './src/base') -> 'Base' + * getNamedImport(node, 'CoreBase', './src/base') -> 'Base' + */ +export function getNamedImport(node: Node, importName: string, importPath: string | RegExp): string | null { + const identifiers = getNamedImportIdentifiers(node, importName, importPath); - const query = `ImportSpecifier:has(Identifier[name="${importName}"]) > Identifier`; - const identifiers = tsquery(namedImportNode, query); - if (identifiers.length === 1) { - return identifiers[0].text; - } - if (identifiers.length > 1) { - return identifiers[identifiers.length - 1].text; - } - return null; + return identifiers.at(0)?.text ?? null; +} + +/** + * Retrieves the alias of the named import from a given node, import name, and import path. + * + * @example + * // Example import statement within a file + * import { Base as CoreBase } from './src/base'; + * + * getNamedImport(node, 'Base', './src/base') -> 'CoreBase' + * getNamedImport(node, 'CoreBase', './src/base') -> 'CoreBase' + */ +export function getNamedImportAlias(node: Node, importName: string, importPath: string | RegExp): string | null { + const identifiers = getNamedImportIdentifiers(node, importName, importPath); + + return identifiers.at(-1)?.text ?? null; } export function findClassDeclarations(node: Node, name: string = null): ClassDeclaration[] { diff --git a/tests/parsers/service.parser.spec.ts b/tests/parsers/service.parser.spec.ts index ea7ee3e..2b16239 100644 --- a/tests/parsers/service.parser.spec.ts +++ b/tests/parsers/service.parser.spec.ts @@ -435,6 +435,31 @@ describe('ServiceParser', () => { expect(keys).to.deep.equal(['test']); }); + it('should recognize the property from an aliased base class imported from a different file', () => { + const baseFileContent = ` + export abstract class Base { + protected translate: TranslateService; + } + `; + + const testFileContent = ` + import { Base as CoreBase } from './src/base'; + + export class Test extends CoreBase { + public constructor() { + super(); + this.translate.instant("test"); + } + } + `; + + fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'base.ts'), baseFileContent); + + const keys = parser.extract(testFileContent, path.join(tempDir, 'test.ts'))?.keys(); + expect(keys).to.deep.equal(['test']); + }); + it('should work with getters in base classes', () => { const file_contents_base = ` export abstract class Base { diff --git a/tests/utils/ast-helpers.spec.ts b/tests/utils/ast-helpers.spec.ts index 053214a..7b57519 100644 --- a/tests/utils/ast-helpers.spec.ts +++ b/tests/utils/ast-helpers.spec.ts @@ -2,7 +2,7 @@ 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'; +import { getAST, getNamedImport, getNamedImportAlias } from '../../src/utils/ast-helpers'; describe('getAST()', () => { const tsqueryAstSpy = vi.spyOn(tsquery, 'ast'); @@ -71,3 +71,99 @@ describe('getAST()', () => { expect(result.languageVariant).toBe(LanguageVariant.Standard); }); }); + +describe('getNamedImport()', () => { + describe('with a normal import', () => { + const node = tsquery.ast(` + import { Base } from './src/base'; + + export class Test extends CoreBase { + public constructor() { + super(); + this.translate.instant("test"); + } + } + `); + + it('should return the original class name when given exact import path', () => { + expect(getNamedImport(node, 'CoreBase', './src/base')).to.equal(null); + expect(getNamedImport(node, 'Base', './src/base')).to.equal('Base'); + }); + + it('should return the original class name when given a regex pattern for the import path', () => { + expect(getNamedImport(node, 'CoreBase', new RegExp('base'))).to.equal(null); + expect(getNamedImport(node, 'Base', new RegExp('base'))).to.equal('Base'); + }); + }); + + describe('with an aliased import', () => { + const node = tsquery.ast(` + import { Base as CoreBase } from './src/base'; + + export class Test extends CoreBase { + public constructor() { + super(); + this.translate.instant("test"); + } + } + `); + + it('should return the original class name when given an alias and exact import path', () => { + expect(getNamedImport(node, 'CoreBase', './src/base')).to.equal('Base'); + expect(getNamedImport(node, 'Base', './src/base')).to.equal('Base'); + }); + + it('should return the original class name when given an alias and a regex pattern for the import path', () => { + expect(getNamedImport(node, 'CoreBase', new RegExp('base'))).to.equal('Base'); + expect(getNamedImport(node, 'Base', new RegExp('base'))).to.equal('Base'); + }); + }); +}); + +describe('getNamedImportAlias()', () => { + describe('with a normal import', () => { + const node = tsquery.ast(` + import { Base } from './src/base'; + + export class Test extends CoreBase { + public constructor() { + super(); + this.translate.instant("test"); + } + } + `); + + it('should return the original class name when given exact import path', () => { + expect(getNamedImportAlias(node, 'CoreBase', './src/base')).to.equal(null); + expect(getNamedImportAlias(node, 'Base', './src/base')).to.equal('Base'); + }); + + it('should return the original class name when given a regex pattern for the import', () => { + expect(getNamedImportAlias(node, 'CoreBase', new RegExp('base'))).to.equal(null); + expect(getNamedImportAlias(node, 'Base', new RegExp('base'))).to.equal('Base'); + }); + }); + + describe('with an aliased import', () => { + const node = tsquery.ast(` + import { Base as CoreBase } from './src/base'; + + export class Test extends CoreBase { + public constructor() { + super(); + this.translate.instant("test"); + } + } + `); + + it('should return the aliased class name when given an alias and exact import path', () => { + expect(getNamedImportAlias(node, 'CoreBase', './src/base')).to.equal('CoreBase'); + expect(getNamedImportAlias(node, 'Base', './src/base')).to.equal('CoreBase'); + }); + + it('should return the aliased class name when given an alias and a regex pattern for the import path', () => { + expect(getNamedImportAlias(node, 'CoreBase', new RegExp('base'))).to.equal('CoreBase'); + expect(getNamedImportAlias(node, 'Base', new RegExp('base'))).to.equal('CoreBase'); + }); + }); +});