Skip to content

Commit

Permalink
feat(language-service): Allow auto-imports of a pipe via quick fix wh…
Browse files Browse the repository at this point in the history
…en its selector is used, both directly and via reexports. (#48354)

A previous PR introduced a new compiler abstraction that tracks *all* known exports and re-exports of Angular traits. This PR harnesses that abstraction in the language service, in order to allow automatic imports of pipes.

PR Close #48354
  • Loading branch information
dylhunn authored and AndrewKushnir committed Jan 12, 2023
1 parent 1413334 commit 4ae384f
Show file tree
Hide file tree
Showing 2 changed files with 133 additions and 21 deletions.
45 changes: 24 additions & 21 deletions packages/language-service/src/codefixes/fix_missing_import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/

import {ASTWithName} from '@angular/compiler';
import {ErrorCode as NgCompilerErrorCode, ngErrorCode} from '@angular/compiler-cli/src/ngtsc/diagnostics/index';
import {PotentialDirective, PotentialImport, TemplateTypeChecker} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import {PotentialDirective, PotentialPipe} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST
import ts from 'typescript';

Expand All @@ -19,6 +20,7 @@ import {CodeActionContext, CodeActionMeta, FixIdForCodeFixesAll} from './utils';

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

/**
Expand All @@ -40,43 +42,44 @@ function getCodeActions(
{templateInfo, start, compiler, formatOptions, preferences, errorCode, tsLs}:
CodeActionContext) {
let codeActions: ts.CodeFixAction[] = [];

const checker = compiler.getTemplateTypeChecker();
const tsChecker = compiler.programDriver.getProgram().getTypeChecker();

// 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) {
if (target === null) {
return [];
}
const missingElement = target.context.node;

const importOn = standaloneTraitOrNgModule(checker, templateInfo.component);
if (importOn === null) {
let matches: Set<PotentialDirective>|Set<PotentialPipe>;
if (target.context.kind === TargetNodeKind.ElementInTagContext &&
target.context.node instanceof t.Element) {
const allPossibleDirectives = checker.getPotentialTemplateDirectives(templateInfo.component);
matches = getDirectiveMatchesForElementTag(target.context.node, allPossibleDirectives);
} else if (
target.context.kind === TargetNodeKind.RawExpression &&
target.context.node instanceof ASTWithName) {
const name = (target.context.node as any).name;
const allPossiblePipes = checker.getPotentialPipes(templateInfo.component);
matches = new Set(allPossiblePipes.filter(p => p.name === name));
} else {
return [];
}

// Find all possible importable directives with a matching selector.
const allPossibleDirectives = checker.getPotentialTemplateDirectives(templateInfo.component);
const matchingDirectives =
getDirectiveMatchesForElementTag(missingElement, allPossibleDirectives);
const matches = matchingDirectives.values();

for (let currMatch of matches) {
const currMatchSymbol = currMatch.tsSymbol.valueDeclaration;
const importOn = standaloneTraitOrNgModule(checker, templateInfo.component);
if (importOn === null) {
return [];
}
for (const currMatch of matches.values()) {
const currMatchSymbol = currMatch.tsSymbol.valueDeclaration!;
const potentialImports = checker.getPotentialImportsFor(currMatch, importOn);
for (let potentialImport of potentialImports) {
let [fileImportChanges, importName] = updateImportsForTypescriptFile(
tsChecker, importOn.getSourceFile(), potentialImport, currMatchSymbol.getSourceFile());
// Always update the trait import, although the TS import might already be present.
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) {
continue;
}
if (traitImportChanges.length === 0) continue;

// Create a code action for this import.
let description = `Import ${importName}`;
if (potentialImport.moduleSpecifier !== undefined) {
description += ` from '${potentialImport.moduleSpecifier}' on ${importOn.name!.text}`;
Expand Down
109 changes: 109 additions & 0 deletions packages/language-service/test/code_fixes_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,115 @@ describe('code fixes', () => {
]
]);
});

it('for a new standalone pipe import', () => {
const standaloneFiles = {
'foo.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'foo',
template: '{{"hello"|bar}}',
standalone: true
})
export class FooComponent {}
`,
'bar.ts': `
import {Pipe} from '@angular/core';
@Pipe({
name: 'bar',
standalone: true
})
export class BarPipe implements PipeTransform {
transform(value: unknown, ...args: unknown[]): unknown {
return null;
}
}
`,
};

const project = createModuleAndProjectWithDeclarations(env, 'test', {}, {}, standaloneFiles);
const diags = project.getDiagnosticsForFile('foo.ts');
const fixFile = project.openFile('foo.ts');
fixFile.moveCursorToText('"hello"|b¦ar');

const codeActions =
project.getCodeFixesAtPosition('foo.ts', fixFile.cursor, fixFile.cursor, [diags[0].code]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);

actionChangesMatch(actionChanges, `Import BarPipe from './bar' on FooComponent`, [
[
``,
`import { BarPipe } from "./bar";`,
],
[
'{',
`{ selector: 'foo', template: '{{"hello"|bar}}', standalone: true, imports: [BarPipe] }`,
]
]);
});

it('for a transitive NgModule-based reexport', () => {
const standaloneFiles = {
'foo.ts': `
import {Component} from '@angular/core';
@Component({
selector: 'foo',
template: '<bar></bar>',
standalone: true
})
export class FooComponent {}
`,
'bar.ts': `
import {Component, NgModule} from '@angular/core';
@Component({
selector: 'bar',
template: '<div>bar</div>',
})
export class BarComponent {}
@NgModule({
declarations: [BarComponent],
exports: [BarComponent],
imports: []
})
export class BarModule {}
@NgModule({
declarations: [],
exports: [BarModule],
imports: []
})
export class Bar2Module {}
`,
};

const project = createModuleAndProjectWithDeclarations(env, 'test', {}, {}, standaloneFiles);
const diags = project.getDiagnosticsForFile('foo.ts');
const fixFile = project.openFile('foo.ts');
fixFile.moveCursorToText('<¦bar>');

const codeActions =
project.getCodeFixesAtPosition('foo.ts', fixFile.cursor, fixFile.cursor, [diags[0].code]);
const actionChanges = allChangesForCodeActions(fixFile.contents, codeActions);
actionChangesMatch(actionChanges, `Import BarModule from './bar' on FooComponent`, [
[
``,
`import { BarModule } from "./bar";`,
],
[
`{`,
`{ selector: 'foo', template: '<bar></bar>', standalone: true, imports: [BarModule] }`,
]
]);
actionChangesMatch(actionChanges, `Import Bar2Module from './bar' on FooComponent`, [
[
``,
`import { Bar2Module } from "./bar";`,
],
[
`{`,
`{ selector: 'foo', template: '<bar></bar>', standalone: true, imports: [Bar2Module] }`,
]
]);
});
});
});

Expand Down

0 comments on commit 4ae384f

Please sign in to comment.