Repository: vendure-ecommerce/ngx-translate-extract (ts)

Issue:
Parser not finding all strings
Not all translateService.instant translations are extracted

package.json
```
{
	"name": "translations",
	"version": "1.0.0",
	"description": "Translations for the interface",
	"scripts": {
		"scan-translations": "ngx-translate-extract --input ./api_translations ./interface_instance/src --format pot --output ./found-translations.pot",
		"test": "echo \"Error: no test specified\" && exit 1"
	},
	"author": "",
	"license": "ISC",
	"devDependencies": {
		"@angular/compiler": "17.3.11",
		"@types/node": "20.14.9",
		"@vendure/ngx-translate-extract": "9.2.0",
		"braces": "3.0.3",
		"ts-node": "10.9.2",
		"typescript": "5.5.3"
	}
}
```

media.service.ts
```
import {HttpClient} from '@angular/common/http';
import {Injectable} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {ApprovalStatusEnum} from 'src/app/admin/data/backend/media/approval-status.enum';
import {ApprovalStatusInterface} from 'src/app/admin/data/backend/media/approval-status.interface';
import {MethodEnum} from 'src/app/admin/data/backend/media/method.enum';
import {MethodInterface} from 'src/app/admin/data/backend/media/method.interface';
import {TypeEnum as BadgeTypeEnum} from 'src/app/core/components/badge/type.enum';
import {MediaService as CoreMediaService} from 'src/app/core/data/backend/media/media.service';
import {CacheService} from 'src/app/core/data/cache/cache.service';

@Injectable({providedIn: 'root'})
export class MediaService extends CoreMediaService {
	constructor(
		protected cacheService: CacheService,
		protected httpClient: HttpClient,
		translateService: TranslateService
	) {
		super(translateService);
	}

	public getApprovalStatuses(): Record<ApprovalStatusEnum, ApprovalStatusInterface> {
		return {
			[ApprovalStatusEnum.APPROVED]: <ApprovalStatusInterface>{
				badgeType: BadgeTypeEnum.SUCCESS,
				value: ApprovalStatusEnum.APPROVED,
				label: this.translateService.instant('media_approval_status_approved')
			},
			[ApprovalStatusEnum.DISAPPROVED]: <ApprovalStatusInterface>{
				badgeType: BadgeTypeEnum.DANGER,
				value: ApprovalStatusEnum.DISAPPROVED,
				label: this.translateService.instant('media_approval_status_disapproved')
			},
			[ApprovalStatusEnum.OPEN]: <ApprovalStatusInterface>{
				badgeType: BadgeTypeEnum.WARNING,
				value: ApprovalStatusEnum.OPEN,
				label: this.translateService.instant('media_approval_status_open')
			},
			[ApprovalStatusEnum.PENDING]: <ApprovalStatusInterface>{
				badgeType: BadgeTypeEnum.WARNING,
				value: ApprovalStatusEnum.PENDING,
				label: this.translateService.instant('media_approval_status_pending')
			},
		};
	}

	public getVerificationMethods(): Record<MethodEnum, MethodInterface> {
		return {
			[MethodEnum.ADMIN]: <MethodInterface>{
				value: MethodEnum.ADMIN,
				label: this.translateService.instant('media_verification_method_admin'),
			},
			[MethodEnum.DNS]: <MethodInterface>{
				value: MethodEnum.DNS,
				label: this.translateService.instant('media_verification_method_dns'),
			},
			[MethodEnum.METATAG]: <MethodInterface>{
				value: MethodEnum.METATAG,
				label: this.translateService.instant('media_verification_method_metatag'),
			},
			[MethodEnum.PAGE]: <MethodInterface>{
				value: MethodEnum.PAGE,
				label: this.translateService.instant('media_verification_method_page'),
			},
			[MethodEnum.NONE]: <MethodInterface>{
				value: MethodEnum.NONE,
				label: this.translateService.instant('media_verification_method_none'),
			},
		};
	}
}
```

core/media.service.ts
```
import {Injectable} from '@angular/core';
import {TranslateService} from '@ngx-translate/core';
import {TypeEnum as BadgeTypeEnum} from 'src/app/core/components/badge/type.enum';
import {TypeGroupEnum} from 'src/app/core/data/backend/media/type-group.enum';
import {TypeGroupInterface} from 'src/app/core/data/backend/media/type-group.interface';

@Injectable()
export class MediaService {
	constructor(
		protected translateService: TranslateService
	) {}

	public getMediaTypeGroups(): Record<TypeGroupEnum, TypeGroupInterface> {
		return <Record<TypeGroupEnum, TypeGroupInterface>>{
			[TypeGroupEnum.GROUPA]: <TypeGroupInterface>{
				badgeType: BadgeTypeEnum.CYAN,
				id: TypeGroupEnum.GROUP_A,
				label: this.translateService.instant('media_type_group_a')
			},
			[TypeGroupEnum.GROUPB]: <TypeGroupInterface>{
				badgeType: BadgeTypeEnum.LIGHT_BROWN,
				id: TypeGroupEnum.GROUP_B,
				label: this.translateService.instant('media_type_group_b')
			},
		};
	}
}
```

command
```
npx ngx-translate-extract --input interface_instance/src/app/admin/data/backend/media/media.service.ts --format pot --output test.pot
```

result:
```
Enabled parsers:
- PipeParser
- DirectiveParser
- ServiceParser
- MarkerParser

Enabled post processors:
(none)

Compiler:
- PoCompiler

Extracting:
- /home/samantha/vhosts/translations/interface_instance/src/app/admin/data/backend/media/media.service.ts

Found 0 strings.

Saving:
- /home/samantha/vhosts/translations/test.txt [CREATED]

Done.
```

Pull request:
fix: Fix service parser to recognize the `TranslateService` property from an aliased superclass
Fixes #52

Patch:
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<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[] {


Test patch:
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');
+		});
+	});
+});