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
This commit is contained in:
ivanwonder 2021-06-30 22:25:01 +08:00 committed by Dylan Hunn
parent 047994b048
commit 7c35ca0e00
5 changed files with 189 additions and 6 deletions

View File

@ -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.
*/

View File

@ -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);

View File

@ -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<TmplAstTemplate|null, Map<string, ReferenceCompletion|VariableCompletion>>();
private expressionCompletionCache =
new Map<PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall, ShimLocation>();
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.

View File

@ -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<BindingPipe>;
type LiteralCompletionBuilder = CompletionBuilder<LiteralPrimitive|TextAttribute>;
export enum CompletionNodeContext {
None,
ElementTag,
@ -72,11 +74,71 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
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<ts.CompletionInfo>|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;
}

View File

@ -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(`<input dir [myInput]="'foo'">`, '', 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(`<input dir myInput="foo">`, '', 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(`<input dir myInput="foo">`, '', 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(`<input dir [myInput]="'foo'|unionTypePipe:'bar'">`, '', 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(`<input dir [myInput]="42">`, '', 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(