From 7c35ca0e0030f2ded12ddca9092e31f510cebeb1 Mon Sep 17 00:00:00 2001 From: ivanwonder Date: Wed, 30 Jun 2021 22:25:01 +0800 Subject: [PATCH] feat(language-service): support autocomplete string literal union types in templates (#42729) The native TS language service has the ability to provide autocompletions for string literal union types. This pr is for Angular to do the same in templates. Fixes https://github.com/angular/vscode-ng-language-service/issues/1096 PR Close #42729 --- .../src/ngtsc/typecheck/api/checker.ts | 11 +++- .../src/ngtsc/typecheck/src/checker.ts | 13 +++- .../src/ngtsc/typecheck/src/completion.ts | 48 +++++++++++++- packages/language-service/ivy/completions.ts | 66 ++++++++++++++++++- .../ivy/test/completions_spec.ts | 57 ++++++++++++++++ 5 files changed, 189 insertions(+), 6 deletions(-) diff --git a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts index 84635508b8..c8b60f410d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/api/checker.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, MethodCall, ParseError, ParseSourceSpan, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; +import {AST, LiteralPrimitive, MethodCall, ParseError, ParseSourceSpan, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstTemplate} from '@angular/compiler'; import {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {TextAttribute} from '@angular/compiler/src/render3/r3_ast'; import * as ts from 'typescript'; import {ErrorCode} from '../../diagnostics'; @@ -119,6 +120,14 @@ export interface TemplateTypeChecker { expr: PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall, component: ts.ClassDeclaration): ShimLocation|null; + /** + * For the given node represents a `LiteralPrimitive`(the `TextAttribute` represents a string + * literal), retrieve a `ShimLocation` that can be used to perform autocompletion at that point in + * the node, if such a location exists. + */ + getLiteralCompletionLocation( + strNode: LiteralPrimitive|TextAttribute, component: ts.ClassDeclaration): ShimLocation|null; + /** * Get basic metadata on the directives which are in scope for the given component. */ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts index a3bd6f5394..504314d6bf 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/checker.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, CssSelector, DomElementSchemaRegistry, MethodCall, ParseError, ParseSourceSpan, parseTemplate, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler'; +import {AST, CssSelector, DomElementSchemaRegistry, LiteralPrimitive, MethodCall, ParseError, ParseSourceSpan, parseTemplate, PropertyRead, SafeMethodCall, SafePropertyRead, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstVariable} from '@angular/compiler'; +import {TextAttribute} from '@angular/compiler/src/render3/r3_ast'; import * as ts from 'typescript'; import {ErrorCode} from '../../diagnostics'; @@ -280,6 +281,16 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker { PerfPhase.TtcAutocompletion, () => engine.getExpressionCompletionLocation(ast)); } + getLiteralCompletionLocation( + node: LiteralPrimitive|TextAttribute, component: ts.ClassDeclaration): ShimLocation|null { + const engine = this.getOrCreateCompletionEngine(component); + if (engine === null) { + return null; + } + return this.perf.inPhase( + PerfPhase.TtcAutocompletion, () => engine.getLiteralCompletionLocation(node)); + } + invalidateClass(clazz: ts.ClassDeclaration): void { this.completionCache.delete(clazz); this.symbolBuilderCache.delete(clazz); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts index 473e5b8a6d..645a628967 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts @@ -7,7 +7,8 @@ */ import {TmplAstReference, TmplAstTemplate} from '@angular/compiler'; -import {AST, EmptyExpr, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstNode} from '@angular/compiler/src/compiler'; +import {AST, EmptyExpr, LiteralPrimitive, MethodCall, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstNode} from '@angular/compiler/src/compiler'; +import {TextAttribute} from '@angular/compiler/src/render3/r3_ast'; import * as ts from 'typescript'; import {AbsoluteFsPath} from '../../file_system'; @@ -32,8 +33,9 @@ export class CompletionEngine { private templateContextCache = new Map>(); - private expressionCompletionCache = - new Map(); + private expressionCompletionCache = new Map< + PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall|LiteralPrimitive|TextAttribute, + ShimLocation>(); constructor(private tcb: ts.Node, private data: TemplateData, private shimPath: AbsoluteFsPath) { @@ -144,6 +146,46 @@ export class CompletionEngine { return res; } + getLiteralCompletionLocation(expr: LiteralPrimitive|TextAttribute): ShimLocation|null { + if (this.expressionCompletionCache.has(expr)) { + return this.expressionCompletionCache.get(expr)!; + } + + let tsExpr: ts.StringLiteral|ts.NumericLiteral|null = null; + + if (expr instanceof TextAttribute) { + const strNode = findFirstMatchingNode(this.tcb, { + filter: ts.isParenthesizedExpression, + withSpan: expr.sourceSpan, + }); + if (strNode !== null && ts.isStringLiteral(strNode.expression)) { + tsExpr = strNode.expression; + } + } else { + tsExpr = findFirstMatchingNode(this.tcb, { + filter: (n: ts.Node): n is ts.NumericLiteral | ts.StringLiteral => + ts.isStringLiteral(n) || ts.isNumericLiteral(n), + withSpan: expr.sourceSpan, + }); + } + + if (tsExpr === null) { + return null; + } + + let positionInShimFile = tsExpr.getEnd(); + if (ts.isStringLiteral(tsExpr)) { + // In the shimFile, if `tsExpr` is a string, the position should be in the quotes. + positionInShimFile -= 1; + } + const res: ShimLocation = { + shimPath: this.shimPath, + positionInShimFile, + }; + this.expressionCompletionCache.set(expr, res); + return res; + } + /** * Get global completions within the given template context - either a `TmplAstTemplate` embedded * view, or `null` for the root context. diff --git a/packages/language-service/ivy/completions.ts b/packages/language-service/ivy/completions.ts index 212e461c68..af42275ecc 100644 --- a/packages/language-service/ivy/completions.ts +++ b/packages/language-service/ivy/completions.ts @@ -9,7 +9,7 @@ import {AST, BindingPipe, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, 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'; +import {BoundEvent, TextAttribute} from '@angular/compiler/src/render3/r3_ast'; import * as ts from 'typescript'; import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions'; @@ -26,6 +26,8 @@ type ElementAttributeCompletionBuilder = type PipeCompletionBuilder = CompletionBuilder; +type LiteralCompletionBuilder = CompletionBuilder; + export enum CompletionNodeContext { None, ElementTag, @@ -72,11 +74,71 @@ export class CompletionBuilder { return this.getElementAttributeCompletions(); } else if (this.isPipeCompletion()) { return this.getPipeCompletions(); + } else if (this.isLiteralCompletion()) { + return this.getLiteralCompletions(options); } else { return undefined; } } + private isLiteralCompletion(): this is LiteralCompletionBuilder { + return this.node instanceof LiteralPrimitive || + (this.node instanceof TextAttribute && + this.nodeContext === CompletionNodeContext.ElementAttributeValue); + } + + private getLiteralCompletions( + this: LiteralCompletionBuilder, options: ts.GetCompletionsAtPositionOptions|undefined): + ts.WithMetadata|undefined { + const location = this.compiler.getTemplateTypeChecker().getLiteralCompletionLocation( + this.node, this.component); + if (location === null) { + return undefined; + } + const tsResults = + this.tsLS.getCompletionsAtPosition(location.shimPath, location.positionInShimFile, options); + if (tsResults === undefined) { + return undefined; + } + + let replacementSpan: ts.TextSpan|undefined; + if (this.node instanceof TextAttribute && this.node.value.length > 0 && this.node.valueSpan) { + replacementSpan = { + start: this.node.valueSpan.start.offset, + length: this.node.value.length, + }; + } + if (this.node instanceof LiteralPrimitive) { + if (typeof this.node.value === 'string' && this.node.value.length > 0) { + replacementSpan = { + // The sourceSpan of `LiteralPrimitive` includes the open quote and the completion entries + // don't, so skip the open quote here. + start: this.node.sourceSpan.start + 1, + length: this.node.value.length, + }; + } else if (typeof this.node.value === 'number') { + replacementSpan = { + start: this.node.sourceSpan.start, + length: this.node.value.toString().length, + }; + } + } + + let ngResults: ts.CompletionEntry[] = []; + for (const result of tsResults.entries) { + if (this.isValidNodeContextCompletion(result)) { + ngResults.push({ + ...result, + replacementSpan, + }); + } + } + return { + ...tsResults, + entries: ngResults, + }; + } + /** * Analogue for `ts.LanguageService.getCompletionEntryDetails`. */ @@ -749,6 +811,8 @@ function nodeContextFromTarget(target: TargetContext): CompletionNodeContext { case TargetNodeKind.AttributeInValueContext: if (target.node instanceof TmplAstBoundEvent) { return CompletionNodeContext.EventValue; + } else if (target.node instanceof TextAttribute) { + return CompletionNodeContext.ElementAttributeValue; } else { return CompletionNodeContext.None; } diff --git a/packages/language-service/ivy/test/completions_spec.ts b/packages/language-service/ivy/test/completions_spec.ts index f4cb8513b4..6a2a05b806 100644 --- a/packages/language-service/ivy/test/completions_spec.ts +++ b/packages/language-service/ivy/test/completions_spec.ts @@ -116,6 +116,19 @@ const SOME_PIPE = { ` }; +const UNION_TYPE_PIPE = { + 'UnionTypePipe': ` + @Pipe({ + name: 'unionTypePipe', + }) + export class UnionTypePipe { + transform(value: string, config: 'foo' | 'bar'): string { + return value; + } + } + ` +}; + describe('completions', () => { beforeEach(() => { initMockFileSystem('Native'); @@ -749,6 +762,50 @@ describe('completions', () => { expect(completions?.entries.length).toBe(0); }); }); + + describe('literal primitive scope', () => { + it('should complete a string union types in square brackets binding', () => { + const {templateFile} = setup(``, '', DIR_WITH_UNION_TYPE_INPUT); + templateFile.moveCursorToText(`[myInput]="'foo¦'"`); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.string, ['foo']); + expectReplacementText(completions, templateFile.contents, 'foo'); + }); + + it('should complete a string union types in binding without brackets', () => { + const {templateFile} = setup(``, '', DIR_WITH_UNION_TYPE_INPUT); + templateFile.moveCursorToText('myInput="foo¦"'); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.string, ['foo']); + expectReplacementText(completions, templateFile.contents, 'foo'); + }); + + it('should complete a string union types in binding without brackets when the cursor at the start of the string', + () => { + const {templateFile} = setup(``, '', DIR_WITH_UNION_TYPE_INPUT); + templateFile.moveCursorToText('myInput="¦foo"'); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.string, ['foo']); + expectReplacementText(completions, templateFile.contents, 'foo'); + }); + + it('should complete a string union types in pipe', () => { + const {templateFile} = + setup(``, '', UNION_TYPE_PIPE); + templateFile.moveCursorToText(`[myInput]="'foo'|unionTypePipe:'bar¦'"`); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.string, ['bar']); + expectReplacementText(completions, templateFile.contents, 'bar'); + }); + + it('should complete a number union types', () => { + const {templateFile} = setup(``, '', DIR_WITH_UNION_TYPE_INPUT); + templateFile.moveCursorToText(`[myInput]="42¦"`); + const completions = templateFile.getCompletionsAtPosition(); + expectContain(completions, ts.ScriptElementKind.string, ['42']); + expectReplacementText(completions, templateFile.contents, '42'); + }); + }); }); function expectContain(