Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(language-service): Quick fix to import a component when its sele…
…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
Showing
8 changed files
with
487 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
179 changes: 179 additions & 0 deletions
179
packages/language-service/src/codefixes/fix_missing_import.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()) | ||
}]; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.