Skip to content

Commit

Permalink
feat: implement support for spec version of Call Hierarchy (#649)
Browse files Browse the repository at this point in the history
  • Loading branch information
rchl committed Dec 29, 2022
1 parent b15f8a7 commit 3ce0e17
Show file tree
Hide file tree
Showing 12 changed files with 305 additions and 1 deletion.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,9 @@ export interface InlayHintsOptions extends UserPreferences {
- [x] textDocument/formatting
- [x] textDocument/hover
- [x] textDocument/inlayHint (no support for `inlayHint/resolve` or `workspace/inlayHint/refresh`)
- [x] textDocument/prepareCallHierarchy
- [x] callHierarchy/incomingCalls
- [x] callHierarchy/outgoingCalls
- [x] textDocument/prepareRename
- [x] textDocument/rangeFormatting
- [x] textDocument/references
Expand Down
129 changes: 129 additions & 0 deletions src/features/call-hierarchy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2022 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/

import * as chai from 'chai';
import * as lsp from 'vscode-languageserver';
import { uri, createServer, TestLspServer, positionAfter, documentFromFile } from './../test-utils.js';

const assert = chai.assert;

const diagnostics: Map<string, lsp.PublishDiagnosticsParams> = new Map();
let server: TestLspServer;

interface CallHierarchyItemWithChildren extends lsp.CallHierarchyItem {
children: CallHierarchyItemWithChildren[];
}

async function getIncomingCalls(server: TestLspServer, item: lsp.CallHierarchyItem): Promise<CallHierarchyItemWithChildren> {
const incomingCalls = await server.callHierarchyIncomingCalls({ item });
const children = await Promise.all((incomingCalls || []).map(incomingCall => getIncomingCalls(server, incomingCall.from)));
return {
...item,
children,
};
}

async function getOutgoingCalls(server: TestLspServer, item: lsp.CallHierarchyItem): Promise<CallHierarchyItemWithChildren> {
const outgoingCalls = await server.callHierarchyOutgoingCalls({ item });
const children = await Promise.all((outgoingCalls || []).map(outgoingCall => getOutgoingCalls(server, outgoingCall.to)));
return {
...item,
children,
};
}

function itemToString(item: lsp.CallHierarchyItem | null, indentLevel: number): string {
if (!item) {
return '<not found>';
}
return `${new Array(indentLevel * 2 + 1).join(' ')}-|> ${item.name} (symbol: ${item.uri.split('/').pop()}#${item.selectionRange.start.line})`;
}

function callsToString(item: CallHierarchyItemWithChildren, indentLevel: number, lines: string[]): void {
for (const child of item.children) {
lines.push(itemToString(child, indentLevel + 1));
callsToString(child, indentLevel + 1, lines);
}
}

before(async () => {
server = await createServer({
rootUri: uri(),
publishDiagnostics: args => diagnostics.set(args.uri, args),
});
});

beforeEach(() => {
server.closeAll();
// "closeAll" triggers final publishDiagnostics with an empty list so clear last.
diagnostics.clear();
});

after(() => {
server.closeAll();
server.shutdown();
});

describe.only('call hierarchy', () => {
const oneDoc = documentFromFile({ path: 'call-hierarchy/one.ts' });
const twoDoc = documentFromFile({ path: 'call-hierarchy/two.ts' });
const threeDoc = documentFromFile({ path: 'call-hierarchy/three.ts' });

function openDocuments() {
for (const textDocument of [oneDoc, twoDoc, threeDoc]) {
server.didOpenTextDocument({ textDocument });
}
}

it('incoming calls', async () => {
openDocuments();
const items = await server.prepareCallHierarchy({
textDocument: twoDoc,
position: positionAfter(twoDoc, 'new Three().tada'),
});
assert.isNotNull(items);
assert.lengthOf(items!, 1);
const lines: string[] = [];
for (const item of items!) {
lines.push(itemToString(item, 0));
const incomingCalls = await getIncomingCalls(server, item);
callsToString(incomingCalls, 0, lines);
}
assert.equal(lines.join('\n'),
`
-|> tada (symbol: three.ts#2)
-|> callThreeTwice (symbol: two.ts#3)
-|> main (symbol: one.ts#2)
`.trim(),
);
});

it('outgoing calls', async () => {
openDocuments();
const items = await server.prepareCallHierarchy({
textDocument: oneDoc,
position: positionAfter(oneDoc, 'new Two().callThreeTwice'),
});
assert.isNotNull(items);
assert.lengthOf(items!, 1);
const lines: string[] = [];
for (const item of items!) {
lines.push(itemToString(item, 0));
const outgoingCalls = await getOutgoingCalls(server, item);
callsToString(outgoingCalls, 0, lines);
}
assert.equal(lines.join('\n'),
`
-|> callThreeTwice (symbol: two.ts#3)
-|> tada (symbol: three.ts#2)
-|> print (symbol: three.ts#6)
-|> log (symbol: console.d.ts#220)
-|> Three (symbol: three.ts#1)
`.trim(),
);
});
});
89 changes: 89 additions & 0 deletions src/features/call-hierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright (C) 2022 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import path from 'node:path';
import * as lsp from 'vscode-languageserver';
import type { LspDocuments } from '../document.js';
import { pathToUri } from '../protocol-translation.js';
import { tslib, tsp } from '../ts-protocol.js';
import { Range } from '../utils/typeConverters.js';

export function fromProtocolCallHierarchyItem(item: tsp.CallHierarchyItem, documents: LspDocuments, workspaceRoot: string | undefined): lsp.CallHierarchyItem {
const useFileName = isSourceFileItem(item);
const name = useFileName ? path.basename(item.file) : item.name;
const detail = useFileName
? workspaceRoot ? path.relative(workspaceRoot, path.dirname(item.file)) : path.dirname(item.file)
: item.containerName ?? '';
const result: lsp.CallHierarchyItem = {
kind: fromProtocolScriptElementKind(item.kind),
name,
detail,
uri: pathToUri(item.file, documents),
range: Range.fromTextSpan(item.span),
selectionRange: Range.fromTextSpan(item.selectionSpan),
};

const kindModifiers = item.kindModifiers ? parseKindModifier(item.kindModifiers) : undefined;
if (kindModifiers?.has(tslib.ScriptElementKindModifier.deprecatedModifier)) {
result.tags = [lsp.SymbolTag.Deprecated];
}
return result;
}

export function fromProtocolCallHierarchyIncomingCall(item: tsp.CallHierarchyIncomingCall, documents: LspDocuments, workspaceRoot: string | undefined): lsp.CallHierarchyIncomingCall {
return {
from: fromProtocolCallHierarchyItem(item.from, documents, workspaceRoot),
fromRanges: item.fromSpans.map(Range.fromTextSpan),
};
}

export function fromProtocolCallHierarchyOutgoingCall(item: tsp.CallHierarchyOutgoingCall, documents: LspDocuments, workspaceRoot: string | undefined): lsp.CallHierarchyOutgoingCall {
return {
to: fromProtocolCallHierarchyItem(item.to, documents, workspaceRoot),
fromRanges: item.fromSpans.map(Range.fromTextSpan),
};
}

function isSourceFileItem(item: tsp.CallHierarchyItem) {
return item.kind === tslib.ScriptElementKind.scriptElement || item.kind === tslib.ScriptElementKind.moduleElement && item.selectionSpan.start.line === 1 && item.selectionSpan.start.offset === 1;
}

function fromProtocolScriptElementKind(kind: tslib.ScriptElementKind): lsp.SymbolKind {
switch (kind) {
case tslib.ScriptElementKind.moduleElement:return lsp.SymbolKind.Module;
case tslib.ScriptElementKind.classElement:return lsp.SymbolKind.Class;
case tslib.ScriptElementKind.enumElement:return lsp.SymbolKind.Enum;
case tslib.ScriptElementKind.enumMemberElement:return lsp.SymbolKind.EnumMember;
case tslib.ScriptElementKind.interfaceElement:return lsp.SymbolKind.Interface;
case tslib.ScriptElementKind.indexSignatureElement:return lsp.SymbolKind.Method;
case tslib.ScriptElementKind.callSignatureElement:return lsp.SymbolKind.Method;
case tslib.ScriptElementKind.memberFunctionElement:return lsp.SymbolKind.Method;
case tslib.ScriptElementKind.memberVariableElement:return lsp.SymbolKind.Property;
case tslib.ScriptElementKind.memberGetAccessorElement:return lsp.SymbolKind.Property;
case tslib.ScriptElementKind.memberSetAccessorElement:return lsp.SymbolKind.Property;
case tslib.ScriptElementKind.variableElement:return lsp.SymbolKind.Variable;
case tslib.ScriptElementKind.letElement:return lsp.SymbolKind.Variable;
case tslib.ScriptElementKind.constElement:return lsp.SymbolKind.Variable;
case tslib.ScriptElementKind.localVariableElement:return lsp.SymbolKind.Variable;
case tslib.ScriptElementKind.alias:return lsp.SymbolKind.Variable;
case tslib.ScriptElementKind.functionElement:return lsp.SymbolKind.Function;
case tslib.ScriptElementKind.localFunctionElement:return lsp.SymbolKind.Function;
case tslib.ScriptElementKind.constructSignatureElement:return lsp.SymbolKind.Constructor;
case tslib.ScriptElementKind.constructorImplementationElement:return lsp.SymbolKind.Constructor;
case tslib.ScriptElementKind.typeParameterElement:return lsp.SymbolKind.TypeParameter;
case tslib.ScriptElementKind.string:return lsp.SymbolKind.String;
default: return lsp.SymbolKind.Variable;
}
}

function parseKindModifier(kindModifiers: string): Set<string> {
return new Set(kindModifiers.split(/,|\s+/g));
}
3 changes: 3 additions & 0 deletions src/lsp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export function createLspConnection(options: LspConnectionOptions): lsp.Connecti
connection.onSignatureHelp(server.signatureHelp.bind(server));
connection.onWorkspaceSymbol(server.workspaceSymbol.bind(server));
connection.onFoldingRanges(server.foldingRanges.bind(server));
connection.languages.callHierarchy.onPrepare(server.prepareCallHierarchy.bind(server));
connection.languages.callHierarchy.onIncomingCalls(server.callHierarchyIncomingCalls.bind(server));
connection.languages.callHierarchy.onOutgoingCalls(server.callHierarchyOutgoingCalls.bind(server));
connection.languages.inlayHint.on(server.inlayHints.bind(server));
connection.languages.semanticTokens.on(server.semanticTokensFull.bind(server));
connection.languages.semanticTokens.onRange(server.semanticTokensRange.bind(server));
Expand Down
44 changes: 44 additions & 0 deletions src/lsp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { provideOrganizeImports } from './organize-imports.js';
import { tsp, EventTypes, TypeScriptInitializeParams, TypeScriptInitializationOptions, SupportedFeatures } from './ts-protocol.js';
import { collectDocumentSymbols, collectSymbolInformation } from './document-symbol.js';
import { TsServerLogLevel, TypeScriptServiceConfiguration } from './utils/configuration.js';
import { fromProtocolCallHierarchyItem, fromProtocolCallHierarchyIncomingCall, fromProtocolCallHierarchyOutgoingCall } from './features/call-hierarchy.js';
import { TypeScriptAutoFixProvider } from './features/fix-all.js';
import { TypeScriptInlayHintsProvider } from './features/inlay-hints.js';
import * as SemanticTokens from './features/semantic-tokens.js';
Expand Down Expand Up @@ -256,6 +257,9 @@ export class LspServer {
},
},
};
if (textDocument?.callHierarchy && typescriptVersion.version?.gte(API.v380)) {
initializeResult.capabilities.callHierarchyProvider = true;
}
this.logger.log('onInitialize result', initializeResult);
return initializeResult;
}
Expand Down Expand Up @@ -1156,6 +1160,46 @@ export class LspServer {
}
}

