Skip to content

Commit

Permalink
[call-hierarchy] Revise implementation of the proposed call hierarchy…
Browse files Browse the repository at this point in the history
… protocol.

Links:
microsoft/language-server-protocol#468
microsoft/vscode-languageserver-node#420

Signed-off-by: Alex Tugarev <alex.tugarev@typefox.io>
  • Loading branch information
AlexTugarev committed Jan 23, 2019
1 parent 6bdc7c6 commit e032d7d
Show file tree
Hide file tree
Showing 10 changed files with 541 additions and 103 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Maintained by [TypeFox](http://typefox.io) and others.
- [x] textDocument/didSave

- [x] textDocument/codeAction
- [x] textDocument/callHierarchy (proposed protocol extension)
- [x] textDocument/completion (incl. completion/resolve)
- [x] textDocument/definition
- [x] textDocument/documentHighlight
Expand Down
235 changes: 235 additions & 0 deletions server/src/callHierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@

import * as tsp from 'typescript/lib/protocol';
import * as lsp from 'vscode-languageserver';
import * as lspCallHierarchy from './lsp-protocol.callHierarchy.proposed';
import { TspClient } from './tsp-client';
import { CommandTypes } from './tsp-command-types';
import { uriToPath, toLocation, asRange, Range, toSymbolKind, pathToUri, asTextSpan } from './protocol-translation';

export type DocumentProvider = (file: string) => lsp.TextDocument | undefined;

// tslint:disable-next-line:max-line-length
export async function computeCallHierarchy(tspClient: TspClient, documentProvider: DocumentProvider, params: lspCallHierarchy.CallHierarchyParams | lspCallHierarchy.ResolveCallHierarchyItemParams): Promise<lspCallHierarchy.CallHierarchyItem | null> {
const item = ('item' in params) ? params.item : await getItem(tspClient, params);
if (!item) {
return null;
}
const direction = params.direction || 'incoming';
const levelsToResolve = params.resolve || 0;
if (direction === 'incoming') {
// TODO consider levels
if (levelsToResolve > 0) {
const callers = await resolveCallers(tspClient, item);
item.callers = callers;
}
return item;
} else {
// TODO consider levels
if (levelsToResolve > 0) {
const callees = await resolveCallees(tspClient, documentProvider, item);
item.callees = callees;
}
return item;
}
}

async function getItem(tspClient: TspClient, params: lspCallHierarchy.CallHierarchyParams): Promise<lspCallHierarchy.CallHierarchyItem | null> {
const contextDefinition = await getDefinition(tspClient, params);
const uri = contextDefinition && pathToUri(contextDefinition.file, undefined);
const contextSymbol = contextDefinition && await findEnclosingSymbol(tspClient, contextDefinition);
if (!contextSymbol || !uri) {
return null;
}
const { name, detail, kind, range, selectionRange } = contextSymbol;
return { uri, name, detail, kind, range, selectionRange };
}

async function resolveCallers(tspClient: TspClient, item: lspCallHierarchy.CallHierarchyItem): Promise<lspCallHierarchy.CallHierarchyItem[]> {

const file = uriToPath(item.uri);
if (!file) {
return [];
}
const range = asTextSpan(item.selectionRange);
const callerReferences = await findNonDefinitionReferences(tspClient, { file, ...range });

const callers: lspCallHierarchy.CallHierarchyItem[] = [];
for (const callerReference of callerReferences) {
const symbol = await findEnclosingSymbol(tspClient, callerReference);
if (!symbol) {
continue;
}
const { name, detail, kind, range, selectionRange } = symbol;
const uri = pathToUri(callerReference.file, undefined);
const callLocation = toLocation(callerReference, undefined);
callers.push({ uri, name, detail, kind, range, selectionRange, callLocation });
}
return callers;
}

async function resolveCallees(tspClient: TspClient, documentProvider: DocumentProvider, item: lspCallHierarchy.CallHierarchyItem): Promise<lspCallHierarchy.CallHierarchyItem[]> {

const file = uriToPath(item.uri);
if (!file) {
return [];
}
const range = asTextSpan(item.selectionRange);

const outgoingCallReferences = await findOutgoingCalls(tspClient, item, documentProvider);
const callees: lspCallHierarchy.CallHierarchyItem[] = [];
for (const reference of outgoingCallReferences) {
let definitionSymbol: lsp.DocumentSymbol | undefined;
let uri: string | undefined;
const definitionReferences = await findDefinitionReferences(tspClient, reference);
for (const definitionReference of definitionReferences) {
definitionSymbol = await findEnclosingSymbol(tspClient, definitionReference);
if (definitionSymbol) {
uri = pathToUri(definitionReference.file, undefined);
break;
}
}
if (!definitionSymbol || !uri) {
continue;
}
const callLocation = toLocation(reference, undefined);
const { name, detail, kind, range, selectionRange } = definitionSymbol;
callees.push({ uri, name, detail, kind, range, selectionRange, callLocation });
}
return callees;
}

async function findOutgoingCalls(tspClient: TspClient, callHierarchyItem: lspCallHierarchy.CallHierarchyItem, documentProvider: DocumentProvider): Promise<tsp.FileSpan[]> {
/**
* The TSP does not provide call references.
* As long as we are not able to access the AST in a tsserver plugin and return the information necessary as metadata to the reponse,
* we need to test possible calls.
*/
const computeCallCandidates = (document: lsp.TextDocument, range: lsp.Range): lsp.Range[] => {
const symbolText = document.getText(range);
const regex = /\W([$_a-zA-Z0-9\u{00C0}-\u{E007F}]+)(<.*>)?\(/gmu; // Example: matches `candidate` in " candidate()", "Foo.candidate<T>()", etc.
let match = regex.exec(symbolText);
const candidates: { identifier: string; start: number; end: number; }[] = []
while (match) {
const identifier = match[1];
if (identifier) {
const start = match.index + match[0].indexOf(identifier);
const end = start + identifier.length;
candidates.push({ identifier, start, end });
}
match = regex.exec(symbolText);
}
const offset = document.offsetAt(range.start);
const candidateRanges = candidates.map(c => lsp.Range.create(document.positionAt(offset + c.start), document.positionAt(offset + c.end)));
return candidateRanges;
}

/**
* This function tests a candidate and returns a locaion for a valid call.
*/
const validateCall = async (file: string, candidateRange: lsp.Range): Promise<tsp.FileSpan | undefined> => {
const tspPosition = { line: candidateRange.start.line + 1, offset: candidateRange.start.character + 1 };
const references = await findNonDefinitionReferences(tspClient, { file, start: tspPosition, end: tspPosition });
for (const reference of references) {
const tspPosition = { line: candidateRange.start.line + 1, offset: candidateRange.start.character + 1 };
if (tspPosition.line === reference.start.line) {
return reference;
}
}
}

const calls: tsp.FileSpan[] = [];
const file = uriToPath(callHierarchyItem.uri)!;
const document = documentProvider(file);
if (!document) {
return calls;
}
const candidateRanges = computeCallCandidates(document, callHierarchyItem.range);
for (const candidateRange of candidateRanges) {
const call = await validateCall(file, candidateRange);
if (call) {
calls.push(call);
}
}
return calls;
}

async function getDefinition(tspClient: TspClient, args: lsp.TextDocumentPositionParams): Promise<tsp.FileSpan | undefined> {
const file = uriToPath(args.textDocument.uri);
if (!file) {
return undefined;
}
const definitionResult = await tspClient.request(CommandTypes.Definition, {
file,
line: args.position.line + 1,
offset: args.position.character + 1
});
return definitionResult.body ? definitionResult.body[0] : undefined;
}

async function findEnclosingSymbol(tspClient: TspClient, args: tsp.FileSpan): Promise<lsp.DocumentSymbol | undefined> {
const file = args.file;
const response = await tspClient.request(CommandTypes.NavTree, { file });
const tree = response.body;
if (!tree || !tree.childItems) {
return undefined;
}
const pos = lsp.Position.create(args.start.line - 1, args.start.offset - 1);
const symbol = await findEnclosingSymbolInTree(tree, lsp.Range.create(pos, pos));
return symbol;
}

async function findEnclosingSymbolInTree(parent: tsp.NavigationTree, range: lsp.Range): Promise<lsp.DocumentSymbol | undefined> {
const inSpan = (span: tsp.TextSpan) => !!Range.intersection(asRange(span), range);
const inTree = (tree: tsp.NavigationTree) => tree.spans.some(span => inSpan(span));

let candidate = inTree(parent) ? parent : undefined;
outer: while (candidate) {
const children = candidate.childItems || [];
for (const child of children) {
if (inTree(child)) {
candidate = child;
continue outer;
}
}
break;
}
if (!candidate) {
return undefined;
}
const span = candidate.spans.find(span => inSpan(span))!;
const spanRange = asRange(span);
let selectionRange = spanRange;
if (candidate.nameSpan) {
const nameRange = asRange(candidate.nameSpan);
if (Range.intersection(spanRange, nameRange)) {
selectionRange = nameRange;
}
}
return {
name: candidate.text,
kind: toSymbolKind(candidate.kind),
range: spanRange,
selectionRange: selectionRange
}
}

async function findDefinitionReferences(tspClient: TspClient, args: tsp.FileSpan): Promise<tsp.FileSpan[]> {
return (await findReferences(tspClient, args)).filter(ref => ref.isDefinition);
}

async function findNonDefinitionReferences(tspClient: TspClient, args: tsp.FileSpan): Promise<tsp.FileSpan[]> {
return (await findReferences(tspClient, args)).filter(ref => !ref.isDefinition);
}

async function findReferences(tspClient: TspClient, args: tsp.FileSpan): Promise<tsp.ReferencesResponseItem[]> {
const file = args.file;
const result = await tspClient.request(CommandTypes.References, {
file,
line: args.start.line,
offset: args.start.offset
});
if (!result.body) {
return [];
}
return result.body.refs;
}
6 changes: 3 additions & 3 deletions server/src/lsp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import * as lsp from 'vscode-languageserver';
import * as lspcalls from './lsp-protocol.calls.proposed'
import * as lspCallHierarchy from './lsp-protocol.callHierarchy.proposed'

import { Logger, LspClientLogger } from './logger';
import { LspServer } from './lsp-server';
Expand Down Expand Up @@ -55,8 +55,8 @@ export function createLspConnection(options: IServerOptions): lsp.IConnection {
connection.onWorkspaceSymbol(server.workspaceSymbol.bind(server));
connection.onFoldingRanges(server.foldingRanges.bind(server));

// proposed `textDocument/calls` request
connection.onRequest(lspcalls.CallsRequest.type, server.calls.bind(server));
// proposed `textDocument/callHierarchy` request
connection.onRequest(lspCallHierarchy.CallHierarchyRequest.type, server.callHierarchy.bind(server));

return connection;
}
128 changes: 128 additions & 0 deletions server/src/lsp-protocol.callHierarchy.proposed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/* --------------------------------------------------------------------------------------------
* Copyright (c) TypeFox and others. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
* ------------------------------------------------------------------------------------------ */
'use strict';

import { RequestType, RequestHandler } from 'vscode-jsonrpc';
import { Location, SymbolKind, Range, DocumentSymbol } from 'vscode-languageserver-types';
import * as lsp from 'vscode-languageserver';

export interface CallHierarchyClientCapabilities {
/**
* The text document client capabilities
*/
textDocument?: {
/**
* Capabilities specific to the `textDocument/callHierarchy`
*/
callHierarchy?: {
/**
* Whether implementation supports dynamic registration. If this is set to `true`
* the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)`
* return value for the corresponding server capability as well.
*/
dynamicRegistration?: boolean;
};
}
}

export interface CallHierarchyServerCapabilities {
/**
* The server provides Call Hierarchy support.
*/
callHierarchyProvider?: boolean | (lsp.TextDocumentRegistrationOptions & lsp.StaticRegistrationOptions);
}

/**
* Request to request or resolve the call hierarchy at a given text document position.
*
* The request's parameter for the first request is of type [CallHierarchyParams](#CallHierarchyParams). The request's
* parameter for the following requests is of type [ResolveCallHierarchyItemParams](#ResolveCallHierarchyItemParams).
*
* The first request returns an item for the given cursor position.
*
* The response is of type [CallHierarchyItem](#CallHierarchyItem) or a Thenable that resolves to such.
*/
export namespace CallHierarchyRequest {
export const type = new RequestType<CallHierarchyParams, ResolveCallHierarchyItemParams, void, lsp.TextDocumentRegistrationOptions>('textDocument/callHierarchy');
export type HandlerSignature = RequestHandler<CallHierarchyParams | ResolveCallHierarchyItemParams, CallHierarchyItem | null, void>;
}

/**
* The parameters of a `textDocument/callHierarchy` request.
*/
export interface CallHierarchyParams extends lsp.TextDocumentPositionParams {
resolve?: number; // the levels to resolve.
/**
* Outgoing direction for callees.
* The default is incoming for callers.
*/
direction?: 'incoming' | 'outgoing';
}

/**
* The parameters of a `textDocument/callHierarchy` request.
*/
export interface ResolveCallHierarchyItemParams {
item: CallHierarchyItem;
resolve: number; // the levels to resolve.
/**
* Outgoing direction for callees.
* The default is incoming for callers.
*/
direction: 'incoming' | 'outgoing';
}

/**
* The result of a `textDocument/callHierarchy` request.
*/
export interface CallHierarchyItem {
/**
* The name of the symbol targeted by the call hierarchy request.
*/
name: string;
/**
* More detail for this symbol, e.g the signature of a function.
*/
detail?: string;
/**
* The kind of this symbol.
*/
kind: SymbolKind;
/**
* URI of the document containing the symbol.
*/
uri: string;
/**
* The range enclosing this symbol not including leading/trailing whitespace but everything else
* like comments. This information is typically used to determine if the the clients cursor is
* inside the symbol to reveal in the symbol in the UI.
*/
range: Range;
/**
* The range that should be selected and revealed when this symbol is being picked, e.g the name of a function.
* Must be contained by the the `range`.
*/
selectionRange: Range;

/**
* The actual location of the call.
*
* Must be defined in resolved callers/callees.
*/
callLocation?: Location;

/**
* List of incoming calls.
*
* *Note*: The items is _unresolved_ if `callers` and `callees` is undefined.
*/
callers?: CallHierarchyItem[];
/**
* List of outgoing calls.
*
* *Note*: The items is _unresolved_ if `callers` and `callees` is undefined.
*/
callees?: CallHierarchyItem[];
}

0 comments on commit e032d7d

Please sign in to comment.