import { access, readdir, readFile, writeFile } from "node:fs/promises"; import { execFile } from "node:child_process"; import { join } from "node:path"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "vscode-jsonrpc/node.js"; import { isInsideWorkspace, isRubyPath, languageIdForPath, pathToUri, uriToPath } from "./paths.js"; import type { CategorizedRubyLspError, DoctorResult, LspCodeAction, LspDiagnostic, LspTextEdit, LspWorkspaceEdit, RubyRootInfo } from "./types.js"; import { categorizeRubyLspError, commandPlanForRoot, discoverRubyRoots, type CommandPlan } from "./workspace.js"; const DEFAULT_REQUEST_TIMEOUT_MS = 8000; const DEFAULT_INITIALIZE_TIMEOUT_MS = Number(process.env.PI_RUBY_LSP_INITIALIZE_TIMEOUT_MS ?? 30000); const DIAGNOSTIC_SETTLE_MS = 500; export type RubyLspMessageConnection = { sendRequest(method: string, params?: unknown): Promise; sendNotification(method: string, params?: unknown): void; onNotification(method: string, handler: (params: any) => void): unknown; listen(): void; dispose(): void; }; type RubyLspClientDeps = { spawnProcess?: typeof spawn; createConnection?: (child: ChildProcessWithoutNullStreams) => RubyLspMessageConnection; probeEnvironment?: (cwd: string) => Promise; requestTimeoutMs?: number; initializeTimeoutMs?: number; diagnosticSettleMs?: number; }; type OpenDocument = { version: number; text: string; }; function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function withTimeout(promise: Promise, ms: number, label: string): Promise { let timeout: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_resolve, reject) => { timeout = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); }); return Promise.race([promise, timeoutPromise]).finally(() => { if (timeout) clearTimeout(timeout); }); } async function fileExists(path: string): Promise { try { await access(path); return true; } catch { return false; } } async function hasRubyFiles(cwd: string): Promise { const ignored = new Set([".git", "node_modules", "vendor", "tmp", "log", "coverage"]); const queue = [cwd]; let scanned = 0; while (queue.length > 0 && scanned < 500) { const dir = queue.shift()!; scanned++; let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>; try { entries = await readdir(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (ignored.has(entry.name)) continue; const path = join(dir, entry.name); if (entry.isDirectory()) queue.push(path); if (entry.isFile() && (/\.(rb|rake|gemspec)$/.test(entry.name) || entry.name === "Gemfile" || entry.name === "Rakefile")) { return true; } } } return false; } async function rubyLspProject(cwd: string, rootInfo?: RubyRootInfo): Promise { const hasGemfile = await fileExists(join(cwd, "Gemfile")); const rubyFilesFound = await hasRubyFiles(cwd); const rails = rootInfo?.rails ?? { detected: false, markers: [] }; return { hasGemfile, hasRubyFiles: rubyFilesFound, kind: rootInfo?.kind, rails, note: !hasGemfile && !rubyFilesFound ? "No Gemfile or Ruby files found. Ruby LSP can still start, but it may compose a temporary bundle and print Bundler setup noise." : undefined, }; } function formatCommand(command: string[]): string { return command.join(" "); } async function probe(command: string, args: string[], cwd: string): Promise { return new Promise((resolve) => { execFile(command, args, { cwd, timeout: 2000, maxBuffer: 128 * 1024 }, (error, stdout, stderr) => { if (error) return resolve(undefined); resolve((stdout || stderr).trim().split(/\r?\n/)[0]); }); }); } function offsetAt(text: string, line: number, character: number): number { let currentLine = 0; let offset = 0; while (currentLine < line && offset < text.length) { const nextNewline = text.indexOf("\n", offset); if (nextNewline === -1) return text.length; offset = nextNewline + 1; currentLine++; } return Math.min(offset + character, text.length); } function applyTextEdits(text: string, edits: LspTextEdit[]): string { const sorted = [...edits].sort((a, b) => offsetAt(text, b.range.start.line, b.range.start.character) - offsetAt(text, a.range.start.line, a.range.start.character)); let next = text; for (const edit of sorted) { const start = offsetAt(next, edit.range.start.line, edit.range.start.character); const end = offsetAt(next, edit.range.end.line, edit.range.end.character); next = `${next.slice(0, start)}${edit.newText}${next.slice(end)}`; } return next; } function nullableArrayResponse(value: unknown, label: string): T[] { if (value === null || value === undefined) return []; if (!Array.isArray(value)) throw new Error(`Malformed Ruby LSP ${label} response: expected an array or null`); return value as T[]; } type WorkspaceEditSummary = { fileCount: number; editCount: number; files: Array<{ path: string; edits: number }>; }; export class RubyLspClient { private child?: ChildProcessWithoutNullStreams; private connection?: RubyLspMessageConnection; private command: string[] = []; private initialized = false; private project: DoctorResult["project"] = { hasGemfile: false, hasRubyFiles: false, rails: { detected: false, markers: [] } }; private roots: RubyRootInfo[] = []; private commandPlan: CommandPlan | undefined; private environment: DoctorResult["environment"] | undefined; private errorInfo: CategorizedRubyLspError | undefined; private serverCapabilities: Record = {}; private lastError: string | undefined; private stderrMessages: string[] = []; private diagnosticsByUri = new Map(); private openDocuments = new Map(); constructor(private readonly cwd: string, private readonly debug = false, private readonly deps: RubyLspClientDeps = {}) {} private async probeEnvironment(): Promise { if (this.deps.probeEnvironment) return this.deps.probeEnvironment(this.cwd); const rubyVersion = await probe("ruby", ["--version"], this.cwd); const bundlerVersion = await probe("bundle", ["--version"], this.cwd); const rubyLspVersion = await probe("ruby-lsp", ["--version"], this.cwd); return { rubyVersion, bundlerVersion, rubyLspVersion }; } async start(): Promise { if (this.initialized && this.isProcessRunning()) return; if (this.initialized || this.child || this.connection) await this.disposeProcess(); const discovery = await discoverRubyRoots(this.cwd); this.roots = discovery.candidates.length ? discovery.candidates : [discovery.activeRoot]; this.project = await rubyLspProject(this.cwd, discovery.activeRoot); this.commandPlan = await commandPlanForRoot(this.cwd); this.environment = await this.probeEnvironment(); this.errorInfo = undefined; const failures: string[] = []; let fallbackUsed = false; for (const command of this.commandPlan.candidates) { try { await this.startWithCommand(command); return; } catch (error) { const stderr = this.stderrMessages.join("\n"); failures.push(`${formatCommand(command)}: ${error instanceof Error ? error.message : String(error)}`); this.errorInfo = categorizeRubyLspError({ error, root: this.cwd, command, cwd: this.cwd, stderr, fallbackUsed }); fallbackUsed = true; await this.disposeProcess(); } } this.lastError = `Ruby LSP unavailable. Tried ${failures.join(" | ")}`; this.errorInfo = categorizeRubyLspError({ error: this.lastError, root: this.cwd, command: this.command, cwd: this.cwd, stderr: this.stderrMessages.join("\n"), fallbackUsed }); throw new Error(`${this.errorInfo.message} ${this.errorInfo.suggestions.join(" ")}`); } private isProcessRunning(): boolean { return Boolean( this.child && !this.child.killed && this.child.exitCode === null && this.child.signalCode === null, ); } private async startWithCommand(commandWithArgs: string[]): Promise { this.command = commandWithArgs; this.lastError = undefined; this.errorInfo = undefined; this.stderrMessages = []; this.openDocuments.clear(); this.diagnosticsByUri.clear(); this.serverCapabilities = {}; const [command, ...args] = commandWithArgs; this.child = (this.deps.spawnProcess ?? spawn)(command, args, { cwd: this.cwd, stdio: "pipe", env: { ...process.env }, }); this.child.on("error", (error) => { this.lastError = error.message; }); this.child.on("exit", (code, signal) => { this.initialized = false; this.openDocuments.clear(); this.diagnosticsByUri.clear(); this.serverCapabilities = {}; this.connection?.dispose(); this.connection = undefined; if (code !== null && code !== 0) { this.lastError = `Ruby LSP exited with code ${code}`; } else if (signal && signal !== "SIGTERM") { this.lastError = `Ruby LSP exited from signal ${signal}`; } }); this.child.stderr.on("data", (chunk) => { const text = String(chunk).trim(); if (!text) return; this.stderrMessages.push(text); this.stderrMessages = this.stderrMessages.slice(-20); }); this.connection = this.deps.createConnection ? this.deps.createConnection(this.child) : createMessageConnection( new StreamMessageReader(this.child.stdout), new StreamMessageWriter(this.child.stdin), ); this.connection.onNotification("textDocument/publishDiagnostics", (params: { uri: string; diagnostics: LspDiagnostic[]; }) => { if (this.debug) console.debug(`[pi-ruby-lsp] diagnostics for ${params.uri}: ${params.diagnostics?.length ?? 0} items`); this.diagnosticsByUri.set(params.uri, params.diagnostics ?? []); }); this.connection.listen(); const initializeRequest = this.connection.sendRequest("initialize", { processId: process.pid, clientInfo: { name: "pi-ruby-lsp", version: "1.0.0" }, rootUri: pathToUri(this.cwd), workspaceFolders: [{ uri: pathToUri(this.cwd), name: "workspace" }], capabilities: { textDocument: { synchronization: { didSave: true, dynamicRegistration: false, }, hover: { dynamicRegistration: false }, definition: { dynamicRegistration: false }, references: { dynamicRegistration: false }, rename: { dynamicRegistration: false, prepareSupport: true }, formatting: { dynamicRegistration: false }, codeAction: { dynamicRegistration: false, codeActionLiteralSupport: { codeActionKind: { valueSet: ["", "quickfix", "refactor", "refactor.extract", "refactor.inline", "refactor.rewrite", "source", "source.organizeImports", "source.fixAll"] }, }, }, documentSymbol: { dynamicRegistration: false, hierarchicalDocumentSymbolSupport: true, }, publishDiagnostics: { relatedInformation: true, }, }, workspace: { workspaceFolders: true, symbol: { dynamicRegistration: false }, }, }, }); const exitDuringInitialize = new Promise((_resolve, reject) => { const onExit = (code: number | null, signal: NodeJS.Signals | null) => { const stderr = this.stderrMessages.length ? `\nstderr:\n${this.stderrMessages.join("\n")}` : ""; reject(new Error(`exited during initialize (code ${code ?? "null"}, signal ${signal ?? "null"})${stderr}`)); }; const onError = (error: Error) => { reject(error); }; this.child?.once("exit", onExit); this.child?.once("error", onError); initializeRequest.finally(() => { this.child?.off("exit", onExit); this.child?.off("error", onError); }).catch(() => { // The race below reports the initialization failure. }); }); try { const initializeResult = await withTimeout( Promise.race([initializeRequest, exitDuringInitialize]), this.deps.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS, "Ruby LSP initialize", ) as { capabilities?: Record } | undefined; this.serverCapabilities = initializeResult?.capabilities ?? {}; } catch (error) { const message = error instanceof Error ? error.message : String(error); const stderr = this.stderrMessages.length ? `\nstderr:\n${this.stderrMessages.join("\n")}` : ""; throw new Error(`${message}${stderr}`); } this.connection.sendNotification("initialized", {}); this.initialized = true; } private async disposeProcess(): Promise { const connection = this.connection; this.connection = undefined; this.initialized = false; this.openDocuments.clear(); this.diagnosticsByUri.clear(); this.serverCapabilities = {}; if (connection) { connection.dispose(); } if (this.child && !this.child.killed) { this.child.kill("SIGTERM"); } this.child = undefined; } async stop(): Promise { const connection = this.connection; this.connection = undefined; this.initialized = false; this.openDocuments.clear(); this.diagnosticsByUri.clear(); this.serverCapabilities = {}; if (connection) { try { await withTimeout(connection.sendRequest("shutdown"), 2000, "Ruby LSP shutdown"); connection.sendNotification("exit"); } catch { // Fall through to process termination. } connection.dispose(); } if (this.child && !this.child.killed) { this.child.kill("SIGTERM"); } this.child = undefined; } doctor(): DoctorResult { const running = this.isProcessRunning(); const status = this.lastError ? (running ? "error" : "unavailable") : this.initialized && running ? "ready" : "stopped"; return { cwd: this.cwd, root: this.cwd, roots: this.roots, command: this.command.length ? this.command : ["not started"], commandPlan: this.commandPlan, environment: this.environment, status, running, initialized: this.initialized, project: this.project, capabilities: this.capabilities(), error: this.lastError, errorInfo: this.errorInfo, stderr: this.stderrMessages.length ? [...this.stderrMessages] : undefined, recommendations: this.errorInfo?.suggestions, }; } capabilities(): DoctorResult["capabilities"] { const renameProvider = this.serverCapabilities.renameProvider; return { documentFormattingProvider: Boolean(this.serverCapabilities.documentFormattingProvider), renameProvider: Boolean(renameProvider), prepareProvider: typeof renameProvider === "object" && renameProvider !== null && Boolean((renameProvider as { prepareProvider?: unknown }).prepareProvider), codeActionProvider: Boolean(this.serverCapabilities.codeActionProvider), }; } getProject(): DoctorResult["project"] { return this.project; } async openDocument(path: string, text?: string): Promise { await this.start(); if (!this.connection || !this.isProcessRunning()) throw new Error("Ruby LSP connection is not available"); const uri = pathToUri(path); if (this.debug) console.debug(`[pi-ruby-lsp] opening ${path}`); const nextText = text ?? await readFile(path, "utf8"); const existing = this.openDocuments.get(uri); if (!existing) { this.openDocuments.set(uri, { version: 1, text: nextText }); this.connection.sendNotification("textDocument/didOpen", { textDocument: { uri, languageId: languageIdForPath(path), version: 1, text: nextText, }, }); return; } if (existing.text === nextText) return; const version = existing.version + 1; this.openDocuments.set(uri, { version, text: nextText }); this.connection.sendNotification("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text: nextText }], }); } async diagnostics(path: string): Promise { await this.openDocument(path); const uri = pathToUri(path); // Ruby LSP publishes diagnostics asynchronously. Give it a short window. await delay(this.deps.diagnosticSettleMs ?? DIAGNOSTIC_SETTLE_MS); const diagnostics = this.diagnosticsByUri.get(uri) ?? []; if (this.debug) console.debug(`[pi-ruby-lsp] diagnostics for ${path}: ${diagnostics.length} items`); return diagnostics; } async documentSymbols(path: string): Promise { await this.openDocument(path); if (!this.connection) throw new Error("Ruby LSP connection is not available"); return withTimeout( this.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri: pathToUri(path) }, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP documentSymbol", ); } async definition(path: string, line: number, character: number): Promise { await this.openDocument(path); if (!this.connection) throw new Error("Ruby LSP connection is not available"); return withTimeout( this.connection.sendRequest("textDocument/definition", { textDocument: { uri: pathToUri(path) }, position: { line, character }, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP definition", ); } async hover(path: string, line: number, character: number): Promise { await this.openDocument(path); if (!this.connection) throw new Error("Ruby LSP connection is not available"); return withTimeout( this.connection.sendRequest("textDocument/hover", { textDocument: { uri: pathToUri(path) }, position: { line, character }, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP hover", ); } async references(path: string, line: number, character: number, includeDeclaration = true): Promise { await this.openDocument(path); if (!this.connection) throw new Error("Ruby LSP connection is not available"); return withTimeout( this.connection.sendRequest("textDocument/references", { textDocument: { uri: pathToUri(path) }, position: { line, character }, context: { includeDeclaration }, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP references", ); } async format(path: string, apply = false): Promise<{ path: string; edits: LspTextEdit[]; applied: boolean; summary: string }> { await this.openDocument(path); if (!this.connection) throw new Error("Ruby LSP connection is not available"); if (!this.capabilities()?.documentFormattingProvider) throw new Error("Ruby LSP server does not advertise document formatting support"); const edits = (await withTimeout( this.connection.sendRequest("textDocument/formatting", { textDocument: { uri: pathToUri(path) }, options: { tabSize: 2, insertSpaces: true }, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP formatting", ) as LspTextEdit[] | null) ?? []; if (apply && edits.length > 0) await this.applyTextEditsToFile(path, edits); return { path, edits, applied: apply && edits.length > 0, summary: edits.length === 0 ? "No formatting changes" : `${apply ? "Applied" : "Preview"} ${edits.length} formatting edit(s)`, }; } async rename(path: string, line: number, character: number, newName: string, apply = false): Promise<{ path: string; newName: string; edit: LspWorkspaceEdit | null; summary: WorkspaceEditSummary; applied: boolean }> { await this.openDocument(path); if (!this.connection) throw new Error("Ruby LSP connection is not available"); if (!this.capabilities()?.renameProvider) throw new Error("Ruby LSP server does not advertise rename support"); if (this.capabilities()?.prepareProvider) { await withTimeout( this.connection.sendRequest("textDocument/prepareRename", { textDocument: { uri: pathToUri(path) }, position: { line, character }, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP prepareRename", ); } const edit = await withTimeout( this.connection.sendRequest("textDocument/rename", { textDocument: { uri: pathToUri(path) }, position: { line, character }, newName, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP rename", ) as LspWorkspaceEdit | null; const summary = this.summarizeWorkspaceEdit(edit); if (apply && edit) await this.applyWorkspaceEdit(edit); return { path, newName, edit, summary, applied: apply && summary.editCount > 0 }; } async codeActions(path: string, range: { startLine?: number; startCharacter?: number; endLine?: number; endCharacter?: number }, kind?: string): Promise { await this.openDocument(path); if (!this.connection) throw new Error("Ruby LSP connection is not available"); if (!this.capabilities()?.codeActionProvider) throw new Error("Ruby LSP server does not advertise code action support"); const startLine = range.startLine ?? 0; const startCharacter = range.startCharacter ?? 0; const endLine = range.endLine ?? startLine; const endCharacter = range.endCharacter ?? startCharacter; const response = await withTimeout( this.connection.sendRequest("textDocument/codeAction", { textDocument: { uri: pathToUri(path) }, range: { start: { line: startLine, character: startCharacter }, end: { line: endLine, character: endCharacter } }, context: { diagnostics: [], only: kind ? [kind] : undefined }, }), this.deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, "Ruby LSP codeAction", ); return nullableArrayResponse(response, "codeAction"); } async applyCodeAction(action: LspCodeAction): Promise<{ summary: WorkspaceEditSummary; applied: boolean; unsupportedCommand?: unknown }> { if (!action.edit) { return { summary: this.summarizeWorkspaceEdit(null), applied: false, unsupportedCommand: action.command ?? "Action has no workspace edit" }; } const summary = this.summarizeWorkspaceEdit(action.edit); await this.applyWorkspaceEdit(action.edit); return { summary, applied: summary.editCount > 0, unsupportedCommand: action.command }; } private summarizeWorkspaceEdit(edit: LspWorkspaceEdit | null | undefined): WorkspaceEditSummary { const grouped = this.workspaceEditEntries(edit); return { fileCount: grouped.size, editCount: [...grouped.values()].reduce((count, edits) => count + edits.length, 0), files: [...grouped.entries()].map(([path, edits]) => ({ path, edits: edits.length })), }; } private workspaceEditEntries(edit: LspWorkspaceEdit | null | undefined): Map { const grouped = new Map(); if (!edit) return grouped; for (const [uri, edits] of Object.entries(edit.changes ?? {})) { grouped.set(uriToPath(uri), [...(grouped.get(uriToPath(uri)) ?? []), ...edits]); } for (const change of edit.documentChanges ?? []) { if ("kind" in change) throw new Error(`Unsupported workspace edit document change: ${change.kind}`); const uri = change.textDocument?.uri; if (!uri) continue; const filePath = uriToPath(uri); grouped.set(filePath, [...(grouped.get(filePath) ?? []), ...change.edits]); } return grouped; } private async applyWorkspaceEdit(edit: LspWorkspaceEdit): Promise { const grouped = this.workspaceEditEntries(edit); const prepared: Array<{ path: string; text: string }> = []; for (const [filePath, edits] of grouped.entries()) { if (!isInsideWorkspace(this.cwd, filePath)) throw new Error(`Refusing to edit outside workspace: ${filePath}`); const original = await readFile(filePath, "utf8"); const next = applyTextEdits(original, edits); if (next !== original) prepared.push({ path: filePath, text: next }); } const changed: string[] = []; try { for (const file of prepared) { await writeFile(file.path, file.text, "utf8"); changed.push(file.path); await this.openDocument(file.path, file.text); } } catch (error) { throw new Error(`Workspace edit partially applied. Changed files: ${changed.join(", ") || "none"}. Failed: ${error instanceof Error ? error.message : String(error)}`); } for (const file of prepared) { if (isRubyPath(file.path)) void this.diagnostics(file.path).catch(() => undefined); } } private async applyTextEditsToFile(path: string, edits: LspTextEdit[]): Promise { if (!isInsideWorkspace(this.cwd, path)) throw new Error(`Refusing to edit outside workspace: ${path}`); const original = await readFile(path, "utf8"); const next = applyTextEdits(original, edits); if (next === original) return; await writeFile(path, next, "utf8"); await this.openDocument(path, next); if (isRubyPath(path)) void this.diagnostics(path).catch(() => undefined); } }