/** * @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 = new Set(['html', 'script', 'noscript', 'base', 'body', 'title', 'head', 'link']); const HTML_ELEMENTS: ReadonlyArray = elementNames().filter(name => !HIDDEN_HTML_ELEMENTS.has(name)).map(name => { return { name, kind: ng.CompletionKind.HTML_ELEMENT, sortText: name, }; }); const ANGULAR_ELEMENTS: ReadonlyArray = [ { 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

text |

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): 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(); 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 '

Some ' produces a text nodes inside of the H1 // element "Some ): 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 `= (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(); 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; /** * Attributes with the @Input annotation. */ inputs: Set; /** * Attributes with the @Output annotation. */ outputs: Set; /** * Attributes that support the [()] or bindon- syntax. */ bananas: Set; /** * General attributes that match the specified element. */ others: Set; } /** * 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(); const inputs = new Set(); const outputs = new Set(); const bananas = new Set(); const others = new Set(); 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}; }