diff --git a/packages/compiler-cli/src/ngtsc/perf/src/api.ts b/packages/compiler-cli/src/ngtsc/perf/src/api.ts index 39e2a44bc0..1f6ffa50a3 100644 --- a/packages/compiler-cli/src/ngtsc/perf/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/perf/src/api.ts @@ -144,6 +144,11 @@ export enum PerfPhase { */ LsComponentLocations, + /** + * Time spent by the Angular Language Service calculating signature help. + */ + LsSignatureHelp, + /** * Tracks the number of `PerfPhase`s, and must appear at the end of the list. */ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts index 61a5535fe0..1b57a408f4 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts @@ -69,7 +69,9 @@ class AstTranslator implements AstVisitor { // The `EmptyExpr` doesn't have a dedicated method on `AstVisitor`, so it's special cased here. if (ast instanceof EmptyExpr) { - return UNDEFINED; + const res = ts.factory.createIdentifier('undefined'); + addParseSpanInfo(res, ast.sourceSpan); + return res; } // First attempt to let any custom resolution logic provide a translation for the given node. diff --git a/packages/language-service/ivy/language_service.ts b/packages/language-service/ivy/language_service.ts index e1607c7f32..23d1c7e07e 100644 --- a/packages/language-service/ivy/language_service.ts +++ b/packages/language-service/ivy/language_service.ts @@ -27,6 +27,7 @@ import {CompletionBuilder, CompletionNodeContext} from './completions'; import {DefinitionBuilder} from './definitions'; import {QuickInfoBuilder} from './quick_info'; import {ReferencesAndRenameBuilder} from './references'; +import {getSignatureHelp} from './signature_help'; import {getTargetAtPosition, TargetContext, TargetNodeKind} from './template_target'; import {findTightestNode, getClassDeclFromDecoratorProp, getPropertyAssignmentFromValue} from './ts_utils'; import {getTemplateInfoAtPosition, isTypeScriptFile} from './utils'; @@ -254,6 +255,19 @@ export class LanguageService { }); } + getSignatureHelpItems(fileName: string, position: number, options?: ts.SignatureHelpItemsOptions): + ts.SignatureHelpItems|undefined { + return this.withCompilerAndPerfTracing(PerfPhase.LsSignatureHelp, compiler => { + if (!isTemplateContext(compiler.getCurrentProgram(), fileName, position)) { + return undefined; + } + + return getSignatureHelp(compiler, this.tsLS, fileName, position, options); + + return undefined; + }); + } + getCompletionEntrySymbol(fileName: string, position: number, entryName: string): ts.Symbol |undefined { return this.withCompilerAndPerfTracing(PerfPhase.LsCompletions, (compiler) => { diff --git a/packages/language-service/ivy/signature_help.ts b/packages/language-service/ivy/signature_help.ts new file mode 100644 index 0000000000..a294163d1f --- /dev/null +++ b/packages/language-service/ivy/signature_help.ts @@ -0,0 +1,135 @@ +/** + * @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 {MethodCall, SafeMethodCall} from '@angular/compiler'; +import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core'; +import {getSourceFileOrError} from '@angular/compiler-cli/src/ngtsc/file_system'; +import {SymbolKind} from '@angular/compiler-cli/src/ngtsc/typecheck/api'; +import * as ts from 'typescript/lib/tsserverlibrary'; + +import {getTargetAtPosition, TargetNodeKind} from './template_target'; +import {findTightestNode} from './ts_utils'; +import {getTemplateInfoAtPosition} from './utils'; + +/** + * Queries the TypeScript Language Service to get signature help for a template position. + */ +export function getSignatureHelp( + compiler: NgCompiler, tsLS: ts.LanguageService, fileName: string, position: number, + options: ts.SignatureHelpItemsOptions|undefined): ts.SignatureHelpItems|undefined { + const templateInfo = getTemplateInfoAtPosition(fileName, position, compiler); + if (templateInfo === undefined) { + return undefined; + } + + const targetInfo = getTargetAtPosition(templateInfo.template, position); + if (targetInfo === null) { + return undefined; + } + + if (targetInfo.context.kind !== TargetNodeKind.RawExpression && + targetInfo.context.kind !== TargetNodeKind.MethodCallExpressionInArgContext) { + // Signature completions are only available in expressions. + return undefined; + } + + const symbol = compiler.getTemplateTypeChecker().getSymbolOfNode( + targetInfo.context.node, templateInfo.component); + if (symbol === null || symbol.kind !== SymbolKind.Expression) { + return undefined; + } + + // Determine a shim position to use in the request to the TypeScript Language Service. + // Additionally, extract the `MethodCall` or `SafeMethodCall` node for which signature help is + // being queried, as this is needed to construct the correct span for the results later. + let shimPosition: number; + let expr: MethodCall|SafeMethodCall; + switch (targetInfo.context.kind) { + case TargetNodeKind.RawExpression: + // For normal expressions, just use the primary TCB position of the expression. + shimPosition = symbol.shimLocation.positionInShimFile; + + // Walk up the parents of this expression and try to find a `MethodCall` or `SafeMethodCall` + // for which signature information is being fetched. + let callExpr: MethodCall|SafeMethodCall|null = null; + const parents = targetInfo.context.parents; + for (let i = parents.length - 1; i >= 0; i--) { + const parent = parents[i]; + if (parent instanceof MethodCall || parent instanceof SafeMethodCall) { + callExpr = parent; + break; + } + } + + // If no MethodCall or SafeMethodCall node could be found, then this query cannot be safely + // answered as a correct span for the results will not be obtainable. + if (callExpr === null) { + return undefined; + } + + expr = callExpr; + break; + case TargetNodeKind.MethodCallExpressionInArgContext: + // The `Symbol` points to a `MethodCall` or `SafeMethodCall` expression in the TCB (where it + // will be represented as a `ts.CallExpression`) *and* the template position was within the + // argument list of the method call. This happens when there was no narrower expression inside + // the argument list that matched the template position, such as when the call has no + // arguments: `foo(|)`. + // + // The `Symbol`'s shim position is to the start of the call expression (`|foo()`) and + // therefore wouldn't return accurate signature help from the TS language service. For that, a + // position within the argument list for the `ts.CallExpression` in the TCB will need to be + // determined. This is done by finding that call expression and extracting a viable position + // from it directly. + // + // First, use `findTightestNode` to locate the `ts.Node` at `symbol`'s location. + const shimSf = + getSourceFileOrError(compiler.getCurrentProgram(), symbol.shimLocation.shimPath); + let shimNode: ts.Node|null = + findTightestNode(shimSf, symbol.shimLocation.positionInShimFile) ?? null; + + // This node should be somewhere inside a `ts.CallExpression`. Walk up the AST to find it. + while (shimNode !== null) { + if (ts.isCallExpression(shimNode)) { + break; + } + shimNode = shimNode.parent ?? null; + } + + // If one couldn't be found, something is wrong, so bail rather than report incorrect results. + if (shimNode === null || !ts.isCallExpression(shimNode)) { + return undefined; + } + + // Position the cursor in the TCB at the start of the argument list for the + // `ts.CallExpression`. This will allow us to get the correct signature help, even though the + // template itself doesn't have an expression inside the argument list. + shimPosition = shimNode.arguments.pos; + + // In this case, getting the right call AST node is easy. + expr = targetInfo.context.node; + break; + } + + const res = tsLS.getSignatureHelpItems(symbol.shimLocation.shimPath, shimPosition, options); + if (res === undefined) { + return undefined; + } + + // The TS language service results are almost returnable as-is. However, they contain an + // `applicableSpan` which marks the entire argument list, and that span is in the context of the + // TCB's `ts.CallExpression`. It needs to be replaced with the span for the `MethodCall` (or + // `SafeMethodCall`) argument list. + return { + ...res, + applicableSpan: { + start: expr.argumentSpan.start, + length: expr.argumentSpan.end - expr.argumentSpan.start, + }, + }; +} diff --git a/packages/language-service/ivy/test/signature_help_spec.ts b/packages/language-service/ivy/test/signature_help_spec.ts new file mode 100644 index 0000000000..bd3657d920 --- /dev/null +++ b/packages/language-service/ivy/test/signature_help_spec.ts @@ -0,0 +1,142 @@ +/** + * @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 {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing'; +import {getText} from '@angular/language-service/ivy/testing/src/util'; + +import {LanguageServiceTestEnv, OpenBuffer} from '../testing'; + +describe('signature help', () => { + beforeEach(() => { + initMockFileSystem('Native'); + }); + + it('should handle an empty argument list', () => { + const main = setup(` + import {Component} from '@angular/core'; + + @Component({ + template: '{{ foo() }}', + }) + export class MainCmp { + foo(alpha: string, beta: number): string { + return 'blah'; + } + } + `); + main.moveCursorToText('foo(¦)'); + + const items = main.getSignatureHelpItems()!; + expect(items).toBeDefined(); + expect(items.applicableSpan.start).toEqual(main.cursor); + expect(items.applicableSpan.length).toEqual(0); + expect(items.argumentCount).toEqual(0); + expect(items.argumentIndex).toEqual(0); + expect(items.items.length).toEqual(1); + }); + + it('should handle a single argument', () => { + const main = setup(` + import {Component} from '@angular/core'; + + @Component({ + template: '{{ foo("test") }}', + }) + export class MainCmp { + foo(alpha: string, beta: number): string { + return 'blah'; + } + } + `); + main.moveCursorToText('foo("test"¦)'); + + const items = main.getSignatureHelpItems()!; + expect(items).toBeDefined(); + expect(getText(main.contents, items.applicableSpan)).toEqual('"test"'); + expect(items.argumentCount).toEqual(1); + expect(items.argumentIndex).toEqual(0); + expect(items.items.length).toEqual(1); + }); + + it('should handle a position within the first of two arguments', () => { + const main = setup(` + import {Component} from '@angular/core'; + + @Component({ + template: '{{ foo("test", 3) }}', + }) + export class MainCmp { + foo(alpha: string, beta: number): string { + return 'blah'; + } + } + `); + main.moveCursorToText('foo("te¦st", 3)'); + + const items = main.getSignatureHelpItems()!; + expect(items).toBeDefined(); + expect(getText(main.contents, items.applicableSpan)).toEqual('"test", 3'); + expect(items.argumentCount).toEqual(2); + expect(items.argumentIndex).toEqual(0); + expect(items.items.length).toEqual(1); + }); + + it('should handle a position within the second of two arguments', () => { + const main = setup(` + import {Component} from '@angular/core'; + + @Component({ + template: '{{ foo("test", 1 + 2) }}', + }) + export class MainCmp { + foo(alpha: string, beta: number): string { + return 'blah'; + } + } + `); + main.moveCursorToText('foo("test", 1 +¦ 2)'); + + const items = main.getSignatureHelpItems()!; + expect(items).toBeDefined(); + expect(getText(main.contents, items.applicableSpan)).toEqual('"test", 1 + 2'); + expect(items.argumentCount).toEqual(2); + expect(items.argumentIndex).toEqual(1); + expect(items.items.length).toEqual(1); + }); + + it('should handle a position within a new, EmptyExpr argument', () => { + const main = setup(` + import {Component} from '@angular/core'; + + @Component({ + template: '{{ foo("test", ) }}', + }) + export class MainCmp { + foo(alpha: string, beta: number): string { + return 'blah'; + } + } + `); + main.moveCursorToText('foo("test", ¦)'); + + const items = main.getSignatureHelpItems()!; + expect(items).toBeDefined(); + expect(getText(main.contents, items.applicableSpan)).toEqual('"test", '); + expect(items.argumentCount).toEqual(2); + expect(items.argumentIndex).toEqual(1); + expect(items.items.length).toEqual(1); + }); +}); + +function setup(mainTs: string): OpenBuffer { + const env = LanguageServiceTestEnv.setup(); + const project = env.addProject('test', { + 'main.ts': mainTs, + }); + return project.openFile('main.ts'); +} diff --git a/packages/language-service/ivy/testing/src/buffer.ts b/packages/language-service/ivy/testing/src/buffer.ts index da6d0a2b9f..78453739ee 100644 --- a/packages/language-service/ivy/testing/src/buffer.ts +++ b/packages/language-service/ivy/testing/src/buffer.ts @@ -98,6 +98,10 @@ export class OpenBuffer { getRenameInfo() { return this.ngLS.getRenameInfo(this.scriptInfo.fileName, this._cursor); } + + getSignatureHelpItems() { + return this.ngLS.getSignatureHelpItems(this.scriptInfo.fileName, this._cursor); + } } /** diff --git a/packages/language-service/ivy/ts_plugin.ts b/packages/language-service/ivy/ts_plugin.ts index 5d964b1db4..e8c6c9084a 100644 --- a/packages/language-service/ivy/ts_plugin.ts +++ b/packages/language-service/ivy/ts_plugin.ts @@ -130,6 +130,17 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService { return diagnostics; } + function getSignatureHelpItems( + fileName: string, position: number, + options: ts.SignatureHelpItemsOptions): ts.SignatureHelpItems|undefined { + if (angularOnly) { + return ngLS.getSignatureHelpItems(fileName, position, options); + } else { + return tsLS.getSignatureHelpItems(fileName, position, options) ?? + ngLS.getSignatureHelpItems(fileName, position, options); + } + } + function getTcb(fileName: string, position: number): GetTcbResponse|undefined { return ngLS.getTcb(fileName, position); } @@ -157,6 +168,7 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService { getTcb, getCompilerOptionsDiagnostics, getComponentLocationsForTemplate, + getSignatureHelpItems, }; } diff --git a/packages/language-service/src/ts_plugin.ts b/packages/language-service/src/ts_plugin.ts index 671e811e81..d6050d7465 100644 --- a/packages/language-service/src/ts_plugin.ts +++ b/packages/language-service/src/ts_plugin.ts @@ -136,6 +136,13 @@ export function create(info: tss.server.PluginCreateInfo): NgLanguageService { return undefined; } + function getSignatureHelpItems( + fileName: string, position: number, + options: ts.SignatureHelpItemsOptions|undefined): ts.SignatureHelpItems|undefined { + // not implemented in VE Language Service + return undefined; + } + function getTcb(fileName: string, position: number) { // Not implemented in VE Language Service return undefined; @@ -157,6 +164,7 @@ export function create(info: tss.server.PluginCreateInfo): NgLanguageService { getDefinitionAndBoundSpan, getTypeDefinitionAtPosition, getReferencesAtPosition, + getSignatureHelpItems, findRenameLocations, getTcb, getComponentLocationsForTemplate,