diff --git a/packages/pi-coding-agent/src/core/lsp/config.ts b/packages/pi-coding-agent/src/core/lsp/config.ts index 97c40f990..39c2374df 100644 --- a/packages/pi-coding-agent/src/core/lsp/config.ts +++ b/packages/pi-coding-agent/src/core/lsp/config.ts @@ -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\...). diff --git a/packages/pi-coding-agent/src/core/lsp/index.ts b/packages/pi-coding-agent/src/core/lsp/index.ts index 9bcfa66f7..61237e7eb 100644 --- a/packages/pi-coding-agent/src/core/lsp/index.ts +++ b/packages/pi-coding-agent/src/core/lsp/index.ts @@ -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 { + 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 { + 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 { let output = `Restarted ${serverName}`; const reloadMethods = ["rust-analyzer/reloadWorkspace", "workspace/didChangeConfiguration"]; @@ -647,7 +705,7 @@ export function createLspTool(cwd: string): AgentTool 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 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; } diff --git a/packages/pi-coding-agent/src/core/lsp/lspmux.ts b/packages/pi-coding-agent/src/core/lsp/lspmux.ts index 952c9a06c..05ef13b38 100644 --- a/packages/pi-coding-agent/src/core/lsp/lspmux.ts +++ b/packages/pi-coding-agent/src/core/lsp/lspmux.ts @@ -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 // ============================================================================= diff --git a/packages/pi-coding-agent/src/core/lsp/types.ts b/packages/pi-coding-agent/src/core/lsp/types.ts index 2187edb49..c2da12065 100644 --- a/packages/pi-coding-agent/src/core/lsp/types.ts +++ b/packages/pi-coding-agent/src/core/lsp/types.ts @@ -256,35 +256,6 @@ export type SymbolKind = | 25 // Operator | 26; // TypeParameter -export const SYMBOL_KIND_NAMES: Record = { - 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;