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:
parent
7765b648e7
commit
1e3c870ee6
|
@ -6,7 +6,7 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* 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 {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} 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 {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
|
||||||
import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
|
import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
|
||||||
|
import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target';
|
||||||
import {filterAliasImports} from './utils';
|
import {filterAliasImports} from './utils';
|
||||||
|
|
||||||
type PropertyExpressionCompletionBuilder =
|
type PropertyExpressionCompletionBuilder =
|
||||||
|
@ -48,13 +49,15 @@ export enum CompletionNodeContext {
|
||||||
export class CompletionBuilder<N extends TmplAstNode|AST> {
|
export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker();
|
private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker();
|
||||||
private readonly templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
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(
|
constructor(
|
||||||
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler,
|
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler,
|
||||||
private readonly component: ts.ClassDeclaration, private readonly node: N,
|
private readonly component: ts.ClassDeclaration, private readonly node: N,
|
||||||
private readonly nodeContext: CompletionNodeContext,
|
private readonly targetDetails: TemplateTarget) {}
|
||||||
private readonly nodeParent: TmplAstNode|AST|null,
|
|
||||||
private readonly template: TmplAstTemplate|null) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analogue for `ts.LanguageService.getCompletionsAtPosition`.
|
* Analogue for `ts.LanguageService.getCompletionsAtPosition`.
|
||||||
|
@ -335,20 +338,37 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement> {
|
private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement|TmplAstText> {
|
||||||
return this.node instanceof TmplAstElement &&
|
if (this.node instanceof TmplAstText) {
|
||||||
this.nodeContext === CompletionNodeContext.ElementTag;
|
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 {
|
ts.WithMetadata<ts.CompletionInfo>|undefined {
|
||||||
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
||||||
|
|
||||||
// The replacementSpan is the tag name.
|
let start: number;
|
||||||
const replacementSpan: ts.TextSpan = {
|
let length: number;
|
||||||
start: this.node.sourceSpan.start.offset + 1, // account for leading '<'
|
if (this.node instanceof TmplAstElement) {
|
||||||
length: this.node.name.length,
|
// The replacementSpan is the tag name.
|
||||||
};
|
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[] =
|
const entries: ts.CompletionEntry[] =
|
||||||
Array.from(templateTypeChecker.getPotentialElementTags(this.component))
|
Array.from(templateTypeChecker.getPotentialElementTags(this.component))
|
||||||
|
@ -368,8 +388,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElementTagCompletionDetails(
|
private getElementTagCompletionDetails(
|
||||||
this: CompletionBuilder<TmplAstElement>, entryName: string): ts.CompletionEntryDetails
|
this: CompletionBuilder<TmplAstElement|TmplAstText>,
|
||||||
|undefined {
|
entryName: string): ts.CompletionEntryDetails|undefined {
|
||||||
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
||||||
|
|
||||||
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
|
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
|
||||||
|
@ -397,8 +417,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getElementTagCompletionSymbol(this: CompletionBuilder<TmplAstElement>, entryName: string):
|
private getElementTagCompletionSymbol(
|
||||||
ts.Symbol|undefined {
|
this: CompletionBuilder<TmplAstElement|TmplAstText>, entryName: string): ts.Symbol|undefined {
|
||||||
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
|
||||||
|
|
||||||
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
|
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
|
||||||
|
@ -664,3 +684,26 @@ function stripBindingSugar(binding: string): {name: string, kind: DisplayInfoKin
|
||||||
return {name, kind: DisplayInfoKind.ATTRIBUTE};
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -180,9 +180,7 @@ export class LanguageService {
|
||||||
positionDetails.context.nodes[0] :
|
positionDetails.context.nodes[0] :
|
||||||
positionDetails.context.node;
|
positionDetails.context.node;
|
||||||
return new CompletionBuilder(
|
return new CompletionBuilder(
|
||||||
this.tsLS, compiler, templateInfo.component, node,
|
this.tsLS, compiler, templateInfo.component, node, positionDetails);
|
||||||
nodeContextFromTarget(positionDetails.context), positionDetails.parent,
|
|
||||||
positionDetails.template);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCompletionsAtPosition(
|
getCompletionsAtPosition(
|
||||||
|
@ -450,29 +448,6 @@ function getOrCreateTypeCheckScriptInfo(
|
||||||
return scriptInfo;
|
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 {
|
function isTemplateContext(program: ts.Program, fileName: string, position: number): boolean {
|
||||||
if (!isTypeScriptFile(fileName)) {
|
if (!isTypeScriptFile(fileName)) {
|
||||||
// If we aren't in a TS file, we must be in an HTML file, which we treat as template context
|
// If we aren't in a TS file, we must be in an HTML file, which we treat as template context
|
||||||
|
|
|
@ -353,6 +353,52 @@ describe('completions', () => {
|
||||||
expect(ts.displayPartsToString(details.documentation!)).toEqual('This is another component.');
|
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('element attribute scope', () => {
|
||||||
describe('dom completions', () => {
|
describe('dom completions', () => {
|
||||||
it('should return completions for a new element attribute', () => {
|
it('should return completions for a new element attribute', () => {
|
||||||
|
|
Loading…
Reference in New Issue