diff --git a/integration/language_service_plugin/goldens/styleUrlsDefinition.json b/integration/language_service_plugin/goldens/styleUrlsDefinition.json new file mode 100644 index 0000000000..6662ef602e --- /dev/null +++ b/integration/language_service_plugin/goldens/styleUrlsDefinition.json @@ -0,0 +1,32 @@ +{ + "seq": 0, + "type": "response", + "command": "definitionAndBoundSpan", + "request_seq": 3, + "success": true, + "body": { + "definitions": [ + { + "file": "${PWD}/project/app/style.css", + "start": { + "line": 1, + "offset": 1 + }, + "end": { + "line": 1, + "offset": 1 + } + } + ], + "textSpan": { + "start": { + "line": 6, + "offset": 16 + }, + "end": { + "line": 6, + "offset": 27 + } + } + } +} diff --git a/integration/language_service_plugin/project/app/style.css b/integration/language_service_plugin/project/app/style.css new file mode 100644 index 0000000000..bc647cc4bb --- /dev/null +++ b/integration/language_service_plugin/project/app/style.css @@ -0,0 +1,4 @@ +body, +html { + width: 100%; +} diff --git a/integration/language_service_plugin/project/app/widget.component.ts b/integration/language_service_plugin/project/app/widget.component.ts index 293578176c..185936c4cc 100644 --- a/integration/language_service_plugin/project/app/widget.component.ts +++ b/integration/language_service_plugin/project/app/widget.component.ts @@ -3,5 +3,6 @@ import { Component } from '@angular/core'; @Component({ selector: 'my-widget', templateUrl: './widget.component.html', + styleUrls: ['./style.css'], }) export class WidgetComponent { name = 'Angular'; } diff --git a/integration/language_service_plugin/test.ts b/integration/language_service_plugin/test.ts index 160ae8d3c1..d13a07b7ee 100644 --- a/integration/language_service_plugin/test.ts +++ b/integration/language_service_plugin/test.ts @@ -5,7 +5,7 @@ import {Client} from './tsclient'; describe('Angular Language Service', () => { jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; /* 10 seconds */ - const PWD = process.env.PWD !; + const PWD = process.env.PWD!; const SERVER_PATH = './node_modules/typescript/lib/tsserver.js'; let server: ChildProcess; let client: Client; @@ -27,14 +27,14 @@ describe('Angular Language Service', () => { client.listen(); }); - afterEach(async() => { + afterEach(async () => { client.sendRequest('exit', {}); // Give server process some time to flush all messages await new Promise((resolve) => setTimeout(resolve, 1000)); }); - it('should be launched as tsserver plugin', async() => { + it('should be launched as tsserver plugin', async () => { let response = await client.sendRequest('configure', { hostInfo: 'vscode', }); @@ -60,7 +60,7 @@ describe('Angular Language Service', () => { client.sendRequest('geterr', {delay: 0, files: [`${PWD}/project/app/app.module.ts`]}); }); - it('should perform completions', async() => { + it('should perform completions', async () => { await client.sendRequest('configure', { hostInfo: 'vscode', }); @@ -98,7 +98,7 @@ describe('Angular Language Service', () => { expect(response).toMatchGolden('completionInfo.json'); }); - it('should perform quickinfo', async() => { + it('should perform quickinfo', async () => { client.sendRequest('open', { file: `${PWD}/project/app/app.component.ts`, }); @@ -118,7 +118,7 @@ describe('Angular Language Service', () => { expect(resp2).toMatchGolden('quickinfo.json'); }); - it('should perform definition', async() => { + it('should perform definition', async () => { client.sendRequest('open', { file: `${PWD}/project/app/app.component.ts`, }); @@ -138,7 +138,7 @@ describe('Angular Language Service', () => { expect(resp2).toMatchGolden('definition.json'); }); - it('should perform definitionAndBoundSpan', async() => { + it('should perform definitionAndBoundSpan', async () => { client.sendRequest('open', { file: `${PWD}/project/app/app.component.ts`, }); @@ -158,7 +158,7 @@ describe('Angular Language Service', () => { expect(resp2).toMatchGolden('definitionAndBoundSpan.json'); }); - it('should perform definitionAndBoundSpan for template URLs', async() => { + it('should perform definitionAndBoundSpan for template URLs', async () => { client.sendRequest('open', { file: `${PWD}/project/app/widget.component.ts`, }); @@ -177,4 +177,27 @@ describe('Angular Language Service', () => { }); expect(resp2).toMatchGolden('templateUrlDefinition.json'); }); + + it('should perform definitionAndBoundSpan for style URLs', async () => { + client.sendRequest('open', { + file: `${PWD}/project/app/widget.component.ts`, + }); + client.sendRequest('open', { + file: `${PWD}/project/app/style.css`, + }); + + const resp1 = await client.sendRequest('reload', { + file: `${PWD}/project/app/widget.component.ts`, + tmpFile: `${PWD}/project/app/widget.component.ts`, + }) as any; + expect(resp1.command).toBe('reload'); + expect(resp1.success).toBe(true); + + const resp2 = await client.sendRequest('definitionAndBoundSpan', { + file: `${PWD}/project/app/widget.component.ts`, + line: 6, + offset: 18, + }); + expect(resp2).toMatchGolden('styleUrlsDefinition.json'); + }); }); diff --git a/packages/language-service/src/definitions.ts b/packages/language-service/src/definitions.ts index 6a0eb7029b..1d12c7df89 100644 --- a/packages/language-service/src/definitions.ts +++ b/packages/language-service/src/definitions.ts @@ -84,45 +84,61 @@ export function getTsDefinitionAndBoundSpan( /** * Attempts to get the definition of a file whose URL is specified in a property assignment in a * directive decorator. - * Currently applies to `templateUrl` properties. + * Currently applies to `templateUrl` and `styleUrls` properties. */ function getUrlFromProperty( urlNode: ts.StringLiteralLike, tsLsHost: Readonly): ts.DefinitionInfoAndBoundSpan|undefined { - const asgn = getPropertyAssignmentFromValue(urlNode); - if (!asgn) return; - // If the URL is not a property of a class decorator, don't generate definitions for it. + // Get the property assignment node corresponding to the `templateUrl` or `styleUrls` assignment. + // These assignments are specified differently; `templateUrl` is a string, and `styleUrls` is + // an array of strings: + // { + // templateUrl: './template.ng.html', + // styleUrls: ['./style.css', './other-style.css'] + // } + // `templateUrl`'s property assignment can be found from the string literal node; + // `styleUrls`'s property assignment can be found from the array (parent) node. + // + // First search for `templateUrl`. + let asgn = getPropertyAssignmentFromValue(urlNode); + if (!asgn || asgn.name.getText() !== 'templateUrl') { + // `templateUrl` assignment not found; search for `styleUrls` array assignment. + asgn = getPropertyAssignmentFromValue(urlNode.parent); + if (!asgn || asgn.name.getText() !== 'styleUrls') { + // Nothing found, bail. + return; + } + } + + // If the property assignment is not a property of a class decorator, don't generate definitions + // for it. if (!isClassDecoratorProperty(asgn)) return; const sf = urlNode.getSourceFile(); - switch (asgn.name.getText()) { - case 'templateUrl': - // Extract definition of the template file specified by this `templateUrl` property. - const url = path.join(path.dirname(sf.fileName), urlNode.text); + // Extract url path specified by the url node, which is relative to the TypeScript source file + // the url node is defined in. + const url = path.join(path.dirname(sf.fileName), urlNode.text); - // If the file does not exist, bail. It is possible that the TypeScript language service host - // does not have a `fileExists` method, in which case optimistically assume the file exists. - if (tsLsHost.fileExists && !tsLsHost.fileExists(url)) return; + // If the file does not exist, bail. It is possible that the TypeScript language service host + // does not have a `fileExists` method, in which case optimistically assume the file exists. + if (tsLsHost.fileExists && !tsLsHost.fileExists(url)) return; - const templateDefinitions: ts.DefinitionInfo[] = [{ - kind: ts.ScriptElementKind.externalModuleName, - name: url, - containerKind: ts.ScriptElementKind.unknown, - containerName: '', - // Reading the template is expensive, so don't provide a preview. - textSpan: {start: 0, length: 0}, - fileName: url, - }]; + const templateDefinitions: ts.DefinitionInfo[] = [{ + kind: ts.ScriptElementKind.externalModuleName, + name: url, + containerKind: ts.ScriptElementKind.unknown, + containerName: '', + // Reading the template is expensive, so don't provide a preview. + textSpan: {start: 0, length: 0}, + fileName: url, + }]; - return { - definitions: templateDefinitions, - textSpan: { - // Exclude opening and closing quotes in the url span. - start: urlNode.getStart() + 1, - length: urlNode.getWidth() - 2, - }, - }; - default: - return undefined; - } + return { + definitions: templateDefinitions, + textSpan: { + // Exclude opening and closing quotes in the url span. + start: urlNode.getStart() + 1, + length: urlNode.getWidth() - 2, + }, + }; } diff --git a/packages/language-service/test/definitions_spec.ts b/packages/language-service/test/definitions_spec.ts index b6849140bd..286b8cf587 100644 --- a/packages/language-service/test/definitions_spec.ts +++ b/packages/language-service/test/definitions_spec.ts @@ -275,4 +275,27 @@ describe('definitions', () => { expect(def.fileName).toBe('/app/test.ng'); expect(def.textSpan).toEqual({start: 0, length: 0}); }); + + it('should be able to find a stylesheet from a url', () => { + const fileName = mockHost.addCode(` + @Component({ + templateUrl: './test.ng', + styleUrls: ['./«test».css'], + }) + export class MyComponent {}`); + + const marker = mockHost.getReferenceMarkerFor(fileName, 'test'); + const result = ngService.getDefinitionAt(fileName, marker.start); + + expect(result).toBeDefined(); + const {textSpan, definitions} = result !; + + expect(textSpan).toEqual({start: marker.start - 2, length: 10}); + + expect(definitions).toBeDefined(); + expect(definitions !.length).toBe(1); + const [def] = definitions !; + expect(def.fileName).toBe('/app/test.css'); + expect(def.textSpan).toEqual({start: 0, length: 0}); + }); }); diff --git a/packages/language-service/test/test_data.ts b/packages/language-service/test/test_data.ts index 9db91e508d..6eee966eac 100644 --- a/packages/language-service/test/test_data.ts +++ b/packages/language-service/test/test_data.ts @@ -232,6 +232,11 @@ export class ShowIf { &~{entity-amp}amp; - ` +`, + 'test.css': ` +body, html { + width: 100%; +} +`, } };