diff --git a/packages/language-service/common/BUILD.bazel b/packages/language-service/common/BUILD.bazel index 5c3ccefc5a..afe8f39b56 100644 --- a/packages/language-service/common/BUILD.bazel +++ b/packages/language-service/common/BUILD.bazel @@ -6,7 +6,6 @@ ts_library( name = "common", srcs = glob(["*.ts"]), deps = [ - "@npm//@types/node", "@npm//typescript", ], ) diff --git a/packages/language-service/common/definitions.ts b/packages/language-service/common/definitions.ts index 0db481cd14..7e38cd8f8f 100644 --- a/packages/language-service/common/definitions.ts +++ b/packages/language-service/common/definitions.ts @@ -6,24 +6,35 @@ * found in the LICENSE file at https://angular.io/license */ -import * as path from 'path'; import * as ts from 'typescript'; import {findTightestNode, getClassDeclFromDecoratorProp, getPropertyAssignmentFromValue} from './ts_utils'; +export interface ResourceResolver { + /** + * Resolve the url of a resource relative to the file that contains the reference to it. + * + * @param file The, possibly relative, url of the resource. + * @param basePath The path to the file that contains the URL of the resource. + * @returns A resolved url of resource. + * @throws An error if the resource cannot be resolved. + */ + resolve(file: string, basePath: string): string; +} + /** * Gets an Angular-specific definition in a TypeScript source file. */ export function getTsDefinitionAndBoundSpan( sf: ts.SourceFile, position: number, - tsLsHost: Pick): ts.DefinitionInfoAndBoundSpan|undefined { + resourceResolver: ResourceResolver): 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); + return getUrlFromProperty(node as ts.StringLiteralLike, resourceResolver); default: return undefined; } @@ -34,9 +45,8 @@ export function getTsDefinitionAndBoundSpan( * directive decorator. * Currently applies to `templateUrl` and `styleUrls` properties. */ -function getUrlFromProperty( - urlNode: ts.StringLiteralLike, - tsLsHost: Pick): ts.DefinitionInfoAndBoundSpan|undefined { +function getUrlFromProperty(urlNode: ts.StringLiteralLike, resourceResolver: ResourceResolver): + ts.DefinitionInfoAndBoundSpan|undefined { // 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: @@ -65,13 +75,13 @@ function getUrlFromProperty( } const sf = urlNode.getSourceFile(); - // 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; + let url: string; + try { + url = resourceResolver.resolve(urlNode.text, sf.fileName); + } catch { + // If the file does not exist, bail. + return; + } const templateDefinitions: ts.DefinitionInfo[] = [{ kind: ts.ScriptElementKind.externalModuleName, @@ -79,6 +89,9 @@ function getUrlFromProperty( containerKind: ts.ScriptElementKind.unknown, containerName: '', // Reading the template is expensive, so don't provide a preview. + // TODO(ayazhafiz): Consider providing an actual span: + // 1. We're likely to read the template anyway + // 2. We could show just the first 100 chars or so textSpan: {start: 0, length: 0}, fileName: url, }]; diff --git a/packages/language-service/common/ts_utils.ts b/packages/language-service/common/ts_utils.ts index 08b0d22f56..3ff50242e0 100644 --- a/packages/language-service/common/ts_utils.ts +++ b/packages/language-service/common/ts_utils.ts @@ -83,5 +83,5 @@ export function getClassDeclOfInlineTemplateNode(templateStringNode: ts.Node): t if (!tmplAsgn) { return; } - return getClassDeclFromDecoratorProp(tmplAsgn) + return getClassDeclFromDecoratorProp(tmplAsgn); } diff --git a/packages/language-service/ivy/BUILD.bazel b/packages/language-service/ivy/BUILD.bazel index d9c4434b73..c8df602d71 100644 --- a/packages/language-service/ivy/BUILD.bazel +++ b/packages/language-service/ivy/BUILD.bazel @@ -13,6 +13,7 @@ ts_library( "//packages/compiler-cli/src/ngtsc/file_system", "//packages/compiler-cli/src/ngtsc/incremental", "//packages/compiler-cli/src/ngtsc/reflection", + "//packages/compiler-cli/src/ngtsc/resource", "//packages/compiler-cli/src/ngtsc/shims", "//packages/compiler-cli/src/ngtsc/typecheck", "//packages/compiler-cli/src/ngtsc/typecheck/api", diff --git a/packages/language-service/ivy/compiler_factory.ts b/packages/language-service/ivy/compiler_factory.ts index 8875018139..8397044e7a 100644 --- a/packages/language-service/ivy/compiler_factory.ts +++ b/packages/language-service/ivy/compiler_factory.ts @@ -12,7 +12,8 @@ import {TrackedIncrementalBuildStrategy} from '@angular/compiler-cli/src/ngtsc/i import {TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import * as ts from 'typescript/lib/tsserverlibrary'; -import {isExternalTemplate, LanguageServiceAdapter} from './language_service_adapter'; +import {LanguageServiceAdapter} from './language_service_adapter'; +import {isExternalTemplate} from './utils'; export class CompilerFactory { private readonly incrementalStrategy = new TrackedIncrementalBuildStrategy(); diff --git a/packages/language-service/ivy/definitions.ts b/packages/language-service/ivy/definitions.ts index 4ce1e15caa..48fb9d9715 100644 --- a/packages/language-service/ivy/definitions.ts +++ b/packages/language-service/ivy/definitions.ts @@ -11,8 +11,10 @@ import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {DirectiveSymbol, DomBindingSymbol, ElementSymbol, ShimLocation, Symbol, SymbolKind, TemplateSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import * as ts from 'typescript'; +import {getTsDefinitionAndBoundSpan, ResourceResolver} from '../common/definitions'; + import {getPathToNodeAtPosition} from './hybrid_visitor'; -import {flatMap, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTextSpanOfNode, isDollarEvent, TemplateInfo, toTextSpan} from './utils'; +import {flatMap, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTextSpanOfNode, isDollarEvent, isTypeScriptFile, TemplateInfo, toTextSpan} from './utils'; interface DefinitionMeta { node: AST|TmplAstNode; @@ -25,13 +27,25 @@ interface HasShimLocation { } export class DefinitionBuilder { - constructor(private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {} + constructor( + private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler, + private readonly resourceResolver: ResourceResolver) {} getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan |undefined { const templateInfo = getTemplateInfoAtPosition(fileName, position, this.compiler); if (templateInfo === undefined) { - return; + // We were unable to get a template at the given position. If we are in a TS file, instead + // attempt to get an Angular definition at the location inside a TS file (examples of this + // would be templateUrl or a url in styleUrls). + if (!isTypeScriptFile(fileName)) { + return; + } + const sf = this.compiler.getNextProgram().getSourceFile(fileName); + if (!sf) { + return; + } + return getTsDefinitionAndBoundSpan(sf, position, this.resourceResolver); } const definitionMeta = this.getDefinitionMetaAtPosition(templateInfo, position); // The `$event` of event handlers would point to the $event parameter in the shim file, as in diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index 7e6c6534fe..b8d7e5fbfe 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -7,7 +7,6 @@ */ import {CompilerOptions, createNgCompilerOptions} from '@angular/compiler-cli'; -import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {absoluteFromSourceFile, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; import {TypeCheckShimGenerator} from '@angular/compiler-cli/src/ngtsc/typecheck'; import {OptimizeFor, TypeCheckingProgramStrategy} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; @@ -15,8 +14,9 @@ import * as ts from 'typescript/lib/tsserverlibrary'; import {CompilerFactory} from './compiler_factory'; import {DefinitionBuilder} from './definitions'; -import {isExternalTemplate, isTypeScriptFile, LanguageServiceAdapter} from './language_service_adapter'; +import {LanguageServiceAdapter} from './language_service_adapter'; import {QuickInfoBuilder} from './quick_info'; +import {isTypeScriptFile} from './utils'; export class LanguageService { private options: CompilerOptions; @@ -57,8 +57,8 @@ export class LanguageService { getDefinitionAndBoundSpan(fileName: string, position: number): ts.DefinitionInfoAndBoundSpan |undefined { const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options); - const results = - new DefinitionBuilder(this.tsLS, compiler).getDefinitionAndBoundSpan(fileName, position); + const results = new DefinitionBuilder(this.tsLS, compiler, this.adapter) + .getDefinitionAndBoundSpan(fileName, position); this.compilerFactory.registerLastKnownProgram(); return results; } @@ -66,8 +66,8 @@ export class LanguageService { getTypeDefinitionAtPosition(fileName: string, position: number): readonly ts.DefinitionInfo[]|undefined { const compiler = this.compilerFactory.getOrCreateWithChangedFile(fileName, this.options); - const results = - new DefinitionBuilder(this.tsLS, compiler).getTypeDefinitionsAtPosition(fileName, position); + const results = new DefinitionBuilder(this.tsLS, compiler, this.adapter) + .getTypeDefinitionsAtPosition(fileName, position); this.compilerFactory.registerLastKnownProgram(); return results; } diff --git a/packages/language-service/ivy/language_service_adapter.ts b/packages/language-service/ivy/language_service_adapter.ts index 0cff21179c..7b2cc77395 100644 --- a/packages/language-service/ivy/language_service_adapter.ts +++ b/packages/language-service/ivy/language_service_adapter.ts @@ -8,10 +8,15 @@ import {NgCompilerAdapter} from '@angular/compiler-cli/src/ngtsc/core/api'; import {absoluteFrom, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {AdapterResourceLoader} from '@angular/compiler-cli/src/ngtsc/resource'; import {isShim} from '@angular/compiler-cli/src/ngtsc/shims'; import * as ts from 'typescript/lib/tsserverlibrary'; -export class LanguageServiceAdapter implements NgCompilerAdapter { +import {ResourceResolver} from '../common/definitions'; + +import {isTypeScriptFile} from './utils'; + +export class LanguageServiceAdapter implements NgCompilerAdapter, ResourceResolver { readonly entryPoint = null; readonly constructionDiagnostics: ts.Diagnostic[] = []; readonly ignoreForEmit: Set = new Set(); @@ -75,12 +80,9 @@ export class LanguageServiceAdapter implements NgCompilerAdapter { const latestVersion = this.project.getScriptVersion(fileName); return lastVersion !== latestVersion; } -} -export function isTypeScriptFile(fileName: string): boolean { - return fileName.endsWith('.ts'); -} - -export function isExternalTemplate(fileName: string): boolean { - return !isTypeScriptFile(fileName); + resolve(file: string, basePath: string): string { + const loader = new AdapterResourceLoader(this, this.project.getCompilationSettings()); + return loader.resolve(file, basePath); + } } diff --git a/packages/language-service/ivy/test/definitions_spec.ts b/packages/language-service/ivy/test/definitions_spec.ts index 6e2e568bf1..169ed2cc69 100644 --- a/packages/language-service/ivy/test/definitions_spec.ts +++ b/packages/language-service/ivy/test/definitions_spec.ts @@ -448,6 +448,53 @@ describe('definitions', () => { }); }); + describe('external resources', () => { + it('should be able to find a template from a url', () => { + const {position, text} = service.overwrite(APP_COMPONENT, ` + import {Component} from '@angular/core'; + @Component({ + templateUrl: './tes¦t.ng', + }) + export class MyComponent {}`); + const result = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position); + + expect(result).toBeDefined(); + const {textSpan, definitions} = result!; + + expect(text.substring(textSpan.start, textSpan.start + textSpan.length)).toEqual('./test.ng'); + + expect(definitions).toBeDefined(); + expect(definitions!.length).toBe(1); + const [def] = definitions!; + expect(def.fileName).toContain('/app/test.ng'); + expect(def.textSpan).toEqual({start: 0, length: 0}); + }); + + it('should be able to find a stylesheet from a url', () => { + const {position, text} = service.overwrite(APP_COMPONENT, ` + import {Component} from '@angular/core'; + @Component({ + template: 'empty', + styleUrls: ['./te¦st.css'] + }) + export class MyComponent {}`); + const result = ngLS.getDefinitionAndBoundSpan(APP_COMPONENT, position); + + + expect(result).toBeDefined(); + const {textSpan, definitions} = result!; + + expect(text.substring(textSpan.start, textSpan.start + textSpan.length)) + .toEqual('./test.css'); + + expect(definitions).toBeDefined(); + expect(definitions!.length).toBe(1); + const [def] = definitions!; + expect(def.fileName).toContain('/app/test.css'); + expect(def.textSpan).toEqual({start: 0, length: 0}); + }); + }); + function getDefinitionsAndAssertBoundSpan( {templateOverride, expectedSpanText}: {templateOverride: string, expectedSpanText: string}): Array<{textSpan: string, contextSpan: string | undefined, fileName: string}> { diff --git a/packages/language-service/ivy/test/mock_host.ts b/packages/language-service/ivy/test/mock_host.ts index f620d74d9d..34a052eacf 100644 --- a/packages/language-service/ivy/test/mock_host.ts +++ b/packages/language-service/ivy/test/mock_host.ts @@ -8,7 +8,8 @@ import {join} from 'path'; import * as ts from 'typescript/lib/tsserverlibrary'; -import {isTypeScriptFile} from '../language_service_adapter'; + +import {isTypeScriptFile} from '../utils'; const logger: ts.server.Logger = { close(): void{}, diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index 8fb027c7c6..6888f2d32d 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -14,6 +14,7 @@ import * as t from '@angular/compiler/src/render3/r3_ast'; // t for temp import * as ts from 'typescript'; import {ALIAS_NAME, SYMBOL_PUNC} from '../common/quick_info'; +import {findTightestNode, getClassDeclOfInlineTemplateNode} from '../common/ts_utils'; export function getTextSpanOfNode(node: t.Node|e.AST): ts.TextSpan { if (isTemplateNodeWithKeyAndValue(node)) { @@ -70,8 +71,8 @@ export interface TemplateInfo { */ export function getTemplateInfoAtPosition( fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined { - if (fileName.endsWith('.ts')) { - return getInlineTemplateInfoAtPosition(fileName, position, compiler); + if (isTypeScriptFile(fileName)) { + return getTemplateInfoFromClassMeta(fileName, position, compiler); } else { return getFirstComponentForTemplateFile(fileName, compiler); } @@ -116,28 +117,29 @@ function getFirstComponentForTemplateFile(fileName: string, compiler: NgCompiler /** * Retrieves the `ts.ClassDeclaration` at a location along with its template nodes. */ -function getInlineTemplateInfoAtPosition( +function getTemplateInfoFromClassMeta( fileName: string, position: number, compiler: NgCompiler): TemplateInfo|undefined { + const classDecl = getClassDeclForInlineTemplateAtPosition(fileName, position, compiler); + if (!classDecl || !classDecl.name) { // Does not handle anonymous class + return; + } + const template = compiler.getTemplateTypeChecker().getTemplate(classDecl); + if (template === null) { + return; + } + + return {template, component: classDecl}; +} + +function getClassDeclForInlineTemplateAtPosition( + fileName: string, position: number, compiler: NgCompiler): ts.ClassDeclaration|undefined { const sourceFile = compiler.getNextProgram().getSourceFile(fileName); if (!sourceFile) { return undefined; } - - // We only support top level statements / class declarations - for (const statement of sourceFile.statements) { - if (!ts.isClassDeclaration(statement) || position < statement.pos || position > statement.end) { - continue; - } - - const template = compiler.getTemplateTypeChecker().getTemplate(statement); - if (template === null) { - return undefined; - } - - return {template, component: statement}; - } - - return undefined; + const node = findTightestNode(sourceFile, position); + if (!node) return; + return getClassDeclOfInlineTemplateNode(node); } /** @@ -291,3 +293,11 @@ export function flatMap(items: T[]|readonly T[], f: (item: T) => R[] | rea } return results; } + +export function isTypeScriptFile(fileName: string): boolean { + return fileName.endsWith('.ts'); +} + +export function isExternalTemplate(fileName: string): boolean { + return !isTypeScriptFile(fileName); +} diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index 3ee3e24a4c..e9eeac68fe 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -6,9 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {getTsDefinitionAndBoundSpan} from '@angular/language-service/common/definitions'; +import * as path from 'path'; import * as tss from 'typescript/lib/tsserverlibrary'; +import {getTsDefinitionAndBoundSpan, ResourceResolver} from '../common/definitions'; + import {getTemplateCompletions} from './completions'; import {getDefinitionAndBoundSpan} from './definitions'; import {getDeclarationDiagnostics, getTemplateDiagnostics, ngDiagnosticToTsDiagnostic} from './diagnostics'; @@ -76,13 +78,13 @@ class LanguageServiceImpl implements ng.LanguageService { if (templateInfo) { 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.tsLsHost); + return getTsDefinitionAndBoundSpan( + sf, position, new ViewEngineLSResourceResolver(this.host.tsLsHost)); } } } @@ -113,3 +115,20 @@ class LanguageServiceImpl implements ng.LanguageService { return this.host.tsLS.getReferencesAtPosition(tsDef.fileName, tsDef.textSpan.start); } } + +class ViewEngineLSResourceResolver implements ResourceResolver { + constructor(private host: ts.LanguageServiceHost) {} + + resolve(file: string, basePath: string): string { + // 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(basePath), file); + + // 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 (this.host.fileExists && !this.host.fileExists(url)) { + throw new Error(`ResourceResolver: could not resolve ${url} in context of ${basePath})`); + } + return url; + } +} diff --git a/packages/language-service/test/BUILD.bazel b/packages/language-service/test/BUILD.bazel index 36bd6f7771..a3d512a6c3 100644 --- a/packages/language-service/test/BUILD.bazel +++ b/packages/language-service/test/BUILD.bazel @@ -59,6 +59,7 @@ ts_library( "//packages/compiler", "//packages/language-service", "//packages/language-service:ts_utils", + "//packages/language-service/common", "@npm//typescript", ], ) diff --git a/packages/language-service/test/utils_spec.ts b/packages/language-service/test/utils_spec.ts index b29b7cccb0..24001eafff 100644 --- a/packages/language-service/test/utils_spec.ts +++ b/packages/language-service/test/utils_spec.ts @@ -9,7 +9,8 @@ import * as ng from '@angular/compiler'; import * as ts from 'typescript'; -import {getClassDeclFromDecoratorProp, getDirectiveClassLike} from '../src/ts_utils'; +import {getClassDeclFromDecoratorProp} from '../common/ts_utils'; +import {getDirectiveClassLike} from '../src/ts_utils'; import {getPathToNodeAtPosition} from '../src/utils'; import {MockTypescriptHost} from './test_utils';