Add support for Call Hierarchies in language server (#35176)

* Add support for Call Hierarchies in language server

* Use baselines for callHierarchy tests

* Clean up commented code

* Support multiple hierarchy items when an implementation can't be found

* Use optional chaining in a few places

* Use getFileAndProject
This commit is contained in:
Ron Buckton
2019-12-22 13:25:09 -08:00
committed by GitHub
parent 114dad7f56
commit 6c413e0bbb
55 changed files with 2712 additions and 55 deletions

View File

@@ -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 | 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 && mapOneOrMany(response.body, item => this.convertCallHierarchyItem(item));
}
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.");
}

View File

@@ -147,6 +147,12 @@ namespace FourSlash {
return ts.ScriptSnapshot.fromString(sourceText);
}
const enum CallHierarchyItemDirection {
Root,
Incoming,
Outgoing
}
export class TestState {
// Language service instance
private languageServiceAdapterHost: Harness.LanguageService.LanguageServiceAdapterHost;
@@ -734,11 +740,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");
}
}
@@ -1411,18 +1414,91 @@ namespace FourSlash {
private alignmentForExtraInfo = 50;
private spanInfoToString(spanInfo: ts.TextSpan, prefixString: string) {
private spanLines(file: FourSlashFile, spanInfo: ts.TextSpan, { selection = false, fullLines = false, lineNumbers = false } = {}) {
if (selection) {
fullLines = true;
}
let contextStartPos = spanInfo.start;
let contextEndPos = contextStartPos + spanInfo.length;
if (fullLines) {
if (contextStartPos > 0) {
while (contextStartPos > 1) {
const ch = file.content.charCodeAt(contextStartPos - 1);
if (ch === ts.CharacterCodes.lineFeed || ch === ts.CharacterCodes.carriageReturn) {
break;
}
contextStartPos--;
}
}
if (contextEndPos < file.content.length) {
while (contextEndPos < file.content.length - 1) {
const ch = file.content.charCodeAt(contextEndPos);
if (ch === ts.CharacterCodes.lineFeed || ch === ts.CharacterCodes.carriageReturn) {
break;
}
contextEndPos++;
}
}
}
let contextString: string;
let contextLineMap: number[];
let contextStart: ts.LineAndCharacter;
let contextEnd: ts.LineAndCharacter;
let selectionStart: ts.LineAndCharacter;
let selectionEnd: ts.LineAndCharacter;
let lineNumberPrefixLength: number;
if (lineNumbers) {
contextString = file.content;
contextLineMap = ts.computeLineStarts(contextString);
contextStart = ts.computeLineAndCharacterOfPosition(contextLineMap, contextStartPos);
contextEnd = ts.computeLineAndCharacterOfPosition(contextLineMap, contextEndPos);
selectionStart = ts.computeLineAndCharacterOfPosition(contextLineMap, spanInfo.start);
selectionEnd = ts.computeLineAndCharacterOfPosition(contextLineMap, ts.textSpanEnd(spanInfo));
lineNumberPrefixLength = (contextEnd.line + 1).toString().length + 2;
}
else {
contextString = file.content.substring(contextStartPos, contextEndPos);
contextLineMap = ts.computeLineStarts(contextString);
contextStart = { line: 0, character: 0 };
contextEnd = { line: contextLineMap.length - 1, character: 0 };
selectionStart = selection ? ts.computeLineAndCharacterOfPosition(contextLineMap, spanInfo.start - contextStartPos) : contextStart;
selectionEnd = selection ? ts.computeLineAndCharacterOfPosition(contextLineMap, ts.textSpanEnd(spanInfo) - contextStartPos) : contextEnd;
lineNumberPrefixLength = 0;
}
const output: string[] = [];
for (let lineNumber = contextStart.line; lineNumber <= contextEnd.line; lineNumber++) {
const spanLine = contextString.substring(contextLineMap[lineNumber], contextLineMap[lineNumber + 1]);
output.push(lineNumbers ? `${`${lineNumber + 1}: `.padStart(lineNumberPrefixLength, " ")}${spanLine}` : spanLine);
if (selection) {
if (lineNumber < selectionStart.line || lineNumber > selectionEnd.line) {
continue;
}
const isEmpty = selectionStart.line === selectionEnd.line && selectionStart.character === selectionEnd.character;
const selectionPadLength = lineNumber === selectionStart.line ? selectionStart.character : 0;
const selectionPad = " ".repeat(selectionPadLength + lineNumberPrefixLength);
const selectionLength = isEmpty ? 0 : Math.max(lineNumber < selectionEnd.line ? spanLine.trimRight().length - selectionPadLength : selectionEnd.character - selectionPadLength, 1);
const selectionLine = isEmpty ? "<" : "^".repeat(selectionLength);
output.push(`${selectionPad}${selectionLine}`);
}
}
return output;
}
private spanInfoToString(spanInfo: ts.TextSpan, prefixString: string, file: FourSlashFile = this.activeFile) {
let resultString = "SpanInfo: " + JSON.stringify(spanInfo);
if (spanInfo) {
const spanString = this.activeFile.content.substr(spanInfo.start, spanInfo.length);
const spanLineMap = ts.computeLineStarts(spanString);
for (let i = 0; i < spanLineMap.length; i++) {
const spanLines = this.spanLines(file, spanInfo);
for (let i = 0; i < spanLines.length; i++) {
if (!i) {
resultString += "\n";
}
resultString += prefixString + spanString.substring(spanLineMap[i], spanLineMap[i + 1]);
resultString += prefixString + spanLines[i];
}
resultString += "\n" + prefixString + ":=> (" + this.getLineColStringAtPosition(spanInfo.start) + ") to (" + this.getLineColStringAtPosition(ts.textSpanEnd(spanInfo)) + ")";
resultString += "\n" + prefixString + ":=> (" + this.getLineColStringAtPosition(spanInfo.start, file) + ") to (" + this.getLineColStringAtPosition(ts.textSpanEnd(spanInfo), file) + ")";
}
return resultString;
@@ -1701,13 +1777,13 @@ namespace FourSlash {
Harness.IO.log(stringify(help.items[help.selectedItemIndex]));
}
private getBaselineFileNameForInternalFourslashFile() {
private getBaselineFileNameForInternalFourslashFile(ext = ".baseline") {
return this.testData.globalOptions[MetadataOptionNames.baselineFile] ||
ts.getBaseFileName(this.activeFile.fileName).replace(ts.Extension.Ts, ".baseline");
ts.getBaseFileName(this.activeFile.fileName).replace(ts.Extension.Ts, ext);
}
private getBaselineFileNameForContainingTestFile() {
return ts.getBaseFileName(this.originalInputFileName).replace(ts.Extension.Ts, ".baseline");
private getBaselineFileNameForContainingTestFile(ext = ".baseline") {
return ts.getBaseFileName(this.originalInputFileName).replace(ts.Extension.Ts, ext);
}
private getSignatureHelp({ triggerReason }: FourSlashInterface.VerifySignatureHelpOptions): ts.SignatureHelpItems | undefined {
@@ -3164,6 +3240,131 @@ namespace FourSlash {
Harness.IO.log(stringify(codeFixes));
}
private formatCallHierarchyItemSpan(file: FourSlashFile, span: ts.TextSpan, prefix: string, trailingPrefix = prefix) {
const startLc = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, span.start);
const endLc = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, ts.textSpanEnd(span));
const lines = this.spanLines(file, span, { fullLines: true, lineNumbers: true, selection: true });
let text = "";
text += `${prefix}${file.fileName}:${startLc.line + 1}:${startLc.character + 1}-${endLc.line + 1}:${endLc.character + 1}\n`;
for (const line of lines) {
text += `${prefix}${line.trimRight()}\n`;
}
text += `${trailingPrefix}\n`;
return text;
}
private formatCallHierarchyItemSpans(file: FourSlashFile, spans: ts.TextSpan[], prefix: string, trailingPrefix = prefix) {
let text = "";
for (let i = 0; i < spans.length; i++) {
text += this.formatCallHierarchyItemSpan(file, spans[i], prefix, i < spans.length - 1 ? prefix : trailingPrefix);
}
return text;
}
private formatCallHierarchyItem(file: FourSlashFile, callHierarchyItem: ts.CallHierarchyItem, direction: CallHierarchyItemDirection, seen: ts.Map<boolean>, prefix: string, trailingPrefix: string = prefix) {
const key = `${callHierarchyItem.file}|${JSON.stringify(callHierarchyItem.span)}|${direction}`;
const alreadySeen = seen.has(key);
seen.set(key, true);
const incomingCalls =
direction === CallHierarchyItemDirection.Outgoing ? { result: "skip" } as const :
alreadySeen ? { result: "seen" } as const :
{ result: "show", values: this.languageService.provideCallHierarchyIncomingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start) } as const;
const outgoingCalls =
direction === CallHierarchyItemDirection.Incoming ? { result: "skip" } as const :
alreadySeen ? { result: "seen" } as const :
{ result: "show", values: this.languageService.provideCallHierarchyOutgoingCalls(callHierarchyItem.file, callHierarchyItem.selectionSpan.start) } as const;
let text = "";
text += `${prefix}╭ name: ${callHierarchyItem.name}\n`;
text += `${prefix}├ kind: ${callHierarchyItem.kind}\n`;
text += `${prefix}├ file: ${callHierarchyItem.file}\n`;
text += `${prefix}├ span:\n`;
text += this.formatCallHierarchyItemSpan(file, callHierarchyItem.span, `${prefix}`);
text += `${prefix}├ selectionSpan:\n`;
text += this.formatCallHierarchyItemSpan(file, callHierarchyItem.selectionSpan, `${prefix}`,
incomingCalls.result !== "skip" || outgoingCalls.result !== "skip" ? `${prefix}` :
`${trailingPrefix}`);
if (incomingCalls.result === "seen") {
if (outgoingCalls.result === "skip") {
text += `${trailingPrefix}╰ incoming: ...\n`;
}
else {
text += `${prefix}├ incoming: ...\n`;
}
}
else if (incomingCalls.result === "show") {
if (!ts.some(incomingCalls.values)) {
if (outgoingCalls.result === "skip") {
text += `${trailingPrefix}╰ incoming: none\n`;
}
else {
text += `${prefix}├ incoming: none\n`;
}
}
else {
text += `${prefix}├ incoming:\n`;
for (let i = 0; i < incomingCalls.values.length; i++) {
const incomingCall = incomingCalls.values[i];
const file = this.findFile(incomingCall.from.file);
text += `${prefix}│ ╭ from:\n`;
text += this.formatCallHierarchyItem(file, incomingCall.from, CallHierarchyItemDirection.Incoming, seen, `${prefix}│ │ `);
text += `${prefix}│ ├ fromSpans:\n`;
text += this.formatCallHierarchyItemSpans(file, incomingCall.fromSpans, `${prefix}│ │ `,
i < incomingCalls.values.length - 1 ? `${prefix}│ ╰ ` :
outgoingCalls.result !== "skip" ? `${prefix}│ ╰ ` :
`${trailingPrefix}╰ ╰ `);
}
}
}
if (outgoingCalls.result === "seen") {
text += `${trailingPrefix}╰ outgoing: ...\n`;
}
else if (outgoingCalls.result === "show") {
if (!ts.some(outgoingCalls.values)) {
text += `${trailingPrefix}╰ outgoing: none\n`;
}
else {
text += `${prefix}├ outgoing:\n`;
for (let i = 0; i < outgoingCalls.values.length; i++) {
const outgoingCall = outgoingCalls.values[i];
text += `${prefix}│ ╭ to:\n`;
text += this.formatCallHierarchyItem(this.findFile(outgoingCall.to.file), outgoingCall.to, CallHierarchyItemDirection.Outgoing, seen, `${prefix}│ │ `);
text += `${prefix}│ ├ fromSpans:\n`;
text += this.formatCallHierarchyItemSpans(file, outgoingCall.fromSpans, `${prefix}│ │ `,
i < outgoingCalls.values.length - 1 ? `${prefix}│ ╰ ` :
`${trailingPrefix}╰ ╰ `);
}
}
}
return text;
}
private formatCallHierarchy(callHierarchyItem: ts.CallHierarchyItem | undefined) {
let text = "";
if (callHierarchyItem) {
const file = this.findFile(callHierarchyItem.file);
text += this.formatCallHierarchyItem(file, callHierarchyItem, CallHierarchyItemDirection.Root, ts.createMap(), "");
}
return text;
}
public baselineCallHierarchy() {
const baselineFile = this.getBaselineFileNameForContainingTestFile(".callHierarchy.txt");
const callHierarchyItem = this.languageService.prepareCallHierarchy(this.activeFile.fileName, this.currentCaretPosition);
const text = callHierarchyItem ? ts.mapOneOrMany(callHierarchyItem, item => this.formatCallHierarchy(item), result => result.join("")) : "none";
Harness.Baseline.runBaseline(baselineFile, text);
}
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 });
@@ -3243,8 +3444,8 @@ namespace FourSlash {
return this.tryFindFileWorker(name).file !== undefined;
}
private getLineColStringAtPosition(position: number) {
const pos = this.languageServiceAdapterHost.positionToLineAndCharacter(this.activeFile.fileName, position);
private getLineColStringAtPosition(position: number, file: FourSlashFile = this.activeFile) {
const pos = this.languageServiceAdapterHost.positionToLineAndCharacter(file.fileName, position);
return `line ${(pos.line + 1)}, col ${pos.character}`;
}
@@ -3297,6 +3498,14 @@ namespace FourSlash {
}
}
function prefixMessage(message: string | undefined) {
return message ? `${message} - ` : "";
}
function textSpanEqualsRange(span: ts.TextSpan, range: Range) {
return span.start === range.pos && span.length === range.end - range.pos;
}
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);

View File

@@ -541,9 +541,14 @@ namespace FourSlashInterface {
this.state.getEditsForFileRename(options);
}
public baselineCallHierarchy() {
this.state.baselineCallHierarchy();
}
public moveToNewFile(options: MoveToNewFileOptions): void {
this.state.moveToNewFile(options);
}
public noMoveToNewFile(): void {
this.state.noMoveToNewFile();
}

View File

@@ -574,6 +574,15 @@ namespace Harness.LanguageService {
getEditsForFileRename(): readonly ts.FileTextChanges[] {
throw new Error("Not supported on the shim.");
}
prepareCallHierarchy(fileName: string, position: number) {
return unwrapJSONCallResult(this.shim.prepareCallHierarchy(fileName, position));
}
provideCallHierarchyIncomingCalls(fileName: string, position: number) {
return unwrapJSONCallResult(this.shim.provideCallHierarchyIncomingCalls(fileName, position));
}
provideCallHierarchyOutgoingCalls(fileName: string, position: number) {
return unwrapJSONCallResult(this.shim.provideCallHierarchyOutgoingCalls(fileName, position));
}
getEmitOutput(fileName: string): ts.EmitOutput {
return unwrapJSONCallResult(this.shim.getEmitOutput(fileName));
}