fix(language-service): correctly parse expressions in an attribute (#34517)

Currently, the language service provides completions in a template node
attribute by first checking if the attribute contains template bindings
to provide completions for, and then providing completions for the
expression in the attribute.

In the latter case, the expression AST was being constructed
"synthetically" inside the language service, in particular declaring the
expression to be a `PropertyRead` with an implicit receiver.
Unfortunately, this AST can be incorrect if the expression is actually a
property read on a component property receiver (e.g. when reading
`key` in the expression `obj.key`, `obj` is the receiver).

The fix is pretty simple - rather than a synthetic construction of the
AST, ask the expression parser to parse the expression in the attribute.

Fixes https://github.com/angular/vscode-ng-language-service/issues/523

PR Close #34517
This commit is contained in:
ayazhafiz 2019-12-20 14:26:27 -06:00 committed by Alex Rickabaugh
parent c079f38cbb
commit ee46b9b44f
2 changed files with 32 additions and 13 deletions

View File

@ -419,11 +419,9 @@ class ExpressionVisitor extends NullTemplateVisitor {
} }
visitAttr(ast: AttrAst) { visitAttr(ast: AttrAst) {
// The attribute value is a template expression but the expression AST // First, verify the attribute consists of some binding we can give completions for.
// was not produced when the TemplateAst was produced so do that here.
const {templateBindings} = this.info.expressionParser.parseTemplateBindings( const {templateBindings} = this.info.expressionParser.parseTemplateBindings(
ast.name, ast.value, ast.sourceSpan.toString(), ast.sourceSpan.start.offset); ast.name, ast.value, ast.sourceSpan.toString(), ast.sourceSpan.start.offset);
// Find where the cursor is relative to the start of the attribute value. // Find where the cursor is relative to the start of the attribute value.
const valueRelativePosition = this.position - ast.sourceSpan.start.offset; const valueRelativePosition = this.position - ast.sourceSpan.start.offset;
// Find the template binding that contains the position // Find the template binding that contains the position
@ -436,12 +434,11 @@ class ExpressionVisitor extends NullTemplateVisitor {
if (ast.name.startsWith('*')) { if (ast.name.startsWith('*')) {
this.microSyntaxInAttributeValue(ast, binding); this.microSyntaxInAttributeValue(ast, binding);
} else { } else {
// If the position is in the expression or after the key or there is no key, // If the position is in the expression or after the key or there is no key, return the
// return the expression completions // expression completions.
const span = new ParseSpan(0, ast.value.length); // The expression must be reparsed to get a valid AST rather than only template bindings.
const offset = ast.sourceSpan.start.offset; const expressionAst = this.info.expressionParser.parseBinding(
const receiver = new ImplicitReceiver(span, span.toAbsolute(offset)); ast.value, ast.sourceSpan.toString(), ast.sourceSpan.start.offset);
const expressionAst = new PropertyRead(span, span.toAbsolute(offset), receiver, '');
this.addAttributeValuesToCompletions(expressionAst); this.addAttributeValuesToCompletions(expressionAst);
} }
} }
@ -542,10 +539,8 @@ class ExpressionVisitor extends NullTemplateVisitor {
const KW_OF = ' of '; const KW_OF = ' of ';
const ofLocation = attr.value.indexOf(KW_OF); const ofLocation = attr.value.indexOf(KW_OF);
if (ofLocation > 0 && valueRelativePosition >= ofLocation + KW_OF.length) { if (ofLocation > 0 && valueRelativePosition >= ofLocation + KW_OF.length) {
const span = new ParseSpan(0, attr.value.length); const expressionAst = this.info.expressionParser.parseBinding(
const offset = attr.sourceSpan.start.offset; attr.value, attr.sourceSpan.toString(), attr.sourceSpan.start.offset);
const receiver = new ImplicitReceiver(span, span.toAbsolute(offset));
const expressionAst = new PropertyRead(span, span.toAbsolute(offset), receiver, '');
this.addAttributeValuesToCompletions(expressionAst); this.addAttributeValuesToCompletions(expressionAst);
} }
} }

View File

@ -320,6 +320,13 @@ describe('completions', () => {
expectContain(completions, CompletionKind.VARIABLE, ['x']); expectContain(completions, CompletionKind.VARIABLE, ['x']);
}); });
it('should include expression completions', () => {
mockHost.override(TEST_TEMPLATE, `<div *ngFor="let x of hero.~{expr-property-read}"></div>`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'expr-property-read');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.PROPERTY, ['name']);
});
it('should include variable in the let scope in interpolation', () => { it('should include variable in the let scope in interpolation', () => {
mockHost.override(TEST_TEMPLATE, ` mockHost.override(TEST_TEMPLATE, `
<div *ngFor="let h of heroes"> <div *ngFor="let h of heroes">
@ -370,6 +377,13 @@ describe('completions', () => {
expectContain(completions, CompletionKind.PROPERTY, ['test']); expectContain(completions, CompletionKind.PROPERTY, ['test']);
}); });
it('should be able to complete property read', () => {
mockHost.override(TEST_TEMPLATE, `<h1 [model]="hero.~{property-read}"></h1>`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'property-read');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']);
});
it('should be able to complete an event', () => { it('should be able to complete an event', () => {
const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'event-binding-model'); const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'event-binding-model');
const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start);
@ -471,6 +485,16 @@ describe('completions', () => {
expectContain(completions, CompletionKind.PROPERTY, ['name', 'testEvent']); expectContain(completions, CompletionKind.PROPERTY, ['name', 'testEvent']);
}); });
it('should get reference property completions in a data binding', () => {
mockHost.override(TEST_TEMPLATE, `
<test-comp #test></test-comp>
<div (click)="test.~{property-read}"></div>
`);
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'property-read');
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
expectContain(completions, CompletionKind.PROPERTY, ['name', 'testEvent']);
});
// TODO: Enable when we have a flag that indicates the project targets the DOM // TODO: Enable when we have a flag that indicates the project targets the DOM
// it('should reference the element if no component', () => { // it('should reference the element if no component', () => {
// const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'test-comp-after-div'); // const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'test-comp-after-div');