diff --git a/packages/language-service/src/utils.ts b/packages/language-service/src/utils.ts index 2da2af621c5..1bdb57817ae 100644 --- a/packages/language-service/src/utils.ts +++ b/packages/language-service/src/utils.ts @@ -167,14 +167,19 @@ function getFirstComponentForTemplateFile(fileName: string, compiler: NgCompiler } /** - * Given an attribute node, converts it to string form. + * Given an attribute node, converts it to string form for use as a CSS selector. */ -function toAttributeString(attribute: t.TextAttribute|t.BoundAttribute|t.BoundEvent): string { +function toAttributeCssSelector(attribute: t.TextAttribute|t.BoundAttribute|t.BoundEvent): string { + let selector: string; if (attribute instanceof t.BoundEvent || attribute instanceof t.BoundAttribute) { - return `[${attribute.name}]`; + selector = `[${attribute.name}]`; } else { - return `[${attribute.name}=${attribute.valueSpan?.toString() ?? ''}]`; + selector = `[${attribute.name}=${attribute.valueSpan?.toString() ?? ''}]`; } + // Any dollar signs that appear in the attribute name and/or value need to be escaped because they + // need to be taken as literal characters rather than special selector behavior of dollar signs in + // CSS. + return selector.replace('$', '\\$'); } function getNodeName(node: t.Template|t.Element): string { @@ -222,7 +227,7 @@ function difference(left: Set, right: Set): Set { export function getDirectiveMatchesForElementTag( element: t.Template|t.Element, directives: DirectiveSymbol[]): Set { const attributes = getAttributes(element); - const allAttrs = attributes.map(toAttributeString); + const allAttrs = attributes.map(toAttributeCssSelector); const allDirectiveMatches = getDirectiveMatchesForSelector(directives, getNodeName(element) + allAttrs.join('')); const matchesWithoutElement = getDirectiveMatchesForSelector(directives, allAttrs.join('')); @@ -232,7 +237,7 @@ export function getDirectiveMatchesForElementTag( export function makeElementSelector(element: t.Element|t.Template): string { const attributes = getAttributes(element); - const allAttrs = attributes.map(toAttributeString); + const allAttrs = attributes.map(toAttributeCssSelector); return getNodeName(element) + allAttrs.join(''); } @@ -251,10 +256,10 @@ export function getDirectiveMatchesForAttribute( name: string, hostNode: t.Template|t.Element, directives: DirectiveSymbol[]): Set { const attributes = getAttributes(hostNode); - const allAttrs = attributes.map(toAttributeString); + const allAttrs = attributes.map(toAttributeCssSelector); const allDirectiveMatches = getDirectiveMatchesForSelector(directives, getNodeName(hostNode) + allAttrs.join('')); - const attrsExcludingName = attributes.filter(a => a.name !== name).map(toAttributeString); + const attrsExcludingName = attributes.filter(a => a.name !== name).map(toAttributeCssSelector); const matchesWithoutAttr = getDirectiveMatchesForSelector( directives, getNodeName(hostNode) + attrsExcludingName.join('')); return difference(allDirectiveMatches, matchesWithoutAttr); @@ -266,20 +271,26 @@ export function getDirectiveMatchesForAttribute( */ function getDirectiveMatchesForSelector( directives: DirectiveSymbol[], selector: string): Set { - const selectors = CssSelector.parse(selector); - if (selectors.length === 0) { + try { + const selectors = CssSelector.parse(selector); + if (selectors.length === 0) { + return new Set(); + } + return new Set(directives.filter((dir: DirectiveSymbol) => { + if (dir.selector === null) { + return false; + } + + const matcher = new SelectorMatcher(); + matcher.addSelectables(CssSelector.parse(dir.selector)); + + return selectors.some(selector => matcher.match(selector, null)); + })); + } catch { + // An invalid selector may throw an error. There would be no directive matches for an invalid + // selector. return new Set(); } - return new Set(directives.filter((dir: DirectiveSymbol) => { - if (dir.selector === null) { - return false; - } - - const matcher = new SelectorMatcher(); - matcher.addSelectables(CssSelector.parse(dir.selector)); - - return selectors.some(selector => matcher.match(selector, null)); - })); } /** diff --git a/packages/language-service/test/definitions_spec.ts b/packages/language-service/test/definitions_spec.ts new file mode 100644 index 00000000000..75fd473a2af --- /dev/null +++ b/packages/language-service/test/definitions_spec.ts @@ -0,0 +1,103 @@ +/** + * @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 {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; + +import {assertFileNames, assertTextSpans, humanizeDocumentSpanLike, LanguageServiceTestEnv, Project} from '../testing'; + +describe('definitions', () => { + let env: LanguageServiceTestEnv; + + describe('when an input has a dollar sign', () => { + const files = { + 'app.ts': ` + import {Component, NgModule, Input} from '@angular/core'; + + @Component({selector: 'dollar-cmp', template: ''}) + export class DollarCmp { + @Input() obs$!: string; + } + + @Component({template: ''}) + export class AppCmp { + greeting = 'hello'; + } + + @NgModule({declarations: [AppCmp, DollarCmp]}) + export class AppModule {} + `, + }; + + beforeEach(() => { + initMockFileSystem('Native'); + env = LanguageServiceTestEnv.setup(); + }); + + it('can get definitions for input', () => { + const project = env.addProject('test', files, {strictTemplates: false}); + const definitions = getDefinitionsAndAssertBoundSpan(project, 'app.ts', '[o¦bs$]="greeting"'); + expect(definitions!.length).toEqual(1); + + assertTextSpans(definitions, ['obs$']); + assertFileNames(definitions, ['app.ts']); + }); + + it('can get definitions for component', () => { + const project = env.addProject('test', files, {strictTemplates: false}); + const definitions = getDefinitionsAndAssertBoundSpan(project, 'app.ts', ' { + it('can get definitions', () => { + initMockFileSystem('Native'); + env = LanguageServiceTestEnv.setup(); + const files = { + 'app.ts': ` + import {Component, Directive, NgModule, Input} from '@angular/core'; + + @Directive({selector: '[dollar\\\\$]', template: ''}) + export class DollarDir { + @Input() dollar$!: string; + } + + @Component({template: '
'}) + export class AppCmp { + greeting = 'hello'; + } + + @NgModule({declarations: [AppCmp, DollarDir]}) + export class AppModule {} + `, + }; + const project = env.addProject('test', files, {strictTemplates: false}); + const definitions = + getDefinitionsAndAssertBoundSpan(project, 'app.ts', '[dollar¦$]="greeting"'); + expect(definitions!.length).toEqual(2); + + assertTextSpans(definitions, ['dollar$', 'DollarDir']); + assertFileNames(definitions, ['app.ts']); + }); + }); + + function getDefinitionsAndAssertBoundSpan(project: Project, file: string, targetText: string) { + const template = project.openFile(file); + env.expectNoSourceDiagnostics(); + project.expectNoTemplateDiagnostics('app.ts', 'AppCmp'); + + template.moveCursorToText(targetText); + const defAndBoundSpan = template.getDefinitionAndBoundSpan(); + expect(defAndBoundSpan).toBeTruthy(); + expect(defAndBoundSpan!.definitions).toBeTruthy(); + return defAndBoundSpan!.definitions!.map(d => humanizeDocumentSpanLike(d, env)); + } +});