Skip to content

Commit

Permalink
feat(language-service): Quick fix to import a component when its sele…
Browse files Browse the repository at this point in the history
…ctor is used

The language service can now generate an import corresponding to a selector. This includes both the TypeScript module import and the decorator import. This applies to both standalone components and components declared in NgModules.
  • Loading branch information
dylhunn committed Oct 6, 2022
1 parent 8df8c77 commit ea053eb
Show file tree
Hide file tree
Showing 8 changed files with 487 additions and 23 deletions.
Expand Up @@ -7,10 +7,12 @@
*/

import {fixInvalidBananaInBoxMeta} from './fix_invalid_banana_in_box';
import {missingImportMeta} from './fix_missing_import';
import {missingMemberMeta} from './fix_missing_member';
import {CodeActionMeta} from './utils';

export const ALL_CODE_FIXES_METAS: CodeActionMeta[] = [
missingMemberMeta,
fixInvalidBananaInBoxMeta,
missingImportMeta,
];
179 changes: 179 additions & 0 deletions packages/language-service/src/codefixes/fix_missing_import.ts
@@ -0,0 +1,179 @@
/**
* @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 {ErrorCode as NgCompilerErrorCode, ngErrorCode} from '@angular/compiler-cli/src/ngtsc/diagnostics/index';
import {PotentialImport, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import {findAllMatchingNodes, findFirstMatchingNode} from '@angular/compiler-cli/src/ngtsc/typecheck/src/comments';
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
import ts from 'typescript';

import {getTargetAtPosition, TargetNodeKind} from '../template_target';
import {ensureArrayWithIdentifier, generateImport, hasImport, moduleSpecifierHasPath, nonCollidingImportName, printNode, updateImport, updateObjectValueForKey} from '../ts_utils';
import {getDirectiveMatchesForElementTag} from '../utils';

import {CodeActionContext, CodeActionMeta, FixIdForCodeFixesAll} from './utils';

const errorCodes: number[] = [
ngErrorCode(NgCompilerErrorCode.SCHEMA_INVALID_ELEMENT),
];

/**
* This code action will generate a new import for an unknown selector.
*/
export const missingImportMeta: CodeActionMeta = {
errorCodes,
getCodeActions,
fixIds: [FixIdForCodeFixesAll.FIX_MISSING_IMPORT],
// TODO(dylhunn): implement "Fix All"
getAllCodeActions: ({tsLs, scope, fixId, formatOptions, preferences, compiler, diagnostics}) => {
return {
changes: [],
};
}
};

function getCodeActions(
{templateInfo, start, compiler, formatOptions, preferences, errorCode, tsLs}:
CodeActionContext) {
const checker = compiler.getTemplateTypeChecker();

// The error must be an invalid element in tag, which is interpreted as an intended selector.
const target = getTargetAtPosition(templateInfo.template, start);
if (target === null || target?.context.kind !== TargetNodeKind.ElementInTagContext ||
target.context.node instanceof t.Template) {
return [];
}
const missingElement = target.context.node;

// The class which has an imports array; either a standalone trait or its owning NgModule.
const importOn = checker.getOwningNgModule(templateInfo.component) ?? templateInfo.component;

// Find all possible importable directives with a matching selector, and take one of them.
// In the future, we could handle multiple matches as additional quick fixes.
const allPossibleDirectives = checker.getPotentialTemplateDirectives(templateInfo.component);
const matchingDirectives =
getDirectiveMatchesForElementTag(missingElement, allPossibleDirectives);
if (matchingDirectives.size === 0) {
return [];
}
const bestMatch = matchingDirectives.values().next().value;

// Get possible trait imports corresponding to the recommended directive. Only use the
// compiler's best import; in the future, we could suggest multiple imports if they exist.
const potentialImports = checker.getPotentialImportsFor(bestMatch, importOn);
if (potentialImports.length === 0) {
return [];
}
const potentialImport = potentialImports[0];

// Update the imports on the TypeScript file and Angular decorator.
let [fileImportChanges, importName] =
updateImportsForTypescriptFile(importOn.getSourceFile(), potentialImport);
let traitImportChanges = updateImportsForAngularTrait(checker, importOn, importName);

// All quick fixes should always update the trait import; however, the TypeScript import might
// already be present.
if (traitImportChanges.length === 0) {
return [];
}

// Create the code action to insert the new imports.
const codeActions: ts.CodeFixAction[] = [{
fixName: FixIdForCodeFixesAll.FIX_MISSING_IMPORT,
description:
`Import ${importName} from '${potentialImport.moduleSpecifier}' on ${importOn.name?.text}`,
changes: [{
fileName: importOn.getSourceFile().fileName,
textChanges: [...fileImportChanges, ...traitImportChanges],
}],
}];
return codeActions;
}

