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
|
||||
*/
|
||||
|
||||
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();
|
||||
|
||||
// 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,
|
||||
};
|
||||
let start: number;
|
||||
let length: number;
|
||||
if (this.node instanceof TmplAstElement) {
|
||||
// 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[] =
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
Loading…
Reference in New Issue