feat(language-service): autocomplete pipe binding expressions (#40032)

This commit adds autocompletion for pipe expressions, built on existing APIs
for checking which pipes are in scope.

PR Close #40032
This commit is contained in:
Alex Rickabaugh 2020-12-03 13:45:34 -08:00
parent 66378ed0ef
commit cbb6eae4a9
2 changed files with 81 additions and 6 deletions

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license * found in the LICENSE file at https://angular.io/license
*/ */
import {AST, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler'; import {AST, BindingPipe, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
import {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
import {BoundEvent} from '@angular/compiler/src/render3/r3_ast'; import {BoundEvent} from '@angular/compiler/src/render3/r3_ast';
@ -23,6 +23,7 @@ type PropertyExpressionCompletionBuilder =
type ElementAttributeCompletionBuilder = type ElementAttributeCompletionBuilder =
CompletionBuilder<TmplAstElement|TmplAstBoundAttribute|TmplAstTextAttribute|TmplAstBoundEvent>; CompletionBuilder<TmplAstElement|TmplAstBoundAttribute|TmplAstTextAttribute|TmplAstBoundEvent>;
type PipeCompletionBuilder = CompletionBuilder<BindingPipe>;
export enum CompletionNodeContext { export enum CompletionNodeContext {
None, None,
@ -65,6 +66,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
return this.getElementTagCompletion(); return this.getElementTagCompletion();
} else if (this.isElementAttributeCompletion()) { } else if (this.isElementAttributeCompletion()) {
return this.getElementAttributeCompletions(); return this.getElementAttributeCompletions();
} else if (this.isPipeCompletion()) {
return this.getPipeCompletions();
} else { } else {
return undefined; return undefined;
} }
@ -577,6 +580,34 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
const completion = attrTable.get(name)!; const completion = attrTable.get(name)!;
return getAttributeCompletionSymbol(completion, this.typeChecker) ?? undefined; return getAttributeCompletionSymbol(completion, this.typeChecker) ?? undefined;
} }
private isPipeCompletion(): this is PipeCompletionBuilder {
return this.node instanceof BindingPipe;
}
private getPipeCompletions(this: PipeCompletionBuilder):
ts.WithMetadata<ts.CompletionInfo>|undefined {
const pipes = this.templateTypeChecker.getPipesInScope(this.component);
if (pipes === null) {
return undefined;
}
const replacementSpan = makeReplacementSpanFromAst(this.node);
const entries: ts.CompletionEntry[] =
pipes.map(pipe => ({
name: pipe.name,
sortText: pipe.name,
kind: unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE),
replacementSpan,
}));
return {
entries,
isGlobalCompletion: false,
isMemberCompletion: false,
isNewIdentifierLocation: false,
};
}
} }
function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextSpan { function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextSpan {
@ -587,7 +618,7 @@ function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextS
} }
function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead| function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead|
SafeMethodCall): ts.TextSpan { SafeMethodCall|BindingPipe): ts.TextSpan {
return { return {
start: node.nameSpan.start, start: node.nameSpan.start,
length: node.nameSpan.end - node.nameSpan.start, length: node.nameSpan.end - node.nameSpan.start,

View File

@ -51,6 +51,19 @@ const DIR_WITH_SELECTED_INPUT = {
` `
}; };
const SOME_PIPE = {
'SomePipe': `
@Pipe({
name: 'somePipe',
})
export class SomePipe {
transform(value: string): string {
return value;
}
}
`
};
describe('completions', () => { describe('completions', () => {
beforeEach(() => { beforeEach(() => {
initMockFileSystem('Native'); initMockFileSystem('Native');
@ -445,6 +458,37 @@ describe('completions', () => {
}); });
}); });
}); });
describe('pipe scope', () => {
it('should complete a pipe binding', () => {
const {ngLS, fileName, cursor, text} = setup(`{{ foo | some¦ }}`, '', SOME_PIPE);
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE),
['somePipe']);
expectReplacementText(completions, text, 'some');
});
// TODO(alxhub): currently disabled as the template targeting system identifies the cursor
// position as the entire Interpolation node, not the BindingPipe node. This happens because the
// BindingPipe node's span ends at the '|' character. To make this case work, the targeting
// system will need to artificially expand the BindingPipe's span to encompass any trailing
// spaces, which will be done in a future PR.
xit('should complete an empty pipe binding', () => {
const {ngLS, fileName, cursor, text} = setup(`{{ foo | ¦ }}`, '', SOME_PIPE);
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.PIPE),
['somePipe']);
expectReplacementText(completions, text, 'some');
});
it('should not return extraneous completions', () => {
const {ngLS, fileName, cursor, text} = setup(`{{ foo | some¦ }}`, '');
const completions = ngLS.getCompletionsAtPosition(fileName, cursor, /* options */ undefined);
expect(completions?.entries.length).toBe(0);
});
});
}); });
function expectContain( function expectContain(
@ -495,7 +539,7 @@ function toText(displayParts?: ts.SymbolDisplayPart[]): string {
function setup( function setup(
templateWithCursor: string, classContents: string, templateWithCursor: string, classContents: string,
otherDirectives: {[name: string]: string} = {}): { otherDeclarations: {[name: string]: string} = {}): {
env: LanguageServiceTestEnvironment, env: LanguageServiceTestEnvironment,
fileName: AbsoluteFsPath, fileName: AbsoluteFsPath,
AppCmp: ts.ClassDeclaration, AppCmp: ts.ClassDeclaration,
@ -507,15 +551,15 @@ function setup(
const codePath = absoluteFrom('/test.ts'); const codePath = absoluteFrom('/test.ts');
const templatePath = absoluteFrom('/test.html'); const templatePath = absoluteFrom('/test.html');
const decls = ['AppCmp', ...Object.keys(otherDirectives)]; const decls = ['AppCmp', ...Object.keys(otherDeclarations)];
const otherDirectiveClassDecls = Object.values(otherDirectives).join('\n\n'); const otherDirectiveClassDecls = Object.values(otherDeclarations).join('\n\n');
const env = LanguageServiceTestEnvironment.setup([ const env = LanguageServiceTestEnvironment.setup([
{ {
name: codePath, name: codePath,
contents: ` contents: `
import {Component, Directive, NgModule} from '@angular/core'; import {Component, Directive, NgModule, Pipe} from '@angular/core';
@Component({ @Component({
templateUrl: './test.html', templateUrl: './test.html',