Skip to content

Commit f09e7ab

Browse files
atscottzarend
authored andcommitted
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
1 parent e4774da commit f09e7ab

File tree

3 files changed

+107
-43
lines changed

3 files changed

+107
-43
lines changed

packages/language-service/ivy/completions.ts

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {AST, BindingPipe, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
9+
import {AST, BindingPipe, EmptyExpr, ImplicitReceiver, LiteralPrimitive, MethodCall, ParseSourceSpan, PropertyRead, PropertyWrite, SafeMethodCall, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
1010
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
1111
import {CompletionKind, DirectiveInScope, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
1212
import {BoundEvent} from '@angular/compiler/src/render3/r3_ast';
1313
import * as ts from 'typescript';
1414

1515
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
1616
import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
17+
import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target';
1718
import {filterAliasImports} from './utils';
1819

1920
type PropertyExpressionCompletionBuilder =
@@ -48,13 +49,15 @@ export enum CompletionNodeContext {
4849
export class CompletionBuilder<N extends TmplAstNode|AST> {
4950
private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker();
5051
private readonly templateTypeChecker = this.compiler.getTemplateTypeChecker();
52+
private readonly nodeParent = this.targetDetails.parent;
53+
private readonly nodeContext = nodeContextFromTarget(this.targetDetails.context);
54+
private readonly template = this.targetDetails.template;
55+
private readonly position = this.targetDetails.position;
5156

5257
constructor(
5358
private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler,
5459
private readonly component: ts.ClassDeclaration, private readonly node: N,
55-
private readonly nodeContext: CompletionNodeContext,
56-
private readonly nodeParent: TmplAstNode|AST|null,
57-
private readonly template: TmplAstTemplate|null) {}
60+
private readonly targetDetails: TemplateTarget) {}
5861

5962
/**
6063
* Analogue for `ts.LanguageService.getCompletionsAtPosition`.
@@ -335,20 +338,37 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
335338
}
336339
}
337340

338-
private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement> {
339-
return this.node instanceof TmplAstElement &&
340-
this.nodeContext === CompletionNodeContext.ElementTag;
341+
private isElementTagCompletion(): this is CompletionBuilder<TmplAstElement|TmplAstText> {
342+
if (this.node instanceof TmplAstText) {
343+
const positionInTextNode = this.position - this.node.sourceSpan.start.offset;
344+
// We only provide element completions in a text node when there is an open tag immediately to
345+
// the left of the position.
346+
return this.node.value.substring(0, positionInTextNode).endsWith('<');
347+
} else if (this.node instanceof TmplAstElement) {
348+
return this.nodeContext === CompletionNodeContext.ElementTag;
349+
}
350+
return false;
341351
}
342352

343-
private getElementTagCompletion(this: CompletionBuilder<TmplAstElement>):
353+
private getElementTagCompletion(this: CompletionBuilder<TmplAstElement|TmplAstText>):
344354
ts.WithMetadata<ts.CompletionInfo>|undefined {
345355
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
346356

347-
// The replacementSpan is the tag name.
348-
const replacementSpan: ts.TextSpan = {
349-
start: this.node.sourceSpan.start.offset + 1, // account for leading '<'
350-
length: this.node.name.length,
351-
};
357+
let start: number;
358+
let length: number;
359+
if (this.node instanceof TmplAstElement) {
360+
// The replacementSpan is the tag name.
361+
start = this.node.sourceSpan.start.offset + 1; // account for leading '<'
362+
length = this.node.name.length;
363+
} else {
364+
const positionInTextNode = this.position - this.node.sourceSpan.start.offset;
365+
const textToLeftOfPosition = this.node.value.substring(0, positionInTextNode);
366+
start = this.node.sourceSpan.start.offset + textToLeftOfPosition.lastIndexOf('<') + 1;
367+
// We only autocomplete immediately after the < so we don't replace any existing text
368+
length = 0;
369+
}
370+
371+
const replacementSpan: ts.TextSpan = {start, length};
352372

353373
const entries: ts.CompletionEntry[] =
354374
Array.from(templateTypeChecker.getPotentialElementTags(this.component))
@@ -368,8 +388,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
368388
}
369389

370390
private getElementTagCompletionDetails(
371-
this: CompletionBuilder<TmplAstElement>, entryName: string): ts.CompletionEntryDetails
372-
|undefined {
391+
this: CompletionBuilder<TmplAstElement|TmplAstText>,
392+
entryName: string): ts.CompletionEntryDetails|undefined {
373393
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
374394

375395
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
@@ -397,8 +417,8 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
397417
};
398418
}
399419

400-
private getElementTagCompletionSymbol(this: CompletionBuilder<TmplAstElement>, entryName: string):
401-
ts.Symbol|undefined {
420+
private getElementTagCompletionSymbol(
421+
this: CompletionBuilder<TmplAstElement|TmplAstText>, entryName: string): ts.Symbol|undefined {
402422
const templateTypeChecker = this.compiler.getTemplateTypeChecker();
403423

404424
const tagMap = templateTypeChecker.getPotentialElementTags(this.component);
@@ -664,3 +684,26 @@ function stripBindingSugar(binding: string): {name: string, kind: DisplayInfoKin
664684
return {name, kind: DisplayInfoKind.ATTRIBUTE};
665685
}
666686
}
687+
688+
function nodeContextFromTarget(target: TargetContext): CompletionNodeContext {
689+
switch (target.kind) {
690+
case TargetNodeKind.ElementInTagContext:
691+
return CompletionNodeContext.ElementTag;
692+
case TargetNodeKind.ElementInBodyContext:
693+
// Completions in element bodies are for new attributes.
694+
return CompletionNodeContext.ElementAttributeKey;
695+
case TargetNodeKind.TwoWayBindingContext:
696+
return CompletionNodeContext.TwoWayBinding;
697+
case TargetNodeKind.AttributeInKeyContext:
698+
return CompletionNodeContext.ElementAttributeKey;
699+
case TargetNodeKind.AttributeInValueContext:
700+
if (target.node instanceof TmplAstBoundEvent) {
701+
return CompletionNodeContext.EventValue;
702+
} else {
703+
return CompletionNodeContext.None;
704+
}
705+
default:
706+
// No special context is available.
707+
return CompletionNodeContext.None;
708+
}
709+
}

packages/language-service/ivy/language_service.ts

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,7 @@ export class LanguageService {
178178
positionDetails.context.nodes[0] :
179179
positionDetails.context.node;
180180
return new CompletionBuilder(
181-
this.tsLS, compiler, templateInfo.component, node,
182-
nodeContextFromTarget(positionDetails.context), positionDetails.parent,
183-
positionDetails.template);
181+
this.tsLS, compiler, templateInfo.component, node, positionDetails);
184182
}
185183

186184
getCompletionsAtPosition(
@@ -425,29 +423,6 @@ function getOrCreateTypeCheckScriptInfo(
425423
return scriptInfo;
426424
}
427425

428-
function nodeContextFromTarget(target: TargetContext): CompletionNodeContext {
429-
switch (target.kind) {
430-
case TargetNodeKind.ElementInTagContext:
431-
return CompletionNodeContext.ElementTag;
432-
case TargetNodeKind.ElementInBodyContext:
433-
// Completions in element bodies are for new attributes.
434-
return CompletionNodeContext.ElementAttributeKey;
435-
case TargetNodeKind.TwoWayBindingContext:
436-
return CompletionNodeContext.TwoWayBinding;
437-
case TargetNodeKind.AttributeInKeyContext:
438-
return CompletionNodeContext.ElementAttributeKey;
439-
case TargetNodeKind.AttributeInValueContext:
440-
if (target.node instanceof TmplAstBoundEvent) {
441-
return CompletionNodeContext.EventValue;
442-
} else {
443-
return CompletionNodeContext.None;
444-
}
445-
default:
446-
// No special context is available.
447-
return CompletionNodeContext.None;
448-
}
449-
}
450-
451426
function isTemplateContext(program: ts.Program, fileName: string, position: number): boolean {
452427
if (!isTypeScriptFile(fileName)) {
453428
// If we aren't in a TS file, we must be in an HTML file, which we treat as template context

packages/language-service/ivy/test/completions_spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,52 @@ describe('completions', () => {
353353
expect(ts.displayPartsToString(details.documentation!)).toEqual('This is another component.');
354354
});
355355

356+
it('should return completions with a blank open tag', () => {
357+
const OTHER_CMP = {
358+
'OtherCmp': `
359+
@Component({selector: 'other-cmp', template: 'unimportant'})
360+
export class OtherCmp {}
361+
`,
362+
};
363+
const {templateFile} = setup(`<`, '', OTHER_CMP);
364+
templateFile.moveCursorToText('<¦');
365+
366+
const completions = templateFile.getCompletionsAtPosition();
367+
expectContain(
368+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.COMPONENT),
369+
['other-cmp']);
370+
});
371+
372+
it('should return completions with a blank open tag a character before', () => {
373+
const OTHER_CMP = {
374+
'OtherCmp': `
375+
@Component({selector: 'other-cmp', template: 'unimportant'})
376+
export class OtherCmp {}
377+
`,
378+
};
379+
const {templateFile} = setup(`a <`, '', OTHER_CMP);
380+
templateFile.moveCursorToText('a <¦');
381+
382+
const completions = templateFile.getCompletionsAtPosition();
383+
expectContain(
384+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.COMPONENT),
385+
['other-cmp']);
386+
});
387+
388+
it('should not return completions when cursor is not after the open tag', () => {
389+
const OTHER_CMP = {
390+
'OtherCmp': `
391+
@Component({selector: 'other-cmp', template: 'unimportant'})
392+
export class OtherCmp {}
393+
`,
394+
};
395+
const {templateFile} = setup(`\n\n< `, '', OTHER_CMP);
396+
templateFile.moveCursorToText('< ¦');
397+
398+
const completions = templateFile.getCompletionsAtPosition();
399+
expect(completions).toBeUndefined();
400+
});
401+
356402
describe('element attribute scope', () => {
357403
describe('dom completions', () => {
358404
it('should return completions for a new element attribute', () => {

0 commit comments

Comments
 (0)