Skip to content

Commit 7c0b25f

Browse files
Keen Yee LiauAndrewKushnir
authored andcommitted
fix(language-service): incorrect autocomplete results on unknown symbol (#37518)
This commit fixes a bug whereby the language service would incorrectly return HTML elements if autocomplete is requested for an unknown symbol. This is because we walk through every possible scenario, and fallback to element autocomplete if none of the scenarios match. The fix here is to return results from interpolation if we know for sure we are in a bound text. This means we will now return an empty results if there is no suggestions. This commit also refactors the code a little to make it easier to understand. PR Close #37518
1 parent 07b5df3 commit 7c0b25f

File tree

2 files changed

+89
-76
lines changed

2 files changed

+89
-76
lines changed

packages/language-service/src/completions.ts

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

9-
import {AbsoluteSourceSpan, AST, AstPath, AttrAst, Attribute, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, Element, ElementAst, EmptyExpr, ExpressionBinding, getHtmlTagDefinition, HtmlAstPath, Node as HtmlAst, NullTemplateVisitor, ParseSpan, ReferenceAst, TagContentType, TemplateBinding, Text, VariableBinding} from '@angular/compiler';
9+
import {AbsoluteSourceSpan, AST, AstPath, AttrAst, Attribute, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, Element, ElementAst, EmptyExpr, ExpressionBinding, getHtmlTagDefinition, HtmlAstPath, Node as HtmlAst, NullTemplateVisitor, ParseSpan, ReferenceAst, TagContentType, TemplateBinding, Text, VariableBinding, Visitor} from '@angular/compiler';
1010
import {$$, $_, isAsciiLetter, isDigit} from '@angular/compiler/src/chars';
1111

1212
import {ATTR, getBindingDescriptor} from './binding_utils';
@@ -127,79 +127,97 @@ function getBoundedWordSpan(
127127

128128
export function getTemplateCompletions(
129129
templateInfo: ng.AstResult, position: number): ng.CompletionEntry[] {
130-
let result: ng.CompletionEntry[] = [];
131130
const {htmlAst, template} = templateInfo;
132-
// The templateNode starts at the delimiter character so we add 1 to skip it.
131+
// Calculate the position relative to the start of the template. This is needed
132+
// because spans in HTML AST are relative. Inline template has non-zero start position.
133133
const templatePosition = position - template.span.start;
134-
const path = getPathToNodeAtPosition(htmlAst, templatePosition);
135-
const mostSpecific = path.tail;
136-
if (path.empty || !mostSpecific) {
137-
result = elementCompletions(templateInfo);
138-
} else {
139-
const astPosition = templatePosition - mostSpecific.sourceSpan.start.offset;
140-
mostSpecific.visit(
141-
{
142-
visitElement(ast) {
143-
const startTagSpan = spanOf(ast.sourceSpan);
144-
const tagLen = ast.name.length;
145-
// + 1 for the opening angle bracket
146-
if (templatePosition <= startTagSpan.start + tagLen + 1) {
147-
// If we are in the tag then return the element completions.
148-
result = elementCompletions(templateInfo);
149-
} else if (templatePosition < startTagSpan.end) {
150-
// We are in the attribute section of the element (but not in an attribute).
151-
// Return the attribute completions.
152-
result = attributeCompletionsForElement(templateInfo, ast.name);
153-
}
154-
},
155-
visitAttribute(ast: Attribute) {
156-
// An attribute consists of two parts, LHS="RHS".
157-
// Determine if completions are requested for LHS or RHS
158-
if (ast.valueSpan && inSpan(templatePosition, spanOf(ast.valueSpan))) {
159-
// RHS completion
160-
result = attributeValueCompletions(templateInfo, path);
161-
} else {
162-
// LHS completion
163-
result = attributeCompletions(templateInfo, path);
164-
}
165-
},
166-
visitText(ast) {
167-
result = interpolationCompletions(templateInfo, templatePosition);
168-
if (result.length) return result;
169-
const element = path.first(Element);
170-
if (element) {
171-
const definition = getHtmlTagDefinition(element.name);
172-
if (definition.contentType === TagContentType.PARSABLE_DATA) {
173-
result = voidElementAttributeCompletions(templateInfo, path);
174-
if (!result.length) {
175-
// If the element can hold content, show element completions.
176-
result = elementCompletions(templateInfo);
177-
}
178-
}
179-
} else {
180-
// If no element container, implies parsable data so show elements.
181-
result = voidElementAttributeCompletions(templateInfo, path);
182-
if (!result.length) {
183-
result = elementCompletions(templateInfo);
184-
}
185-
}
186-
},
187-
visitComment() {},
188-
visitExpansion() {},
189-
visitExpansionCase() {}
190-
},
191-
null);
192-
}
193-
134+
const htmlPath: HtmlAstPath = getPathToNodeAtPosition(htmlAst, templatePosition);
135+
const mostSpecific = htmlPath.tail;
136+
const visitor = new HtmlVisitor(templateInfo, htmlPath);
137+
const results: ng.CompletionEntry[] = mostSpecific ?
138+
mostSpecific.visit(visitor, null /* context */) :
139+
elementCompletions(templateInfo);
194140
const replacementSpan = getBoundedWordSpan(templateInfo, position, mostSpecific);
195-
return result.map(entry => {
141+
return results.map(entry => {
196142
return {
197143
...entry,
198144
replacementSpan,
199145
};
200146
});
201147
}
202148

149+
class HtmlVisitor implements Visitor {
150+
/**
151+
* Position relative to the start of the template.
152+
*/
153+
private readonly relativePosition: number;
154+
constructor(private readonly templateInfo: ng.AstResult, private readonly htmlPath: HtmlAstPath) {
155+
this.relativePosition = htmlPath.position;
156+
}
157+
// Note that every visitor method must explicitly specify return type because
158+
// Visitor returns `any` for all methods.
159+
visitElement(ast: Element): ng.CompletionEntry[] {
160+
const startTagSpan = spanOf(ast.sourceSpan);
161+
const tagLen = ast.name.length;
162+
// + 1 for the opening angle bracket
163+
if (this.relativePosition <= startTagSpan.start + tagLen + 1) {
164+
// If we are in the tag then return the element completions.
165+
return elementCompletions(this.templateInfo);
166+
}
167+
if (this.relativePosition < startTagSpan.end) {
168+
// We are in the attribute section of the element (but not in an attribute).
169+
// Return the attribute completions.
170+
return attributeCompletionsForElement(this.templateInfo, ast.name);
171+
}
172+
return [];
173+
}
174+
visitAttribute(ast: Attribute): ng.CompletionEntry[] {
175+
// An attribute consists of two parts, LHS="RHS".
176+
// Determine if completions are requested for LHS or RHS
177+
if (ast.valueSpan && inSpan(this.relativePosition, spanOf(ast.valueSpan))) {
178+
// RHS completion
179+
return attributeValueCompletions(this.templateInfo, this.htmlPath);
180+
}
181+
// LHS completion
182+
return attributeCompletions(this.templateInfo, this.htmlPath);
183+
}
184+
visitText(): ng.CompletionEntry[] {
185+
const templatePath = findTemplateAstAt(this.templateInfo.templateAst, this.relativePosition);
186+
if (templatePath.tail instanceof BoundTextAst) {
187+
// If we know that this is an interpolation then do not try other scenarios.
188+
const visitor = new ExpressionVisitor(
189+
this.templateInfo, this.relativePosition,
190+
() =>
191+
getExpressionScope(diagnosticInfoFromTemplateInfo(this.templateInfo), templatePath));
192+
templatePath.tail?.visit(visitor, null);
193+
return visitor.results;
194+
}
195+
// TODO(kyliau): Not sure if this check is really needed since we don't have
196+
// any test cases for it.
197+
const element = this.htmlPath.first(Element);
198+
if (element &&
199+
getHtmlTagDefinition(element.name).contentType !== TagContentType.PARSABLE_DATA) {
200+
return [];
201+
}
202+
// This is to account for cases like <h1> <a> text | </h1> where the
203+
// closest element has no closing tag and thus is considered plain text.
204+
const results = voidElementAttributeCompletions(this.templateInfo, this.htmlPath);
205+
if (results.length) {
206+
return results;
207+
}
208+
return elementCompletions(this.templateInfo);
209+
}
210+
visitComment(): ng.CompletionEntry[] {
211+
return [];
212+
}
213+
visitExpansion(): ng.CompletionEntry[] {
214+
return [];
215+
}
216+
visitExpansionCase(): ng.CompletionEntry[] {
217+
return [];
218+
}
219+
}
220+
203221
function attributeCompletions(info: ng.AstResult, path: AstPath<HtmlAst>): ng.CompletionEntry[] {
204222
const attr = path.tail;
205223
const elem = path.parentOf(attr);
@@ -356,18 +374,6 @@ function elementCompletions(info: ng.AstResult): ng.CompletionEntry[] {
356374
return results;
357375
}
358376

359-
function interpolationCompletions(info: ng.AstResult, position: number): ng.CompletionEntry[] {
360-
// Look for an interpolation in at the position.
361-
const templatePath = findTemplateAstAt(info.templateAst, position);
362-
if (!templatePath.tail) {
363-
return [];
364-
}
365-
const visitor = new ExpressionVisitor(
366-
info, position, () => getExpressionScope(diagnosticInfoFromTemplateInfo(info), templatePath));
367-
templatePath.tail.visit(visitor, null);
368-
return visitor.results;
369-
}
370-
371377
// There is a special case of HTML where text that contains a unclosed tag is treated as
372378
// text. For exaple '<h1> Some <a text </h1>' produces a text nodes inside of the H1
373379
// element "Some <a text". We, however, want to treat this as if the user was requesting

packages/language-service/test/completions_spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,6 +841,13 @@ describe('completions', () => {
841841
'trim',
842842
]);
843843
});
844+
845+
it('should not return any results for unknown symbol', () => {
846+
mockHost.override(TEST_TEMPLATE, '{{ doesnotexist.~{cursor} }}');
847+
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
848+
const completions = ngLS.getCompletionsAtPosition(TEST_TEMPLATE, marker.start);
849+
expect(completions).toBeUndefined();
850+
});
844851
});
845852

846853
function expectContain(

0 commit comments

Comments
 (0)