feat(language-service): implement signature help (#41581)
This commit implements signature help in the Language Service, on top of TypeScript's implementation within the TCB. A separate PR adds support for translation of signature help data from TS' API to the LSP in the Language Service extension. PR Close #41581
This commit is contained in:
parent
d85e74e05c
commit
c7f9516ab9
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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');
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue