From cbb6eae4a95306a42346ac83d7a51ceec33e8ed5 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Thu, 3 Dec 2020 13:45:34 -0800 Subject: [PATCH] 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 --- packages/language-service/ivy/completions.ts | 35 ++++++++++++- .../ivy/test/completions_spec.ts | 52 +++++++++++++++++-- 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/language-service/ivy/completions.ts b/packages/language-service/ivy/completions.ts index 216baa904d..4a72808aae 100644 --- a/packages/language-service/ivy/completions.ts +++ b/packages/language-service/ivy/completions.ts @@ -6,7 +6,7 @@ * 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 {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import {BoundEvent} from '@angular/compiler/src/render3/r3_ast'; @@ -23,6 +23,7 @@ type PropertyExpressionCompletionBuilder = type ElementAttributeCompletionBuilder = CompletionBuilder; +type PipeCompletionBuilder = CompletionBuilder; export enum CompletionNodeContext { None, @@ -65,6 +66,8 @@ export class CompletionBuilder { return this.getElementTagCompletion(); } else if (this.isElementAttributeCompletion()) { return this.getElementAttributeCompletions(); + } else if (this.isPipeCompletion()) { + return this.getPipeCompletions(); } else { return undefined; } @@ -577,6 +580,34 @@ export class CompletionBuilder { const completion = attrTable.get(name)!; return getAttributeCompletionSymbol(completion, this.typeChecker) ?? undefined; } + + private isPipeCompletion(): this is PipeCompletionBuilder { + return this.node instanceof BindingPipe; + } + + private getPipeCompletions(this: PipeCompletionBuilder): + ts.WithMetadata|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 { @@ -587,7 +618,7 @@ function makeReplacementSpanFromParseSourceSpan(span: ParseSourceSpan): ts.TextS } function makeReplacementSpanFromAst(node: PropertyRead|PropertyWrite|MethodCall|SafePropertyRead| - SafeMethodCall): ts.TextSpan { + SafeMethodCall|BindingPipe): ts.TextSpan { return { start: node.nameSpan.start, length: node.nameSpan.end - node.nameSpan.start, diff --git a/packages/language-service/ivy/test/completions_spec.ts b/packages/language-service/ivy/test/completions_spec.ts index b2ff0754d7..09ff457330 100644 --- a/packages/language-service/ivy/test/completions_spec.ts +++ b/packages/language-service/ivy/test/completions_spec.ts @@ -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', () => { beforeEach(() => { 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( @@ -495,7 +539,7 @@ function toText(displayParts?: ts.SymbolDisplayPart[]): string { function setup( templateWithCursor: string, classContents: string, - otherDirectives: {[name: string]: string} = {}): { + otherDeclarations: {[name: string]: string} = {}): { env: LanguageServiceTestEnvironment, fileName: AbsoluteFsPath, AppCmp: ts.ClassDeclaration, @@ -507,15 +551,15 @@ function setup( const codePath = absoluteFrom('/test.ts'); 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([ { name: codePath, contents: ` - import {Component, Directive, NgModule} from '@angular/core'; + import {Component, Directive, NgModule, Pipe} from '@angular/core'; @Component({ templateUrl: './test.html',