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:
parent
047994b048
commit
7c35ca0e00
|
@ -6,8 +6,9 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* 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 {AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system';
|
||||||
|
import {TextAttribute} from '@angular/compiler/src/render3/r3_ast';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
import {ErrorCode} from '../../diagnostics';
|
import {ErrorCode} from '../../diagnostics';
|
||||||
|
|
||||||
|
@ -119,6 +120,14 @@ export interface TemplateTypeChecker {
|
||||||
expr: PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall,
|
expr: PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall,
|
||||||
component: ts.ClassDeclaration): ShimLocation|null;
|
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.
|
* Get basic metadata on the directives which are in scope for the given component.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -6,7 +6,8 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* 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 * as ts from 'typescript';
|
||||||
import {ErrorCode} from '../../diagnostics';
|
import {ErrorCode} from '../../diagnostics';
|
||||||
|
|
||||||
|
@ -280,6 +281,16 @@ export class TemplateTypeCheckerImpl implements TemplateTypeChecker {
|
||||||
PerfPhase.TtcAutocompletion, () => engine.getExpressionCompletionLocation(ast));
|
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 {
|
invalidateClass(clazz: ts.ClassDeclaration): void {
|
||||||
this.completionCache.delete(clazz);
|
this.completionCache.delete(clazz);
|
||||||
this.symbolBuilderCache.delete(clazz);
|
this.symbolBuilderCache.delete(clazz);
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {TmplAstReference, TmplAstTemplate} from '@angular/compiler';
|
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 * as ts from 'typescript';
|
||||||
|
|
||||||
import {AbsoluteFsPath} from '../../file_system';
|
import {AbsoluteFsPath} from '../../file_system';
|
||||||
|
@ -32,8 +33,9 @@ export class CompletionEngine {
|
||||||
private templateContextCache =
|
private templateContextCache =
|
||||||
new Map<TmplAstTemplate|null, Map<string, ReferenceCompletion|VariableCompletion>>();
|
new Map<TmplAstTemplate|null, Map<string, ReferenceCompletion|VariableCompletion>>();
|
||||||
|
|
||||||
private expressionCompletionCache =
|
private expressionCompletionCache = new Map<
|
||||||
new Map<PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall, ShimLocation>();
|
PropertyRead|SafePropertyRead|MethodCall|SafeMethodCall|LiteralPrimitive|TextAttribute,
|
||||||
|
ShimLocation>();
|
||||||
|
|
||||||
|
|
||||||
constructor(private tcb: ts.Node, private data: TemplateData, private shimPath: AbsoluteFsPath) {
|
constructor(private tcb: ts.Node, private data: TemplateData, private shimPath: AbsoluteFsPath) {
|
||||||
|
@ -144,6 +146,46 @@ export class CompletionEngine {
|
||||||
return res;
|
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
|
* Get global completions within the given template context - either a `TmplAstTemplate` embedded
|
||||||
* view, or `null` for the root context.
|
* view, or `null` for the root context.
|
||||||
|
|
|
@ -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 {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 {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, TextAttribute} from '@angular/compiler/src/render3/r3_ast';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
|
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
|
||||||
|
@ -26,6 +26,8 @@ type ElementAttributeCompletionBuilder =
|
||||||
|
|
||||||
type PipeCompletionBuilder = CompletionBuilder<BindingPipe>;
|
type PipeCompletionBuilder = CompletionBuilder<BindingPipe>;
|
||||||
|
|
||||||
|
type LiteralCompletionBuilder = CompletionBuilder<LiteralPrimitive|TextAttribute>;
|
||||||
|
|
||||||
export enum CompletionNodeContext {
|
export enum CompletionNodeContext {
|
||||||
None,
|
None,
|
||||||
ElementTag,
|
ElementTag,
|
||||||
|
@ -72,11 +74,71 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
return this.getElementAttributeCompletions();
|
return this.getElementAttributeCompletions();
|
||||||
} else if (this.isPipeCompletion()) {
|
} else if (this.isPipeCompletion()) {
|
||||||
return this.getPipeCompletions();
|
return this.getPipeCompletions();
|
||||||
|
} else if (this.isLiteralCompletion()) {
|
||||||
|
return this.getLiteralCompletions(options);
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
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`.
|
* Analogue for `ts.LanguageService.getCompletionEntryDetails`.
|
||||||
*/
|
*/
|
||||||
|
@ -749,6 +811,8 @@ function nodeContextFromTarget(target: TargetContext): CompletionNodeContext {
|
||||||
case TargetNodeKind.AttributeInValueContext:
|
case TargetNodeKind.AttributeInValueContext:
|
||||||
if (target.node instanceof TmplAstBoundEvent) {
|
if (target.node instanceof TmplAstBoundEvent) {
|
||||||
return CompletionNodeContext.EventValue;
|
return CompletionNodeContext.EventValue;
|
||||||
|
} else if (target.node instanceof TextAttribute) {
|
||||||
|
return CompletionNodeContext.ElementAttributeValue;
|
||||||
} else {
|
} else {
|
||||||
return CompletionNodeContext.None;
|
return CompletionNodeContext.None;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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', () => {
|
describe('completions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initMockFileSystem('Native');
|
initMockFileSystem('Native');
|
||||||
|
@ -749,6 +762,50 @@ describe('completions', () => {
|
||||||
expect(completions?.entries.length).toBe(0);
|
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(
|
function expectContain(
|
||||||
|
|
Loading…
Reference in New Issue