/**
* 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.
*/
function updateImportsForTypescriptFile(
file: ts.SourceFile, newImport: PotentialImport): [ts.TextChange[], string] {
const changes = new Array<ts.TextChange>();

// 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(allImports, newImport.symbolName, newImport.moduleSpecifier);
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 => moduleSpecifierHasPath(decl.moduleSpecifier, newImport.moduleSpecifier));
const importName = nonCollidingImportName(allImports, newImport.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.
return [[], ''];
}
let span = {start: bindings.getStart(), length: bindings.getWidth()};
const updatedBindings = updateImport(bindings, newImport.symbolName, importName);
const importString = printNode(updatedBindings, file);
return [[{span, newText: importString}], importName];
}

// Generate a new import declaration, and insert it at the top of the file.
const newImportDeclaration =
generateImport(newImport.symbolName, importName, newImport.moduleSpecifier);
const importString = printNode(newImportDeclaration, file) + '\n';
return [[{span: {start: 0, length: 0}, 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.
*/
function updateImportsForAngularTrait(
checker: TemplateTypeChecker, trait: ts.ClassDeclaration, importName: string): 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 newArr = ensureArrayWithIdentifier(ts.factory.createIdentifier(importName), oldValue);
updateRequired = newArr !== null;
return newArr!;
});

if (!updateRequired) {
return [];
}
return [{
span: {
start: decoratorProps.getStart(),
length: decoratorProps.getEnd() - decoratorProps.getStart()
},
newText: printNode(newDecoratorProps, trait.getSourceFile())
}];
}
1 change: 1 addition & 0 deletions packages/language-service/src/codefixes/utils.ts
Expand Up @@ -134,4 +134,5 @@ export enum FixIdForCodeFixesAll {
FIX_SPELLING = 'fixSpelling',
FIX_MISSING_MEMBER = 'fixMissingMember',
FIX_INVALID_BANANA_IN_BOX = 'fixInvalidBananaInBox',
FIX_MISSING_IMPORT = 'fixMissingImport',
}
162 changes: 162 additions & 0 deletions packages/language-service/src/ts_utils.ts
Expand Up @@ -129,3 +129,165 @@ export function updateObjectValueForKey(
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(
identifier: ts.Identifier, arr?: ts.ArrayLiteralExpression): ts.ArrayLiteralExpression|null {
if (!arr) {
return ts.factory.createArrayLiteralExpression([identifier]);
}
if (arr.elements.find(v => ts.isIdentifier(v) && v.text === identifier.text)) {
return null;
}
return ts.factory.updateArrayLiteralExpression(arr, [...arr.elements, identifier]);
}

/**
* Determine whether a given module specifier points to the exact path provided.
*/
export function moduleSpecifierHasPath(moduleSpecifier: ts.Expression, path: string): boolean {
return ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === path;
}

/**
* 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(
importDeclarations: ts.ImportDeclaration[], propName: string, moduleSpecifier: string): string|
null {
return importDeclarations
.filter(
declaration =>
moduleSpecifierHasPath(declaration.moduleSpecifier, moduleSpecifier))
.map(decl => importHas(decl, propName))
.find(p => p !== null) ??
null;
}

/**
* 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)) {
// The name of the property in its export scope might differ from its local name.
const nameInExportScope = (i: ts.ImportSpecifier) => i.propertyName?.text ?? i.name.text;
// Find any import specifier whose property name in the *export* scope equals the expected
// name.
const specifier = bindings.elements.find(i => propName == nameInExportScope(i));
// Return the name of the property in the *local* scope.
return specifier?.name.text ?? null;
}
// 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, starting with the name itself.
*/
function* suggestAlternativeSymbolNames(name: string): Iterator<string> {
yield name;
for (let i = 1; true; i++) {
yield `${name}_${i}`; // The _n suffix is the same atyle 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;
}

/**
* 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);
}

0 comments on commit ea053eb

Please sign in to comment.