mirror of
https://github.com/microsoft/TypeScript.git
synced 2026-05-25 05:29:07 -05:00
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:
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user