angular/packages/language-service/src/ts_utils.ts
Joey Perrott ca517d7f2c refactor: migrate language-service to prettier formatting (#55405)
Migrate formatting to prettier for language-service from clang-format

PR Close #55405
2024-04-18 14:18:38 -07:00

561 lines
18 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {PotentialImport, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import ts from 'typescript';
/**
* Return the node that most tightly encompasses the specified `position`.
* @param node The starting node to start the top-down search.
* @param position The target position within the `node`.
*/
export function findTightestNode(node: ts.Node, position: number): ts.Node | undefined {
if (node.getStart() <= position && position < node.getEnd()) {
return node.forEachChild((c) => findTightestNode(c, position)) ?? node;
}
return undefined;
}
export interface FindOptions<T extends ts.Node> {
filter: (node: ts.Node) => node is T;
}
/**
* Finds TypeScript nodes descending from the provided root which match the given filter.
*/
export function findAllMatchingNodes<T extends ts.Node>(root: ts.Node, opts: FindOptions<T>): T[] {
const matches: T[] = [];
const explore = (currNode: ts.Node) => {
if (opts.filter(currNode)) {
matches.push(currNode);
}
currNode.forEachChild((descendent) => explore(descendent));
};
explore(root);
return matches;
}
/**
* Finds TypeScript nodes descending from the provided root which match the given filter.
*/
export function findFirstMatchingNode<T extends ts.Node>(
root: ts.Node,
opts: FindOptions<T>,
): T | null {
let match: T | null = null;
const explore = (currNode: ts.Node) => {
if (match !== null) {
return;
}
if (opts.filter(currNode)) {
match = currNode;
return;
}
currNode.forEachChild((descendent) => explore(descendent));
};
explore(root);
return match;
}
export function getParentClassDeclaration(startNode: ts.Node): ts.ClassDeclaration | undefined {
while (startNode) {
if (ts.isClassDeclaration(startNode)) {
return startNode;
}
startNode = startNode.parent;
}
return undefined;
}
/**
* Returns a property assignment from the assignment value if the property name
* matches the specified `key`, or `null` if there is no match.
*/
export function getPropertyAssignmentFromValue(
value: ts.Node,
key: string,
): ts.PropertyAssignment | null {
const propAssignment = value.parent;
if (
!propAssignment ||
!ts.isPropertyAssignment(propAssignment) ||
propAssignment.name.getText() !== key
) {
return null;
}
return propAssignment;
}
/**
* Given a decorator property assignment, return the ClassDeclaration node that corresponds to the
* directive class the property applies to.
* If the property assignment is not on a class decorator, no declaration is returned.
*
* For example,
*
* @Component({
* template: '<div></div>'
* ^^^^^^^^^^^^^^^^^^^^^^^---- property assignment
* })
* class AppComponent {}
* ^---- class declaration node
*
* @param propAsgnNode property assignment
*/
export function getClassDeclFromDecoratorProp(
propAsgnNode: ts.PropertyAssignment,
): ts.ClassDeclaration | undefined {
if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) {
return;
}
const objLitExprNode = propAsgnNode.parent;
if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) {
return;
}
const callExprNode = objLitExprNode.parent;
if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) {
return;
}
const decorator = callExprNode.parent;
if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) {
return;
}
const classDeclNode = decorator.parent;
return classDeclNode;
}
/**
* Collects all member methods, including those from base classes.
*/
export function collectMemberMethods(
clazz: ts.ClassDeclaration,
typeChecker: ts.TypeChecker,
): ts.MethodDeclaration[] {
const members: ts.MethodDeclaration[] = [];
const apparentProps = typeChecker.getTypeAtLocation(clazz).getApparentProperties();
for (const prop of apparentProps) {
if (prop.valueDeclaration && ts.isMethodDeclaration(prop.valueDeclaration)) {
members.push(prop.valueDeclaration);
}
}
return members;
}
/**
* Given an existing array literal expression, update it by pushing a new expression.
*/
export function addElementToArrayLiteral(
arr: ts.ArrayLiteralExpression,
elem: ts.Expression,
): ts.ArrayLiteralExpression {
return ts.factory.updateArrayLiteralExpression(arr, [...arr.elements, elem]);
}
/**
* Given an ObjectLiteralExpression node, extract and return the PropertyAssignment corresponding to
* the given key. `null` if no such key exists.
*/
export function objectPropertyAssignmentForKey(
obj: ts.ObjectLiteralExpression,
key: string,
): ts.PropertyAssignment | null {
const matchingProperty = obj.properties.filter(
(a) => a.name !== undefined && ts.isIdentifier(a.name) && a.name.escapedText === key,
)[0];
return matchingProperty && ts.isPropertyAssignment(matchingProperty) ? matchingProperty : null;
}
/**
* Given an ObjectLiteralExpression node, create or update the specified key, using the provided
* callback to generate the new value (possibly based on an old value).
*/
export function updateObjectValueForKey(
obj: ts.ObjectLiteralExpression,
key: string,
newValueFn: (oldValue?: ts.Expression) => ts.Expression,
): ts.ObjectLiteralExpression {
const existingProp = objectPropertyAssignmentForKey(obj, key);
const newProp = ts.factory.createPropertyAssignment(
ts.factory.createIdentifier(key),
newValueFn(existingProp?.initializer),
);
return ts.factory.updateObjectLiteralExpression(obj, [
...obj.properties.filter((p) => p !== existingProp),
newProp,
]);
}
/**
* Create a new ArrayLiteralExpression, or accept an existing one.
* Ensure the array contains the provided identifier.
* Returns the array, either updated or newly created.
* If no update is needed, returns `null`.
*/
export function ensureArrayWithIdentifier(
identifierText: string,
expression: ts.Expression,
arr?: ts.ArrayLiteralExpression,
): ts.ArrayLiteralExpression | null {
if (arr === undefined) {
return ts.factory.createArrayLiteralExpression([expression]);
}
if (arr.elements.find((v) => ts.isIdentifier(v) && v.text === identifierText)) {
return null;
}
return ts.factory.updateArrayLiteralExpression(arr, [...arr.elements, expression]);
}
export function moduleSpecifierPointsToFile(
tsChecker: ts.TypeChecker,
moduleSpecifier: ts.Expression,
file: ts.SourceFile,
): boolean {
const specifierSymbol = tsChecker.getSymbolAtLocation(moduleSpecifier);
if (specifierSymbol === undefined) {
console.error(`Undefined symbol for module specifier ${moduleSpecifier.getText()}`);
return false;
}
const symbolDeclarations = specifierSymbol.declarations;
if (symbolDeclarations === undefined || symbolDeclarations.length === 0) {
console.error(`Unknown symbol declarations for module specifier ${moduleSpecifier.getText()}`);
return false;
}
for (const symbolDeclaration of symbolDeclarations) {
if (symbolDeclaration.getSourceFile().fileName === file.fileName) {
return true;
}
}
return false;
}
/**
* Determine whether this an import of the given `propertyName` from a particular module
* specifier already exists. If so, return the local name for that import, which might be an
* alias.
*/
export function hasImport(
tsChecker: ts.TypeChecker,
importDeclarations: ts.ImportDeclaration[],
propName: string,
origin: ts.SourceFile,
): string | null {
return (
importDeclarations
.filter((declaration) =>
moduleSpecifierPointsToFile(tsChecker, declaration.moduleSpecifier, origin),
)
.map((declaration) => importHas(declaration, propName))
.find((prop) => prop !== null) ?? null
);
}
function nameInExportScope(importSpecifier: ts.ImportSpecifier): string {
return importSpecifier.propertyName?.text ?? importSpecifier.name.text;
}
/**
* Determine whether this import declaration already contains an import of the given
* `propertyName`, and if so, the name it can be referred to with in the local scope.
*/
function importHas(importDecl: ts.ImportDeclaration, propName: string): string | null {
const bindings = importDecl.importClause?.namedBindings;
if (bindings === undefined) {
return null;
}
// First, we handle the case of explicit named imports.
if (ts.isNamedImports(bindings)) {
// Find any import specifier whose property name in the *export* scope equals the expected
// name.
const specifier = bindings.elements.find(
(importSpecifier) => propName == nameInExportScope(importSpecifier),
);
// Return the name of the property in the *local* scope.
if (specifier === undefined) {
return null;
}
return specifier.name.text;
}
// The other case is a namespace import.
return `${bindings.name.text}.${propName}`;
}
/**
* Given an unqualified name, determine whether an existing import is already using this name in
* the current scope.
* TODO: It would be better to check if *any* symbol uses this name in the current scope.
*/
function importCollisionExists(importDeclaration: ts.ImportDeclaration[], name: string): boolean {
const bindings = importDeclaration.map((declaration) => declaration.importClause?.namedBindings);
const namedBindings: ts.NamedImports[] = bindings.filter(
(binding) => binding !== undefined && ts.isNamedImports(binding),
) as ts.NamedImports[];
const specifiers = namedBindings.flatMap((b) => b.elements);
return specifiers.some((s) => s.name.text === name);
}
/**
* Generator function that yields an infinite sequence of alternative aliases for a given symbol
* name.
*/
function* suggestAlternativeSymbolNames(name: string): Iterator<string> {
for (let i = 1; true; i++) {
yield `${name}_${i}`; // The _n suffix is the same style as TS generated aliases
}
}
/**
* Transform the given import name into an alias that does not collide with any other import
* symbol.
*/
export function nonCollidingImportName(
importDeclarations: ts.ImportDeclaration[],
name: string,
): string {
const possibleNames = suggestAlternativeSymbolNames(name);
while (importCollisionExists(importDeclarations, name)) {
name = possibleNames.next().value;
}
return name;
}
/**
* If the provided trait is standalone, just return it. Otherwise, returns the owning ngModule.
*/
export function standaloneTraitOrNgModule(
checker: TemplateTypeChecker,
trait: ts.ClassDeclaration,
): ts.ClassDeclaration | null {
const componentDecorator = checker.getPrimaryAngularDecorator(trait);
if (componentDecorator == null) {
return null;
}
const owningNgModule = checker.getOwningNgModule(trait);
const isMarkedStandalone = isStandaloneDecorator(componentDecorator);
if (owningNgModule === null && !isMarkedStandalone) {
// TODO(dylhunn): This is a "moduleless component." We should probably suggest the user add
// `standalone: true`.
return null;
}
return owningNgModule ?? trait;
}
/**
* Updates the imports on a TypeScript file, by ensuring the provided import is present.
* Returns the text changes, as well as the name with which the imported symbol can be referred to.
*/
export function updateImportsForTypescriptFile(
tsChecker: ts.TypeChecker,
file: ts.SourceFile,
symbolName: string,
moduleSpecifier: string,
tsFileToImport: ts.SourceFile,
): [ts.TextChange[], string] {
// The trait might already be imported, possibly under a different name. If so, determine the
// local name of the imported trait.
const allImports = findAllMatchingNodes(file, {filter: ts.isImportDeclaration});
const existingImportName: string | null = hasImport(
tsChecker,
allImports,
symbolName,
tsFileToImport,
);
if (existingImportName !== null) {
return [[], existingImportName];
}
// If the trait has not already been imported, we need to insert the new import.
const existingImportDeclaration = allImports.find((decl) =>
moduleSpecifierPointsToFile(tsChecker, decl.moduleSpecifier, tsFileToImport),
);
const importName = nonCollidingImportName(allImports, symbolName);
if (existingImportDeclaration !== undefined) {
// Update an existing import declaration.
const bindings = existingImportDeclaration.importClause?.namedBindings;
if (bindings === undefined || ts.isNamespaceImport(bindings)) {
// This should be impossible. If a namespace import is present, the symbol was already
// considered imported above.
console.error(`Unexpected namespace import ${existingImportDeclaration.getText()}`);
return [[], ''];
}
let span = {start: bindings.getStart(), length: bindings.getWidth()};
const updatedBindings = updateImport(bindings, symbolName, importName);
const importString = printNode(updatedBindings, file);
return [[{span, newText: importString}], importName];
}
// Find the last import in the file.
let lastImport: ts.ImportDeclaration | null = null;
file.forEachChild((child) => {
if (ts.isImportDeclaration(child)) lastImport = child;
});
// Generate a new import declaration, and insert it after the last import declaration, only
// looking at root nodes in the AST. If no import exists, place it at the start of the file.
let span: ts.TextSpan = {start: 0, length: 0};
if ((lastImport as any) !== null) {
// TODO: Why does the compiler insist this is null?
span.start = lastImport!.getStart() + lastImport!.getWidth();
}
const newImportDeclaration = generateImport(symbolName, importName, moduleSpecifier);
const importString = '\n' + printNode(newImportDeclaration, file);
return [[{span, newText: importString}], importName];
}
/**
* Updates a given Angular trait, such as an NgModule or standalone Component, by adding
* `importName` to the list of imports on the decorator arguments.
*/
export function updateImportsForAngularTrait(
checker: TemplateTypeChecker,
trait: ts.ClassDeclaration,
importName: string,
forwardRefName: string | null,
): ts.TextChange[] {
// Get the object with arguments passed into the primary Angular decorator for this trait.
const decorator = checker.getPrimaryAngularDecorator(trait);
if (decorator === null) {
return [];
}
const decoratorProps = findFirstMatchingNode(decorator, {filter: ts.isObjectLiteralExpression});
if (decoratorProps === null) {
return [];
}
let updateRequired = true;
// Update the trait's imports.
const newDecoratorProps = updateObjectValueForKey(
decoratorProps,
'imports',
(oldValue?: ts.Expression) => {
if (oldValue && !ts.isArrayLiteralExpression(oldValue)) {
return oldValue;
}
const identifier = ts.factory.createIdentifier(importName);
const expression = forwardRefName
? ts.factory.createCallExpression(ts.factory.createIdentifier(forwardRefName), undefined, [
ts.factory.createArrowFunction(
undefined,
undefined,
[],
undefined,
undefined,
identifier,
),
])
: identifier;
const newArr = ensureArrayWithIdentifier(importName, expression, oldValue);
updateRequired = newArr !== null;
return newArr!;
},
);
if (!updateRequired) {
return [];
}
return [
{
span: {
start: decoratorProps.getStart(),
length: decoratorProps.getEnd() - decoratorProps.getStart(),
},
newText: printNode(newDecoratorProps, trait.getSourceFile()),
},
];
}
/**
* Return whether a given Angular decorator specifies `standalone: true`.
*/
export function isStandaloneDecorator(decorator: ts.Decorator): boolean | null {
const decoratorProps = findFirstMatchingNode(decorator, {filter: ts.isObjectLiteralExpression});
if (decoratorProps === null) {
return null;
}
for (const property of decoratorProps.properties) {
if (!ts.isPropertyAssignment(property)) {
continue;
}
// TODO(dylhunn): What if this is a dynamically evaluated expression?
if (property.name.getText() === 'standalone' && property.initializer.getText() === 'true') {
return true;
}
}
return false;
}
/**
* Generate a new import. Follows the format:
* ```
* import {exportedSpecifierName as localName} from 'rawModuleSpecifier';
* ```
*
* If `exportedSpecifierName` is null, or is equal to `name`, then the qualified import alias will
* be omitted.
*/
export function generateImport(
localName: string,
exportedSpecifierName: string | null,
rawModuleSpecifier: string,
): ts.ImportDeclaration {
let propName: ts.Identifier | undefined;
if (exportedSpecifierName !== null && exportedSpecifierName !== localName) {
propName = ts.factory.createIdentifier(exportedSpecifierName);
}
const name = ts.factory.createIdentifier(localName);
const moduleSpec = ts.factory.createStringLiteral(rawModuleSpecifier);
return ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(
false,
undefined,
ts.factory.createNamedImports([ts.factory.createImportSpecifier(false, propName, name)]),
),
moduleSpec,
undefined,
);
}
/**
* Update an existing named import with a new member.
* If `exportedSpecifierName` is null, or is equal to `name`, then the qualified import alias will
* be omitted.
*/
export function updateImport(
imp: ts.NamedImports,
localName: string,
exportedSpecifierName: string | null,
): ts.NamedImports {
let propertyName: ts.Identifier | undefined;
if (exportedSpecifierName !== null && exportedSpecifierName !== localName) {
propertyName = ts.factory.createIdentifier(exportedSpecifierName);
}
const name = ts.factory.createIdentifier(localName);
const newImport = ts.factory.createImportSpecifier(false, propertyName, name);
return ts.factory.updateNamedImports(imp, [...imp.elements, newImport]);
}
let printer: ts.Printer | null = null;
/**
* Get a ts.Printer for printing AST nodes, reusing the previous Printer if already created.
*/
function getOrCreatePrinter(): ts.Printer {
if (printer === null) {
printer = ts.createPrinter();
}
return printer;
}
/**
* Print a given TypeScript node into a string. Used to serialize entirely synthetic generated AST,
* which will not have `.text` or `.fullText` set.
*/
export function printNode(node: ts.Node, sourceFile: ts.SourceFile): string {
return getOrCreatePrinter().printNode(ts.EmitHint.Unspecified, node, sourceFile);
}