Skip to content

Commit 3aafa76

Browse files
atscottdylhunn
authored andcommitted
fix(language-service): Correctly parse inputs and selectors with dollar signs (angular#44268)
When we are going to the definition of an input, we find _both_ the definition of the input _and_ also look for any directives which have a selector that matches the input. For example: ``` @directive({ selector: '[greeting]' }) export class MyDir { @input() greeting!: string; } ``` With this commit, we now correctly handle the case where inputs and/or selectors have a dollar sign in them. The dollar sign has special meaning in CSS, but when we encounter the dollar in a template, we need to escape it when used as a selector so that it is taken as a dollar literal rather than a character with special meaning. Previously, we were not escaping the dollar sign and the CSS parsing logic would throw an error. The change in this commit prevents that error from happening, but a `try...catch` is still added in case there is another unhandled use-case. If this happens, we do not want the `goToDefinition` operation to completely fail. Fixes angular/vscode-ng-language-service#1574 PR Close angular#44268
1 parent 708eb8c commit 3aafa76

File tree

2 files changed

+134
-20
lines changed

2 files changed

+134
-20
lines changed

packages/language-service/src/utils.ts

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,19 @@ function getFirstComponentForTemplateFile(fileName: string, compiler: NgCompiler
167167
}
168168

169169
/**
170-
* Given an attribute node, converts it to string form.
170+
* Given an attribute node, converts it to string form for use as a CSS selector.
171171
*/
172-
function toAttributeString(attribute: t.TextAttribute|t.BoundAttribute|t.BoundEvent): string {
172+
function toAttributeCssSelector(attribute: t.TextAttribute|t.BoundAttribute|t.BoundEvent): string {
173+
let selector: string;
173174
if (attribute instanceof t.BoundEvent || attribute instanceof t.BoundAttribute) {
174-
return `[${attribute.name}]`;
175+
selector = `[${attribute.name}]`;
175176
} else {
176-
return `[${attribute.name}=${attribute.valueSpan?.toString() ?? ''}]`;
177+
selector = `[${attribute.name}=${attribute.valueSpan?.toString() ?? ''}]`;
177178
}
179+
// Any dollar signs that appear in the attribute name and/or value need to be escaped because they
180+
// need to be taken as literal characters rather than special selector behavior of dollar signs in
181+
// CSS.
182+
return selector.replace('$', '\\$');
178183
}
179184

180185
function getNodeName(node: t.Template|t.Element): string {
@@ -222,7 +227,7 @@ function difference<T>(left: Set<T>, right: Set<T>): Set<T> {
222227
export function getDirectiveMatchesForElementTag(
223228
element: t.Template|t.Element, directives: DirectiveSymbol[]): Set<DirectiveSymbol> {
224229
const attributes = getAttributes(element);
225-
const allAttrs = attributes.map(toAttributeString);
230+
const allAttrs = attributes.map(toAttributeCssSelector);
226231
const allDirectiveMatches =
227232
getDirectiveMatchesForSelector(directives, getNodeName(element) + allAttrs.join(''));
228233
const matchesWithoutElement = getDirectiveMatchesForSelector(directives, allAttrs.join(''));
@@ -232,7 +237,7 @@ export function getDirectiveMatchesForElementTag(
232237

233238
export function makeElementSelector(element: t.Element|t.Template): string {
234239
const attributes = getAttributes(element);
235-
const allAttrs = attributes.map(toAttributeString);
240+
const allAttrs = attributes.map(toAttributeCssSelector);
236241
return getNodeName(element) + allAttrs.join('');
237242
}
238243

@@ -251,10 +256,10 @@ export function getDirectiveMatchesForAttribute(
251256
name: string, hostNode: t.Template|t.Element,
252257
directives: DirectiveSymbol[]): Set<DirectiveSymbol> {
253258
const attributes = getAttributes(hostNode);
254-
const allAttrs = attributes.map(toAttributeString);
259+
const allAttrs = attributes.map(toAttributeCssSelector);
255260
const allDirectiveMatches =
256261
getDirectiveMatchesForSelector(directives, getNodeName(hostNode) + allAttrs.join(''));
257-
const attrsExcludingName = attributes.filter(a => a.name !== name).map(toAttributeString);
262+
const attrsExcludingName = attributes.filter(a => a.name !== name).map(toAttributeCssSelector);
258263
const matchesWithoutAttr = getDirectiveMatchesForSelector(
259264
directives, getNodeName(hostNode) + attrsExcludingName.join(''));
260265
return difference(allDirectiveMatches, matchesWithoutAttr);
@@ -266,20 +271,26 @@ export function getDirectiveMatchesForAttribute(
266271
*/
267272
function getDirectiveMatchesForSelector(
268273
directives: DirectiveSymbol[], selector: string): Set<DirectiveSymbol> {
269-
const selectors = CssSelector.parse(selector);
270-
if (selectors.length === 0) {
274+
try {
275+
const selectors = CssSelector.parse(selector);
276+
if (selectors.length === 0) {
277+
return new Set();
278+
}
279+
return new Set(directives.filter((dir: DirectiveSymbol) => {
280+
if (dir.selector === null) {
281+
return false;
282+
}
283+
284+
const matcher = new SelectorMatcher();
285+
matcher.addSelectables(CssSelector.parse(dir.selector));
286+
287+
return selectors.some(selector => matcher.match(selector, null));
288+
}));
289+
} catch {
290+
// An invalid selector may throw an error. There would be no directive matches for an invalid
291+
// selector.
271292
return new Set();
272293
}
273-
return new Set(directives.filter((dir: DirectiveSymbol) => {
274-
if (dir.selector === null) {
275-
return false;
276-
}
277-
278-
const matcher = new SelectorMatcher();
279-
matcher.addSelectables(CssSelector.parse(dir.selector));
280-
281-
return selectors.some(selector => matcher.match(selector, null));
282-
}));
283294
}
284295

285296
/**
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
10+
11+
import {assertFileNames, assertTextSpans, humanizeDocumentSpanLike, LanguageServiceTestEnv, Project} from '../testing';
12+
13+
describe('definitions', () => {
14+
let env: LanguageServiceTestEnv;
15+
16+
describe('when an input has a dollar sign', () => {
17+
const files = {
18+
'app.ts': `
19+
import {Component, NgModule, Input} from '@angular/core';
20+
21+
@Component({selector: 'dollar-cmp', template: ''})
22+
export class DollarCmp {
23+
@Input() obs$!: string;
24+
}
25+
26+
@Component({template: '<dollar-cmp [obs$]="greeting"></dollar-cmp>'})
27+
export class AppCmp {
28+
greeting = 'hello';
29+
}
30+
31+
@NgModule({declarations: [AppCmp, DollarCmp]})
32+
export class AppModule {}
33+
`,
34+
};
35+
36+
beforeEach(() => {
37+
initMockFileSystem('Native');
38+
env = LanguageServiceTestEnv.setup();
39+
});
40+
41+
it('can get definitions for input', () => {
42+
const project = env.addProject('test', files, {strictTemplates: false});
43+
const definitions = getDefinitionsAndAssertBoundSpan(project, 'app.ts', '[o¦bs$]="greeting"');
44+
expect(definitions!.length).toEqual(1);
45+
46+
assertTextSpans(definitions, ['obs$']);
47+
assertFileNames(definitions, ['app.ts']);
48+
});
49+
50+
it('can get definitions for component', () => {
51+
const project = env.addProject('test', files, {strictTemplates: false});
52+
const definitions = getDefinitionsAndAssertBoundSpan(project, 'app.ts', '<dollar-cm¦p');
53+
expect(definitions!.length).toEqual(1);
54+
55+
assertTextSpans(definitions, ['DollarCmp']);
56+
assertFileNames(definitions, ['app.ts']);
57+
});
58+
});
59+
60+
describe('when a selector and input of a directive have a dollar sign', () => {
61+
it('can get definitions', () => {
62+
initMockFileSystem('Native');
63+
env = LanguageServiceTestEnv.setup();
64+
const files = {
65+
'app.ts': `
66+
import {Component, Directive, NgModule, Input} from '@angular/core';
67+
68+
@Directive({selector: '[dollar\\\\$]', template: ''})
69+
export class DollarDir {
70+
@Input() dollar$!: string;
71+
}
72+
73+
@Component({template: '<div [dollar$]="greeting"></div>'})
74+
export class AppCmp {
75+
greeting = 'hello';
76+
}
77+
78+
@NgModule({declarations: [AppCmp, DollarDir]})
79+
export class AppModule {}
80+
`,
81+
};
82+
const project = env.addProject('test', files, {strictTemplates: false});
83+
const definitions =
84+
getDefinitionsAndAssertBoundSpan(project, 'app.ts', '[dollar¦$]="greeting"');
85+
expect(definitions!.length).toEqual(2);
86+
87+
assertTextSpans(definitions, ['dollar$', 'DollarDir']);
88+
assertFileNames(definitions, ['app.ts']);
89+
});
90+
});
91+
92+
function getDefinitionsAndAssertBoundSpan(project: Project, file: string, targetText: string) {
93+
const template = project.openFile(file);
94+
env.expectNoSourceDiagnostics();
95+
project.expectNoTemplateDiagnostics('app.ts', 'AppCmp');
96+
97+
template.moveCursorToText(targetText);
98+
const defAndBoundSpan = template.getDefinitionAndBoundSpan();
99+
expect(defAndBoundSpan).toBeTruthy();
100+
expect(defAndBoundSpan!.definitions).toBeTruthy();
101+
return defAndBoundSpan!.definitions!.map(d => humanizeDocumentSpanLike(d, env));
102+
}
103+
});

0 commit comments

Comments
 (0)