feat(language-service): add definitions for styleUrls (#32464)
Adds support for `styleUrls` definitions in the same way `templateUrl` definitions are provided; clicking on styleUrl will take a user to the respective file. Unifies some code in determining a URL definition. We first check if a url is a `templateUrl`; if it's not, we check that it's a `styleUrl` or return no definitions. PR Close #32464
This commit is contained in:
parent
c3a1ef219e
commit
a391aebbcf
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
width: 100%;
|
||||||
|
}
|
|
@ -3,5 +3,6 @@ import { Component } from '@angular/core';
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-widget',
|
selector: 'my-widget',
|
||||||
templateUrl: './widget.component.html',
|
templateUrl: './widget.component.html',
|
||||||
|
styleUrls: ['./style.css'],
|
||||||
})
|
})
|
||||||
export class WidgetComponent { name = 'Angular'; }
|
export class WidgetComponent { name = 'Angular'; }
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {Client} from './tsclient';
|
||||||
|
|
||||||
describe('Angular Language Service', () => {
|
describe('Angular Language Service', () => {
|
||||||
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; /* 10 seconds */
|
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';
|
const SERVER_PATH = './node_modules/typescript/lib/tsserver.js';
|
||||||
let server: ChildProcess;
|
let server: ChildProcess;
|
||||||
let client: Client;
|
let client: Client;
|
||||||
|
@ -27,14 +27,14 @@ describe('Angular Language Service', () => {
|
||||||
client.listen();
|
client.listen();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async() => {
|
afterEach(async () => {
|
||||||
client.sendRequest('exit', {});
|
client.sendRequest('exit', {});
|
||||||
|
|
||||||
// Give server process some time to flush all messages
|
// Give server process some time to flush all messages
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
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', {
|
let response = await client.sendRequest('configure', {
|
||||||
hostInfo: 'vscode',
|
hostInfo: 'vscode',
|
||||||
});
|
});
|
||||||
|
@ -60,7 +60,7 @@ describe('Angular Language Service', () => {
|
||||||
client.sendRequest('geterr', {delay: 0, files: [`${PWD}/project/app/app.module.ts`]});
|
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', {
|
await client.sendRequest('configure', {
|
||||||
hostInfo: 'vscode',
|
hostInfo: 'vscode',
|
||||||
});
|
});
|
||||||
|
@ -98,7 +98,7 @@ describe('Angular Language Service', () => {
|
||||||
expect(response).toMatchGolden('completionInfo.json');
|
expect(response).toMatchGolden('completionInfo.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should perform quickinfo', async() => {
|
it('should perform quickinfo', async () => {
|
||||||
client.sendRequest('open', {
|
client.sendRequest('open', {
|
||||||
file: `${PWD}/project/app/app.component.ts`,
|
file: `${PWD}/project/app/app.component.ts`,
|
||||||
});
|
});
|
||||||
|
@ -118,7 +118,7 @@ describe('Angular Language Service', () => {
|
||||||
expect(resp2).toMatchGolden('quickinfo.json');
|
expect(resp2).toMatchGolden('quickinfo.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should perform definition', async() => {
|
it('should perform definition', async () => {
|
||||||
client.sendRequest('open', {
|
client.sendRequest('open', {
|
||||||
file: `${PWD}/project/app/app.component.ts`,
|
file: `${PWD}/project/app/app.component.ts`,
|
||||||
});
|
});
|
||||||
|
@ -138,7 +138,7 @@ describe('Angular Language Service', () => {
|
||||||
expect(resp2).toMatchGolden('definition.json');
|
expect(resp2).toMatchGolden('definition.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should perform definitionAndBoundSpan', async() => {
|
it('should perform definitionAndBoundSpan', async () => {
|
||||||
client.sendRequest('open', {
|
client.sendRequest('open', {
|
||||||
file: `${PWD}/project/app/app.component.ts`,
|
file: `${PWD}/project/app/app.component.ts`,
|
||||||
});
|
});
|
||||||
|
@ -158,7 +158,7 @@ describe('Angular Language Service', () => {
|
||||||
expect(resp2).toMatchGolden('definitionAndBoundSpan.json');
|
expect(resp2).toMatchGolden('definitionAndBoundSpan.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should perform definitionAndBoundSpan for template URLs', async() => {
|
it('should perform definitionAndBoundSpan for template URLs', async () => {
|
||||||
client.sendRequest('open', {
|
client.sendRequest('open', {
|
||||||
file: `${PWD}/project/app/widget.component.ts`,
|
file: `${PWD}/project/app/widget.component.ts`,
|
||||||
});
|
});
|
||||||
|
@ -177,4 +177,27 @@ describe('Angular Language Service', () => {
|
||||||
});
|
});
|
||||||
expect(resp2).toMatchGolden('templateUrlDefinition.json');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
* Attempts to get the definition of a file whose URL is specified in a property assignment in a
|
||||||
* directive decorator.
|
* directive decorator.
|
||||||
* Currently applies to `templateUrl` properties.
|
* Currently applies to `templateUrl` and `styleUrls` properties.
|
||||||
*/
|
*/
|
||||||
function getUrlFromProperty(
|
function getUrlFromProperty(
|
||||||
urlNode: ts.StringLiteralLike,
|
urlNode: ts.StringLiteralLike,
|
||||||
tsLsHost: Readonly<ts.LanguageServiceHost>): ts.DefinitionInfoAndBoundSpan|undefined {
|
tsLsHost: Readonly<ts.LanguageServiceHost>): ts.DefinitionInfoAndBoundSpan|undefined {
|
||||||
const asgn = getPropertyAssignmentFromValue(urlNode);
|
// Get the property assignment node corresponding to the `templateUrl` or `styleUrls` assignment.
|
||||||
if (!asgn) return;
|
// These assignments are specified differently; `templateUrl` is a string, and `styleUrls` is
|
||||||
// If the URL is not a property of a class decorator, don't generate definitions for it.
|
// 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;
|
if (!isClassDecoratorProperty(asgn)) return;
|
||||||
|
|
||||||
const sf = urlNode.getSourceFile();
|
const sf = urlNode.getSourceFile();
|
||||||
switch (asgn.name.getText()) {
|
// Extract url path specified by the url node, which is relative to the TypeScript source file
|
||||||
case 'templateUrl':
|
// the url node is defined in.
|
||||||
// Extract definition of the template file specified by this `templateUrl` property.
|
const url = path.join(path.dirname(sf.fileName), urlNode.text);
|
||||||
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
|
// 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.
|
// does not have a `fileExists` method, in which case optimistically assume the file exists.
|
||||||
if (tsLsHost.fileExists && !tsLsHost.fileExists(url)) return;
|
if (tsLsHost.fileExists && !tsLsHost.fileExists(url)) return;
|
||||||
|
|
||||||
const templateDefinitions: ts.DefinitionInfo[] = [{
|
const templateDefinitions: ts.DefinitionInfo[] = [{
|
||||||
kind: ts.ScriptElementKind.externalModuleName,
|
kind: ts.ScriptElementKind.externalModuleName,
|
||||||
name: url,
|
name: url,
|
||||||
containerKind: ts.ScriptElementKind.unknown,
|
containerKind: ts.ScriptElementKind.unknown,
|
||||||
containerName: '',
|
containerName: '',
|
||||||
// Reading the template is expensive, so don't provide a preview.
|
// Reading the template is expensive, so don't provide a preview.
|
||||||
textSpan: {start: 0, length: 0},
|
textSpan: {start: 0, length: 0},
|
||||||
fileName: url,
|
fileName: url,
|
||||||
}];
|
}];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
definitions: templateDefinitions,
|
definitions: templateDefinitions,
|
||||||
textSpan: {
|
textSpan: {
|
||||||
// Exclude opening and closing quotes in the url span.
|
// Exclude opening and closing quotes in the url span.
|
||||||
start: urlNode.getStart() + 1,
|
start: urlNode.getStart() + 1,
|
||||||
length: urlNode.getWidth() - 2,
|
length: urlNode.getWidth() - 2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -275,4 +275,27 @@ describe('definitions', () => {
|
||||||
expect(def.fileName).toBe('/app/test.ng');
|
expect(def.fileName).toBe('/app/test.ng');
|
||||||
expect(def.textSpan).toEqual({start: 0, length: 0});
|
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});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -232,6 +232,11 @@ export class ShowIf {
|
||||||
<label>name: </label>
|
<label>name: </label>
|
||||||
</div>
|
</div>
|
||||||
&~{entity-amp}amp;
|
&~{entity-amp}amp;
|
||||||
`
|
`,
|
||||||
|
'test.css': `
|
||||||
|
body, html {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
`,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue