feat(language-service): add definitions for templateUrl (#32238)
Adds support for `getDefinitionAt` when called on a templateUrl property assignment. The currrent architecture for getting definitions is designed to be called on templates, so we have to introduce a new `getTsDefinitionAndBoundSpan` method to get Angular-specific definitions in TypeScript files and pass a `readTemplate` closure that will read the contents of a template using `TypeScriptServiceHost#getTemplates`. We can probably go in and make this nicer in a future PR, though I'm not sure what the best architecture should be yet. Part of angular/vscode-ng-language-service#111 PR Close #32238
This commit is contained in:
parent
98420c27de
commit
46caf88b2c
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"seq": 0,
|
||||||
|
"type": "response",
|
||||||
|
"command": "definitionAndBoundSpan",
|
||||||
|
"request_seq": 2,
|
||||||
|
"success": true,
|
||||||
|
"body": {
|
||||||
|
"definitions": [
|
||||||
|
{
|
||||||
|
"file": "${PWD}/project/app/widget.component.html",
|
||||||
|
"start": {
|
||||||
|
"line": 1,
|
||||||
|
"offset": 1
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"line": 1,
|
||||||
|
"offset": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"textSpan": {
|
||||||
|
"start": {
|
||||||
|
"line": 5,
|
||||||
|
"offset": 17
|
||||||
|
},
|
||||||
|
"end": {
|
||||||
|
"line": 5,
|
||||||
|
"offset": 40
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,26 @@
|
||||||
import { fork, ChildProcess } from 'child_process';
|
import {ChildProcess, fork} from 'child_process';
|
||||||
import {join} from 'path';
|
import {join} from 'path';
|
||||||
import { Client } from './tsclient';
|
|
||||||
import {goldenMatcher} from './matcher';
|
import {goldenMatcher} from './matcher';
|
||||||
|
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;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jasmine.addMatchers(goldenMatcher);
|
jasmine.addMatchers(goldenMatcher);
|
||||||
server = fork(SERVER_PATH, [
|
server = fork(
|
||||||
'--logVerbosity', 'verbose',
|
SERVER_PATH,
|
||||||
'--logFile', join(PWD, 'tsserver.log'),
|
[
|
||||||
], {
|
'--logVerbosity',
|
||||||
|
'verbose',
|
||||||
|
'--logFile',
|
||||||
|
join(PWD, 'tsserver.log'),
|
||||||
|
],
|
||||||
|
{
|
||||||
stdio: ['pipe', 'pipe', 'inherit', 'ipc'],
|
stdio: ['pipe', 'pipe', 'inherit', 'ipc'],
|
||||||
});
|
});
|
||||||
client = new Client(server);
|
client = new Client(server);
|
||||||
|
@ -35,13 +40,13 @@ describe('Angular Language Service', () => {
|
||||||
});
|
});
|
||||||
expect(response).toMatchGolden('configure.json');
|
expect(response).toMatchGolden('configure.json');
|
||||||
response = await client.sendRequest('compilerOptionsForInferredProjects', {
|
response = await client.sendRequest('compilerOptionsForInferredProjects', {
|
||||||
"options": {
|
'options': {
|
||||||
module: "CommonJS",
|
module: 'CommonJS',
|
||||||
target: "ES6",
|
target: 'ES6',
|
||||||
allowSyntheticDefaultImports: true,
|
allowSyntheticDefaultImports: true,
|
||||||
allowNonTsExtensions: true,
|
allowNonTsExtensions: true,
|
||||||
allowJs: true,
|
allowJs: true,
|
||||||
jsx: "Preserve"
|
jsx: 'Preserve'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(response).toMatchGolden('compilerOptionsForInferredProjects.json');
|
expect(response).toMatchGolden('compilerOptionsForInferredProjects.json');
|
||||||
|
@ -52,10 +57,7 @@ describe('Angular Language Service', () => {
|
||||||
});
|
});
|
||||||
// Server does not send response to geterr request
|
// Server does not send response to geterr request
|
||||||
// https://github.com/Microsoft/TypeScript/blob/master/lib/protocol.d.ts#L1770
|
// https://github.com/Microsoft/TypeScript/blob/master/lib/protocol.d.ts#L1770
|
||||||
client.sendRequest('geterr', {
|
client.sendRequest('geterr', {delay: 0, files: [`${PWD}/project/app/app.module.ts`]});
|
||||||
delay: 0,
|
|
||||||
files: [`${PWD}/project/app/app.module.ts`]
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should perform completions', async() => {
|
it('should perform completions', async() => {
|
||||||
|
@ -63,13 +65,13 @@ describe('Angular Language Service', () => {
|
||||||
hostInfo: 'vscode',
|
hostInfo: 'vscode',
|
||||||
});
|
});
|
||||||
await client.sendRequest('compilerOptionsForInferredProjects', {
|
await client.sendRequest('compilerOptionsForInferredProjects', {
|
||||||
"options": {
|
'options': {
|
||||||
module: "CommonJS",
|
module: 'CommonJS',
|
||||||
target: "ES6",
|
target: 'ES6',
|
||||||
allowSyntheticDefaultImports: true,
|
allowSyntheticDefaultImports: true,
|
||||||
allowNonTsExtensions: true,
|
allowNonTsExtensions: true,
|
||||||
allowJs: true,
|
allowJs: true,
|
||||||
jsx: "Preserve"
|
jsx: 'Preserve'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -77,10 +79,7 @@ describe('Angular Language Service', () => {
|
||||||
file: `${PWD}/project/app/app.component.ts`,
|
file: `${PWD}/project/app/app.component.ts`,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.sendRequest('geterr', {
|
client.sendRequest('geterr', {delay: 0, files: [`${PWD}/project/app/app.component.ts`]});
|
||||||
delay: 0,
|
|
||||||
files: [`${PWD}/project/app/app.component.ts`]
|
|
||||||
});
|
|
||||||
|
|
||||||
client.sendRequest('change', {
|
client.sendRequest('change', {
|
||||||
file: `${PWD}/project/app/app.component.ts`,
|
file: `${PWD}/project/app/app.component.ts`,
|
||||||
|
@ -159,4 +158,23 @@ describe('Angular Language Service', () => {
|
||||||
expect(resp2).toMatchGolden('definitionAndBoundSpan.json');
|
expect(resp2).toMatchGolden('definitionAndBoundSpan.json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should perform definitionAndBoundSpan for template URLs', async() => {
|
||||||
|
client.sendRequest('open', {
|
||||||
|
file: `${PWD}/project/app/widget.component.ts`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 5,
|
||||||
|
offset: 19,
|
||||||
|
});
|
||||||
|
expect(resp2).toMatchGolden('templateUrlDefinition.json');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,10 +6,13 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as path from 'path';
|
||||||
import * as ts from 'typescript'; // used as value and is provided at runtime
|
import * as ts from 'typescript'; // used as value and is provided at runtime
|
||||||
import {AstResult} from './common';
|
import {AstResult} from './common';
|
||||||
import {locateSymbol} from './locate_symbol';
|
import {locateSymbol} from './locate_symbol';
|
||||||
import {Span} from './types';
|
import {getPropertyAssignmentFromValue, isClassDecoratorProperty} from './template';
|
||||||
|
import {Span, TemplateSource} from './types';
|
||||||
|
import {findTightestNode} from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Angular Span to TypeScript TextSpan. Angular Span has 'start' and
|
* Convert Angular Span to TypeScript TextSpan. Angular Span has 'start' and
|
||||||
|
@ -59,3 +62,67 @@ export function getDefinitionAndBoundSpan(
|
||||||
definitions, textSpan,
|
definitions, textSpan,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets an Angular-specific definition in a TypeScript source file.
|
||||||
|
*/
|
||||||
|
export function getTsDefinitionAndBoundSpan(
|
||||||
|
sf: ts.SourceFile, position: number,
|
||||||
|
tsLsHost: Readonly<ts.LanguageServiceHost>): ts.DefinitionInfoAndBoundSpan|undefined {
|
||||||
|
const node = findTightestNode(sf, position);
|
||||||
|
if (!node) return;
|
||||||
|
switch (node.kind) {
|
||||||
|
case ts.SyntaxKind.StringLiteral:
|
||||||
|
case ts.SyntaxKind.NoSubstitutionTemplateLiteral:
|
||||||
|
// Attempt to extract definition of a URL in a property assignment.
|
||||||
|
return getUrlFromProperty(node as ts.StringLiteralLike, tsLsHost);
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function getUrlFromProperty(
|
||||||
|
urlNode: ts.StringLiteralLike,
|
||||||
|
tsLsHost: Readonly<ts.LanguageServiceHost>): 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.
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}];
|
||||||
|
|
||||||
|
return {
|
||||||
|
definitions: templateDefinitions,
|
||||||
|
textSpan: {
|
||||||
|
// Exclude opening and closing quotes in the url span.
|
||||||
|
start: urlNode.getStart() + 1,
|
||||||
|
length: urlNode.getWidth() - 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ import * as tss from 'typescript/lib/tsserverlibrary';
|
||||||
|
|
||||||
import {isAstResult} from './common';
|
import {isAstResult} from './common';
|
||||||
import {getTemplateCompletions, ngCompletionToTsCompletionEntry} from './completions';
|
import {getTemplateCompletions, ngCompletionToTsCompletionEntry} from './completions';
|
||||||
import {getDefinitionAndBoundSpan} from './definitions';
|
import {getDefinitionAndBoundSpan, getTsDefinitionAndBoundSpan} from './definitions';
|
||||||
import {getDeclarationDiagnostics, getTemplateDiagnostics, ngDiagnosticToTsDiagnostic, uniqueBySpan} from './diagnostics';
|
import {getDeclarationDiagnostics, getTemplateDiagnostics, ngDiagnosticToTsDiagnostic, uniqueBySpan} from './diagnostics';
|
||||||
import {getHover} from './hover';
|
import {getHover} from './hover';
|
||||||
import {Diagnostic, LanguageService} from './types';
|
import {Diagnostic, LanguageService} from './types';
|
||||||
|
@ -80,6 +80,15 @@ class LanguageServiceImpl implements LanguageService {
|
||||||
if (templateInfo) {
|
if (templateInfo) {
|
||||||
return getDefinitionAndBoundSpan(templateInfo, position);
|
return getDefinitionAndBoundSpan(templateInfo, position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attempt to get Angular-specific definitions in a TypeScript file, like templates defined
|
||||||
|
// in a `templateUrl` property.
|
||||||
|
if (fileName.endsWith('.ts')) {
|
||||||
|
const sf = this.host.getSourceFile(fileName);
|
||||||
|
if (sf) {
|
||||||
|
return getTsDefinitionAndBoundSpan(sf, position, this.host.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getHoverAt(fileName: string, position: number): tss.QuickInfo|undefined {
|
getHoverAt(fileName: string, position: number): tss.QuickInfo|undefined {
|
||||||
|
|
|
@ -73,8 +73,7 @@ export class TypeScriptServiceHost implements LanguageServiceHost {
|
||||||
ngModules: [],
|
ngModules: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(readonly host: ts.LanguageServiceHost, private readonly tsLS: ts.LanguageService) {
|
||||||
private readonly host: ts.LanguageServiceHost, private readonly tsLS: ts.LanguageService) {
|
|
||||||
this.summaryResolver = new AotSummaryResolver(
|
this.summaryResolver = new AotSummaryResolver(
|
||||||
{
|
{
|
||||||
loadSummary(filePath: string) { return null; },
|
loadSummary(filePath: string) { return null; },
|
||||||
|
|
|
@ -254,6 +254,28 @@ describe('definitions', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be able to find a template from a url', () => {
|
||||||
|
const fileName = addCode(`
|
||||||
|
@Component({
|
||||||
|
templateUrl: './«test».ng',
|
||||||
|
})
|
||||||
|
export class MyComponent {}`);
|
||||||
|
|
||||||
|
const marker = 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: 9});
|
||||||
|
|
||||||
|
expect(definitions).toBeDefined();
|
||||||
|
expect(definitions !.length).toBe(1);
|
||||||
|
const [def] = definitions !;
|
||||||
|
expect(def.fileName).toBe('/app/test.ng');
|
||||||
|
expect(def.textSpan).toEqual({start: 0, length: 0});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a snippet of code to `app.component.ts` and return the file name.
|
* Append a snippet of code to `app.component.ts` and return the file name.
|
||||||
* There must not be any name collision with existing code.
|
* There must not be any name collision with existing code.
|
||||||
|
|
Loading…
Reference in New Issue