async prepareCallHierarchy(params: lsp.CallHierarchyPrepareParams): Promise<lsp.CallHierarchyItem[] | null> {
const file = uriToPath(params.textDocument.uri);
if (!file) {
return null;
}
const args = Position.toFileLocationRequestArgs(file, params.position);
const response = await this.tspClient.request(CommandTypes.PrepareCallHierarchy, args);
if (response.type !== 'response' || !response.body) {
return null;
}
const items = Array.isArray(response.body) ? response.body : [response.body];
return items.map(item => fromProtocolCallHierarchyItem(item, this.documents, this.workspaceRoot));
}

async callHierarchyIncomingCalls(params: lsp.CallHierarchyIncomingCallsParams): Promise<lsp.CallHierarchyIncomingCall[] | null> {
const file = uriToPath(params.item.uri);
if (!file) {
return null;
}
const args = Position.toFileLocationRequestArgs(file, params.item.selectionRange.start);
const response = await this.tspClient.request(CommandTypes.ProvideCallHierarchyIncomingCalls, args);
if (response.type !== 'response' || !response.body) {
return null;
}
return response.body.map(item => fromProtocolCallHierarchyIncomingCall(item, this.documents, this.workspaceRoot));
}

async callHierarchyOutgoingCalls(params: lsp.CallHierarchyOutgoingCallsParams): Promise<lsp.CallHierarchyOutgoingCall[] | null> {
const file = uriToPath(params.item.uri);
if (!file) {
return null;
}
const args = Position.toFileLocationRequestArgs(file, params.item.selectionRange.start);
const response = await this.tspClient.request(CommandTypes.ProvideCallHierarchyOutgoingCalls, args);
if (response.type !== 'response' || !response.body) {
return null;
}
return response.body.map(item => fromProtocolCallHierarchyOutgoingCall(item, this.documents, this.workspaceRoot));
}

