/** * @license * Copyright Google Inc. 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 {AST, AstPath, AttrAst, Attribute, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, Element, ElementAst, HtmlAstPath, NAMED_ENTITIES, Node as HtmlAst, NullTemplateVisitor, ReferenceAst, TagContentType, TemplateBinding, Text, getHtmlTagDefinition} from '@angular/compiler'; import {$$, $_, isAsciiLetter, isDigit} from '@angular/compiler/src/chars'; import {AstResult} from './common'; 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, hasTemplateReference, inSpan, 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', }, ]; // This is adapted from packages/compiler/src/render3/r3_template_transform.ts // to allow empty binding names. const BIND_NAME_REGEXP = /^(?:(?:(?:(bind-)|(let-)|(ref-|#)|(on-)|(bindon-)|(@))(.*))|\[\(([^\)]*)\)\]|\[([^\]]*)\]|\(([^\)]*)\))$/; enum ATTR { // Group 1 = "bind-" KW_BIND_IDX = 1, // Group 2 = "let-" KW_LET_IDX = 2, // Group 3 = "ref-/#" KW_REF_IDX = 3, // Group 4 = "on-" KW_ON_IDX = 4, // Group 5 = "bindon-" KW_BINDON_IDX = 5, // Group 6 = "@" KW_AT_IDX = 6, // Group 7 = the identifier after "bind-", "let-", "ref-/#", "on-", "bindon-" or "@" IDENT_KW_IDX = 7, // Group 8 = identifier inside [()] IDENT_BANANA_BOX_IDX = 8, // Group 9 = identifier inside [] IDENT_PROPERTY_IDX = 9, // Group 10 = identifier inside () IDENT_EVENT_IDX = 10, } 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: AstResult, position: number): ts.TextSpan|undefined { const {template} = templateInfo; const templateSrc = template.source; if (!templateSrc) return; // 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: AstResult, position: number): ng.CompletionEntry[] { let result: ng.CompletionEntry[] = []; const {htmlAst, template} = templateInfo; // The templateNode starts at the delimiter character so we add 1 to skip it. const templatePosition = position - template.span.start; const path = getPathToNodeAtPosition(htmlAst, templatePosition); const mostSpecific = path.tail; if (path.empty || !mostSpecific) { result = elementCompletions(templateInfo); } else { const astPosition = templatePosition - mostSpecific.sourceSpan.start.offset; mostSpecific.visit( { visitElement(ast) { const startTagSpan = spanOf(ast.sourceSpan); const tagLen = ast.name.length; // + 1 for the opening angle bracket if (templatePosition <= startTagSpan.start + tagLen + 1) { // If we are in the tag then return the element completions. result = elementCompletions(templateInfo); } else if (templatePosition < startTagSpan.end) { // We are in the attribute section of the element (but not in an attribute). // Return the attribute completions. result = attributeCompletionsForElement(templateInfo, ast.name); } }, visitAttribute(ast: Attribute) { // An attribute consists of two parts, LHS="RHS". // Determine if completions are requested for LHS or RHS if (ast.valueSpan && inSpan(templatePosition, spanOf(ast.valueSpan))) { // RHS completion result = attributeValueCompletions(templateInfo, path); } else { // LHS completion result = attributeCompletions(templateInfo, path); } }, visitText(ast) { // Check if we are in a entity. result = entityCompletions(getSourceText(template, spanOf(ast)), astPosition); if (result.length) return result; result = interpolationCompletions(templateInfo, templatePosition); if (result.length) return result; const element = path.first(Element); if (element) { const definition = getHtmlTagDefinition(element.name); if (definition.contentType === TagContentType.PARSABLE_DATA) { result = voidElementAttributeCompletions(templateInfo, path); if (!result.length) { // If the element can hold content, show element completions. result = elementCompletions(templateInfo); } } } else { // If no element container, implies parsable data so show elements. result = voidElementAttributeCompletions(templateInfo, path); if (!result.length) { result = elementCompletions(templateInfo); } } }, visitComment() {}, visitExpansion() {}, visitExpansionCase() {} }, null); } const replacementSpan = getBoundedWordSpan(templateInfo, position); return result.map(entry => { return { ...entry, replacementSpan, }; }); } function attributeCompletions(info: 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 bindParts = attr.name.match(BIND_NAME_REGEXP); // TemplateRef starts with '*'. See https://angular.io/api/core/TemplateRef const isTemplateRef = attr.name.startsWith('*'); const isBinding = bindParts !== null || isTemplateRef; if (!isBinding) { return attributeCompletionsForElement(info, elem.name); } const results: string[] = []; const ngAttrs = angularAttributes(info, elem.name); if (!bindParts) { // If bindParts is null then this must be a TemplateRef. results.push(...ngAttrs.templateRefs); } else if ( bindParts[ATTR.KW_BIND_IDX] !== undefined || bindParts[ATTR.IDENT_PROPERTY_IDX] !== undefined) { // property binding via bind- or [] results.push(...propertyNames(elem.name), ...ngAttrs.inputs); } else if ( bindParts[ATTR.KW_ON_IDX] !== undefined || bindParts[ATTR.IDENT_EVENT_IDX] !== undefined) { // event binding via on- or () results.push(...eventNames(elem.name), ...ngAttrs.outputs); } else if ( bindParts[ATTR.KW_BINDON_IDX] !== undefined || bindParts[ATTR.IDENT_BANANA_BOX_IDX] !== undefined) { // banana-in-a-box binding via bindon- or [()] results.push(...ngAttrs.bananas); } return results.map(name => { return { name, kind: ng.CompletionKind.ATTRIBUTE, sortText: name, }; }); } function attributeCompletionsForElement( info: 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: 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 bindParts = htmlAttr.name.match(BIND_NAME_REGEXP); if (bindParts && bindParts[ATTR.KW_REF_IDX] !== undefined) { 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: 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; } function entityCompletions(value: string, position: number): ng.CompletionEntry[] { // Look for entity completions const re = /&[A-Za-z]*;?(?!\d)/g; let found: RegExpExecArray|null; let result: ng.CompletionEntry[] = []; while (found = re.exec(value)) { let len = found[0].length; if (position >= found.index && position < (found.index + len)) { result = Object.keys(NAMED_ENTITIES).map(name => { return { name: `&${name};`, kind: ng.CompletionKind.ENTITY, sortText: name, }; }); break; } } return result; } function interpolationCompletions(info: AstResult, position: number): ng.CompletionEntry[] { // Look for an interpolation in at the position. const templatePath = findTemplateAstAt(info.templateAst, position); if (!templatePath.tail) { return []; } const visitor = new ExpressionVisitor( info, position, () => getExpressionScope(diagnosticInfoFromTemplateInfo(info), templatePath)); templatePath.tail.visit(visitor, null); return visitor.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: 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) { if (ast.name.startsWith('*')) { // This a template binding given by micro syntax expression. // First, verify the attribute consists of some binding we can give completions for. const {templateBindings} = this.info.expressionParser.parseTemplateBindings( ast.name, ast.value, ast.sourceSpan.toString(), ast.sourceSpan.start.offset); // Find where the cursor is relative to the start of the attribute value. const valueRelativePosition = this.position - ast.sourceSpan.start.offset; // Find the template binding that contains the position. const binding = templateBindings.find(b => inSpan(valueRelativePosition, b.span)); if (!binding) { return; } this.microSyntaxInAttributeValue(ast, binding); } 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.query); if (completions) { this.addSymbolsToCompletions(completions); } } } private processExpressionCompletions(value: AST) { const symbols = getExpressionCompletions( this.getExpressionScope(), value, this.position, this.info.template.query); 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.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.processExpressionCompletions(binding.expression.ast); return; } // If the expression is incomplete, for example *ngFor="let x of |" // binding.expression is null. We could still try to provide suggestions // by looking for symbols that are in scope. const KW_OF = ' of '; const ofLocation = attr.value.indexOf(KW_OF); if (ofLocation > 0 && valueRelativePosition >= ofLocation + KW_OF.length) { const expressionAst = this.info.expressionParser.parseBinding( attr.value, attr.sourceSpan.toString(), attr.sourceSpan.start.offset); this.processExpressionCompletions(expressionAst); } } } function getSourceText(template: ng.TemplateSource, span: ng.Span): string { return template.source.substring(span.start, span.end); } 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: 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 isTemplateRef = hasTemplateReference(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 (isTemplateRef) { 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}; }