Skip to content

Commit

Permalink
fix: Fix service parser to recognize the TranslateService property …
Browse files Browse the repository at this point in the history
…from an aliased superclass

Fixes #52
  • Loading branch information
pmpak committed Jul 18, 2024
1 parent bfcdacb commit e0a5315
Show file tree
Hide file tree
Showing 5 changed files with 169 additions and 24 deletions.
2 changes: 1 addition & 1 deletion src/parsers/marker.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
12 changes: 8 additions & 4 deletions src/parsers/service.parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
getImportPath,
findFunctionExpressions,
findVariableNameByInjectType,
getAST
getAST,
getNamedImport
} from '../utils/ast-helpers.js';

const TRANSLATE_SERVICE_TYPE_REFERENCE = 'TranslateService';
Expand Down Expand Up @@ -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}`;
Expand Down
56 changes: 38 additions & 18 deletions src/utils/ast-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { extname } from 'node:path';
import { ScriptKind, tsquery } from '@phenomnomnominal/tsquery';
import pkg, {
Node,
NamedImports,
Identifier,
ClassDeclaration,
ConstructorDeclaration,
Expand All @@ -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<NamedImports>(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<Identifier>(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<Identifier>(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[] {
Expand Down
25 changes: 25 additions & 0 deletions tests/parsers/service.parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
98 changes: 97 additions & 1 deletion tests/utils/ast-helpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
});
});
});

0 comments on commit e0a5315

Please sign in to comment.