Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(language-service): Provide completions for attribute values #33839

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 81 additions & 58 deletions packages/language-service/src/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* 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 {AstResult} from './common';
Expand All @@ -17,7 +17,6 @@ import {InlineTemplate} from './template';
import * as ng from './types';
import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, getSelectors, hasTemplateReference, inSpan, spanOf} from './utils';

const TEMPLATE_ATTR_PREFIX = '*';
const HIDDEN_HTML_ELEMENTS: ReadonlySet<string> =
new Set(['html', 'script', 'noscript', 'base', 'body', 'title', 'head', 'link']);
const HTML_ELEMENTS: ReadonlyArray<ng.CompletionEntry> =
Expand Down Expand Up @@ -365,69 +364,36 @@ class ExpressionVisitor extends NullTemplateVisitor {
visitEvent(ast: BoundEventAst): void { this.addAttributeValuesToCompletions(ast.handler); }

visitElement(ast: ElementAst): void {
if (!this.attr || !this.attr.valueSpan || !this.attr.name.startsWith(TEMPLATE_ATTR_PREFIX)) {
if (!this.attr || !this.attr.valueSpan) {
return;
}

// The value is a template expression but the expression AST was not produced when the
// TemplateAst was produce so do that now.
const key = this.attr.name.substr(TEMPLATE_ATTR_PREFIX.length);
// Find the selector
const selectorInfo = getSelectors(this.info);
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 =
this.info.expressionParser.parseTemplateBindings(key, this.attr.value, null, 0);
// The attribute value is a template expression but the expression AST
// was not produced when the TemplateAst was produced so do that here.
const {templateBindings} = this.info.expressionParser.parseTemplateBindings(
this.attr.name, this.attr.value, this.attr.sourceSpan.toString(),
this.attr.sourceSpan.start.offset);

// find the template binding that contains the position
// Find where the cursor is relative to the start of the attribute value.
const valueRelativePosition = this.position - this.attr.valueSpan.start.offset;
const bindings = templateBindingResult.templateBindings;
const binding =
bindings.find(
binding => inSpan(valueRelativePosition, binding.span, /* exclusive */ true)) ||
bindings.find(binding => inSpan(valueRelativePosition, binding.span));

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;
}
}
}
}
if ((binding.expression && inSpan(valueRelativePosition, binding.expression.ast.span)) ||
// If the position is in the expression or after the key or there is no key, return the
// expression completions
valueRelativePosition > binding.span.start + binding.key.length - key.length) {
const span = new ParseSpan(0, this.attr.value.length);
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));
expressionAst = new PropertyRead(span, span.toAbsolute(offset), receiver, '');
}
this.addAttributeValuesToCompletions(expressionAst, this.position);
return;
}
// Find the template binding that contains the position
const binding = templateBindings.find(b => inSpan(valueRelativePosition, b.span));

if (!binding) {
return;
}

this.addKeysToCompletions(selector, key);
if (this.attr.name.startsWith('*')) {
this.microSyntaxInAttributeValue(this.attr, binding);
} else if (valueRelativePosition >= 0) {
// If the position is in the expression or after the key or there is no key,
// return the expression completions
const span = new ParseSpan(0, this.attr.value.length);
const offset = ast.sourceSpan.start.offset;
const receiver = new ImplicitReceiver(span, span.toAbsolute(offset));
const expressionAst = new PropertyRead(span, span.toAbsolute(offset), receiver, '');
this.addAttributeValuesToCompletions(expressionAst, valueRelativePosition);
}
}

visitBoundText(ast: BoundTextAst) {
Expand Down Expand Up @@ -486,6 +452,63 @@ class ExpressionVisitor extends NullTemplateVisitor {
}
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) {
kyliau marked this conversation as resolved.
Show resolved Hide resolved
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 {
Expand Down
20 changes: 20 additions & 0 deletions packages/language-service/test/completions_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,26 @@ describe('completions', () => {
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', () => {
it('should be able to get entity completions in external template', () => {
const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'entity-amp');
Expand Down