-
Notifications
You must be signed in to change notification settings - Fork 24.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(language-service): implement signature help
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
Showing
8 changed files
with
323 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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
142
packages/language-service/ivy/test/signature_help_spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters