
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
643 lines
24 KiB
TypeScript
643 lines
24 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
|
|
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';
|
|
import {$$, $_, isAsciiLetter, isDigit} from '@angular/compiler/src/chars';
|
|
|
|
import {ATTR, getBindingDescriptor} from './binding_utils';
|
|
import {getExpressionScope} from './expression_diagnostics';
|
|
import {getExpressionCompletions} from './expressions';
|
|
import {attributeNames, elementNames, eventNames, propertyNames} from './html_info';
|
|
import {InlineTemplate} from './template';
|
|
import * as ng from './types';
|
|
import {diagnosticInfoFromTemplateInfo, findTemplateAstAt, getPathToNodeAtPosition, getSelectors, inSpan, isStructuralDirective, spanOf} from './utils';
|
|
|
|
const HIDDEN_HTML_ELEMENTS: ReadonlySet<string> =
|
|
new Set(['html', 'script', 'noscript', 'base', 'body', 'title', 'head', 'link']);
|
|
const HTML_ELEMENTS: ReadonlyArray<ng.CompletionEntry> =
|
|
elementNames().filter(name => !HIDDEN_HTML_ELEMENTS.has(name)).map(name => {
|
|
return {
|
|
name,
|
|
kind: ng.CompletionKind.HTML_ELEMENT,
|
|
sortText: name,
|
|
};
|
|
});
|
|
const ANGULAR_ELEMENTS: ReadonlyArray<ng.CompletionEntry> = [
|
|
{
|
|
name: 'ng-container',
|
|
kind: ng.CompletionKind.ANGULAR_ELEMENT,
|
|
sortText: 'ng-container',
|
|
},
|
|
{
|
|
name: 'ng-content',
|
|
kind: ng.CompletionKind.ANGULAR_ELEMENT,
|
|
sortText: 'ng-content',
|
|
},
|
|
{
|
|
name: 'ng-template',
|
|
kind: ng.CompletionKind.ANGULAR_ELEMENT,
|
|
sortText: 'ng-template',
|
|
},
|
|
];
|
|
|
|
function isIdentifierPart(code: number) {
|
|
// Identifiers consist of alphanumeric characters, '_', or '$'.
|
|
return isAsciiLetter(code) || isDigit(code) || code == $$ || code == $_;
|
|
}
|
|
|
|
/**
|
|
* Gets the span of word in a template that surrounds `position`. If there is no word around
|
|
* `position`, nothing is returned.
|
|
*/
|
|
function getBoundedWordSpan(
|
|
templateInfo: ng.AstResult, position: number, ast: HtmlAst|undefined): ts.TextSpan|undefined {
|
|
const {template} = templateInfo;
|
|
const templateSrc = template.source;
|
|
|
|
if (!templateSrc) return;
|
|
|
|
if (ast instanceof Element) {
|
|
// The HTML tag may include `-` (e.g. `app-root`),
|
|
// so use the HtmlAst to get the span before ayazhafiz refactor the code.
|
|
return {
|
|
start: templateInfo.template.span.start + ast.startSourceSpan!.start.offset + 1,
|
|
length: ast.name.length
|
|
};
|
|
}
|
|
|
|
// TODO(ayazhafiz): A solution based on word expansion will always be expensive compared to one
|
|
// based on ASTs. Whatever penalty we incur is probably manageable for small-length (i.e. the
|
|
// majority of) identifiers, but the current solution involes a number of branchings and we can't
|
|
// control potentially very long identifiers. Consider moving to an AST-based solution once
|
|
// existing difficulties with AST spans are more clearly resolved (see #31898 for discussion of
|
|
// known problems, and #33091 for how they affect text replacement).
|
|
//
|
|
// `templatePosition` represents the right-bound location of a cursor in the template.
|
|
// key.ent|ry
|
|
// ^---- cursor, at position `r` is at.
|
|
// A cursor is not itself a character in the template; it has a left (lower) and right (upper)
|
|
// index bound that hugs the cursor itself.
|
|
let templatePosition = position - template.span.start;
|
|
// To perform word expansion, we want to determine the left and right indices that hug the cursor.
|
|
// There are three cases here.
|
|
let left, right;
|
|
if (templatePosition === 0) {
|
|
// 1. Case like
|
|
// |rest of template
|
|
// the cursor is at the start of the template, hugged only by the right side (0-index).
|
|
left = right = 0;
|
|
} else if (templatePosition === templateSrc.length) {
|
|
// 2. Case like
|
|
// rest of template|
|
|
// the cursor is at the end of the template, hugged only by the left side (last-index).
|
|
left = right = templateSrc.length - 1;
|
|
} else {
|
|
// 3. Case like
|
|
// wo|rd
|
|
// there is a clear left and right index.
|
|
left = templatePosition - 1;
|
|
right = templatePosition;
|
|
}
|
|
|
|
if (!isIdentifierPart(templateSrc.charCodeAt(left)) &&
|
|
!isIdentifierPart(templateSrc.charCodeAt(right))) {
|
|
// Case like
|
|
// .|.
|
|
// left ---^ ^--- right
|
|
// There is no word here.
|
|
return;
|
|
}
|
|
|
|
// Expand on the left and right side until a word boundary is hit. Back up one expansion on both
|
|
// side to stay inside the word.
|
|
while (left >= 0 && isIdentifierPart(templateSrc.charCodeAt(left))) --left;
|
|
++left;
|
|
while (right < templateSrc.length && isIdentifierPart(templateSrc.charCodeAt(right))) ++right;
|
|
--right;
|
|
|
|
const absoluteStartPosition = position - (templatePosition - left);
|
|
const length = right - left + 1;
|
|
return {start: absoluteStartPosition, length};
|
|
}
|
|
|
|
export function getTemplateCompletions(
|
|
templateInfo: ng.AstResult, position: number): ng.CompletionEntry[] {
|
|
const {htmlAst, template} = templateInfo;
|
|
// Calculate the position relative to the start of the template. This is needed
|
|
// because spans in HTML AST are relative. Inline template has non-zero start position.
|
|
const templatePosition = position - template.span.start;
|
|
const htmlPath: HtmlAstPath = getPathToNodeAtPosition(htmlAst, templatePosition);
|
|
const mostSpecific = htmlPath.tail;
|
|
const visitor = new HtmlVisitor(templateInfo, htmlPath);
|
|
const results: ng.CompletionEntry[] = mostSpecific ?
|
|
mostSpecific.visit(visitor, null /* context */) :
|
|
elementCompletions(templateInfo);
|
|
const replacementSpan = getBoundedWordSpan(templateInfo, position, mostSpecific);
|
|
return results.map(entry => {
|
|
return {
|
|
...entry,
|
|
replacementSpan,
|
|
};
|
|
});
|
|
}
|
|
|
|
class HtmlVisitor implements Visitor {
|
|
/**
|
|
* Position relative to the start of the template.
|
|
*/
|
|
private readonly relativePosition: number;
|
|
constructor(private readonly templateInfo: ng.AstResult, private readonly htmlPath: HtmlAstPath) {
|
|
this.relativePosition = htmlPath.position;
|
|
}
|
|
// Note that every visitor method must explicitly specify return type because
|
|
// Visitor returns `any` for all methods.
|
|
visitElement(ast: Element): ng.CompletionEntry[] {
|
|
const startTagSpan = spanOf(ast.sourceSpan);
|
|
const tagLen = ast.name.length;
|
|
// + 1 for the opening angle bracket
|
|
if (this.relativePosition <= startTagSpan.start + tagLen + 1) {
|
|
// If we are in the tag then return the element completions.
|
|
return elementCompletions(this.templateInfo);
|
|
}
|
|
if (this.relativePosition < startTagSpan.end) {
|
|
// We are in the attribute section of the element (but not in an attribute).
|
|
// Return the attribute completions.
|
|
return attributeCompletionsForElement(this.templateInfo, ast.name);
|
|
}
|
|
return [];
|
|
}
|
|
visitAttribute(ast: Attribute): ng.CompletionEntry[] {
|
|
// An attribute consists of two parts, LHS="RHS".
|
|
// Determine if completions are requested for LHS or RHS
|
|
if (ast.valueSpan && inSpan(this.relativePosition, spanOf(ast.valueSpan))) {
|
|
// RHS completion
|
|
return attributeValueCompletions(this.templateInfo, this.htmlPath);
|
|
}
|
|
// LHS completion
|
|
return attributeCompletions(this.templateInfo, this.htmlPath);
|
|
}
|
|
visitText(): ng.CompletionEntry[] {
|
|
const templatePath = findTemplateAstAt(this.templateInfo.templateAst, this.relativePosition);
|
|
if (templatePath.tail instanceof BoundTextAst) {
|
|
// If we know that this is an interpolation then do not try other scenarios.
|
|
const visitor = new ExpressionVisitor(
|
|
this.templateInfo, this.relativePosition,
|
|
() =>
|
|
getExpressionScope(diagnosticInfoFromTemplateInfo(this.templateInfo), templatePath));
|
|
templatePath.tail?.visit(visitor, null);
|
|
return visitor.results;
|
|
}
|
|
// TODO(kyliau): Not sure if this check is really needed since we don't have
|
|
// any test cases for it.
|
|
const element = this.htmlPath.first(Element);
|
|
if (element &&
|
|
getHtmlTagDefinition(element.name).contentType !== TagContentType.PARSABLE_DATA) {
|
|
return [];
|
|
}
|
|
// This is to account for cases like <h1> <a> text | </h1> where the
|
|
// closest element has no closing tag and thus is considered plain text.
|
|
const results = voidElementAttributeCompletions(this.templateInfo, this.htmlPath);
|
|
if (results.length) {
|
|
return results;
|
|
}
|
|
return elementCompletions(this.templateInfo);
|
|
}
|
|
visitComment(): ng.CompletionEntry[] {
|
|
return [];
|
|
}
|
|
visitExpansion(): ng.CompletionEntry[] {
|
|
return [];
|
|
}
|
|
visitExpansionCase(): ng.CompletionEntry[] {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function attributeCompletions(info: ng.AstResult, path: AstPath<HtmlAst>): ng.CompletionEntry[] {
|
|
const attr = path.tail;
|
|
const elem = path.parentOf(attr);
|
|
if (!(attr instanceof Attribute) || !(elem instanceof Element)) {
|
|
return [];
|
|
}
|
|
|
|
// TODO: Consider parsing the attrinute name to a proper AST instead of
|
|
// matching using regex. This is because the regexp would incorrectly identify
|
|
// bind parts for cases like [()|]
|
|
// ^ cursor is here
|
|
const binding = getBindingDescriptor(attr.name);
|
|
if (!binding) {
|
|
// This is a normal HTML attribute, not an Angular attribute.
|
|
return attributeCompletionsForElement(info, elem.name);
|
|
}
|
|
|
|
const results: string[] = [];
|
|
const ngAttrs = angularAttributes(info, elem.name);
|
|
switch (binding.kind) {
|
|
case ATTR.KW_MICROSYNTAX:
|
|
// template reference attribute: *attrName
|
|
results.push(...ngAttrs.templateRefs);
|
|
break;
|
|
|
|
case ATTR.KW_BIND:
|
|
case ATTR.IDENT_PROPERTY:
|
|
// property binding via bind- or []
|
|
results.push(...propertyNames(elem.name), ...ngAttrs.inputs);
|
|
break;
|
|
|
|
case ATTR.KW_ON:
|
|
case ATTR.IDENT_EVENT:
|
|
// event binding via on- or ()
|
|
results.push(...eventNames(elem.name), ...ngAttrs.outputs);
|
|
break;
|
|
|
|
case ATTR.KW_BINDON:
|
|
case ATTR.IDENT_BANANA_BOX:
|
|
// banana-in-a-box binding via bindon- or [()]
|
|
results.push(...ngAttrs.bananas);
|
|
break;
|
|
}
|
|
|
|
return results.map(name => {
|
|
return {
|
|
name,
|
|
kind: ng.CompletionKind.ATTRIBUTE,
|
|
sortText: name,
|
|
};
|
|
});
|
|
}
|
|
|
|
function attributeCompletionsForElement(
|
|
info: ng.AstResult, elementName: string): ng.CompletionEntry[] {
|
|
const results: ng.CompletionEntry[] = [];
|
|
|
|
if (info.template instanceof InlineTemplate) {
|
|
// Provide HTML attributes completion only for inline templates
|
|
for (const name of attributeNames(elementName)) {
|
|
results.push({
|
|
name,
|
|
kind: ng.CompletionKind.HTML_ATTRIBUTE,
|
|
sortText: name,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Add Angular attributes
|
|
const ngAttrs = angularAttributes(info, elementName);
|
|
for (const name of ngAttrs.others) {
|
|
results.push({
|
|
name,
|
|
kind: ng.CompletionKind.ATTRIBUTE,
|
|
sortText: name,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Provide completions to the RHS of an attribute, which is of the form
|
|
* LHS="RHS". The template path is computed from the specified `info` whereas
|
|
* the context is determined from the specified `htmlPath`.
|
|
* @param info Object that contains the template AST
|
|
* @param htmlPath Path to the HTML node
|
|
*/
|
|
function attributeValueCompletions(
|
|
info: ng.AstResult, htmlPath: HtmlAstPath): ng.CompletionEntry[] {
|
|
// Find the corresponding Template AST path.
|
|
const templatePath = findTemplateAstAt(info.templateAst, htmlPath.position);
|
|
const visitor = new ExpressionVisitor(info, htmlPath.position, () => {
|
|
const dinfo = diagnosticInfoFromTemplateInfo(info);
|
|
return getExpressionScope(dinfo, templatePath);
|
|
});
|
|
if (templatePath.tail instanceof AttrAst ||
|
|
templatePath.tail instanceof BoundElementPropertyAst ||
|
|
templatePath.tail instanceof BoundEventAst) {
|
|
templatePath.tail.visit(visitor, null);
|
|
return visitor.results;
|
|
}
|
|
// In order to provide accurate attribute value completion, we need to know
|
|
// what the LHS is, and construct the proper AST if it is missing.
|
|
const htmlAttr = htmlPath.tail as Attribute;
|
|
const binding = getBindingDescriptor(htmlAttr.name);
|
|
if (binding && binding.kind === ATTR.KW_REF) {
|
|
let refAst: ReferenceAst|undefined;
|
|
let elemAst: ElementAst|undefined;
|
|
if (templatePath.tail instanceof ReferenceAst) {
|
|
refAst = templatePath.tail;
|
|
const parent = templatePath.parentOf(refAst);
|
|
if (parent instanceof ElementAst) {
|
|
elemAst = parent;
|
|
}
|
|
} else if (templatePath.tail instanceof ElementAst) {
|
|
refAst = new ReferenceAst(htmlAttr.name, null!, htmlAttr.value, htmlAttr.valueSpan!);
|
|
elemAst = templatePath.tail;
|
|
}
|
|
if (refAst && elemAst) {
|
|
refAst.visit(visitor, elemAst);
|
|
}
|
|
} else {
|
|
// HtmlAst contains the `Attribute` node, however the corresponding `AttrAst`
|
|
// node is missing from the TemplateAst.
|
|
const attrAst = new AttrAst(htmlAttr.name, htmlAttr.value, htmlAttr.valueSpan!);
|
|
attrAst.visit(visitor, null);
|
|
}
|
|
return visitor.results;
|
|
}
|
|
|
|
function elementCompletions(info: ng.AstResult): ng.CompletionEntry[] {
|
|
const results: ng.CompletionEntry[] = [...ANGULAR_ELEMENTS];
|
|
|
|
if (info.template instanceof InlineTemplate) {
|
|
// Provide HTML elements completion only for inline templates
|
|
results.push(...HTML_ELEMENTS);
|
|
}
|
|
|
|
// Collect the elements referenced by the selectors
|
|
const components = new Set<string>();
|
|
for (const selector of getSelectors(info).selectors) {
|
|
const name = selector.element;
|
|
if (name && !components.has(name)) {
|
|
components.add(name);
|
|
results.push({
|
|
name,
|
|
kind: ng.CompletionKind.COMPONENT,
|
|
sortText: name,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// There is a special case of HTML where text that contains a unclosed tag is treated as
|
|
// text. For exaple '<h1> Some <a text </h1>' produces a text nodes inside of the H1
|
|
// element "Some <a text". We, however, want to treat this as if the user was requesting
|
|
// the attributes of an "a" element, not requesting completion in the a text element. This
|
|
// code checks for this case and returns element completions if it is detected or undefined
|
|
// if it is not.
|
|
function voidElementAttributeCompletions(
|
|
info: ng.AstResult, path: AstPath<HtmlAst>): ng.CompletionEntry[] {
|
|
const tail = path.tail;
|
|
if (tail instanceof Text) {
|
|
const match = tail.value.match(/<(\w(\w|\d|-)*:)?(\w(\w|\d|-)*)\s/);
|
|
// The position must be after the match, otherwise we are still in a place where elements
|
|
// are expected (such as `<|a` or `<a|`; we only want attributes for `<a |` or after).
|
|
if (match &&
|
|
path.position >= (match.index || 0) + match[0].length + tail.sourceSpan.start.offset) {
|
|
return attributeCompletionsForElement(info, match[3]);
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
class ExpressionVisitor extends NullTemplateVisitor {
|
|
private readonly completions = new Map<string, ng.CompletionEntry>();
|
|
|
|
constructor(
|
|
private readonly info: ng.AstResult, private readonly position: number,
|
|
private readonly getExpressionScope: () => ng.SymbolTable) {
|
|
super();
|
|
}
|
|
|
|
get results(): ng.CompletionEntry[] {
|
|
return Array.from(this.completions.values());
|
|
}
|
|
|
|
visitDirectiveProperty(ast: BoundDirectivePropertyAst): void {
|
|
this.processExpressionCompletions(ast.value);
|
|
}
|
|
|
|
visitElementProperty(ast: BoundElementPropertyAst): void {
|
|
this.processExpressionCompletions(ast.value);
|
|
}
|
|
|
|
visitEvent(ast: BoundEventAst): void {
|
|
this.processExpressionCompletions(ast.handler);
|
|
}
|
|
|
|
visitElement(): void {
|
|
// no-op for now
|
|
}
|
|
|
|
visitAttr(ast: AttrAst) {
|
|
const binding = getBindingDescriptor(ast.name);
|
|
if (binding && binding.kind === ATTR.KW_MICROSYNTAX) {
|
|
// This a template binding given by micro syntax expression.
|
|
// First, verify the attribute consists of some binding we can give completions for.
|
|
// The sourceSpan of AttrAst points to the RHS of the attribute
|
|
const templateKey = binding.name;
|
|
const templateValue = ast.sourceSpan.toString();
|
|
const templateUrl = ast.sourceSpan.start.file.url;
|
|
// TODO(kyliau): We are unable to determine the absolute offset of the key
|
|
// but it is okay here, because we are only looking at the RHS of the attr
|
|
const absKeyOffset = 0;
|
|
const absValueOffset = ast.sourceSpan.start.offset;
|
|
const {templateBindings} = this.info.expressionParser.parseTemplateBindings(
|
|
templateKey, templateValue, templateUrl, absKeyOffset, absValueOffset);
|
|
// Find the nearest template binding to the position.
|
|
const lastBindingEnd = templateBindings.length > 0 &&
|
|
templateBindings[templateBindings.length - 1].sourceSpan.end;
|
|
const normalizedPositionToBinding =
|
|
lastBindingEnd && this.position > lastBindingEnd ? lastBindingEnd : this.position;
|
|
const templateBinding =
|
|
templateBindings.find(b => inSpan(normalizedPositionToBinding, b.sourceSpan));
|
|
|
|
if (!templateBinding) {
|
|
return;
|
|
}
|
|
|
|
this.microSyntaxInAttributeValue(ast, templateBinding);
|
|
} else {
|
|
const expressionAst = this.info.expressionParser.parseBinding(
|
|
ast.value, ast.sourceSpan.toString(), ast.sourceSpan.start.offset);
|
|
this.processExpressionCompletions(expressionAst);
|
|
}
|
|
}
|
|
|
|
visitReference(_ast: ReferenceAst, context: ElementAst) {
|
|
context.directives.forEach(dir => {
|
|
const {exportAs} = dir.directive;
|
|
if (exportAs) {
|
|
this.completions.set(
|
|
exportAs, {name: exportAs, kind: ng.CompletionKind.REFERENCE, sortText: exportAs});
|
|
}
|
|
});
|
|
}
|
|
|
|
visitBoundText(ast: BoundTextAst) {
|
|
if (inSpan(this.position, ast.value.sourceSpan)) {
|
|
const completions = getExpressionCompletions(
|
|
this.getExpressionScope(), ast.value, this.position, this.info.template);
|
|
if (completions) {
|
|
this.addSymbolsToCompletions(completions);
|
|
}
|
|
}
|
|
}
|
|
|
|
private processExpressionCompletions(value: AST) {
|
|
const symbols = getExpressionCompletions(
|
|
this.getExpressionScope(), value, this.position, this.info.template);
|
|
if (symbols) {
|
|
this.addSymbolsToCompletions(symbols);
|
|
}
|
|
}
|
|
|
|
private addSymbolsToCompletions(symbols: ng.Symbol[]) {
|
|
for (const s of symbols) {
|
|
if (s.name.startsWith('__') || !s.public || this.completions.has(s.name)) {
|
|
continue;
|
|
}
|
|
|
|
// The pipe method should not include parentheses.
|
|
// e.g. {{ value_expression | slice : start [ : end ] }}
|
|
const shouldInsertParentheses = s.callable && s.kind !== ng.CompletionKind.PIPE;
|
|
this.completions.set(s.name, {
|
|
name: s.name,
|
|
kind: s.kind as ng.CompletionKind,
|
|
sortText: s.name,
|
|
insertText: shouldInsertParentheses ? `${s.name}()` : s.name,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: AttrAst, 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.sourceSpan.start.offset;
|
|
|
|
if (binding instanceof VariableBinding) {
|
|
// TODO(kyliau): With expression sourceSpan we shouldn't have to search
|
|
// the attribute value string anymore. Just check if position is in the
|
|
// expression source span.
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
} else if (binding instanceof ExpressionBinding) {
|
|
if (inSpan(this.position, binding.value?.ast.sourceSpan)) {
|
|
this.processExpressionCompletions(binding.value!.ast);
|
|
return;
|
|
} else if (!binding.value && this.position > binding.key.span.end) {
|
|
// No expression is defined for the value of the key expression binding, but the cursor is
|
|
// in a location where the expression would be defined. This can happen in a case like
|
|
// let i of |
|
|
// ^-- cursor
|
|
// In this case, backfill the value to be an empty expression and retrieve completions.
|
|
this.processExpressionCompletions(new EmptyExpr(
|
|
new ParseSpan(valueRelativePosition, valueRelativePosition),
|
|
new AbsoluteSourceSpan(this.position, this.position)));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
interface AngularAttributes {
|
|
/**
|
|
* Attributes that support the * syntax. See https://angular.io/api/core/TemplateRef
|
|
*/
|
|
templateRefs: Set<string>;
|
|
/**
|
|
* Attributes with the @Input annotation.
|
|
*/
|
|
inputs: Set<string>;
|
|
/**
|
|
* Attributes with the @Output annotation.
|
|
*/
|
|
outputs: Set<string>;
|
|
/**
|
|
* Attributes that support the [()] or bindon- syntax.
|
|
*/
|
|
bananas: Set<string>;
|
|
/**
|
|
* General attributes that match the specified element.
|
|
*/
|
|
others: Set<string>;
|
|
}
|
|
|
|
/**
|
|
* Return all Angular-specific attributes for the element with `elementName`.
|
|
* @param info
|
|
* @param elementName
|
|
*/
|
|
function angularAttributes(info: ng.AstResult, elementName: string): AngularAttributes {
|
|
const {selectors, map: selectorMap} = getSelectors(info);
|
|
const templateRefs = new Set<string>();
|
|
const inputs = new Set<string>();
|
|
const outputs = new Set<string>();
|
|
const bananas = new Set<string>();
|
|
const others = new Set<string>();
|
|
for (const selector of selectors) {
|
|
if (selector.element && selector.element !== elementName) {
|
|
continue;
|
|
}
|
|
const summary = selectorMap.get(selector)!;
|
|
const hasTemplateRef = isStructuralDirective(summary.type);
|
|
// attributes are listed in (attribute, value) pairs
|
|
for (let i = 0; i < selector.attrs.length; i += 2) {
|
|
const attr = selector.attrs[i];
|
|
if (hasTemplateRef) {
|
|
templateRefs.add(attr);
|
|
} else {
|
|
others.add(attr);
|
|
}
|
|
}
|
|
for (const input of Object.values(summary.inputs)) {
|
|
inputs.add(input);
|
|
}
|
|
for (const output of Object.values(summary.outputs)) {
|
|
outputs.add(output);
|
|
}
|
|
}
|
|
for (const name of inputs) {
|
|
// Add banana-in-a-box syntax
|
|
// https://angular.io/guide/template-syntax#two-way-binding-
|
|
if (outputs.has(`${name}Change`)) {
|
|
bananas.add(name);
|
|
}
|
|
}
|
|
return {templateRefs, inputs, outputs, bananas, others};
|
|
}
|