libs/ngx-pfe/schematics/ng-update/v51-input-signal-reads/update-input-signal-reads.ts
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 [];
}

results matching ""

    No results matching ""