import path from 'path';
import { ClassDeclaration, Node, Project, PropertyAccessExpression, SourceFile, SyntaxKind } from 'ts-morph';
import {
ALL_MEMBER_NAMES,
FUNCTION_VALUED_MEMBERS,
MEMBERS_BY_SELECTOR,
TARGET_COMPONENTS,
TARGET_MODULE_PREFIXES,
TargetComponent,
} from './input-signal-targets';
import { ManualReviewItem, toSnippet } from './manual-review';
import { migrateTemplate, TemplateMemberSet } from './template-migration';
/**
* A re-export carrying a module specifier — `export * from '…'`, `export * as ns from '…'`,
* `export { X } from '…'`, `export type { X } from '…'`. Pre-filter only; the precise
* decision is made on the AST in {@link collectReExportReviewItems}.
*/
const RE_EXPORT_PATTERN = /\bexport\s+(?:type\s+)?(?:\*(?:\s+as\s+[$\w]+)?|\{[^}]*\})\s*from\s*['"]/;
const SIGNAL_METHODS = new Set(['set', 'update', 'asReadonly']);
const COMPOUND_ASSIGNMENT_OPERATORS = new Set<SyntaxKind>([
SyntaxKind.PlusEqualsToken,
SyntaxKind.MinusEqualsToken,
SyntaxKind.AsteriskEqualsToken,
SyntaxKind.SlashEqualsToken,
SyntaxKind.PercentEqualsToken,
SyntaxKind.AsteriskAsteriskEqualsToken,
SyntaxKind.AmpersandEqualsToken,
SyntaxKind.BarEqualsToken,
SyntaxKind.CaretEqualsToken,
SyntaxKind.LessThanLessThanEqualsToken,
SyntaxKind.GreaterThanGreaterThanEqualsToken,
SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
SyntaxKind.BarBarEqualsToken,
SyntaxKind.AmpersandAmpersandEqualsToken,
SyntaxKind.QuestionQuestionEqualsToken,
]);
type MemberAction =
| 'read'
| 'none'
| 'manual-readonly-write'
| 'manual-compound-assignment'
| 'manual-increment'
| 'manual-destructuring-write'
| 'manual-delete';
const MANUAL_REASONS: Record<string, string> = {
'manual-readonly-write': 'assignment to a signal input — signal inputs are read-only (no setter); resolve manually',
'manual-compound-assignment': 'compound assignment to a signal input — signal inputs are read-only; resolve manually',
'manual-increment': 'increment/decrement of a signal input — signal inputs are read-only; resolve manually',
'manual-destructuring-write': 'destructuring assignment into a signal input — signal inputs are read-only; resolve manually',
'manual-delete': 'delete of a signal input — signal inputs are read-only; resolve manually',
};
export interface SourceInput {
filePath: string;
content: string;
}
export interface FileChange {
filePath: string;
content: string;
}
export interface MigrationResult {
changes: FileChange[];
manualItems: ManualReviewItem[];
}
/**
* Rewrites reads of the inputs that changed from `@Input` to signal `input()`, so every read
* of a migrated member gains a `()`. Three consumer pathways are covered:
*
* - **subclass access** — `this.<member>` inside a class that extends a target, plus that
* class's own template (inline and external);
* - **instance access** — `ref.<member>` where `ref` is a variable, parameter or property
* whose declared type is a target component;
* - **template ref-var access** — `{{ err.message }}` where `err` is a reference variable
* bound to a target element, in any template.
*
* `tsSources` must contain every `.ts` file in scope and `htmlSources` every `.html` file:
* external templates are attributed to their component in a pre-pass over the TypeScript files.
*/
export function updateInputSignalReads(tsSources: SourceInput[], htmlSources: SourceInput[]): MigrationResult {
const changes: FileChange[] = [];
const manualItems: ManualReviewItem[] = [];
// No tsconfig: a migration runs against consumer code where the `@allianz/*` packages may
// not be resolvable, so every match is made on syntax (import specifier + type-annotation
// text) rather than through the type checker.
const project = new Project({ useInMemoryFileSystem: true });
const sourceFiles = new Map<string, SourceFile>();
const addSourceFile = (source: SourceInput): void => {
sourceFiles.set(source.filePath, project.createSourceFile(source.filePath, source.content, { overwrite: true }));
};
const gated = tsSources.filter((source) => mightBeAffected(source.content));
gated.forEach(addSourceFile);
// Widen by one hop: a class extending a *local* subclass of a target names neither the entry
// point nor the target class, so the filter above drops it — yet it is exactly the case that
// has to be reported (see indexExternalTemplates).
const directExtenders = collectDirectExtenders(sourceFiles);
if (directExtenders.size > 0) {
const extenderNames = [...directExtenders.keys()];
const gatedPaths = new Set(gated.map((source) => source.filePath));
for (const source of tsSources) {
if (gatedPaths.has(source.filePath)) continue;
if (extenderNames.some((name) => source.content.includes(name))) addSourceFile(source);
}
}
// Runs before the .html files below, so a subclass's `templateUrl` template can be migrated
// when its own file is visited.
const externalTemplates = indexExternalTemplates(sourceFiles, directExtenders, manualItems);
for (const [filePath, sourceFile] of sourceFiles) {
const original = sourceFile.getFullText();
processTypeScriptFile(sourceFile, filePath, manualItems);
const updated = sourceFile.getFullText();
if (updated !== original) {
changes.push({ filePath, content: updated });
}
}
// A template is migrated even when no component claims it: a ref-var read
// (`{{ err.message }}`) resolves from the template alone.
for (const source of htmlSources) {
const hostMembers: TemplateMemberSet = {
members: externalTemplates.get(normalizePath(source.filePath)) ?? new Set<string>(),
};
const { text, manualItems: templateManualItems } = migrateTemplate(source.content, hostMembers);
if (text !== null && text !== source.content) {
changes.push({ filePath: source.filePath, content: text });
}
for (const item of templateManualItems) {
manualItems.push({ file: source.filePath, line: item.line, snippet: item.snippet, reason: item.reason });
}
}
return { changes, manualItems };
}
/**
* A barrel is admitted via {@link RE_EXPORT_PATTERN} because `export *` names no target class
* of its own and would otherwise be filtered out.
*/
function mightBeAffected(fileContent: string): boolean {
if (mightContainTemplateRefVarRead(fileContent)) return true;
if (!TARGET_MODULE_PREFIXES.some((prefix) => fileContent.includes(prefix))) return false;
return TARGET_COMPONENTS.some((target) => fileContent.includes(target.className)) || RE_EXPORT_PATTERN.test(fileContent);
}
/**
* A file with such an inline template need not import anything from a target entry point — in
* an NgModule consumer the module is imported by the NgModule, not by the component using
* `<pfe-error-message #err>` — so the import-based filter above would drop it.
*/
function mightContainTemplateRefVarRead(fileContent: string): boolean {
const namesTargetSelector = [...MEMBERS_BY_SELECTOR.keys()].some((selector) => fileContent.includes(`<${selector}`));
return namesTargetSelector && [...ALL_MEMBER_NAMES].some((member) => fileContent.includes(member));
}
/** Registry of class name -> the target that class DIRECTLY extends. */
function collectDirectExtenders(sourceFiles: Map<string, SourceFile>): Map<string, TargetComponent> {
const directExtenders = new Map<string, TargetComponent>();
for (const sourceFile of sourceFiles.values()) {
const localNameToTarget = collectLocalTargetReferences(sourceFile);
if (localNameToTarget.size === 0) continue;
for (const classDecl of sourceFile.getClasses()) {
const target = getDirectlyExtendedTarget(classDecl, localNameToTarget);
const className = classDecl.getName();
if (target && className) directExtenders.set(className, target);
}
}
return directExtenders;
}
/**
* Maps each external template path onto the migrated members its component inherits from a
* target: unlike a ref-var read, such a template carries no clue in the HTML about its owner.
*
* Resolution is deliberately shallow — only a class that *directly* extends an import-gated
* target is indexed. A deeper chain is reported rather than guessed at, because following it
* would mean resolving base classes across files in a virtual tree where the `@allianz/*`
* packages are typically not resolvable.
*/
function indexExternalTemplates(
sourceFiles: Map<string, SourceFile>,
directExtenders: Map<string, TargetComponent>,
manualItems: ManualReviewItem[]
): Map<string, Set<string>> {
const owners = new Map<string, Set<string>>();
for (const [filePath, sourceFile] of sourceFiles) {
const localNameToTarget = collectLocalTargetReferences(sourceFile);
for (const classDecl of sourceFile.getClasses()) {
const directTarget = getDirectlyExtendedTarget(classDecl, localNameToTarget);
if (directTarget) {
indexDirectlyExtendingComponent(classDecl, directTarget, filePath, owners, manualItems);
continue;
}
const baseName = classDecl.getExtends()?.getExpression().getText();
const oneHopTarget = baseName ? directExtenders.get(baseName) : undefined;
if (oneHopTarget) {
manualItems.push({
file: filePath,
line: classDecl.getStartLineNumber(),
snippet: toSnippet(`class ${classDecl.getName() ?? '(anonymous)'} extends ${baseName}`),
reason:
`extends '${baseName}', which is itself a subclass of the migrated '${oneHopTarget.className}' — ` +
'multi-level subclasses are not migrated automatically; unwrap reads of ' +
`${oneHopTarget.members.join(', ')} in this class and its template manually`,
});
}
}
}
return owners;
}
function indexDirectlyExtendingComponent(
classDecl: ClassDeclaration,
target: TargetComponent,
filePath: string,
owners: Map<string, Set<string>>,
manualItems: ManualReviewItem[]
): void {
const decoratorArgument = getComponentDecoratorObject(classDecl);
if (!decoratorArgument) return;
const property = decoratorArgument.getProperty('templateUrl')?.asKind(SyntaxKind.PropertyAssignment);
// No external template — an inline `template:` is handled in the same .ts file.
if (!property) return;
const templateUrlLiteral = getStringLikeInitializer(decoratorArgument, 'templateUrl');
if (!templateUrlLiteral) {
manualItems.push({
file: filePath,
line: property.getStartLineNumber(),
snippet: toSnippet(property.getText()),
reason:
`component '${classDecl.getName() ?? '(anonymous)'}' extends the migrated '${target.className}' but its templateUrl is not a ` +
`static string literal, so its template was not migrated; unwrap reads of ${target.members.join(', ')} in it manually`,
});
return;
}
const templatePath = normalizePath(resolveTemplatePath(filePath, templateUrlLiteral.getLiteralText()));
const members = owners.get(templatePath) ?? new Set<string>();
resolveSubclassMembers(classDecl, target).forEach((member) => members.add(member));
owners.set(templatePath, members);
}
function getDirectlyExtendedTarget(
classDecl: ClassDeclaration,
localNameToTarget: Map<string, TargetComponent>
): TargetComponent | undefined {
const baseName = classDecl.getExtends()?.getExpression().getText();
return baseName ? localNameToTarget.get(baseName) : undefined;
}
function processTypeScriptFile(sourceFile: SourceFile, filePath: string, manualItems: ManualReviewItem[]): void {
// Before the import-based bail-out below, because a pure `export * from …` barrel binds no
// local name at all.
manualItems.push(...collectReExportReviewItems(sourceFile, filePath));
const localNameToTarget = collectLocalTargetReferences(sourceFile);
for (const classDecl of sourceFile.getClasses()) {
const target = getDirectlyExtendedTarget(classDecl, localNameToTarget);
if (target) processSubclass(classDecl, target, filePath, manualItems);
// Every inline template is migrated, with an empty host member set when the class is not a
// subclass: such a template can still hold a ref-var read. Gating it on subclass-ness would
// make the outcome depend on whether the author wrote `template:` or `templateUrl:`.
const hostMembers = target ? resolveSubclassMembers(classDecl, target) : new Set<string>();
processInlineTemplate(classDecl, hostMembers, filePath, manualItems);
}
if (localNameToTarget.size === 0) return;
processInstanceAccess(sourceFile, localNameToTarget, filePath, manualItems);
}
/**
* Maps every local name that refers to a target component onto that component. A namespace
* import is keyed on the *qualified* name (`ndbx.PfeErrorMessageComponent`), which is what
* keeps it from bleeding into a named import: an unrelated `other.PfeErrorMessageComponent`
* never matches, because only the exact `<namespace>.<className>` pair is registered.
*/
function collectLocalTargetReferences(sourceFile: SourceFile): Map<string, TargetComponent> {
const localNameToTarget = new Map<string, TargetComponent>();
for (const importDecl of sourceFile.getImportDeclarations()) {
const moduleValue = importDecl.getModuleSpecifierValue();
const targetsForModule = TARGET_COMPONENTS.filter((candidate) => candidate.module === moduleValue);
if (targetsForModule.length === 0) continue;
for (const namedImport of importDecl.getNamedImports()) {
const importedName = namedImport.getName();
const target = targetsForModule.find((candidate) => candidate.className === importedName);
if (!target) continue;
const localName = namedImport.getAliasNode()?.getText() ?? importedName;
localNameToTarget.set(localName, target);
}
const namespaceImport = importDecl.getNamespaceImport();
if (namespaceImport) {
const namespaceName = namespaceImport.getText();
for (const target of targetsForModule) {
localNameToTarget.set(`${namespaceName}.${target.className}`, target);
}
}
}
return localNameToTarget;
}
/**
* Reports every re-export of a target component, so the one shape this migration knowingly
* cannot follow is visible instead of silent.
*
* ```ts
* // local-barrel.ts
* export { PfeErrorMessageComponent } from '@allianz/ngx-pfe-ndbx';
* // consumer.ts — NOT migrated
* import { PfeErrorMessageComponent } from './local-barrel';
* ```
*
* {@link collectLocalTargetReferences} matches an import's module specifier against the entry
* point verbatim, so `'./local-barrel'` never matches, and following the chain across N hops
* is a much bigger surface than the case is worth. Reporting matters because an un-unwrapped
* read does not fail loudly: the member is now a getter function, so it silently evaluates as
* always-truthy. Only the barrel is reported — the set of files importing from it is unbounded.
*/
function collectReExportReviewItems(sourceFile: SourceFile, filePath: string): ManualReviewItem[] {
const items: ManualReviewItem[] = [];
for (const exportDecl of sourceFile.getExportDeclarations()) {
// A local `export { X }` has no module specifier.
const moduleValue = exportDecl.getModuleSpecifierValue();
if (moduleValue === undefined) continue;
const targetsForModule = TARGET_COMPONENTS.filter((candidate) => candidate.module === moduleValue);
if (targetsForModule.length === 0) continue;
const reExported = exportDecl.isNamespaceExport()
? targetsForModule
: targetsForModule.filter((candidate) =>
exportDecl.getNamedExports().some((namedExport) => namedExport.getName() === candidate.className)
);
if (reExported.length === 0) continue;
const classNames = reExported.map((target) => target.className).join(', ');
items.push({
file: filePath,
line: exportDecl.getStartLineNumber(),
snippet: toSnippet(exportDecl.getText()),
reason:
`re-exports migrated component(s) ${classNames} — files importing them through ` +
'this file (instead of directly from the entry point) were NOT migrated; review ' +
'them and unwrap the signal-input reads manually',
});
}
return items;
}
/**
* A member re-declared in the subclass shadows the base-class input — as a property, an
* accessor (`override get message()`) or a method, any of which returns something other than
* the base signal — so `this.member` must NOT be unwrapped and the name is filtered out.
*/
function resolveSubclassMembers(classDecl: ClassDeclaration, target: TargetComponent): Set<string> {
const ownMembers = new Set<string>([
...classDecl.getProperties().map((prop) => prop.getName()),
...classDecl.getGetAccessors().map((accessor) => accessor.getName()),
...classDecl.getSetAccessors().map((accessor) => accessor.getName()),
...classDecl.getMethods().map((method) => method.getName()),
]);
return new Set(target.members.filter((member) => !ownMembers.has(member)));
}
function processSubclass(classDecl: ClassDeclaration, target: TargetComponent, filePath: string, manualItems: ManualReviewItem[]): void {
const members = resolveSubclassMembers(classDecl, target);
if (members.size === 0) return;
transformAccesses(classDecl, (access) => isThisMemberAccess(access, members), filePath, manualItems);
}
function processInlineTemplate(classDecl: ClassDeclaration, members: Set<string>, filePath: string, manualItems: ManualReviewItem[]): void {
const decoratorArgument = getComponentDecoratorObject(classDecl);
if (!decoratorArgument) return;
const inlineLiteral = getStringLikeInitializer(decoratorArgument, 'template');
if (!inlineLiteral) return;
const original = inlineLiteral.getLiteralText();
const { text, manualItems: templateManualItems } = migrateTemplate(original, { members });
if (text !== null && text !== original) {
inlineLiteral.setLiteralValue(text);
}
// Map template-local lines onto the .ts file: the literal's content starts on the literal's
// own start line (line 1 of the template).
const baseLine = inlineLiteral.getStartLineNumber() - 1;
for (const item of templateManualItems) {
manualItems.push({ file: filePath, line: baseLine + item.line, snippet: item.snippet, reason: item.reason });
}
}
function getComponentDecoratorObject(classDecl: ClassDeclaration) {
const decorator = classDecl.getDecorator('Component');
const argument = decorator?.getArguments()[0];
return argument?.asKind(SyntaxKind.ObjectLiteralExpression);
}
/**
* A plain string or no-substitution template literal (both expose `getLiteralText` /
* `setLiteralValue`). Substitution template literals (`\`...${x}...\``) are skipped — an
* inline template that interpolates TS values cannot be rewritten as a single literal.
*/
function getStringLikeInitializer(objectLiteral: ReturnType<typeof getComponentDecoratorObject>, propertyName: string) {
const property = objectLiteral?.getProperty(propertyName)?.asKind(SyntaxKind.PropertyAssignment);
const initializer = property?.getInitializer();
if (!initializer) return undefined;
const stringLiteral = initializer.asKind(SyntaxKind.StringLiteral);
if (stringLiteral) return stringLiteral;
return initializer.asKind(SyntaxKind.NoSubstitutionTemplateLiteral);
}
function resolveTemplatePath(componentFilePath: string, templateUrl: string): string {
const directory = path.dirname(componentFilePath);
return path.join(directory, templateUrl).split(path.sep).join('/');
}
/** Normalizes a path so index keys and lookup keys compare equal. */
export function normalizePath(filePath: string): string {
return path.normalize(filePath).split(path.sep).join('/');
}
/**
* Rewrites `ref.<member>` accesses where `ref` is a variable, parameter or property typed as a
* target component. Matching is resolved per access against the receiver's *actual*
* declaration in scope (via its symbol), not by identifier name — so two references that share
* a name but are typed differently are handled independently.
*/
function processInstanceAccess(
sourceFile: SourceFile,
localNameToTarget: Map<string, TargetComponent>,
filePath: string,
manualItems: ManualReviewItem[]
): void {
const targetByDeclaration = new Map<Node, TargetComponent | null>();
const resolveTarget = (access: PropertyAccessExpression): TargetComponent | null => {
const declaration = getReceiverDeclaration(access);
if (!declaration) return null;
if (targetByDeclaration.has(declaration)) {
return targetByDeclaration.get(declaration) ?? null;
}
const target = resolveDeclarationTarget(declaration, localNameToTarget);
targetByDeclaration.set(declaration, target);
return target;
};
transformAccesses(
sourceFile,
(access) => {
const target = resolveTarget(access);
if (!target) return false;
return target.members.includes(access.getName());
},
filePath,
manualItems
);
}
/**
* Records the accesses that cannot be migrated automatically, then repeatedly unwraps the
* remaining reads (`x` -> `x()`), re-querying the AST after each mutation so no stale ts-morph
* node is touched.
*
* Termination relies on an unwrapped access classifying as `none` afterwards, which is why a
* function-valued member is handled by marking the *new* call node as visited rather than by
* re-classifying it: `this.nxNextAction()` is a legitimate migration target (it must become
* `this.nxNextAction()()`), so it can never be recognised as already-migrated without looping
* forever.
*/
function transformAccesses(
container: Node,
matches: (access: PropertyAccessExpression) => boolean,
filePath: string,
manualItems: ManualReviewItem[]
): void {
for (const access of container.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
if (!matches(access)) continue;
const action = classifyAccess(access);
if (action.startsWith('manual-')) {
manualItems.push({
file: filePath,
line: access.getStartLineNumber(),
snippet: toSnippet((access.getParent() ?? access).getText()),
reason: MANUAL_REASONS[action],
});
}
}
const migrated = new Set<number>();
for (;;) {
const nextAccess = container
.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)
.find((access) => !migrated.has(access.getStart()) && matches(access) && classifyAccess(access) === 'read');
if (!nextAccess) break;
// Record the offset *before* mutating: replaceWithText invalidates the node, and the
// replacement starts at the same offset as the original access.
migrated.add(nextAccess.getStart());
nextAccess.replaceWithText(`${nextAccess.getText()}()`);
}
}
function isThisMemberAccess(access: PropertyAccessExpression, members: Set<string>): boolean {
return access.getExpression().getKind() === SyntaxKind.ThisKeyword && members.has(access.getName());
}
function classifyAccess(access: PropertyAccessExpression): MemberAction {
// Already unwrapped: `this.x()`. A function-valued input is deliberately excluded — the old
// code already called it to invoke the callback (`this.nxNextAction()`), and after the
// migration reading the signal and invoking the value are two separate calls
// (`this.nxNextAction()()`). An existing `()` therefore does NOT mean "already migrated" for
// those members, which is why this migration is not idempotent and must be run only once.
const callParent = access.getParentIfKind(SyntaxKind.CallExpression);
if (callParent && callParent.getExpression() === access && !FUNCTION_VALUED_MEMBERS.has(access.getName())) {
return 'none';
}
// Defensive: signal inputs have no setter, so this only guards a hand-written `.asReadonly()`.
const propertyAccessParent = access.getParentIfKind(SyntaxKind.PropertyAccessExpression);
if (propertyAccessParent && propertyAccessParent.getExpression() === access && SIGNAL_METHODS.has(propertyAccessParent.getName())) {
return 'none';
}
const binaryParent = access.getParentIfKind(SyntaxKind.BinaryExpression);
if (binaryParent && binaryParent.getLeft() === access) {
const operator = binaryParent.getOperatorToken().getKind();
if (operator === SyntaxKind.EqualsToken) return 'manual-readonly-write';
if (COMPOUND_ASSIGNMENT_OPERATORS.has(operator)) return 'manual-compound-assignment';
}
if (isDestructuringAssignmentTarget(access)) return 'manual-destructuring-write';
if (access.getParentIfKind(SyntaxKind.DeleteExpression)) return 'manual-delete';
if (access.getParentIfKind(SyntaxKind.PostfixUnaryExpression)) return 'manual-increment';
const prefixParent = access.getParentIfKind(SyntaxKind.PrefixUnaryExpression);
if (prefixParent) {
const operator = prefixParent.getOperatorToken();
if (operator === SyntaxKind.PlusPlusToken || operator === SyntaxKind.MinusMinusToken) {
return 'manual-increment';
}
}
return 'read';
}
/**
* True when `access` is the assignment target of a destructuring pattern —
* `[this.x] = arr` or `({ a: this.x } = obj)`. Such an access sits (directly, or via nested
* patterns / a leading spread) inside an array/object literal that is the LHS of an `=`.
*/
function isDestructuringAssignmentTarget(access: PropertyAccessExpression): boolean {
let node: Node = access;
let parent = node.getParent();
while (parent) {
if (Node.isArrayLiteralExpression(parent) || Node.isObjectLiteralExpression(parent)) {
const literalParent = parent.getParent();
if (
literalParent &&
Node.isBinaryExpression(literalParent) &&
literalParent.getOperatorToken().getKind() === SyntaxKind.EqualsToken &&
literalParent.getLeft() === parent
) {
return true;
}
return false;
}
// Positions that keep us inside a destructuring pattern on the way up.
if (
Node.isPropertyAssignment(parent) ||
Node.isShorthandPropertyAssignment(parent) ||
Node.isSpreadAssignment(parent) ||
Node.isSpreadElement(parent) ||
Node.isBindingElement(parent)
) {
node = parent;
parent = node.getParent();
continue;
}
return false;
}
return false;
}
/**
* Resolves the declaration a receiver binds to — `foo.member` (the identifier `foo`) or
* `this.bar.member` (the `this.bar` property access); `undefined` for anything more complex.
* Resolving via the symbol rather than the identifier text is what makes the pass scope-aware.
*/
function getReceiverDeclaration(access: PropertyAccessExpression): Node | undefined {
const receiver = access.getExpression();
let nameNode: Node | undefined;
if (receiver.getKind() === SyntaxKind.Identifier) {
nameNode = receiver;
} else if (Node.isPropertyAccessExpression(receiver) && receiver.getExpression().getKind() === SyntaxKind.ThisKeyword) {
nameNode = receiver.getNameNode();
}
if (!nameNode) return undefined;
const declaration = nameNode.getSymbol()?.getDeclarations()?.[0];
if (!declaration) return undefined;
// Only bindings that carry an explicit type annotation can be matched textually.
if (Node.isParameterDeclaration(declaration) || Node.isVariableDeclaration(declaration) || Node.isPropertyDeclaration(declaration)) {
return declaration;
}
return undefined;
}
/**
* Resolves the target a declaration's *explicit type annotation* refers to. The annotation is
* decomposed structurally so any class-name constituent is considered:
*
* - `PfeErrorMessageComponent` — a plain type reference
* - `PfeErrorMessageComponent | null | undefined` — a union (each member checked)
* - `(PfeErrorMessageComponent | null)` — parenthesized (unwrapped)
* - `A & PfeErrorMessageComponent` — an intersection (each member checked)
* - `ndbx.PfeErrorMessageComponent` — qualified via a namespace import
*
* Types with no explicit annotation (inferred) cannot be matched textually and return `null`.
*/
function resolveDeclarationTarget(declaration: Node, localNameToTarget: Map<string, TargetComponent>): TargetComponent | null {
if (!Node.isParameterDeclaration(declaration) && !Node.isVariableDeclaration(declaration) && !Node.isPropertyDeclaration(declaration)) {
return null;
}
const typeNode = declaration.getTypeNode();
if (!typeNode) return null;
for (const name of collectTypeReferenceNames(typeNode)) {
const target = localNameToTarget.get(name);
if (target) return target;
}
return null;
}
/**
* A name is returned exactly as written, so a qualified `ns.Foo` stays qualified. It must NOT
* be reduced to its right-most segment: `other.PfeErrorMessageComponent`, from an unrelated
* module that happens to export the same class name, would then match a
* `PfeErrorMessageComponent` imported elsewhere in the file and be wrongly rewritten.
*/
function collectTypeReferenceNames(typeNode: Node): string[] {
if (Node.isParenthesizedTypeNode(typeNode)) {
return collectTypeReferenceNames(typeNode.getTypeNode());
}
if (Node.isUnionTypeNode(typeNode) || Node.isIntersectionTypeNode(typeNode)) {
return typeNode.getTypeNodes().flatMap((node) => collectTypeReferenceNames(node));
}
if (Node.isTypeReference(typeNode)) {
return [typeNode.getTypeName().getText()];
}
return [];
}