fix(language-service): provide element completions after open tag < (#41068)

An opening tag `<` without any characters after it is interperted as a
text node (just a "less than" character) rather than the start of an
element in the template AST. This commit adjusts the autocomplete engine
to provide element autocompletions when the nearest character to the
left of the cursor is `<`.

Part of the fix for angular/vscode-ng-language-service#1140

PR Close #41068
This commit is contained in:
Andrew Scott 2021-03-02 16:33:13 -08:00 committed by Zach Arend
parent 7765b648e7
commit 1e3c870ee6
3 changed files with 107 additions and 43 deletions

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AST, BindingPipe, 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, 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';
@ -14,6 +14,7 @@ import * as ts from 'typescript';
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target';
import {filterAliasImports} from './utils';
type PropertyExpressionCompletionBuilder =
@ -48,13 +49,15 @@ export enum CompletionNodeContext {
export class CompletionBuilder<N extends TmplAstNode|AST> {
private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker();
private readonly templateTypeChecker = this.compiler.getTemplateTypeChecker();
private readonly nodeParent = this.targetDetails.parent;
private readonly nodeContext = nodeContextFromTarget(this.targetDetails.context);
private readonly template = this.targetDetails.template;
private readonly position = this.targetDetails.position;
constructor(
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler,
private readonly component: ts.ClassDeclaration, private readonly node: N,
private readonly nodeContext: CompletionNodeContext,
private readonly nodeParent: TmplAstNode|AST|null,
private readonly template: TmplAstTemplate|null) {}
private readonly targetDetails: TemplateTarget) {}
/**
* Analogue for `ts.LanguageService.getCompletionsAtPosition`.
@ -335,20 +338,37 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
}
}
private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement> {
return this.node instanceof TmplAstElement &&
this.nodeContext === CompletionNodeContext.ElementTag;
private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement|TmplAstText> {
if (this.node instanceof TmplAstText) {
const positionInTextNode = this.position - this.node.sourceSpan.start.offset;
// We only provide element completions in a text node when there is an open tag immediately to
// the left of the position.
return this.node.value.substring(0, positionInTextNode).endsWith('<');
} else if (this.node instanceof TmplAstElement) {
return this.nodeContext === CompletionNodeContext.ElementTag;
}
return false;
}
private getElementTagCompletion(this: CompletionBuilder<TmplAstElement>):
private getElementTagCompletion(this: CompletionBuilder<TmplAstElement|TmplAstText>):
ts.WithMetadata<ts.CompletionInfo>|undefined {
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
let start: number;
let length: number;
if (this.node instanceof TmplAstElement) {
// The replacementSpan is the tag name.
const replacementSpan: ts.TextSpan = {
start: this.node.sourceSpan.start.offset + 1, // account for leading '<'
length: this.node.name.length,
};
start = this.node.sourceSpan.start.offset + 1; // account for leading '<'
length = this.node.name.length;
} else {
const positionInTextNode = this.position - this.node.sourceSpan.start.offset;
const textToLeftOfPosition = this.node.value.substring(0, positionInTextNode);
start = this.node.sourceSpan.start.offset + textToLeftOfPosition.lastIndexOf('<') + 1;
// We only autocomplete immediately after the < so we don't replace any existing text
length = 0;
}
const replacementSpan: ts.TextSpan = {start, length};
const entries: ts.CompletionEntry[] =
Array.from(templateTypeChecker.getPotentialElementTags(this.component))
@ -368,8 +388,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
}
private getElementTagCompletionDetails(
this: CompletionBuilder<TmplAstElement>, entryName: string): ts.CompletionEntryDetails
|undefined {
this: CompletionBuilder<TmplAstElement|TmplAstText>,
entryName: string): ts.CompletionEntryDetails|undefined {
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
@ -397,8 +417,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
};
}
private getElementTagCompletionSymbol(this: CompletionBuilder<TmplAstElement>, entryName: string):
ts.Symbol|undefined {
private getElementTagCompletionSymbol(
this: CompletionBuilder<TmplAstElement|TmplAstText>, entryName: string): ts.Symbol|undefined {
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
@ -664,3 +684,26 @@ function stripBindingSugar(binding: string): {name: string, kind: DisplayInfoKin
return {name, kind: DisplayInfoKind.ATTRIBUTE};
}
}
function nodeContextFromTarget(target: TargetContext): CompletionNodeContext {
switch (target.kind) {
case TargetNodeKind.ElementInTagContext:
return CompletionNodeContext.ElementTag;
case TargetNodeKind.ElementInBodyContext:
// Completions in element bodies are for new attributes.
return CompletionNodeContext.ElementAttributeKey;
case TargetNodeKind.TwoWayBindingContext:
return CompletionNodeContext.TwoWayBinding;
case TargetNodeKind.AttributeInKeyContext:
return CompletionNodeContext.ElementAttributeKey;
case TargetNodeKind.AttributeInValueContext:
if (target.node instanceof TmplAstBoundEvent) {
return CompletionNodeContext.EventValue;
} else {
return CompletionNodeContext.None;
}
default:
// No special context is available.
return CompletionNodeContext.None;
}
}

View File

@ -180,9 +180,7 @@ export class LanguageService {
positionDetails.context.nodes[0] :
positionDetails.context.node;
return new CompletionBuilder(
this.tsLS, compiler, templateInfo.component, node,
nodeContextFromTarget(positionDetails.context), positionDetails.parent,
positionDetails.template);
this.tsLS, compiler, templateInfo.component, node, positionDetails);
}
getCompletionsAtPosition(
@ -450,29 +448,6 @@ function getOrCreateTypeCheckScriptInfo(
return scriptInfo;
}
function nodeContextFromTarget(target: TargetContext): CompletionNodeContext {
switch (target.kind) {
case TargetNodeKind.ElementInTagContext:
return CompletionNodeContext.ElementTag;
case TargetNodeKind.ElementInBodyContext:
// Completions in element bodies are for new attributes.
return CompletionNodeContext.ElementAttributeKey;
case TargetNodeKind.TwoWayBindingContext:
return CompletionNodeContext.TwoWayBinding;
case TargetNodeKind.AttributeInKeyContext:
return CompletionNodeContext.ElementAttributeKey;
case TargetNodeKind.AttributeInValueContext:
if (target.node instanceof TmplAstBoundEvent) {
return CompletionNodeContext.EventValue;
} else {
return CompletionNodeContext.None;
}
default:
// No special context is available.
return CompletionNodeContext.None;
}
}
function isTemplateContext(program: ts.Program, fileName: string, position: number): boolean {
if (!isTypeScriptFile(fileName)) {
// If we aren't in a TS file, we must be in an HTML file, which we treat as template context

View File

@ -353,6 +353,52 @@ describe('completions', () => {
expect(ts.displayPartsToString(details.documentation!)).toEqual('This is another component.');
});
it('should return completions with a blank open tag', () => {
const OTHER_CMP = {
'OtherCmp': `
@Component({selector: 'other-cmp', template: 'unimportant'})
export class OtherCmp {}
`,
};
const {templateFile} = setup(`<`, '', OTHER_CMP);
templateFile.moveCursorToText('<¦');
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.COMPONENT),
['other-cmp']);
});
it('should return completions with a blank open tag a character before', () => {
const OTHER_CMP = {
'OtherCmp': `
@Component({selector: 'other-cmp', template: 'unimportant'})
export class OtherCmp {}
`,
};
const {templateFile} = setup(`a <`, '', OTHER_CMP);
templateFile.moveCursorToText('a <¦');
const completions = templateFile.getCompletionsAtPosition();
expectContain(
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.COMPONENT),
['other-cmp']);
});
it('should not return completions when cursor is not after the open tag', () => {
const OTHER_CMP = {
'OtherCmp': `
@Component({selector: 'other-cmp', template: 'unimportant'})
export class OtherCmp {}
`,
};
const {templateFile} = setup(`\n\n< `, '', OTHER_CMP);
templateFile.moveCursorToText('< ¦');
const completions = templateFile.getCompletionsAtPosition();
expect(completions).toBeUndefined();
});
describe('element attribute scope', () => {
describe('dom completions', () => {
it('should return completions for a new element attribute', () => {