Skip to content

Commit

Permalink
feat(language-service): implement signature help
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
alxhub committed Apr 12, 2021
1 parent 435dfbd commit 3fb47e7
Show file tree
Hide file tree
Showing 8 changed files with 323 additions and 1 deletion.
5 changes: 5 additions & 0 deletions packages/compiler-cli/src/ngtsc/perf/src/api.ts
Expand Up @@ -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.
*/
Expand Down
4 changes: 3 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions packages/language-service/ivy/language_service.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
135 changes: 135 additions & 0 deletions 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,
},
};
}
142 changes: 142 additions & 0 deletions 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');
}
4 changes: 4 additions & 0 deletions packages/language-service/ivy/testing/src/buffer.ts
Expand Up @@ -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);
}
}

/**
Expand Down
12 changes: 12 additions & 0 deletions packages/language-service/ivy/ts_plugin.ts
Expand Up @@ -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);
}
Expand Down Expand Up @@ -157,6 +168,7 @@ export function create(info: ts.server.PluginCreateInfo): NgLanguageService {
getTcb,
getCompilerOptionsDiagnostics,
getComponentLocationsForTemplate,
getSignatureHelpItems,
};
}

Expand Down
8 changes: 8 additions & 0 deletions packages/language-service/src/ts_plugin.ts
Expand Up @@ -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;
Expand All @@ -157,6 +164,7 @@ export function create(info: tss.server.PluginCreateInfo): NgLanguageService {
getDefinitionAndBoundSpan,
getTypeDefinitionAtPosition,
getReferencesAtPosition,
getSignatureHelpItems,
findRenameLocations,
getTcb,
getComponentLocationsForTemplate,
Expand Down

0 comments on commit 3fb47e7

Please sign in to comment.