Merge pull request #1445 from frizynn/refactor/lsp-deduplication

refactor: consolidate duplicate patterns in LSP module
This commit is contained in:
TÂCHES 2026-03-19 15:40:07 -06:00 committed by GitHub
commit e01536c8a3
4 changed files with 86 additions and 204 deletions

View file

@ -176,7 +176,7 @@ const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [
{ markers: ["go.mod", "go.sum"], binDir: "bin" },
];
function which(command: string): string | null {
export function which(command: string): string | null {
// On Windows, prefer `where.exe` over `which` — MSYS/Git Bash's `which`
// returns POSIX paths (/c/Users/...) that Node's spawn() can't execute.
// `where.exe` returns native Windows paths (C:\Users\...).

View file

@ -14,7 +14,7 @@ import {
setIdleTimeout,
WARMUP_TIMEOUT_MS,
} from "./client.js";
import { getServersForFile, type LspConfig, loadConfig, hasRootMarkers, resolveCommand } from "./config.js";
import { getServerForFile, getServersForFile, type LspConfig, loadConfig, hasRootMarkers, resolveCommand } from "./config.js";
import { applyTextEdits, applyWorkspaceEdit } from "./edits.js";
import { ToolAbortError, clampTimeout, throwIfAborted } from "./helpers.js";
import { detectLspmux } from "./lspmux.js";
@ -144,15 +144,6 @@ function getLspServers(config: LspConfig): Array<[string, ServerConfig]> {
return Object.entries(config.servers) as Array<[string, ServerConfig]>;
}
function getLspServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
return getServersForFile(config, filePath);
}
function getLspServerForFile(config: LspConfig, filePath: string): [string, ServerConfig] | null {
const servers = getLspServersForFile(config, filePath);
return servers.length > 0 ? servers[0] : null;
}
const DIAGNOSTIC_MESSAGE_LIMIT = 50;
const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
@ -197,6 +188,73 @@ async function formatLocationWithContext(location: Location, cwd: string): Promi
return `${header}\n${context.map(lineText => ` ${lineText}`).join("\n")}`;
}
async function formatLocationResults(
result: Location | Location[] | LocationLink | LocationLink[] | null,
label: string,
cwd: string,
): Promise<string> {
const locations = normalizeLocationResult(result);
if (locations.length === 0) {
return `No ${label} found`;
}
const lines = await Promise.all(locations.map(location => formatLocationWithContext(location, cwd)));
return `Found ${locations.length} ${label}(s):\n${lines.join("\n")}`;
}
async function formatCallHierarchyResults(
client: LspClient,
position: { line: number; character: number },
uri: string,
direction: "incoming" | "outgoing",
cwd: string,
signal?: AbortSignal,
): Promise<string> {
const prepareResult = (await sendRequest(
client,
"textDocument/prepareCallHierarchy",
{ textDocument: { uri }, position },
signal,
)) as CallHierarchyItem[] | null;
if (!prepareResult || prepareResult.length === 0) {
return "No call hierarchy item found at this position";
}
const method = direction === "incoming" ? "callHierarchy/incomingCalls" : "callHierarchy/outgoingCalls";
const callResult = (await sendRequest(client, method, { item: prepareResult[0] }, signal)) as
| CallHierarchyIncomingCall[]
| CallHierarchyOutgoingCall[]
| null;
if (!callResult || callResult.length === 0) {
const verb = direction === "incoming" ? "incoming calls" : "outgoing calls";
const prep = direction === "incoming" ? "for" : "from";
return `No ${verb} found ${prep} ${prepareResult[0].name}`;
}
const lines: string[] = [];
const limited = callResult.slice(0, REFERENCE_CONTEXT_LIMIT);
for (const call of limited) {
const item = "from" in call ? call.from : call.to;
const header = formatCallHierarchyItem(item, cwd);
const filePath = uriToFile(item.uri);
const callLine = ("from" in call ? call.fromRanges[0]?.start.line : undefined) ?? item.selectionRange.start.line;
const context = await readLocationContext(filePath, callLine + 1, LOCATION_CONTEXT_LINES);
if (context.length > 0) {
lines.push(` ${header}\n${context.map(l => ` ${l}`).join("\n")}`);
} else {
lines.push(` ${header}`);
}
}
const noun = direction === "incoming" ? "caller" : "callee";
const prep = direction === "incoming" ? "of" : "from";
const truncation = callResult.length > REFERENCE_CONTEXT_LIMIT
? `\n ... ${callResult.length - REFERENCE_CONTEXT_LIMIT} additional ${noun}(s) omitted`
: "";
return `${callResult.length} ${noun}(s) ${prep} ${prepareResult[0].name}:\n${lines.join("\n")}${truncation}`;
}
async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
let output = `Restarted ${serverName}`;
const reloadMethods = ["rust-analyzer/reloadWorkspace", "workspace/didChangeConfiguration"];
@ -647,7 +705,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
}
// File-specific actions
const serverInfo = resolvedFile ? getLspServerForFile(config, resolvedFile) : null;
const serverInfo = resolvedFile ? getServerForFile(config, resolvedFile) : null;
if (!serverInfo) {
return {
content: [{ type: "text", text: "No language server found for this action" }],
@ -676,74 +734,35 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
switch (action) {
case "definition": {
const result = (await sendRequest(
const result = await sendRequest(
client,
"textDocument/definition",
{
textDocument: { uri },
position,
},
{ textDocument: { uri }, position },
signal,
)) as Location | Location[] | LocationLink | LocationLink[] | null;
const locations = normalizeLocationResult(result);
if (locations.length === 0) {
output = "No definition found";
} else {
const lines = await Promise.all(
locations.map(location => formatLocationWithContext(location, cwd)),
);
output = `Found ${locations.length} definition(s):\n${lines.join("\n")}`;
}
);
output = await formatLocationResults(result as Location | Location[] | LocationLink | LocationLink[] | null, "definition", cwd);
break;
}
case "type_definition": {
const result = (await sendRequest(
const result = await sendRequest(
client,
"textDocument/typeDefinition",
{
textDocument: { uri },
position,
},
{ textDocument: { uri }, position },
signal,
)) as Location | Location[] | LocationLink | LocationLink[] | null;
const locations = normalizeLocationResult(result);
if (locations.length === 0) {
output = "No type definition found";
} else {
const lines = await Promise.all(
locations.map(location => formatLocationWithContext(location, cwd)),
);
output = `Found ${locations.length} type definition(s):\n${lines.join("\n")}`;
}
);
output = await formatLocationResults(result as Location | Location[] | LocationLink | LocationLink[] | null, "type definition", cwd);
break;
}
case "implementation": {
const result = (await sendRequest(
const result = await sendRequest(
client,
"textDocument/implementation",
{
textDocument: { uri },
position,
},
{ textDocument: { uri }, position },
signal,
)) as Location | Location[] | LocationLink | LocationLink[] | null;
const locations = normalizeLocationResult(result);
if (locations.length === 0) {
output = "No implementation found";
} else {
const lines = await Promise.all(
locations.map(location => formatLocationWithContext(location, cwd)),
);
output = `Found ${locations.length} implementation(s):\n${lines.join("\n")}`;
}
);
output = await formatLocationResults(result as Location | Location[] | LocationLink | LocationLink[] | null, "implementation", cwd);
break;
}
@ -917,100 +936,12 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
}
case "incoming_calls": {
const prepareResult = (await sendRequest(
client,
"textDocument/prepareCallHierarchy",
{
textDocument: { uri },
position,
},
signal,
)) as CallHierarchyItem[] | null;
if (!prepareResult || prepareResult.length === 0) {
output = "No call hierarchy item found at this position";
break;
}
const incomingResult = (await sendRequest(
client,
"callHierarchy/incomingCalls",
{ item: prepareResult[0] },
signal,
)) as CallHierarchyIncomingCall[] | null;
if (!incomingResult || incomingResult.length === 0) {
output = `No incoming calls found for ${prepareResult[0].name}`;
break;
}
const incomingLines: string[] = [];
const limitedIncoming = incomingResult.slice(0, REFERENCE_CONTEXT_LIMIT);
for (const call of limitedIncoming) {
const header = formatCallHierarchyItem(call.from, cwd);
const filePath = uriToFile(call.from.uri);
const callLine = call.fromRanges[0]?.start.line ?? call.from.selectionRange.start.line;
const context = await readLocationContext(filePath, callLine + 1, LOCATION_CONTEXT_LINES);
if (context.length > 0) {
incomingLines.push(` ${header}\n${context.map(l => ` ${l}`).join("\n")}`);
} else {
incomingLines.push(` ${header}`);
}
}
const truncation = incomingResult.length > REFERENCE_CONTEXT_LIMIT
? `\n ... ${incomingResult.length - REFERENCE_CONTEXT_LIMIT} additional caller(s) omitted`
: "";
output = `${incomingResult.length} caller(s) of ${prepareResult[0].name}:\n${incomingLines.join("\n")}${truncation}`;
output = await formatCallHierarchyResults(client, position, uri, "incoming", cwd, signal);
break;
}
case "outgoing_calls": {
const prepareResult = (await sendRequest(
client,
"textDocument/prepareCallHierarchy",
{
textDocument: { uri },
position,
},
signal,
)) as CallHierarchyItem[] | null;
if (!prepareResult || prepareResult.length === 0) {
output = "No call hierarchy item found at this position";
break;
}
const outgoingResult = (await sendRequest(
client,
"callHierarchy/outgoingCalls",
{ item: prepareResult[0] },
signal,
)) as CallHierarchyOutgoingCall[] | null;
if (!outgoingResult || outgoingResult.length === 0) {
output = `No outgoing calls found from ${prepareResult[0].name}`;
break;
}
const outgoingLines: string[] = [];
const limitedOutgoing = outgoingResult.slice(0, REFERENCE_CONTEXT_LIMIT);
for (const call of limitedOutgoing) {
const header = formatCallHierarchyItem(call.to, cwd);
const filePath = uriToFile(call.to.uri);
const callLine = call.to.selectionRange.start.line;
const context = await readLocationContext(filePath, callLine + 1, LOCATION_CONTEXT_LINES);
if (context.length > 0) {
outgoingLines.push(` ${header}\n${context.map(l => ` ${l}`).join("\n")}`);
} else {
outgoingLines.push(` ${header}`);
}
}
const outTruncation = outgoingResult.length > REFERENCE_CONTEXT_LIMIT
? `\n ... ${outgoingResult.length - REFERENCE_CONTEXT_LIMIT} additional callee(s) omitted`
: "";
output = `${outgoingResult.length} callee(s) from ${prepareResult[0].name}:\n${outgoingLines.join("\n")}${outTruncation}`;
output = await formatCallHierarchyResults(client, position, uri, "outgoing", cwd, signal);
break;
}

View file

@ -1,8 +1,9 @@
import { execSync, spawn } from "node:child_process";
import { spawn } from "node:child_process";
import * as fsPromises from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { LSP_LIVENESS_TIMEOUT_MS, LSP_STATE_CACHE_TTL_MS } from "../constants.js";
import { which } from "./config.js";
/**
* lspmux integration for LSP server multiplexing.
@ -43,27 +44,6 @@ const DEFAULT_SUPPORTED_SERVERS = new Set([
]);
// =============================================================================
// Helpers
// =============================================================================
function which(command: string): string | null {
try {
// On Windows, prefer `where.exe` over `which` — MSYS/Git Bash's `which`
// returns POSIX paths (/c/Users/...) that Node's spawn() can't execute (#1121).
const isWindows = process.platform === "win32";
const cmd = isWindows ? "where.exe" : "which";
const result = isWindows
? execSync(`${cmd} ${command}`, { encoding: "utf-8" })
: execSync(`which ${command}`, { encoding: "utf-8" });
// `where.exe` may return multiple lines — take the first
const resolved = result.trim().split(/\r?\n/)[0]?.trim();
return resolved || null;
} catch {
return null;
}
}
// =============================================================================
// Config Path
// =============================================================================

View file

@ -256,35 +256,6 @@ export type SymbolKind =
| 25 // Operator
| 26; // TypeParameter
export const SYMBOL_KIND_NAMES: Record<SymbolKind, string> = {
1: "File",
2: "Module",
3: "Namespace",
4: "Package",
5: "Class",
6: "Method",
7: "Property",
8: "Field",
9: "Constructor",
10: "Enum",
11: "Interface",
12: "Function",
13: "Variable",
14: "Constant",
15: "String",
16: "Number",
17: "Boolean",
18: "Array",
19: "Object",
20: "Key",
21: "Null",
22: "EnumMember",
23: "Struct",
24: "Event",
25: "Operator",
26: "TypeParameter",
};
export interface DocumentSymbol {
name: string;
detail?: string;