Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Call Hierarchies in language server #35176

Merged
merged 7 commits into from
Dec 22, 2019
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/compiler/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1245,8 +1245,10 @@ namespace ts {
return result;
}

export function group<T>(values: readonly T[], getGroupId: (value: T) => string): readonly (readonly T[])[] {
return arrayFrom(arrayToMultiMap(values, getGroupId).values());
export function group<T>(values: readonly T[], getGroupId: (value: T) => string): readonly (readonly T[])[];
export function group<T, R>(values: readonly T[], getGroupId: (value: T) => string, resultSelector: (values: readonly T[]) => R): R[];
export function group<T>(values: readonly T[], getGroupId: (value: T) => string, resultSelector: (values: readonly T[]) => readonly T[] = identity): readonly (readonly T[])[] {
return arrayFrom(arrayToMultiMap(values, getGroupId).values(), resultSelector);
}

export function clone<T>(object: T): T {
Expand Down
45 changes: 45 additions & 0 deletions src/harness/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,51 @@ namespace ts.server {
return notImplemented();
}

private convertCallHierarchyItem(item: protocol.CallHierarchyItem): CallHierarchyItem {
return {
file: item.file,
name: item.name,
kind: item.kind,
span: this.decodeSpan(item.span, item.file),
selectionSpan: this.decodeSpan(item.selectionSpan, item.file)
};
}

prepareCallHierarchy(fileName: string, position: number): CallHierarchyItem | undefined {
const args = this.createFileLocationRequestArgs(fileName, position);
const request = this.processRequest<protocol.PrepareCallHierarchyRequest>(CommandNames.PrepareCallHierarchy, args);
const response = this.processResponse<protocol.PrepareCallHierarchyResponse>(request);
return response.body && this.convertCallHierarchyItem(response.body);
}

private convertCallHierarchyIncomingCall(item: protocol.CallHierarchyIncomingCall): CallHierarchyIncomingCall {
return {
from: this.convertCallHierarchyItem(item.from),
fromSpans: item.fromSpans.map(span => this.decodeSpan(span, item.from.file))
};
}

provideCallHierarchyIncomingCalls(fileName: string, position: number) {
const args = this.createFileLocationRequestArgs(fileName, position);
const request = this.processRequest<protocol.ProvideCallHierarchyIncomingCallsRequest>(CommandNames.PrepareCallHierarchy, args);
const response = this.processResponse<protocol.ProvideCallHierarchyIncomingCallsResponse>(request);
return response.body.map(item => this.convertCallHierarchyIncomingCall(item));
}

private convertCallHierarchyOutgoingCall(file: string, item: protocol.CallHierarchyOutgoingCall): CallHierarchyOutgoingCall {
return {
to: this.convertCallHierarchyItem(item.to),
fromSpans: item.fromSpans.map(span => this.decodeSpan(span, file))
};
}

provideCallHierarchyOutgoingCalls(fileName: string, position: number) {
const args = this.createFileLocationRequestArgs(fileName, position);
const request = this.processRequest<protocol.ProvideCallHierarchyOutgoingCallsRequest>(CommandNames.PrepareCallHierarchy, args);
const response = this.processResponse<protocol.ProvideCallHierarchyOutgoingCallsResponse>(request);
return response.body.map(item => this.convertCallHierarchyOutgoingCall(fileName, item));
}

getProgram(): Program {
throw new Error("SourceFile objects are not serializable through the server protocol.");
}
Expand Down
181 changes: 174 additions & 7 deletions src/harness/fourslashImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,11 +730,8 @@ namespace FourSlash {
if (!range) {
this.raiseError(`goToDefinitionsAndBoundSpan failed - found a TextSpan ${JSON.stringify(defs.textSpan)} when it wasn't expected.`);
}
else if (defs.textSpan.start !== range.pos || defs.textSpan.length !== range.end - range.pos) {
const expected: ts.TextSpan = {
start: range.pos, length: range.end - range.pos
};
this.raiseError(`goToDefinitionsAndBoundSpan failed - expected to find TextSpan ${JSON.stringify(expected)} but got ${JSON.stringify(defs.textSpan)}`);
else {
this.assertTextSpanEqualsRange(defs.textSpan, range, "goToDefinitionsAndBoundSpan failed");
}
}

Expand Down Expand Up @@ -3133,6 +3130,142 @@ namespace FourSlash {
Harness.IO.log(stringify(codeFixes));
}

public verifyCallHierarchy(options: false | FourSlashInterface.VerifyCallHierarchyOptions | Range) {
const callHierarchyItem = this.languageService.prepareCallHierarchy(this.activeFile.fileName, this.currentCaretPosition)!;
this.assertCallHierarchyItemMatches(callHierarchyItem, options);
}

public verifyCallHierarchyIncomingCalls(options: FourSlashInterface.Sequence<FourSlashInterface.VerifyCallHierarchyIncomingCallOptions | Range>) {
const incomingCalls = this.languageService.provideCallHierarchyIncomingCalls(this.activeFile.fileName, this.currentCaretPosition)!;
this.assertCallHierarchyIncomingCallsMatch(incomingCalls, options);
}

public verifyCallHierarchyOutgoingCalls(options: FourSlashInterface.Sequence<FourSlashInterface.VerifyCallHierarchyOutgoingCallOptions | Range>) {
const outgoingCalls = this.languageService.provideCallHierarchyOutgoingCalls(this.activeFile.fileName, this.currentCaretPosition)!;
this.assertCallHierarchyOutgoingCallsMatch(outgoingCalls, options);
}

private assertSequence<T, U>(actual: readonly T[] | undefined, expected: FourSlashInterface.Sequence<U>, equate: (actual: T, expected: U) => boolean | undefined, assertion: (actual: T, expected: U, message?: string) => void, message?: string, stringifyActual: (actual: T) => string = stringifyFallback, stringifyExpected: (expected: U) => string = stringifyFallback) {
// normalize expected input
if (!expected) {
expected = { exact: true, values: ts.emptyArray };
}
else if (ts.isArray(expected)) {
expected = { exact: false, values: expected };
}

if (expected.exact) {
if (actual === undefined || actual.length === 0) {
if (expected.values.length !== 0) {
this.raiseError(`${prefixMessage(message)}Expected sequence to have exactly ${expected.values.length} item${expected.values.length > 1 ? "s" : ""} but got ${actual ? "an empty array" : "undefined"} instead.\nExpected:\n${stringifyArray(expected.values, stringifyExpected)}`);
}
return;
}
if (expected.values.length === 0) {
this.raiseError(`${prefixMessage(message)}Expected sequence to be empty but got ${actual.length} item${actual.length > 1 ? "s" : ""} instead.\nActual:\n${stringifyArray(actual, stringifyActual)}`);
return;
}
}
else if (actual === undefined || actual.length === 0) {
if (expected.values.length !== 0) {
this.raiseError(`${prefixMessage(message)}Expected sequence to have at least ${expected.values.length} item${expected.values.length > 1 ? "s" : ""} but got ${actual ? "an empty array" : "undefined"} instead.\nExpected:\n${stringifyArray(expected.values, stringifyExpected)}`);
}
return;
}

let expectedIndex = 0;
let actualIndex = 0;
while (expectedIndex < expected.values.length && actualIndex < actual.length) {
const actualItem = actual[actualIndex];
actualIndex++;
const expectedItem = expected.values[expectedIndex];
const result = expected.exact || equate(actualItem, expectedItem);
if (result) {
assertion(actualItem, expectedItem, message);
expectedIndex++;
}
else if (expected.exact) {
this.raiseError(`${prefixMessage(message)}Expected item at index ${expectedIndex} to be ${stringifyExpected(expectedItem)} but got ${stringifyActual(actualItem)} instead.`);
}
else if (result === undefined) {
this.raiseError(`${prefixMessage(message)}Unable to compare items`);
}
}

if (expectedIndex < expected.values.length) {
const expectedItem = expected.values[expectedIndex];
this.raiseError(`${prefixMessage(message)}Expected array to contain ${stringifyExpected(expectedItem)} but it was not found.`);
}
}

private assertCallHierarchyItemMatches(callHierarchyItem: ts.CallHierarchyItem | undefined, options: false | FourSlashInterface.VerifyCallHierarchyOptions | Range, message?: string) {
if (!options) {
assert.isUndefined(callHierarchyItem, this.messageAtLastKnownMarker(`${prefixMessage(message)}Expected location to not have a call hierarchy`));
}
else {
assert.isDefined(callHierarchyItem, this.messageAtLastKnownMarker(`${prefixMessage(message)}Expected location to have a call hierarchy`));
if (!callHierarchyItem) return;
if (isRange(options)) {
options = { selectionRange: options };
}
if (options.kind !== undefined) {
assert.equal(callHierarchyItem.kind, options.kind, this.messageAtLastKnownMarker(`${prefixMessage(message)}Invalid kind`));
}
if (options.range) {
assert.equal(callHierarchyItem.file, options.range.fileName, this.messageAtLastKnownMarker(`${prefixMessage(message)}Incorrect file for call hierarchy item`));
this.assertTextSpanEqualsRange(callHierarchyItem.span, options.range, `${prefixMessage(message)}Incorrect range for declaration`);
}
if (options.selectionRange) {
assert.equal(callHierarchyItem.file, options.selectionRange.fileName, this.messageAtLastKnownMarker(`${prefixMessage(message)}Incorrect file for call hierarchy item`));
this.assertTextSpanEqualsRange(callHierarchyItem.selectionSpan, options.selectionRange, `${prefixMessage(message)}Incorrect selectionRange for declaration`);
}
if (options.incoming !== undefined) {
const incomingCalls = this.languageService.provideCallHierarchyIncomingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start);
this.assertCallHierarchyIncomingCallsMatch(incomingCalls, options.incoming, message);
}
if (options.outgoing !== undefined) {
const outgoingCalls = this.languageService.provideCallHierarchyOutgoingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start);
this.assertCallHierarchyOutgoingCallsMatch(outgoingCalls, options.outgoing, message);
}
}
}

private assertCallHierarchyIncomingCallsMatch(incomingCalls: ts.CallHierarchyIncomingCall[], options: FourSlashInterface.Sequence<FourSlashInterface.VerifyCallHierarchyIncomingCallOptions | Range>, message?: string) {
this.assertSequence(incomingCalls, options,
(actual, expected) => {
expected = normalizeCallHierarchyIncomingCallOptions(expected);
const from = normalizeCallHierarchyOptions(expected.from);
return from.selectionRange && textSpanEqualsRange(actual.from.selectionSpan, from.selectionRange);
},
(actual, expected, message) => {
expected = normalizeCallHierarchyIncomingCallOptions(expected);
const from = normalizeCallHierarchyOptions(expected.from);
this.assertCallHierarchyItemMatches(actual.from, from, message);
if (expected.fromRanges !== undefined) this.assertSequence(actual.fromSpans, expected.fromRanges, textSpanEqualsRange, ts.noop, `${prefixMessage(message)}Invalid fromRange`);
}, `${prefixMessage(message)}Invalid incoming calls`);
}

private assertCallHierarchyOutgoingCallsMatch(outgoingCalls: ts.CallHierarchyOutgoingCall[], options: FourSlashInterface.Sequence<FourSlashInterface.VerifyCallHierarchyOutgoingCallOptions | Range>, message?: string) {
this.assertSequence(outgoingCalls, options,
(actual, expected) => {
expected = normalizeCallHierarchyOutgoingCallOptions(expected);
const to = normalizeCallHierarchyOptions(expected.to);
return to.selectionRange && textSpanEqualsRange(actual.to.selectionSpan, to.selectionRange);
},
(actual, expected, message) => {
expected = normalizeCallHierarchyOutgoingCallOptions(expected);
const to = normalizeCallHierarchyOptions(expected.to);
this.assertCallHierarchyItemMatches(actual.to, to, message);
if (expected.fromRanges !== undefined) this.assertSequence(actual.fromSpans, expected.fromRanges, textSpanEqualsRange, ts.noop, `${prefixMessage(message)}Invalid fromRange`);
}, `${prefixMessage(message)}Invalid outgoing calls`);
}

private assertTextSpanEqualsRange(span: ts.TextSpan, range: Range, message?: string) {
if (!textSpanEqualsRange(span, range)) {
this.raiseError(`${prefixMessage(message)}Expected to find TextSpan ${JSON.stringify({ start: range.pos, length: range.end - range.pos })} but got ${JSON.stringify(span)} instead.`);
}
}

private getLineContent(index: number) {
const text = this.getFileContent(this.activeFile.fileName);
const pos = this.languageServiceAdapterHost.lineAndCharacterToPosition(this.activeFile.fileName, { line: index, character: 0 });
Expand Down Expand Up @@ -3266,6 +3399,40 @@ namespace FourSlash {
}
}

function prefixMessage(message: string | undefined) {
return message ? `${message} - ` : "";
}

function stringifyArray<T>(values: readonly T[], stringify: (value: T) => string, indent = " ") {
return values.length ? `${indent}[\n${indent} ${values.map(stringify).join(`,\n${indent} `)}\n${indent}]` : `${indent}[]`;
}

function stringifyFallback(value: unknown) {
return JSON.stringify(value);
}

function textSpanEqualsRange(span: ts.TextSpan, range: Range) {
return span.start === range.pos && span.length === range.end - range.pos;
}

function isRange(value: object): value is Range {
return ts.hasProperty(value, "pos")
&& ts.hasProperty(value, "end")
&& ts.hasProperty(value, "fileName");
}

function normalizeCallHierarchyOptions(options: Range | FourSlashInterface.VerifyCallHierarchyOptions) {
return isRange(options) ? { selectionRange: options } : options;
}

function normalizeCallHierarchyIncomingCallOptions(options: Range | FourSlashInterface.VerifyCallHierarchyIncomingCallOptions) {
return isRange(options) ? { from: options } : options;
}

function normalizeCallHierarchyOutgoingCallOptions(options: Range | FourSlashInterface.VerifyCallHierarchyOutgoingCallOptions) {
return isRange(options) ? { to: options } : options;
}

function updateTextRangeForTextChanges({ pos, end }: ts.TextRange, textChanges: readonly ts.TextChange[]): ts.TextRange {
forEachTextChange(textChanges, change => {
const update = (p: number): number => updatePosition(p, change.span.start, ts.textSpanEnd(change.span), change.newText);
Expand Down Expand Up @@ -3327,7 +3494,7 @@ namespace FourSlash {
function runCode(code: string, state: TestState): void {
// Compile and execute the test
const wrappedCode =
`(function(test, goTo, plugins, verify, edit, debug, format, cancellation, classification, completion, verifyOperationIsCancelled) {
`(function(test, goTo, plugins, verify, edit, debug, format, cancellation, classification, completion, sequence, verifyOperationIsCancelled) {
${code}
})`;
try {
Expand All @@ -3341,7 +3508,7 @@ ${code}
const cancellation = new FourSlashInterface.Cancellation(state);
// eslint-disable-next-line no-eval
const f = eval(wrappedCode);
f(test, goTo, plugins, verify, edit, debug, format, cancellation, FourSlashInterface.Classification, FourSlashInterface.Completion, verifyOperationIsCancelled);
f(test, goTo, plugins, verify, edit, debug, format, cancellation, FourSlashInterface.Classification, FourSlashInterface.Completion, FourSlashInterface.Sequence, verifyOperationIsCancelled);
}
catch (err) {
throw err;
Expand Down
Loading