async inlayHints(params: lsp.InlayHintParams): Promise<lsp.InlayHint[] | undefined> {
return await TypeScriptInlayHintsProvider.provideInlayHints(
params.textDocument.uri, params.range, this.documents, this.tspClient, this.options.lspClient, this.configurationManager);
Expand Down
2 changes: 1 addition & 1 deletion src/protocol-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import * as lsp from 'vscode-languageserver';
import vscodeUri from 'vscode-uri';
import { LspDocuments } from './document.js';
import type { LspDocuments } from './document.js';
import { tslib, tsp, SupportedFeatures } from './ts-protocol.js';
import { Position, Range } from './utils/typeConverters.js';

Expand Down
10 changes: 10 additions & 0 deletions src/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ export function readContents(path: string): string {
return fs.readFileSync(path, 'utf-8').toString();
}

export function documentFromFile({ path, languageId = 'typescript' }: { path: string; languageId?: string; }): lsp.TextDocumentItem {
const pathComponents = path.split('/');
return {
languageId,
text: readContents(filePath(...pathComponents)),
uri: uri(...pathComponents),
version: 1,
};
}

export function positionAt(document: lsp.TextDocumentItem, idx: number): lsp.Position {
const doc = TextDocument.create(document.uri, document.languageId, document.version, document.text);
const pos = doc.positionAt(idx);
Expand Down
3 changes: 3 additions & 0 deletions src/tsServer/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ export interface TypeScriptRequestTypes {
[CommandTypes.NavTree]: [tsp.FileRequestArgs, tsp.NavTreeResponse];
[CommandTypes.Open]: [tsp.OpenRequestArgs, null];
[CommandTypes.OrganizeImports]: [tsp.OrganizeImportsRequestArgs, tsp.OrganizeImportsResponse];
[CommandTypes.PrepareCallHierarchy]: [tsp.FileLocationRequestArgs, tsp.PrepareCallHierarchyResponse];
[CommandTypes.ProvideCallHierarchyIncomingCalls]: [tsp.FileLocationRequestArgs, tsp.ProvideCallHierarchyIncomingCallsResponse];
[CommandTypes.ProvideCallHierarchyOutgoingCalls]: [tsp.FileLocationRequestArgs, tsp.ProvideCallHierarchyOutgoingCallsResponse];
[CommandTypes.ProjectInfo]: [tsp.ProjectInfoRequestArgs, tsp.ProjectInfoResponse];
[CommandTypes.ProvideInlayHints]: [tsp.InlayHintsRequestArgs, tsp.InlayHintsResponse];
[CommandTypes.Quickinfo]: [tsp.FileLocationRequestArgs, tsp.QuickInfoResponse];
Expand Down
5 changes: 5 additions & 0 deletions test-data/call-hierarchy/one.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// comment line
import { Two } from './two'
export function main() {
new Two().callThreeTwice();
}
9 changes: 9 additions & 0 deletions test-data/call-hierarchy/three.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// comment line
export class Three {
tada() {
print('🎉');
}
}
export function print(s: string) {
console.log(s);
}
8 changes: 8 additions & 0 deletions test-data/call-hierarchy/two.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// comment line
import { Three } from "./three";
export class Two {
callThreeTwice() {
new Three().tada();
new Three().tada();
}
}
1 change: 1 addition & 0 deletions test-data/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"noLib": true
},
}

0 comments on commit 3ce0e17

Please sign in to comment.