fix(language-service): Provide completions for attribute values (#33839)
This commit fixes a bug whereby completions for attribute values are only provided for directives that support the micro-syntax format, all other bindings are ignored. I'm not sure if this is a regresssion or a bug, because there were no tests prior to this. PR Close #33839
This commit is contained in:
parent
7eb3e3bce6
commit
592fd37b3e
|
@ -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, AstPath, Attribute, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, CssSelector, Element, ElementAst, ImplicitReceiver, NAMED_ENTITIES, Node as HtmlAst, NullTemplateVisitor, ParseSpan, PropertyRead, TagContentType, Text, findNode, getHtmlTagDefinition} from '@angular/compiler';
|
import {AST, AstPath, Attribute, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, CssSelector, Element, ElementAst, ImplicitReceiver, NAMED_ENTITIES, Node as HtmlAst, NullTemplateVisitor, ParseSpan, PropertyRead, TagContentType, TemplateBinding, Text, findNode, getHtmlTagDefinition} from '@angular/compiler';
|
||||||
import {$$, $_, isAsciiLetter, isDigit} from '@angular/compiler/src/chars';
|
import {$$, $_, isAsciiLetter, isDigit} from '@angular/compiler/src/chars';
|
||||||
|
|
||||||
import {AstResult} from './common';
|
import {AstResult} from './common';
|
||||||
|
@ -17,7 +17,6 @@ import {InlineTemplate} from './template';
|
||||||
import * as ng from './types';
|
import * as ng from './types';
|
||||||
import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, getSelectors, hasTemplateReference, inSpan, spanOf} from './utils';
|
import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, getSelectors, hasTemplateReference, inSpan, spanOf} from './utils';
|
||||||
|
|
||||||
const TEMPLATE_ATTR_PREFIX = '*';
|
|
||||||
const HIDDEN_HTML_ELEMENTS: ReadonlySet<string> =
|
const HIDDEN_HTML_ELEMENTS: ReadonlySet<string> =
|
||||||
new Set(['html', 'script', 'noscript', 'base', 'body', 'title', 'head', 'link']);
|
new Set(['html', 'script', 'noscript', 'base', 'body', 'title', 'head', 'link']);
|
||||||
const HTML_ELEMENTS: ReadonlyArray<ng.CompletionEntry> =
|
const HTML_ELEMENTS: ReadonlyArray<ng.CompletionEntry> =
|
||||||
|
@ -365,69 +364,36 @@ class ExpressionVisitor extends NullTemplateVisitor {
|
||||||
visitEvent(ast: BoundEventAst): void { this.addAttributeValuesToCompletions(ast.handler); }
|
visitEvent(ast: BoundEventAst): void { this.addAttributeValuesToCompletions(ast.handler); }
|
||||||
|
|
||||||
visitElement(ast: ElementAst): void {
|
visitElement(ast: ElementAst): void {
|
||||||
if (!this.attr || !this.attr.valueSpan || !this.attr.name.startsWith(TEMPLATE_ATTR_PREFIX)) {
|
if (!this.attr || !this.attr.valueSpan) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The value is a template expression but the expression AST was not produced when the
|
// The attribute value is a template expression but the expression AST
|
||||||
// TemplateAst was produce so do that now.
|
// was not produced when the TemplateAst was produced so do that here.
|
||||||
const key = this.attr.name.substr(TEMPLATE_ATTR_PREFIX.length);
|
const {templateBindings} = this.info.expressionParser.parseTemplateBindings(
|
||||||
// Find the selector
|
this.attr.name, this.attr.value, this.attr.sourceSpan.toString(),
|
||||||
const selectorInfo = getSelectors(this.info);
|
this.attr.sourceSpan.start.offset);
|
||||||
const selectors = selectorInfo.selectors;
|
|
||||||
const selector =
|
|
||||||
selectors.filter(s => s.attrs.some((attr, i) => i % 2 === 0 && attr === key))[0];
|
|
||||||
if (!selector) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateBindingResult =
|
// Find where the cursor is relative to the start of the attribute value.
|
||||||
this.info.expressionParser.parseTemplateBindings(key, this.attr.value, null, 0);
|
|
||||||
|
|
||||||
// find the template binding that contains the position
|
|
||||||
const valueRelativePosition = this.position - this.attr.valueSpan.start.offset;
|
const valueRelativePosition = this.position - this.attr.valueSpan.start.offset;
|
||||||
const bindings = templateBindingResult.templateBindings;
|
// Find the template binding that contains the position
|
||||||
const binding =
|
const binding = templateBindings.find(b => inSpan(valueRelativePosition, b.span));
|
||||||
bindings.find(
|
|
||||||
binding => inSpan(valueRelativePosition, binding.span, /* exclusive */ true)) ||
|
|
||||||
bindings.find(binding => inSpan(valueRelativePosition, binding.span));
|
|
||||||
|
|
||||||
if (binding) {
|
if (!binding) {
|
||||||
if (binding.keyIsVar) {
|
|
||||||
const equalLocation = this.attr.value.indexOf('=');
|
|
||||||
if (equalLocation >= 0 && valueRelativePosition >= equalLocation) {
|
|
||||||
// We are after the '=' in a let clause. The valid values here are the members of the
|
|
||||||
// template reference's type parameter.
|
|
||||||
const directiveMetadata = selectorInfo.map.get(selector);
|
|
||||||
if (directiveMetadata) {
|
|
||||||
const contextTable =
|
|
||||||
this.info.template.query.getTemplateContext(directiveMetadata.type.reference);
|
|
||||||
if (contextTable) {
|
|
||||||
this.addSymbolsToCompletions(contextTable.values());
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
if (this.attr.name.startsWith('*')) {
|
||||||
}
|
this.microSyntaxInAttributeValue(this.attr, binding);
|
||||||
if ((binding.expression && inSpan(valueRelativePosition, binding.expression.ast.span)) ||
|
} else if (valueRelativePosition >= 0) {
|
||||||
// If the position is in the expression or after the key or there is no key, return the
|
// If the position is in the expression or after the key or there is no key,
|
||||||
// expression completions
|
// return the expression completions
|
||||||
valueRelativePosition > binding.span.start + binding.key.length - key.length) {
|
|
||||||
const span = new ParseSpan(0, this.attr.value.length);
|
const span = new ParseSpan(0, this.attr.value.length);
|
||||||
const offset = ast.sourceSpan.start.offset;
|
const offset = ast.sourceSpan.start.offset;
|
||||||
let expressionAst: AST;
|
|
||||||
if (binding.expression) {
|
|
||||||
expressionAst = binding.expression.ast;
|
|
||||||
} else {
|
|
||||||
const receiver = new ImplicitReceiver(span, span.toAbsolute(offset));
|
const receiver = new ImplicitReceiver(span, span.toAbsolute(offset));
|
||||||
expressionAst = new PropertyRead(span, span.toAbsolute(offset), receiver, '');
|
const expressionAst = new PropertyRead(span, span.toAbsolute(offset), receiver, '');
|
||||||
|
this.addAttributeValuesToCompletions(expressionAst, valueRelativePosition);
|
||||||
}
|
}
|
||||||
this.addAttributeValuesToCompletions(expressionAst, this.position);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addKeysToCompletions(selector, key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
visitBoundText(ast: BoundTextAst) {
|
visitBoundText(ast: BoundTextAst) {
|
||||||
|
@ -486,6 +452,63 @@ class ExpressionVisitor extends NullTemplateVisitor {
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method handles the completions of attribute values for directives that
|
||||||
|
* support the microsyntax format. Examples are *ngFor and *ngIf.
|
||||||
|
* These directives allows declaration of "let" variables, adds context-specific
|
||||||
|
* symbols like $implicit, index, count, among other behaviors.
|
||||||
|
* For a complete description of such format, see
|
||||||
|
* https://angular.io/guide/structural-directives#the-asterisk--prefix
|
||||||
|
*
|
||||||
|
* @param attr descriptor for attribute name and value pair
|
||||||
|
* @param binding template binding for the expression in the attribute
|
||||||
|
*/
|
||||||
|
private microSyntaxInAttributeValue(attr: Attribute, binding: TemplateBinding) {
|
||||||
|
const key = attr.name.substring(1); // remove leading asterisk
|
||||||
|
|
||||||
|
// Find the selector - eg ngFor, ngIf, etc
|
||||||
|
const selectorInfo = getSelectors(this.info);
|
||||||
|
const selector = selectorInfo.selectors.find(s => {
|
||||||
|
// attributes are listed in (attribute, value) pairs
|
||||||
|
for (let i = 0; i < s.attrs.length; i += 2) {
|
||||||
|
if (s.attrs[i] === key) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueRelativePosition = this.position - attr.valueSpan !.start.offset;
|
||||||
|
|
||||||
|
if (binding.keyIsVar) {
|
||||||
|
const equalLocation = attr.value.indexOf('=');
|
||||||
|
if (equalLocation >= 0 && valueRelativePosition >= equalLocation) {
|
||||||
|
// We are after the '=' in a let clause. The valid values here are the members of the
|
||||||
|
// template reference's type parameter.
|
||||||
|
const directiveMetadata = selectorInfo.map.get(selector);
|
||||||
|
if (directiveMetadata) {
|
||||||
|
const contextTable =
|
||||||
|
this.info.template.query.getTemplateContext(directiveMetadata.type.reference);
|
||||||
|
if (contextTable) {
|
||||||
|
// This adds symbols like $implicit, index, count, etc.
|
||||||
|
this.addSymbolsToCompletions(contextTable.values());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binding.expression && inSpan(valueRelativePosition, binding.expression.ast.span)) {
|
||||||
|
this.addAttributeValuesToCompletions(binding.expression.ast, this.position);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addKeysToCompletions(selector, key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceText(template: ng.TemplateSource, span: ng.Span): string {
|
function getSourceText(template: ng.TemplateSource, span: ng.Span): string {
|
||||||
|
|
|
@ -161,6 +161,26 @@ describe('completions', () => {
|
||||||
expectContain(completions, CompletionKind.METHOD, ['$any']);
|
expectContain(completions, CompletionKind.METHOD, ['$any']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should suggest attribute values', () => {
|
||||||
|
mockHost.override(TEST_TEMPLATE, `<div [id]="~{cursor}"></div>`);
|
||||||
|
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
|
||||||
|
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
|
||||||
|
expectContain(completions, CompletionKind.PROPERTY, [
|
||||||
|
'title',
|
||||||
|
'hero',
|
||||||
|
'heroes',
|
||||||
|
'league',
|
||||||
|
'anyValue',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should suggest event handlers', () => {
|
||||||
|
mockHost.override(TEST_TEMPLATE, `<div (click)="~{cursor}"></div>`);
|
||||||
|
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor');
|
||||||
|
const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start);
|
||||||
|
expectContain(completions, CompletionKind.METHOD, ['myClick']);
|
||||||
|
});
|
||||||
|
|
||||||
describe('in external template', () => {
|
describe('in external template', () => {
|
||||||
it('should be able to get entity completions in external template', () => {
|
it('should be able to get entity completions in external template', () => {
|
||||||
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'entity-amp');
|
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'entity-amp');
|
||||||
|
|
Loading…
Reference in New Issue