Skip to content

Commit bebef5f

Browse files
dylhunnthePunderWoman
authored andcommitted
feat(language-service): Quick fix to import a component when its selector is used (#47088)
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. PR Close #47088
1 parent 75e6297 commit bebef5f

File tree

8 files changed

+612
-37
lines changed

8 files changed

+612
-37
lines changed

packages/language-service/src/codefixes/all_codefixes_metas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
*/
88

99
import {fixInvalidBananaInBoxMeta} from './fix_invalid_banana_in_box';
10+
import {missingImportMeta} from './fix_missing_import';
1011
import {missingMemberMeta} from './fix_missing_member';
1112
import {CodeActionMeta} from './utils';
1213

1314
export const ALL_CODE_FIXES_METAS: CodeActionMeta[] = [
1415
missingMemberMeta,
1516
fixInvalidBananaInBoxMeta,
17+
missingImportMeta,
1618
];
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {ErrorCode as NgCompilerErrorCode, ngErrorCode} from '@angular/compiler-cli/src/ngtsc/diagnostics/index';
10+
import {PotentialDirective, PotentialImport, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
11+
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
12+
import ts from 'typescript';
13+
14+
import {getTargetAtPosition, TargetNodeKind} from '../template_target';
15+
import {ensureArrayWithIdentifier, findAllMatchingNodes, findFirstMatchingNode, generateImport, hasImport, moduleSpecifierPointsToFile, nonCollidingImportName, printNode, updateImport, updateObjectValueForKey} from '../ts_utils';
16+
import {getDirectiveMatchesForElementTag} from '../utils';
17+
18+
import {CodeActionContext, CodeActionMeta, FixIdForCodeFixesAll} from './utils';
19+
20+
const errorCodes: number[] = [
21+
ngErrorCode(NgCompilerErrorCode.SCHEMA_INVALID_ELEMENT),
22+
];
23+
24+
/**
25+
* This code action will generate a new import for an unknown selector.
26+
*/
27+
export const missingImportMeta: CodeActionMeta = {
28+
errorCodes,
29+
getCodeActions,
30+
fixIds: [FixIdForCodeFixesAll.FIX_MISSING_IMPORT],
31+
// TODO(dylhunn): implement "Fix All"
32+
getAllCodeActions: ({tsLs, scope, fixId, formatOptions, preferences, compiler, diagnostics}) => {
33+
return {
34+
changes: [],
35+
};
36+
}
37+
};
38+
39+
function getCodeActions(
40+
{templateInfo, start, compiler, formatOptions, preferences, errorCode, tsLs}:
41+
CodeActionContext) {
42+
const checker = compiler.getTemplateTypeChecker();
43+
const tsChecker = compiler.programDriver.getProgram().getTypeChecker();
44+
45+
// The error must be an invalid element in tag, which is interpreted as an intended selector.
46+
const target = getTargetAtPosition(templateInfo.template, start);
47+
if (target === null || target.context.kind !== TargetNodeKind.ElementInTagContext ||
48+
target.context.node instanceof t.Template) {
49+
return [];
50+
}
51+
const missingElement = target.context.node;
52+
53+
// The class which has an imports array; either a standalone trait or its owning NgModule.
54+
const componentDecorator = checker.getPrimaryAngularDecorator(templateInfo.component);
55+
if (componentDecorator == null) {
56+
return [];
57+
}
58+
const owningNgModule = checker.getOwningNgModule(templateInfo.component);
59+
const isMarkedStandalone = isStandaloneDecorator(componentDecorator);
60+
if (owningNgModule === null && !isMarkedStandalone) {
61+
// TODO(dylhunn): This is a "moduleless component." We should probably suggest the user add
62+
// `standalone: true`.
63+
return [];
64+
}
65+
const importOn = owningNgModule ?? templateInfo.component;
66+
67+
// Find all possible importable directives with a matching selector, and take one of them.
68+
// In the future, we could handle multiple matches as additional quick fixes.
69+
const allPossibleDirectives = checker.getPotentialTemplateDirectives(templateInfo.component);
70+
const matchingDirectives =
71+
getDirectiveMatchesForElementTag(missingElement, allPossibleDirectives);
72+
if (matchingDirectives.size === 0) {
73+
return [];
74+
}
75+
const bestMatch: PotentialDirective = matchingDirectives.values().next().value;
76+
const bestMatchSymbol = bestMatch.tsSymbol.valueDeclaration;
77+
78+
// Get possible trait imports corresponding to the recommended directive. Only use the
79+
// compiler's best import; in the future, we could suggest multiple imports if they exist.
80+
const potentialImports = checker.getPotentialImportsFor(bestMatch, importOn);
81+
if (potentialImports.length === 0) {
82+
return [];
83+
}
84+
const potentialImport = potentialImports[0];
85+
86+
// Update the imports on the TypeScript file and Angular decorator.
87+
let [fileImportChanges, importName] = updateImportsForTypescriptFile(
88+
tsChecker, importOn.getSourceFile(), potentialImport, bestMatchSymbol.getSourceFile());
89+
let traitImportChanges = updateImportsForAngularTrait(checker, importOn, importName);
90+
91+
// All quick fixes should always update the trait import; however, the TypeScript import might
92+
// already be present.
93+
if (traitImportChanges.length === 0) {
94+
return [];
95+
}
96+
97+
// Create the code action to insert the new imports.
98+
const codeActions: ts.CodeFixAction[] = [{
99+
fixName: FixIdForCodeFixesAll.FIX_MISSING_IMPORT,
100+
description:
101+
`Import ${importName} from '${potentialImport.moduleSpecifier}' on ${importOn.name!.text}`,
102+
changes: [{
103+
fileName: importOn.getSourceFile().fileName,
104+
textChanges: [...fileImportChanges, ...traitImportChanges],
105+
}],
106+
}];
107+
return codeActions;
108+
}
109+
110+
/**
111+
* Updates the imports on a TypeScript file, by ensuring the provided import is present.
112+
* Returns the text changes, as well as the name with which the imported symbol can be referred to.
113+
*/
114+
function updateImportsForTypescriptFile(
115+
tsChecker: ts.TypeChecker, file: ts.SourceFile, newImport: PotentialImport,
116+
tsFileToImport: ts.SourceFile): [ts.TextChange[], string] {
117+
const changes = new Array<ts.TextChange>();
118+
119+
// The trait might already be imported, possibly under a different name. If so, determine the
120+
// local name of the imported trait.
121+
const allImports = findAllMatchingNodes(file, {filter: ts.isImportDeclaration});
122+
const existingImportName: string|null =
123+
hasImport(tsChecker, allImports, newImport.symbolName, tsFileToImport);
124+
if (existingImportName !== null) {
125+
return [[], existingImportName];
126+
}
127+
128+
// If the trait has not already been imported, we need to insert the new import.
129+
const existingImportDeclaration = allImports.find(
130+
decl => moduleSpecifierPointsToFile(tsChecker, decl.moduleSpecifier, tsFileToImport));
131+
const importName = nonCollidingImportName(allImports, newImport.symbolName);
132+
133+
if (existingImportDeclaration !== undefined) {
134+
// Update an existing import declaration.
135+
const bindings = existingImportDeclaration.importClause?.namedBindings;
136+
if (bindings === undefined || ts.isNamespaceImport(bindings)) {
137+
// This should be impossible. If a namespace import is present, the symbol was already
138+
// considered imported above.
139+
console.error(`Unexpected namespace import ${existingImportDeclaration.getText()}`);
140+
return [[], ''];
141+
}
142+
let span = {start: bindings.getStart(), length: bindings.getWidth()};
143+
const updatedBindings = updateImport(bindings, newImport.symbolName, importName);
144+
const importString = printNode(updatedBindings, file);
145+
return [[{span, newText: importString}], importName];
146+
}
147+
148+
// Find the last import in the file.
149+
let lastImport: ts.ImportDeclaration|null = null;
150+
file.forEachChild(child => {
151+
if (ts.isImportDeclaration(child)) lastImport = child;
152+
});
153+
154+
// Generate a new import declaration, and insert it after the last import declaration, only
155+
// looking at root nodes in the AST. If no import exists, place it at the start of the file.
156+
let span: ts.TextSpan = {start: 0, length: 0};
157+
if (lastImport as any !== null) { // TODO: Why does the compiler insist this is null?
158+
span.start = lastImport!.getStart() + lastImport!.getWidth();
159+
}
160+
const newImportDeclaration =
161+
generateImport(newImport.symbolName, importName, newImport.moduleSpecifier);
162+
const importString = '\n' + printNode(newImportDeclaration, file);
163+
return [[{span, newText: importString}], importName];
164+
}
165+
166+
/**
167+
* Updates a given Angular trait, such as an NgModule or standalone Component, by adding
168+
* `importName` to the list of imports on the decorator arguments.
169+
*/
170+
function updateImportsForAngularTrait(
171+
checker: TemplateTypeChecker, trait: ts.ClassDeclaration, importName: string): ts.TextChange[] {
172+
// Get the object with arguments passed into the primary Angular decorator for this trait.
173+
const decorator = checker.getPrimaryAngularDecorator(trait);
174+
if (decorator === null) {
175+
return [];
176+
}
177+
const decoratorProps = findFirstMatchingNode(decorator, {filter: ts.isObjectLiteralExpression});
178+
if (decoratorProps === null) {
179+
return [];
180+
}
181+
182+
let updateRequired = true;
183+
// Update the trait's imports.
184+
const newDecoratorProps =
185+
updateObjectValueForKey(decoratorProps, 'imports', (oldValue?: ts.Expression) => {
186+
if (oldValue && !ts.isArrayLiteralExpression(oldValue)) {
187+
return oldValue;
188+
}
189+
const newArr = ensureArrayWithIdentifier(ts.factory.createIdentifier(importName), oldValue);
190+
updateRequired = newArr !== null;
191+
return newArr!;
192+
});
193+
194+
if (!updateRequired) {
195+
return [];
196+
}
197+
return [{
198+
span: {
199+
start: decoratorProps.getStart(),
200+
length: decoratorProps.getEnd() - decoratorProps.getStart()
201+
},
202+
newText: printNode(newDecoratorProps, trait.getSourceFile())
203+
}];
204+
}
205+
206+
function isStandaloneDecorator(decorator: ts.Decorator): boolean|null {
207+
const decoratorProps = findFirstMatchingNode(decorator, {filter: ts.isObjectLiteralExpression});
208+
if (decoratorProps === null) {
209+
return null;
210+
}
211+
212+
for (const property of decoratorProps.properties) {
213+
if (!ts.isPropertyAssignment(property)) {
214+
continue;
215+
}
216+
// TODO(dylhunn): What if this is a dynamically evaluated expression?
217+
if (property.name.getText() === 'standalone' && property.initializer.getText() === 'true') {
218+
return true;
219+
}
220+
}
221+
return false;
222+
}

packages/language-service/src/codefixes/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,5 @@ export enum FixIdForCodeFixesAll {
134134
FIX_SPELLING = 'fixSpelling',
135135
FIX_MISSING_MEMBER = 'fixMissingMember',
136136
FIX_INVALID_BANANA_IN_BOX = 'fixInvalidBananaInBox',
137+
FIX_MISSING_IMPORT = 'fixMissingImport',
137138
}

0 commit comments

Comments
 (0)