From 643c96184c48bb6c07e5c7db371e116ff1021ea5 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Mon, 12 Oct 2020 12:51:43 -0700 Subject: [PATCH] refactor(language-service): introduce DisplayParts abstraction for Ivy (#39505) This commit refactors the QuickInfo abstraction shared between the VE and Ivy services and used to implement hover tooltips (quick info), which was extracted from the VE code in commit faa81dc. The new DisplayParts abstraction is more general and can be used to extract information needed by various LS functions (e.g. autocompletion). This commit effectively reverts faa81dc, returning the original code to the VE implementation as the Ivy code is now diverged. PR Close #39505 --- packages/language-service/BUILD.bazel | 1 - packages/language-service/common/BUILD.bazel | 11 -- .../language-service/common/definitions.ts | 107 --------------- .../language-service/common/quick_info.ts | 60 --------- packages/language-service/common/ts_utils.ts | 87 ------------ packages/language-service/ivy/BUILD.bazel | 1 - packages/language-service/ivy/definitions.ts | 102 +++++++++++++- .../language-service/ivy/display_parts.ts | 126 ++++++++++++++++++ .../ivy/language_service_adapter.ts | 3 +- packages/language-service/ivy/quick_info.ts | 62 +++++---- packages/language-service/ivy/utils.ts | 84 +++++++++++- packages/language-service/src/definitions.ts | 83 ++++++++++++ packages/language-service/src/diagnostics.ts | 4 +- packages/language-service/src/hover.ts | 55 +++++++- .../language-service/src/language_service.ts | 24 +--- packages/language-service/src/ts_utils.ts | 78 +++++++++++ .../language-service/src/typescript_host.ts | 4 +- packages/language-service/src/utils.ts | 1 + packages/language-service/test/BUILD.bazel | 1 - packages/language-service/test/utils_spec.ts | 3 +- 20 files changed, 563 insertions(+), 334 deletions(-) delete mode 100644 packages/language-service/common/BUILD.bazel delete mode 100644 packages/language-service/common/definitions.ts delete mode 100644 packages/language-service/common/quick_info.ts delete mode 100644 packages/language-service/common/ts_utils.ts create mode 100644 packages/language-service/ivy/display_parts.ts diff --git a/packages/language-service/BUILD.bazel b/packages/language-service/BUILD.bazel index 7869219b68..320cc23995 100644 --- a/packages/language-service/BUILD.bazel +++ b/packages/language-service/BUILD.bazel @@ -19,7 +19,6 @@ ts_library( "//packages/compiler", "//packages/compiler-cli", "//packages/core", - "//packages/language-service/common", "@npm//@types/node", "@npm//typescript", ], diff --git a/packages/language-service/common/BUILD.bazel b/packages/language-service/common/BUILD.bazel deleted file mode 100644 index afe8f39b56..0000000000 --- a/packages/language-service/common/BUILD.bazel +++ /dev/null @@ -1,11 +0,0 @@ -load("//tools:defaults.bzl", "ts_library") - -package(default_visibility = ["//packages/language-service:__subpackages__"]) - -ts_library( - name = "common", - srcs = glob(["*.ts"]), - deps = [ - "@npm//typescript", - ], -) diff --git a/packages/language-service/common/definitions.ts b/packages/language-service/common/definitions.ts deleted file mode 100644 index 7e38cd8f8f..0000000000 --- a/packages/language-service/common/definitions.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -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, - 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, resourceResolver); - 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` and `styleUrls` properties. - */ -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: - // { - // 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, 'templateUrl'); - if (!asgn) { - // `templateUrl` assignment not found; search for `styleUrls` array assignment. - asgn = getPropertyAssignmentFromValue(urlNode.parent, 'styleUrls'); - if (!asgn) { - // Nothing found, bail. - return; - } - } - - // If the property assignment is not a property of a class decorator, don't generate definitions - // for it. - if (!getClassDeclFromDecoratorProp(asgn)) { - return; - } - - const sf = urlNode.getSourceFile(); - 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, - name: url, - 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, - }]; - - 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/common/quick_info.ts b/packages/language-service/common/quick_info.ts deleted file mode 100644 index 9f1076e98a..0000000000 --- a/packages/language-service/common/quick_info.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import * as ts from 'typescript'; - -// Reverse mappings of enum would generate strings -export const ALIAS_NAME = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.aliasName]; -export const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName]; -export const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation]; -export const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space]; -export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text]; - -/** - * Construct a QuickInfo object taking into account its container and type. - * @param name Name of the QuickInfo target - * @param kind component, directive, pipe, etc. - * @param textSpan span of the target - * @param containerName either the Symbol's container or the NgModule that contains the directive - * @param type user-friendly name of the type - * @param documentation docstring or comment - */ -export function createQuickInfo( - name: string, kind: string, textSpan: ts.TextSpan, containerName?: string, type?: string, - documentation?: ts.SymbolDisplayPart[]): ts.QuickInfo { - const containerDisplayParts = containerName ? - [ - {text: containerName, kind: SYMBOL_INTERFACE}, - {text: '.', kind: SYMBOL_PUNC}, - ] : - []; - - const typeDisplayParts = type ? - [ - {text: ':', kind: SYMBOL_PUNC}, - {text: ' ', kind: SYMBOL_SPACE}, - {text: type, kind: SYMBOL_INTERFACE}, - ] : - []; - - return { - kind: kind as ts.ScriptElementKind, - kindModifiers: ts.ScriptElementKindModifier.none, - textSpan: textSpan, - displayParts: [ - {text: '(', kind: SYMBOL_PUNC}, - {text: kind, kind: SYMBOL_TEXT}, - {text: ')', kind: SYMBOL_PUNC}, - {text: ' ', kind: SYMBOL_SPACE}, - ...containerDisplayParts, - {text: name, kind: SYMBOL_INTERFACE}, - ...typeDisplayParts, - ], - documentation, - }; -} diff --git a/packages/language-service/common/ts_utils.ts b/packages/language-service/common/ts_utils.ts deleted file mode 100644 index 3ff50242e0..0000000000 --- a/packages/language-service/common/ts_utils.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import * as ts from 'typescript'; - -/** - * Return the node that most tightly encompass the specified `position`. - * @param node - * @param position - */ -export function findTightestNode(node: ts.Node, position: number): ts.Node|undefined { - if (node.getStart() <= position && position < node.getEnd()) { - return node.forEachChild(c => findTightestNode(c, position)) || node; - } -} - -/** - * Returns a property assignment from the assignment value if the property name - * matches the specified `key`, or `undefined` if there is no match. - */ -export function getPropertyAssignmentFromValue(value: ts.Node, key: string): ts.PropertyAssignment| - undefined { - const propAssignment = value.parent; - if (!propAssignment || !ts.isPropertyAssignment(propAssignment) || - propAssignment.name.getText() !== key) { - return; - } - return propAssignment; -} - -/** - * Given a decorator property assignment, return the ClassDeclaration node that corresponds to the - * directive class the property applies to. - * If the property assignment is not on a class decorator, no declaration is returned. - * - * For example, - * - * @Component({ - * template: '
' - * ^^^^^^^^^^^^^^^^^^^^^^^---- property assignment - * }) - * class AppComponent {} - * ^---- class declaration node - * - * @param propAsgnNode property assignment - */ -export function getClassDeclFromDecoratorProp(propAsgnNode: ts.PropertyAssignment): - ts.ClassDeclaration|undefined { - if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) { - return; - } - const objLitExprNode = propAsgnNode.parent; - if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) { - return; - } - const callExprNode = objLitExprNode.parent; - if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) { - return; - } - const decorator = callExprNode.parent; - if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) { - return; - } - const classDeclNode = decorator.parent; - return classDeclNode; -} - -/** - * Given the node which is the string of the inline template for a component, returns the - * `ts.ClassDeclaration` for the component. - */ -export function getClassDeclOfInlineTemplateNode(templateStringNode: ts.Node): ts.ClassDeclaration| - undefined { - if (!ts.isStringLiteralLike(templateStringNode)) { - return; - } - const tmplAsgn = getPropertyAssignmentFromValue(templateStringNode, 'template'); - if (!tmplAsgn) { - return; - } - return getClassDeclFromDecoratorProp(tmplAsgn); -} diff --git a/packages/language-service/ivy/BUILD.bazel b/packages/language-service/ivy/BUILD.bazel index c8df602d71..6a48cdcde5 100644 --- a/packages/language-service/ivy/BUILD.bazel +++ b/packages/language-service/ivy/BUILD.bazel @@ -17,7 +17,6 @@ ts_library( "//packages/compiler-cli/src/ngtsc/shims", "//packages/compiler-cli/src/ngtsc/typecheck", "//packages/compiler-cli/src/ngtsc/typecheck/api", - "//packages/language-service/common", "@npm//typescript", ], ) diff --git a/packages/language-service/ivy/definitions.ts b/packages/language-service/ivy/definitions.ts index 48fb9d9715..94f2feeb73 100644 --- a/packages/language-service/ivy/definitions.ts +++ b/packages/language-service/ivy/definitions.ts @@ -11,10 +11,106 @@ 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, isTypeScriptFile, TemplateInfo, toTextSpan} from './utils'; +import {findTightestNode, flatMap, getClassDeclFromDecoratorProp, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getPropertyAssignmentFromValue, getTemplateInfoAtPosition, getTextSpanOfNode, isDollarEvent, isTypeScriptFile, TemplateInfo, toTextSpan} from './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, + 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, resourceResolver); + 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` and `styleUrls` properties. + */ +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: + // { + // 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, 'templateUrl'); + if (!asgn) { + // `templateUrl` assignment not found; search for `styleUrls` array assignment. + asgn = getPropertyAssignmentFromValue(urlNode.parent, 'styleUrls'); + if (!asgn) { + // Nothing found, bail. + return; + } + } + + // If the property assignment is not a property of a class decorator, don't generate definitions + // for it. + if (!getClassDeclFromDecoratorProp(asgn)) { + return; + } + + const sf = urlNode.getSourceFile(); + 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, + name: url, + 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, + }]; + + return { + definitions: templateDefinitions, + textSpan: { + // Exclude opening and closing quotes in the url span. + start: urlNode.getStart() + 1, + length: urlNode.getWidth() - 2, + }, + }; +} + interface DefinitionMeta { node: AST|TmplAstNode; diff --git a/packages/language-service/ivy/display_parts.ts b/packages/language-service/ivy/display_parts.ts new file mode 100644 index 0000000000..aff731fbf6 --- /dev/null +++ b/packages/language-service/ivy/display_parts.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ReferenceSymbol, ShimLocation, Symbol, SymbolKind, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import * as ts from 'typescript'; + + +// Reverse mappings of enum would generate strings +export const ALIAS_NAME = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.aliasName]; +export const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName]; +export const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation]; +export const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space]; +export const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text]; + + +/** + * Label for various kinds of Angular entities for TS display info. + */ +export enum DisplayInfoKind { + COMPONENT = 'component', + DIRECTIVE = 'directive', + EVENT = 'event', + REFERENCE = 'reference', + ELEMENT = 'element', + VARIABLE = 'variable', + PIPE = 'pipe', + PROPERTY = 'property', + METHOD = 'method', + TEMPLATE = 'template', +} + +export interface DisplayInfo { + kind: DisplayInfoKind; + displayParts: ts.SymbolDisplayPart[]; + documentation: ts.SymbolDisplayPart[]|undefined; +} + +export function getDisplayInfo( + tsLS: ts.LanguageService, typeChecker: ts.TypeChecker, + symbol: ReferenceSymbol|VariableSymbol): DisplayInfo { + let kind: DisplayInfoKind; + if (symbol.kind === SymbolKind.Reference) { + kind = DisplayInfoKind.REFERENCE; + } else if (symbol.kind === SymbolKind.Variable) { + kind = DisplayInfoKind.VARIABLE; + } else { + throw new Error( + `AssertionError: unexpected symbol kind ${SymbolKind[(symbol as Symbol).kind]}`); + } + + const displayParts = createDisplayParts( + symbol.declaration.name, kind, /* containerName */ undefined, + typeChecker.typeToString(symbol.tsType)); + const documentation = getDocumentationFromTypeDefAtLocation(tsLS, symbol.shimLocation); + return { + kind, + displayParts, + documentation, + }; +} + +/** + * Construct a compound `ts.SymbolDisplayPart[]` which incorporates the container and type of a + * target declaration. + * @param name Name of the target + * @param kind component, directive, pipe, etc. + * @param containerName either the Symbol's container or the NgModule that contains the directive + * @param type user-friendly name of the type + * @param documentation docstring or comment + */ +export function createDisplayParts( + name: string, kind: DisplayInfoKind, containerName: string|undefined, + type: string|undefined): ts.SymbolDisplayPart[] { + const containerDisplayParts = containerName !== undefined ? + [ + {text: containerName, kind: SYMBOL_INTERFACE}, + {text: '.', kind: SYMBOL_PUNC}, + ] : + []; + + const typeDisplayParts = type !== undefined ? + [ + {text: ':', kind: SYMBOL_PUNC}, + {text: ' ', kind: SYMBOL_SPACE}, + {text: type, kind: SYMBOL_INTERFACE}, + ] : + []; + return [ + {text: '(', kind: SYMBOL_PUNC}, + {text: kind, kind: SYMBOL_TEXT}, + {text: ')', kind: SYMBOL_PUNC}, + {text: ' ', kind: SYMBOL_SPACE}, + ...containerDisplayParts, + {text: name, kind: SYMBOL_INTERFACE}, + ...typeDisplayParts, + ]; +} + +/** + * Convert a `SymbolDisplayInfoKind` to a `ts.ScriptElementKind` type, allowing it to pass through + * TypeScript APIs. + * + * In practice, this is an "illegal" type cast. Since `ts.ScriptElementKind` is a string, this is + * safe to do if TypeScript only uses the value in a string context. Consumers of this conversion + * function are responsible for ensuring this is the case. + */ +export function unsafeCastDisplayInfoKindToScriptElementKind(kind: DisplayInfoKind): + ts.ScriptElementKind { + return kind as string as ts.ScriptElementKind; +} + +function getDocumentationFromTypeDefAtLocation( + tsLS: ts.LanguageService, shimLocation: ShimLocation): ts.SymbolDisplayPart[]|undefined { + const typeDefs = + tsLS.getTypeDefinitionAtPosition(shimLocation.shimPath, shimLocation.positionInShimFile); + if (typeDefs === undefined || typeDefs.length === 0) { + return undefined; + } + return tsLS.getQuickInfoAtPosition(typeDefs[0].fileName, typeDefs[0].textSpan.start) + ?.documentation; +} diff --git a/packages/language-service/ivy/language_service_adapter.ts b/packages/language-service/ivy/language_service_adapter.ts index 7b2cc77395..377eea2779 100644 --- a/packages/language-service/ivy/language_service_adapter.ts +++ b/packages/language-service/ivy/language_service_adapter.ts @@ -11,8 +11,7 @@ import {absoluteFrom, AbsoluteFsPath} from '@angular/compiler-cli/src/ngtsc/file 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'; - -import {ResourceResolver} from '../common/definitions'; +import {ResourceResolver} from './definitions'; import {isTypeScriptFile} from './utils'; diff --git a/packages/language-service/ivy/quick_info.ts b/packages/language-service/ivy/quick_info.ts index d1e9123e5a..312c23fddd 100644 --- a/packages/language-service/ivy/quick_info.ts +++ b/packages/language-service/ivy/quick_info.ts @@ -10,27 +10,10 @@ import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; import {DirectiveSymbol, DomBindingSymbol, ElementSymbol, ExpressionSymbol, InputBindingSymbol, OutputBindingSymbol, ReferenceSymbol, ShimLocation, Symbol, SymbolKind, VariableSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; import * as ts from 'typescript'; -import {createQuickInfo, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT} from '../common/quick_info'; - +import {createDisplayParts, DisplayInfoKind, SYMBOL_PUNC, SYMBOL_SPACE, SYMBOL_TEXT, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts'; import {findNodeAtPosition} from './hybrid_visitor'; import {filterAliasImports, getDirectiveMatchesForAttribute, getDirectiveMatchesForElementTag, getTemplateInfoAtPosition, getTextSpanOfNode} from './utils'; -/** - * The type of Angular directive. Used for QuickInfo in template. - */ -export enum QuickInfoKind { - COMPONENT = 'component', - DIRECTIVE = 'directive', - EVENT = 'event', - REFERENCE = 'reference', - ELEMENT = 'element', - VARIABLE = 'variable', - PIPE = 'pipe', - PROPERTY = 'property', - METHOD = 'method', - TEMPLATE = 'template', -} - export class QuickInfoBuilder { private readonly typeChecker = this.compiler.getNextProgram().getTypeChecker(); constructor(private readonly tsLS: ts.LanguageService, private readonly compiler: NgCompiler) {} @@ -86,7 +69,8 @@ export class QuickInfoBuilder { return undefined; } - const kind = symbol.kind === SymbolKind.Input ? QuickInfoKind.PROPERTY : QuickInfoKind.EVENT; + const kind = + symbol.kind === SymbolKind.Input ? DisplayInfoKind.PROPERTY : DisplayInfoKind.EVENT; const quickInfo = this.getQuickInfoAtShimLocation(symbol.bindings[0].shimLocation, node); return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, kind); @@ -100,7 +84,7 @@ export class QuickInfoBuilder { } return createQuickInfo( - templateNode.name, QuickInfoKind.ELEMENT, getTextSpanOfNode(templateNode), + templateNode.name, DisplayInfoKind.ELEMENT, getTextSpanOfNode(templateNode), undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType)); } @@ -108,7 +92,7 @@ export class QuickInfoBuilder { ts.QuickInfo { const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation); return createQuickInfo( - symbol.declaration.name, QuickInfoKind.VARIABLE, getTextSpanOfNode(node), + symbol.declaration.name, DisplayInfoKind.VARIABLE, getTextSpanOfNode(node), undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation); } @@ -116,14 +100,15 @@ export class QuickInfoBuilder { ts.QuickInfo { const documentation = this.getDocumentationFromTypeDefAtLocation(symbol.shimLocation); return createQuickInfo( - symbol.declaration.name, QuickInfoKind.REFERENCE, getTextSpanOfNode(node), + symbol.declaration.name, DisplayInfoKind.REFERENCE, getTextSpanOfNode(node), undefined /* containerName */, this.typeChecker.typeToString(symbol.tsType), documentation); } private getQuickInfoForPipeSymbol(symbol: ExpressionSymbol, node: TmplAstNode|AST): ts.QuickInfo |undefined { const quickInfo = this.getQuickInfoAtShimLocation(symbol.shimLocation, node); - return quickInfo === undefined ? undefined : updateQuickInfoKind(quickInfo, QuickInfoKind.PIPE); + return quickInfo === undefined ? undefined : + updateQuickInfoKind(quickInfo, DisplayInfoKind.PIPE); } private getQuickInfoForDomBinding(node: TmplAstNode|AST, symbol: DomBindingSymbol) { @@ -141,7 +126,7 @@ export class QuickInfoBuilder { private getQuickInfoForDirectiveSymbol(dir: DirectiveSymbol, node: TmplAstNode|AST): ts.QuickInfo { - const kind = dir.isComponent ? QuickInfoKind.COMPONENT : QuickInfoKind.DIRECTIVE; + const kind = dir.isComponent ? DisplayInfoKind.COMPONENT : DisplayInfoKind.DIRECTIVE; const documentation = this.getDocumentationFromTypeDefAtLocation(dir.shimLocation); let containerName: string|undefined; if (ts.isClassDeclaration(dir.tsSymbol.valueDeclaration) && dir.ngModule !== null) { @@ -179,7 +164,7 @@ export class QuickInfoBuilder { } } -function updateQuickInfoKind(quickInfo: ts.QuickInfo, kind: QuickInfoKind): ts.QuickInfo { +function updateQuickInfoKind(quickInfo: ts.QuickInfo, kind: DisplayInfoKind): ts.QuickInfo { if (quickInfo.displayParts === undefined) { return quickInfo; } @@ -214,7 +199,7 @@ function isDollarAny(node: TmplAstNode|AST): node is MethodCall { function createDollarAnyQuickInfo(node: MethodCall): ts.QuickInfo { return createQuickInfo( '$any', - QuickInfoKind.METHOD, + DisplayInfoKind.METHOD, getTextSpanOfNode(node), /** containerName */ undefined, 'any', @@ -229,7 +214,7 @@ function createDollarAnyQuickInfo(node: MethodCall): ts.QuickInfo { function createNgTemplateQuickInfo(node: TmplAstNode|AST): ts.QuickInfo { return createQuickInfo( 'ng-template', - QuickInfoKind.TEMPLATE, + DisplayInfoKind.TEMPLATE, getTextSpanOfNode(node), /** containerName */ undefined, /** type */ undefined, @@ -240,3 +225,26 @@ function createNgTemplateQuickInfo(node: TmplAstNode|AST): ts.QuickInfo { }], ); } + +/** + * Construct a QuickInfo object taking into account its container and type. + * @param name Name of the QuickInfo target + * @param kind component, directive, pipe, etc. + * @param textSpan span of the target + * @param containerName either the Symbol's container or the NgModule that contains the directive + * @param type user-friendly name of the type + * @param documentation docstring or comment + */ +export function createQuickInfo( + name: string, kind: DisplayInfoKind, textSpan: ts.TextSpan, containerName?: string, + type?: string, documentation?: ts.SymbolDisplayPart[]): ts.QuickInfo { + const displayParts = createDisplayParts(name, kind, containerName, type); + + return { + kind: unsafeCastDisplayInfoKindToScriptElementKind(kind), + kindModifiers: ts.ScriptElementKindModifier.none, + textSpan: textSpan, + displayParts, + documentation, + }; +} \ No newline at end of file diff --git a/packages/language-service/ivy/utils.ts b/packages/language-service/ivy/utils.ts index e2481dde42..266c43f1f4 100644 --- a/packages/language-service/ivy/utils.ts +++ b/packages/language-service/ivy/utils.ts @@ -13,8 +13,88 @@ import * as e from '@angular/compiler/src/expression_parser/ast'; // e for expr import * as t from '@angular/compiler/src/render3/r3_ast'; // t for template AST import * as ts from 'typescript'; -import {ALIAS_NAME, SYMBOL_PUNC} from '../common/quick_info'; -import {findTightestNode, getClassDeclOfInlineTemplateNode} from '../common/ts_utils'; +import {ALIAS_NAME, SYMBOL_PUNC} from './display_parts'; + + + +/** + * Return the node that most tightly encompass the specified `position`. + * @param node + * @param position + */ +export function findTightestNode(node: ts.Node, position: number): ts.Node|undefined { + if (node.getStart() <= position && position < node.getEnd()) { + return node.forEachChild(c => findTightestNode(c, position)) || node; + } +} + +/** + * Returns a property assignment from the assignment value if the property name + * matches the specified `key`, or `undefined` if there is no match. + */ +export function getPropertyAssignmentFromValue(value: ts.Node, key: string): ts.PropertyAssignment| + undefined { + const propAssignment = value.parent; + if (!propAssignment || !ts.isPropertyAssignment(propAssignment) || + propAssignment.name.getText() !== key) { + return; + } + return propAssignment; +} + +/** + * Given a decorator property assignment, return the ClassDeclaration node that corresponds to the + * directive class the property applies to. + * If the property assignment is not on a class decorator, no declaration is returned. + * + * For example, + * + * @Component({ + * template: '
' + * ^^^^^^^^^^^^^^^^^^^^^^^---- property assignment + * }) + * class AppComponent {} + * ^---- class declaration node + * + * @param propAsgnNode property assignment + */ +export function getClassDeclFromDecoratorProp(propAsgnNode: ts.PropertyAssignment): + ts.ClassDeclaration|undefined { + if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) { + return; + } + const objLitExprNode = propAsgnNode.parent; + if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) { + return; + } + const callExprNode = objLitExprNode.parent; + if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) { + return; + } + const decorator = callExprNode.parent; + if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) { + return; + } + const classDeclNode = decorator.parent; + return classDeclNode; +} + +/** + * Given the node which is the string of the inline template for a component, returns the + * `ts.ClassDeclaration` for the component. + */ +export function getClassDeclOfInlineTemplateNode(templateStringNode: ts.Node): ts.ClassDeclaration| + undefined { + if (!ts.isStringLiteralLike(templateStringNode)) { + return; + } + const tmplAsgn = getPropertyAssignmentFromValue(templateStringNode, 'template'); + if (!tmplAsgn) { + return; + } + return getClassDeclFromDecoratorProp(tmplAsgn); +} + export function getTextSpanOfNode(node: t.Node|e.AST): ts.TextSpan { if (isTemplateNodeWithKeyAndValue(node)) { diff --git a/packages/language-service/src/definitions.ts b/packages/language-service/src/definitions.ts index 6539895d68..620e954e5e 100644 --- a/packages/language-service/src/definitions.ts +++ b/packages/language-service/src/definitions.ts @@ -6,9 +6,11 @@ * 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 {locateSymbols} from './locate_symbol'; +import {findTightestNode, getClassDeclFromDecoratorProp, getPropertyAssignmentFromValue} from './ts_utils'; import {AstResult, Span} from './types'; /** @@ -22,6 +24,69 @@ function ngSpanToTsTextSpan(span: Span): ts.TextSpan { length: span.end - span.start, }; } +/** + * Attempts to get the definition of a file whose URL is specified in a property assignment in a + * directive decorator. + * Currently applies to `templateUrl` and `styleUrls` properties. + */ +function getUrlFromProperty( + urlNode: ts.StringLiteralLike, + tsLsHost: Readonly): 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: + // { + // 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, 'templateUrl'); + if (!asgn) { + // `templateUrl` assignment not found; search for `styleUrls` array assignment. + asgn = getPropertyAssignmentFromValue(urlNode.parent, 'styleUrls'); + if (!asgn) { + // Nothing found, bail. + return; + } + } + + // If the property assignment is not a property of a class decorator, don't generate definitions + // for it. + if (!getClassDeclFromDecoratorProp(asgn)) { + return; + } + + 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; + + 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, + }, + }; +} /** * Traverse the template AST and look for the symbol located at `position`, then @@ -80,3 +145,21 @@ export function getDefinitionAndBoundSpan( textSpan: symbols[0].span, }; } + +/** + * Gets an Angular-specific definition in a TypeScript source file. + */ +export function getTsDefinitionAndBoundSpan( + sf: ts.SourceFile, position: number, + tsLsHost: Readonly): 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; + } +} diff --git a/packages/language-service/src/diagnostics.ts b/packages/language-service/src/diagnostics.ts index 1848156516..adbf824a04 100644 --- a/packages/language-service/src/diagnostics.ts +++ b/packages/language-service/src/diagnostics.ts @@ -10,11 +10,9 @@ import {NgAnalyzedModules} from '@angular/compiler'; import * as path from 'path'; import * as ts from 'typescript'; -import {findTightestNode} from '../common/ts_utils'; - import {createDiagnostic, Diagnostic} from './diagnostic_messages'; import {getTemplateExpressionDiagnostics} from './expression_diagnostics'; -import {findPropertyValueOfType} from './ts_utils'; +import {findPropertyValueOfType, findTightestNode} from './ts_utils'; import * as ng from './types'; import {TypeScriptServiceHost} from './typescript_host'; import {offsetSpan, spanOf} from './utils'; diff --git a/packages/language-service/src/hover.ts b/packages/language-service/src/hover.ts index 9bc98a3f86..3130318a80 100644 --- a/packages/language-service/src/hover.ts +++ b/packages/language-service/src/hover.ts @@ -9,8 +9,6 @@ import {NgAnalyzedModules} from '@angular/compiler'; import * as ts from 'typescript'; -import {createQuickInfo} from '../common/quick_info'; - import {locateSymbols} from './locate_symbol'; import * as ng from './types'; import {inSpan} from './utils'; @@ -67,3 +65,56 @@ export function getTsHover( } } } + + + +// Reverse mappings of enum would generate strings +const ALIAS_NAME = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.aliasName]; +const SYMBOL_INTERFACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.interfaceName]; +const SYMBOL_PUNC = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.punctuation]; +const SYMBOL_SPACE = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.space]; +const SYMBOL_TEXT = ts.SymbolDisplayPartKind[ts.SymbolDisplayPartKind.text]; + +/** + * Construct a QuickInfo object taking into account its container and type. + * @param name Name of the QuickInfo target + * @param kind component, directive, pipe, etc. + * @param textSpan span of the target + * @param containerName either the Symbol's container or the NgModule that contains the directive + * @param type user-friendly name of the type + * @param documentation docstring or comment + */ +function createQuickInfo( + name: string, kind: string, textSpan: ts.TextSpan, containerName?: string, type?: string, + documentation?: ts.SymbolDisplayPart[]): ts.QuickInfo { + const containerDisplayParts = containerName ? + [ + {text: containerName, kind: SYMBOL_INTERFACE}, + {text: '.', kind: SYMBOL_PUNC}, + ] : + []; + + const typeDisplayParts = type ? + [ + {text: ':', kind: SYMBOL_PUNC}, + {text: ' ', kind: SYMBOL_SPACE}, + {text: type, kind: SYMBOL_INTERFACE}, + ] : + []; + + return { + kind: kind as ts.ScriptElementKind, + kindModifiers: ts.ScriptElementKindModifier.none, + textSpan: textSpan, + displayParts: [ + {text: '(', kind: SYMBOL_PUNC}, + {text: kind, kind: SYMBOL_TEXT}, + {text: ')', kind: SYMBOL_PUNC}, + {text: ' ', kind: SYMBOL_SPACE}, + ...containerDisplayParts, + {text: name, kind: SYMBOL_INTERFACE}, + ...typeDisplayParts, + ], + documentation, + }; +} diff --git a/packages/language-service/src/language_service.ts b/packages/language-service/src/language_service.ts index e9eeac68fe..177a73f0d6 100644 --- a/packages/language-service/src/language_service.ts +++ b/packages/language-service/src/language_service.ts @@ -9,10 +9,8 @@ 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 {getDefinitionAndBoundSpan, getTsDefinitionAndBoundSpan} from './definitions'; import {getDeclarationDiagnostics, getTemplateDiagnostics, ngDiagnosticToTsDiagnostic} from './diagnostics'; import {getTemplateHover, getTsHover} from './hover'; import * as ng from './types'; @@ -83,8 +81,7 @@ class LanguageServiceImpl implements ng.LanguageService { if (fileName.endsWith('.ts')) { const sf = this.host.getSourceFile(fileName); if (sf) { - return getTsDefinitionAndBoundSpan( - sf, position, new ViewEngineLSResourceResolver(this.host.tsLsHost)); + return getTsDefinitionAndBoundSpan(sf, position, this.host.tsLsHost); } } } @@ -115,20 +112,3 @@ 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/src/ts_utils.ts b/packages/language-service/src/ts_utils.ts index 11c756007b..ca740e43e6 100644 --- a/packages/language-service/src/ts_utils.ts +++ b/packages/language-service/src/ts_utils.ts @@ -68,3 +68,81 @@ export function findPropertyValueOfType( } return startNode.forEachChild(c => findPropertyValueOfType(c, propName, predicate)); } + +/** + * Return the node that most tightly encompass the specified `position`. + * @param node + * @param position + */ +export function findTightestNode(node: ts.Node, position: number): ts.Node|undefined { + if (node.getStart() <= position && position < node.getEnd()) { + return node.forEachChild(c => findTightestNode(c, position)) || node; + } +} + +/** + * Returns a property assignment from the assignment value if the property name + * matches the specified `key`, or `undefined` if there is no match. + */ +export function getPropertyAssignmentFromValue(value: ts.Node, key: string): ts.PropertyAssignment| + undefined { + const propAssignment = value.parent; + if (!propAssignment || !ts.isPropertyAssignment(propAssignment) || + propAssignment.name.getText() !== key) { + return; + } + return propAssignment; +} + +/** + * Given the node which is the string of the inline template for a component, returns the + * `ts.ClassDeclaration` for the component. + */ +export function getClassDeclOfInlineTemplateNode(templateStringNode: ts.Node): ts.ClassDeclaration| + undefined { + if (!ts.isStringLiteralLike(templateStringNode)) { + return; + } + const tmplAsgn = getPropertyAssignmentFromValue(templateStringNode, 'template'); + if (!tmplAsgn) { + return; + } + return getClassDeclFromDecoratorProp(tmplAsgn); +} + +/** + * Given a decorator property assignment, return the ClassDeclaration node that corresponds to the + * directive class the property applies to. + * If the property assignment is not on a class decorator, no declaration is returned. + * + * For example, + * + * @Component({ + * template: '
' + * ^^^^^^^^^^^^^^^^^^^^^^^---- property assignment + * }) + * class AppComponent {} + * ^---- class declaration node + * + * @param propAsgnNode property assignment + */ +export function getClassDeclFromDecoratorProp(propAsgnNode: ts.PropertyAssignment): + ts.ClassDeclaration|undefined { + if (!propAsgnNode.parent || !ts.isObjectLiteralExpression(propAsgnNode.parent)) { + return; + } + const objLitExprNode = propAsgnNode.parent; + if (!objLitExprNode.parent || !ts.isCallExpression(objLitExprNode.parent)) { + return; + } + const callExprNode = objLitExprNode.parent; + if (!callExprNode.parent || !ts.isDecorator(callExprNode.parent)) { + return; + } + const decorator = callExprNode.parent; + if (!decorator.parent || !ts.isClassDeclaration(decorator.parent)) { + return; + } + const classDeclNode = decorator.parent; + return classDeclNode; +} diff --git a/packages/language-service/src/typescript_host.ts b/packages/language-service/src/typescript_host.ts index 0921f50400..1ce6b56f8b 100644 --- a/packages/language-service/src/typescript_host.ts +++ b/packages/language-service/src/typescript_host.ts @@ -10,12 +10,10 @@ import {analyzeNgModules, AotSummaryResolver, CompileDirectiveSummary, CompileMe import {SchemaMetadata, ViewEncapsulation, ɵConsole as Console} from '@angular/core'; import * as tss from 'typescript/lib/tsserverlibrary'; -import {findTightestNode, getClassDeclOfInlineTemplateNode} from '../common/ts_utils'; - import {createLanguageService} from './language_service'; import {ReflectorHost} from './reflector_host'; import {ExternalTemplate, InlineTemplate} from './template'; -import {getDirectiveClassLike} from './ts_utils'; +import {findTightestNode, getClassDeclOfInlineTemplateNode, getDirectiveClassLike} from './ts_utils'; import {AstResult, Declaration, DeclarationError, DiagnosticMessageChain, LanguageService, LanguageServiceHost, Span, TemplateSource} from './types'; /** diff --git a/packages/language-service/src/utils.ts b/packages/language-service/src/utils.ts index 7a1332030c..9cbb0432fb 100644 --- a/packages/language-service/src/utils.ts +++ b/packages/language-service/src/utils.ts @@ -7,6 +7,7 @@ */ import {AstPath, BoundEventAst, CompileDirectiveSummary, CompileTypeMetadata, CssSelector, DirectiveAst, ElementAst, EmbeddedTemplateAst, HtmlAstPath, identifierName, Identifiers, Node, ParseSourceSpan, RecursiveTemplateAstVisitor, RecursiveVisitor, TemplateAst, TemplateAstPath, templateVisitAll, visitAll} from '@angular/compiler'; +import {getClassDeclFromDecoratorProp, getPropertyAssignmentFromValue} from './ts_utils'; import {AstResult, DiagnosticTemplateInfo, SelectorInfo, Span, Symbol, SymbolQuery} from './types'; interface SpanHolder { diff --git a/packages/language-service/test/BUILD.bazel b/packages/language-service/test/BUILD.bazel index a3d512a6c3..36bd6f7771 100644 --- a/packages/language-service/test/BUILD.bazel +++ b/packages/language-service/test/BUILD.bazel @@ -59,7 +59,6 @@ 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 24001eafff..b29b7cccb0 100644 --- a/packages/language-service/test/utils_spec.ts +++ b/packages/language-service/test/utils_spec.ts @@ -9,8 +9,7 @@ import * as ng from '@angular/compiler'; import * as ts from 'typescript'; -import {getClassDeclFromDecoratorProp} from '../common/ts_utils'; -import {getDirectiveClassLike} from '../src/ts_utils'; +import {getClassDeclFromDecoratorProp, getDirectiveClassLike} from '../src/ts_utils'; import {getPathToNodeAtPosition} from '../src/utils'; import {MockTypescriptHost} from './test_utils';