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:
ayazhafiz 2019-08-20 13:13:46 -05:00 committed by Miško Hevery
parent 98420c27de
commit 46caf88b2c
6 changed files with 187 additions and 40 deletions

View File

@ -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
}
}
}
}

View File

@ -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');
});
}); });

View File

@ -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;
}
}

View File

@ -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 {

View File

@ -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; },

View File

@ -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.