diff --git a/packages/language-service/src/completions.ts b/packages/language-service/src/completions.ts index effa0a367c..aac023bf37 100644 --- a/packages/language-service/src/completions.ts +++ b/packages/language-service/src/completions.ts @@ -45,6 +45,33 @@ const ANGULAR_ELEMENTS: ReadonlyArray = [ }, ]; +// 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 == $_; @@ -139,7 +166,7 @@ export function getTemplateCompletions( } else if (templatePosition < startTagSpan.end) { // We are in the attribute section of the element (but not in an attribute). // Return the attribute completions. - result = attributeCompletions(templateInfo, path); + result = attributeCompletionsForElement(templateInfo, ast.name); } }, visitAttribute(ast) { @@ -190,11 +217,52 @@ export function getTemplateCompletions( } function attributeCompletions(info: AstResult, path: AstPath): ng.CompletionEntry[] { - const item = path.tail instanceof Element ? path.tail : path.parentOf(path.tail); - if (item instanceof Element) { - return attributeCompletionsForElement(info, item.name); + const attr = path.tail; + const elem = path.parentOf(attr); + if (!(attr instanceof Attribute) || !(elem instanceof Element)) { + return []; } - 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( @@ -212,26 +280,15 @@ function attributeCompletionsForElement( } } - // Add html properties - for (const name of propertyNames(elementName)) { - results.push({ - name: `[${name}]`, - kind: ng.CompletionKind.ATTRIBUTE, - sortText: name, - }); - } - - // Add html events - for (const name of eventNames(elementName)) { - results.push({ - name: `(${name})`, - kind: ng.CompletionKind.ATTRIBUTE, - sortText: name, - }); - } - // Add Angular attributes - results.push(...angularAttributes(info, elementName)); + const ngAttrs = angularAttributes(info, elementName); + for (const name of ngAttrs.others) { + results.push({ + name, + kind: ng.CompletionKind.ATTRIBUTE, + sortText: name, + }); + } return results; } @@ -484,24 +541,54 @@ function getSourceText(template: ng.TemplateSource, span: ng.Span): string { return template.source.substring(span.start, span.end); } -function angularAttributes(info: AstResult, elementName: string): ng.CompletionEntry[] { +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) !; - for (const attr of selector.attrs) { - if (attr) { - if (hasTemplateReference(summary.type)) { - templateRefs.add(attr); - } else { - others.add(attr); - } + 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)) { @@ -511,44 +598,12 @@ function angularAttributes(info: AstResult, elementName: string): ng.CompletionE outputs.add(output); } } - - const results: ng.CompletionEntry[] = []; - for (const name of templateRefs) { - results.push({ - name: `*${name}`, - kind: ng.CompletionKind.ATTRIBUTE, - sortText: name, - }); - } for (const name of inputs) { - results.push({ - name: `[${name}]`, - kind: ng.CompletionKind.ATTRIBUTE, - sortText: name, - }); // Add banana-in-a-box syntax // https://angular.io/guide/template-syntax#two-way-binding- if (outputs.has(`${name}Change`)) { - results.push({ - name: `[(${name})]`, - kind: ng.CompletionKind.ATTRIBUTE, - sortText: name, - }); + bananas.add(name); } } - for (const name of outputs) { - results.push({ - name: `(${name})`, - kind: ng.CompletionKind.ATTRIBUTE, - sortText: name, - }); - } - for (const name of others) { - results.push({ - name, - kind: ng.CompletionKind.ATTRIBUTE, - sortText: name, - }); - } - return results; + return {templateRefs, inputs, outputs, bananas, others}; } diff --git a/packages/language-service/src/utils.ts b/packages/language-service/src/utils.ts index 394fed7c37..934457e9ce 100644 --- a/packages/language-service/src/utils.ts +++ b/packages/language-service/src/utils.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AstPath, CompileDirectiveSummary, CompileTypeMetadata, CssSelector, DirectiveAst, ElementAst, EmbeddedTemplateAst, HtmlAstPath, Node, ParseSourceSpan, RecursiveTemplateAstVisitor, RecursiveVisitor, TemplateAst, TemplateAstPath, identifierName, templateVisitAll, visitAll} from '@angular/compiler'; +import {AstPath, CompileDirectiveSummary, CompileTypeMetadata, CssSelector, DirectiveAst, ElementAst, EmbeddedTemplateAst, HtmlAstPath, Identifiers, Node, ParseSourceSpan, RecursiveTemplateAstVisitor, RecursiveVisitor, TemplateAst, TemplateAstPath, identifierName, templateVisitAll, visitAll} from '@angular/compiler'; import * as ts from 'typescript'; import {AstResult, SelectorInfo} from './common'; @@ -57,11 +57,9 @@ export function isNarrower(spanA: Span, spanB: Span): boolean { } export function hasTemplateReference(type: CompileTypeMetadata): boolean { - if (type.diDeps) { - for (let diDep of type.diDeps) { - if (diDep.token && diDep.token.identifier && - identifierName(diDep.token !.identifier !) === 'TemplateRef') - return true; + for (const diDep of type.diDeps) { + if (diDep.token && identifierName(diDep.token.identifier) === Identifiers.TemplateRef.name) { + return true; } } return false; diff --git a/packages/language-service/test/completions_spec.ts b/packages/language-service/test/completions_spec.ts index 54541c662d..3bce45f1b2 100644 --- a/packages/language-service/test/completions_spec.ts +++ b/packages/language-service/test/completions_spec.ts @@ -80,14 +80,15 @@ describe('completions', () => { ]); }); - it('should be able to find common angular attributes', () => { + it('should be able to find common Angular attributes', () => { const marker = mockHost.getLocationMarkerFor(APP_COMPONENT, 'div-attributes'); const completions = ngLS.getCompletionsAt(APP_COMPONENT, marker.start); expectContain(completions, CompletionKind.ATTRIBUTE, [ - '(click)', - '[ngClass]', - '*ngIf', - '*ngFor', + 'ngClass', + 'ngForm', + 'ngModel', + 'string-model', + 'number-model', ]); }); @@ -117,46 +118,21 @@ describe('completions', () => { expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); }); - describe('property completions for members of an indexed type', () => { - it('should work with numeric index signatures (arrays)', () => { - mockHost.override(TEST_TEMPLATE, `{{ heroes[0].~{heroes-number-index}}}`); - const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'heroes-number-index'); - const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); - expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); - }); - - it('should work with numeric index signatures (tuple arrays)', () => { - mockHost.override(TEST_TEMPLATE, `{{ tupleArray[1].~{tuple-array-number-index}}}`); - const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'tuple-array-number-index'); - const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); - expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); - }); - - describe('with string index signatures', () => { - it('should work with index notation', () => { - mockHost.override(TEST_TEMPLATE, `{{ heroesByName['Jacky'].~{heroes-string-index}}}`); - const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'heroes-string-index'); - const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); - expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); - }); - - it('should work with dot notation', () => { - mockHost.override(TEST_TEMPLATE, `{{ heroesByName.jacky.~{heroes-string-index}}}`); - const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'heroes-string-index'); - const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); - expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); - }); - - it('should work with dot notation if stringIndexType is a primitive type', () => { - mockHost.override(TEST_TEMPLATE, `{{ primitiveIndexType.test.~{string-primitive-type}}}`); - const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'string-primitive-type'); - const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); - expectContain(completions, CompletionKind.METHOD, ['substring']); - }); - }); + it('should suggest template refereces', () => { + mockHost.override(TEST_TEMPLATE, `
`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.ATTRIBUTE, [ + 'ngFor', + 'ngForOf', + 'ngIf', + 'ngSwitchCase', + 'ngSwitchDefault', + 'ngPluralCase', + ]); }); - it('should be able to return attribute names with an incompete attribute', () => { + it('should be able to return attribute names with an incomplete attribute', () => { const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'no-value-attribute'); const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); expectContain(completions, CompletionKind.HTML_ATTRIBUTE, ['id', 'class', 'dir', 'lang']); @@ -275,14 +251,16 @@ describe('completions', () => { expect(entries).not.toContain(jasmine.objectContaining({name: 'onmouseup'})); }); - it('should be able to find common angular attributes', () => { - const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'div-attributes'); + it('should be able to find common Angular attributes', () => { + mockHost.override(TEST_TEMPLATE, `
`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); expectContain(completions, CompletionKind.ATTRIBUTE, [ - '(click)', - '[ngClass]', - '*ngIf', - '*ngFor', + 'ngClass', + 'ngForm', + 'ngModel', + 'string-model', + 'number-model', ]); }); }); @@ -366,14 +344,10 @@ describe('completions', () => { }); it('should be able to complete a the LHS of a two-way binding', () => { - const marker = mockHost.getLocationMarkerFor(PARSING_CASES, 'two-way-binding-input'); - const completions = ngLS.getCompletionsAt(PARSING_CASES, marker.start); - expectContain(completions, CompletionKind.ATTRIBUTE, [ - 'ngModel', - '[ngModel]', - '(ngModelChange)', - '[(ngModel)]', - ]); + mockHost.override(TEST_TEMPLATE, `
`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.ATTRIBUTE, ['ngModel']); }); it('should be able to complete a the RHS of a two-way binding', () => { @@ -382,14 +356,46 @@ describe('completions', () => { expectContain(completions, CompletionKind.PROPERTY, ['test']); }); - it('should work with input and output', () => { - const m1 = mockHost.getLocationMarkerFor(PARSING_CASES, 'string-marker'); - const c1 = ngLS.getCompletionsAt(PARSING_CASES, m1.start); - expectContain(c1, CompletionKind.ATTRIBUTE, ['[model]', '(modelChange)', '[(model)]']); + it('should suggest property binding for input', () => { + // Property binding via [] + mockHost.override(TEST_TEMPLATE, `
`); + const m1 = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const c1 = ngLS.getCompletionsAt(TEST_TEMPLATE, m1.start); + expectContain(c1, CompletionKind.ATTRIBUTE, ['inputAlias']); - const m2 = mockHost.getLocationMarkerFor(PARSING_CASES, 'number-marker'); - const c2 = ngLS.getCompletionsAt(PARSING_CASES, m2.start); - expectContain(c2, CompletionKind.ATTRIBUTE, ['[inputAlias]', '(outputAlias)']); + // Property binding via bind- + mockHost.override(TEST_TEMPLATE, `
`); + const m2 = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const c2 = ngLS.getCompletionsAt(TEST_TEMPLATE, m2.start); + expectContain(c2, CompletionKind.ATTRIBUTE, ['inputAlias']); + }); + + it('should suggest event binding for output', () => { + // Event binding via () + mockHost.override(TEST_TEMPLATE, `
`); + const m1 = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const c1 = ngLS.getCompletionsAt(TEST_TEMPLATE, m1.start); + expectContain(c1, CompletionKind.ATTRIBUTE, ['outputAlias']); + + // Event binding via on- + mockHost.override(TEST_TEMPLATE, `
`); + const m2 = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const c2 = ngLS.getCompletionsAt(TEST_TEMPLATE, m2.start); + expectContain(c2, CompletionKind.ATTRIBUTE, ['outputAlias']); + }); + + it('should suggest two-way binding for input and output', () => { + // Banana-in-a-box via [()] + mockHost.override(TEST_TEMPLATE, `
`); + const m1 = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const c1 = ngLS.getCompletionsAt(TEST_TEMPLATE, m1.start); + expectContain(c1, CompletionKind.ATTRIBUTE, ['model']); + + // Banana-in-a-box via bindon- + mockHost.override(TEST_TEMPLATE, `
`); + const m2 = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'cursor'); + const c2 = ngLS.getCompletionsAt(TEST_TEMPLATE, m2.start); + expectContain(c2, CompletionKind.ATTRIBUTE, ['model']); }); }); @@ -543,7 +549,7 @@ describe('completions', () => { @Component({ selector: 'foo-component', template: \` -
+
\`, }) export class FooComponent {} @@ -551,9 +557,9 @@ describe('completions', () => { const location = mockHost.getLocationMarkerFor(fileName, 'click'); const completions = ngLS.getCompletionsAt(fileName, location.start) !; expect(completions).toBeDefined(); - const completion = completions.entries.find(entry => entry.name === '(click)') !; + const completion = completions.entries.find(entry => entry.name === 'click') !; expect(completion).toBeDefined(); - expect(completion.kind).toBe('attribute'); + expect(completion.kind).toBe(CompletionKind.ATTRIBUTE); expect(completion.replacementSpan).toEqual({start: location.start - 2, length: 2}); }); @@ -602,7 +608,7 @@ describe('completions', () => { @Component({ selector: 'foo-component', template: \` - + \`, }) export class FooComponent {} @@ -610,12 +616,51 @@ describe('completions', () => { const location = mockHost.getLocationMarkerFor(fileName, 'model'); const completions = ngLS.getCompletionsAt(fileName, location.start) !; expect(completions).toBeDefined(); - const completion = completions.entries.find(entry => entry.name === '[(ngModel)]') !; + const completion = completions.entries.find(entry => entry.name === 'ngModel') !; expect(completion).toBeDefined(); - expect(completion.kind).toBe('attribute'); + expect(completion.kind).toBe(CompletionKind.ATTRIBUTE); expect(completion.replacementSpan).toEqual({start: location.start - 5, length: 5}); }); }); + + describe('property completions for members of an indexed type', () => { + it('should work with numeric index signatures (arrays)', () => { + mockHost.override(TEST_TEMPLATE, `{{ heroes[0].~{heroes-number-index}}}`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'heroes-number-index'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); + }); + + it('should work with numeric index signatures (tuple arrays)', () => { + mockHost.override(TEST_TEMPLATE, `{{ tupleArray[1].~{tuple-array-number-index}}}`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'tuple-array-number-index'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); + }); + + describe('with string index signatures', () => { + it('should work with index notation', () => { + mockHost.override(TEST_TEMPLATE, `{{ heroesByName['Jacky'].~{heroes-string-index}}}`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'heroes-string-index'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); + }); + + it('should work with dot notation', () => { + mockHost.override(TEST_TEMPLATE, `{{ heroesByName.jacky.~{heroes-string-index}}}`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'heroes-string-index'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.PROPERTY, ['id', 'name']); + }); + + it('should work with dot notation if stringIndexType is a primitive type', () => { + mockHost.override(TEST_TEMPLATE, `{{ primitiveIndexType.test.~{string-primitive-type}}}`); + const marker = mockHost.getLocationMarkerFor(TEST_TEMPLATE, 'string-primitive-type'); + const completions = ngLS.getCompletionsAt(TEST_TEMPLATE, marker.start); + expectContain(completions, CompletionKind.METHOD, ['substring']); + }); + }); + }); }); function expectContain( diff --git a/packages/language-service/test/project/app/main.ts b/packages/language-service/test/project/app/main.ts index 7df8dc7aa4..6ef6479ace 100644 --- a/packages/language-service/test/project/app/main.ts +++ b/packages/language-service/test/project/app/main.ts @@ -36,7 +36,6 @@ import * as ParsingCases from './parsing-cases'; ParsingCases.CaseUnknown, ParsingCases.EmptyInterpolation, ParsingCases.EventBinding, - ParsingCases.FooComponent, ParsingCases.ForLetIEqual, ParsingCases.ForOfLetEmpty, ParsingCases.ForUsingComponent, diff --git a/packages/language-service/test/project/app/parsing-cases.ts b/packages/language-service/test/project/app/parsing-cases.ts index c21400b82d..537574f70f 100644 --- a/packages/language-service/test/project/app/parsing-cases.ts +++ b/packages/language-service/test/project/app/parsing-cases.ts @@ -93,18 +93,6 @@ export class NumberModel { @Output('outputAlias') modelChange: EventEmitter = new EventEmitter(); } -@Component({ - selector: 'foo-component', - template: ` -
-
- `, -}) -export class FooComponent { - text: string = 'some text'; - value: number = 42; -} - interface Person { name: string; age: number; diff --git a/packages/language-service/test/project/app/test.ng b/packages/language-service/test/project/app/test.ng index aec70cabc9..d6717b81b5 100644 --- a/packages/language-service/test/project/app/test.ng +++ b/packages/language-service/test/project/app/test.ng @@ -4,7 +4,4 @@ ~{after-h1}

{{~{h2-hero}hero.~{h2-name}name}} details!

{{~{label-hero}hero.~{label-id}id}}
-
- -
&~{entity-amp}amp; diff --git a/packages/language-service/test/typescript_host_spec.ts b/packages/language-service/test/typescript_host_spec.ts index ef64b6e94d..f9527c2d8e 100644 --- a/packages/language-service/test/typescript_host_spec.ts +++ b/packages/language-service/test/typescript_host_spec.ts @@ -94,7 +94,7 @@ describe('TypeScriptServiceHost', () => { const tsLS = ts.createLanguageService(tsLSHost); const ngLSHost = new TypeScriptServiceHost(tsLSHost, tsLS); const templates = ngLSHost.getTemplates('/app/parsing-cases.ts'); - expect(templates.length).toBe(17); + expect(templates.length).toBe(16); }); it('should be able to find external template', () => {