import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; import { RubyLspClient } from "./lsp-client.js"; import { isRubyPath, normalizeToolPath } from "./paths.js"; import { createRubyLspOperations, type CodeActionsParams } from "./operations.js"; import { runRailsRoutes } from "./rails.js"; import { categorizeRubyLspError, commandPlanForRoot, discoverRubyRoots } from "./workspace.js"; type MinimalContext = Pick; type PathToolParams = { path: string; }; type PositionToolParams = { path: string; line: number; character: number; }; type ApplyableToolParams = { apply?: boolean }; function asJson(value: unknown): string { return JSON.stringify(value, null, 2); } function diagnosticSummary(diagnostics: Array<{ severity?: number }>): string { const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 1).length; const warnings = diagnostics.filter((diagnostic) => diagnostic.severity === 2).length; const info = diagnostics.filter((diagnostic) => diagnostic.severity === 3).length; const hints = diagnostics.filter((diagnostic) => diagnostic.severity === 4).length; return `${errors} errors, ${warnings} warnings, ${info} info, ${hints} hints`; } function userFacingError(error: unknown): string { if (error instanceof Error) return error.message; return String(error); } export default function rubyLspExtension(pi: ExtensionAPI) { const clients = new Map(); async function resolveRoot(ctx: MinimalContext, inputPath?: string): Promise { const targetPath = inputPath ? normalizeToolPath(ctx.cwd, inputPath) : undefined; const discovery = await discoverRubyRoots(ctx.cwd, targetPath); return discovery.activeRoot.root; } async function resolveRailsRoot(ctx: MinimalContext): Promise { const discovery = await discoverRubyRoots(ctx.cwd); if (discovery.activeRoot.rails.detected) return discovery.activeRoot.root; return discovery.candidates.find((candidate) => candidate.rails.detected)?.root ?? discovery.activeRoot.root; } async function getClient(ctx: MinimalContext, inputPath?: string): Promise { const root = await resolveRoot(ctx, inputPath); let client = clients.get(root); if (!client) { client = new RubyLspClient(root, process.env.DEBUG_RUBY_LSP === "1"); clients.set(root, client); try { await client.start(); if (ctx.hasUI) ctx.ui.setStatus("ruby-lsp", `Ruby LSP: ready (${root === ctx.cwd ? "." : root})`); } catch (error) { if (ctx.hasUI) ctx.ui.setStatus("ruby-lsp", "Ruby LSP: unavailable"); clients.delete(root); throw error; } } return client; } async function stopClient(root?: string) { if (root) { await clients.get(root)?.stop(); clients.delete(root); return; } await Promise.all([...clients.values()].map((client) => client.stop())); clients.clear(); } async function doctorFallback(ctx: MinimalContext, error: unknown) { const discovery = await discoverRubyRoots(ctx.cwd); const plan = await commandPlanForRoot(discovery.activeRoot.root); const errorInfo = categorizeRubyLspError({ error, root: discovery.activeRoot.root, command: plan.command, cwd: plan.cwd }); return { cwd: ctx.cwd, root: discovery.activeRoot.root, roots: discovery.candidates.length ? discovery.candidates : [discovery.activeRoot], command: plan.command, commandPlan: plan, status: "unavailable" as const, running: false, initialized: false, project: { hasGemfile: discovery.activeRoot.markers.includes("Gemfile"), hasRubyFiles: discovery.activeRoot.markers.length > 0, kind: discovery.activeRoot.kind, rails: discovery.activeRoot.rails, }, error: userFacingError(error), errorInfo, recommendations: errorInfo.suggestions, }; } async function runDiagnostics(ctx: MinimalContext, inputPath: string) { const path = normalizeToolPath(ctx.cwd, inputPath); const c = await getClient(ctx, inputPath); const diagnostics = await c.diagnostics(path); if (ctx.hasUI) ctx.ui.setStatus("ruby-lsp", `Ruby LSP: ${diagnosticSummary(diagnostics)}`); return { path, diagnostics }; } const operations = createRubyLspOperations({ getClient, stopClient, doctorFallback, runDiagnostics, resolveRailsRoot, runRailsRoutes, }); pi.on("session_start", async (_event, ctx) => { if (ctx.hasUI) ctx.ui.setStatus("ruby-lsp", "Ruby LSP: idle"); }); pi.on("session_shutdown", async () => { await stopClient(); }); pi.registerCommand("ruby-lsp", { description: "Ruby LSP commands: doctor, restart, stop, diagnostics , format [--apply] , rename [--apply] , code-actions [--apply ] [line] [character], rails routes [filter]", handler: async (args, ctx) => { const [command, ...rest] = args.trim().split(/\s+/).filter(Boolean); if (!command || command === "doctor") { const result = await operations.doctor(ctx); const unavailable = (result as { status?: string }).status === "unavailable"; if (unavailable) ctx.ui.setStatus("ruby-lsp", "Ruby LSP: unavailable"); ctx.ui.notify(asJson(result), unavailable ? "error" : "info"); return; } if (command === "restart") { try { await operations.restart(ctx); ctx.ui.notify("Ruby LSP restarted", "info"); } catch (error) { ctx.ui.notify(`Ruby LSP restart failed: ${userFacingError(error)}`, "error"); } return; } if (command === "stop") { await operations.stop(); ctx.ui.setStatus("ruby-lsp", "Ruby LSP: stopped"); ctx.ui.notify("Ruby LSP stopped", "info"); return; } if (command === "diagnostics") { const file = rest.join(" "); if (!file) { ctx.ui.notify("Usage: /ruby-lsp diagnostics ", "warning"); return; } try { const result = await operations.diagnostics(ctx, file); ctx.ui.notify(asJson(result), "info"); } catch (error) { ctx.ui.notify(`Ruby LSP diagnostics failed: ${userFacingError(error)}`, "error"); } return; } if (command === "format") { const apply = rest[0] === "--apply"; const file = (apply ? rest.slice(1) : rest).join(" "); if (!file) return ctx.ui.notify("Usage: /ruby-lsp format [--apply] ", "warning"); try { const result = await operations.format(ctx, { path: file, apply }); ctx.ui.notify(asJson(result), "info"); } catch (error) { ctx.ui.notify(`Ruby LSP format failed: ${userFacingError(error)}`, "error"); } return; } if (command === "rename") { const apply = rest[0] === "--apply"; const args = apply ? rest.slice(1) : rest; if (args.length < 4) return ctx.ui.notify("Usage: /ruby-lsp rename [--apply] ", "warning"); const [file, line, character, ...newNameParts] = args; try { const result = await operations.rename(ctx, { path: file, line: Number(line), character: Number(character), newName: newNameParts.join(" "), apply }); ctx.ui.notify(asJson(result), "info"); } catch (error) { ctx.ui.notify(`Ruby LSP rename failed: ${userFacingError(error)}`, "error"); } return; } if (command === "code-actions") { let apply = false; let actionIndex: number | undefined; const args = [...rest]; if (args[0] === "--apply") { apply = true; actionIndex = Number(args[1]); args.splice(0, 2); } const [file, line, character] = args; if (!file) return ctx.ui.notify("Usage: /ruby-lsp code-actions [--apply ] [line] [character]", "warning"); try { const result = await operations.codeActions(ctx, { path: file, startLine: line === undefined ? undefined : Number(line), startCharacter: character === undefined ? undefined : Number(character), apply, actionIndex }); ctx.ui.notify(asJson({ ...result, actions: result.actions.map((action) => ({ index: action.index, title: action.title, kind: action.kind, applyable: action.applyable, disabled: action.disabled })) }), "info"); } catch (error) { ctx.ui.notify(`Ruby LSP code actions failed: ${userFacingError(error)}`, "error"); } return; } if (command === "rails" && rest[0] === "routes") { try { ctx.ui.notify(asJson(await operations.railsRoutes(ctx, rest.slice(1).join(" ") || undefined)), "info"); } catch (error) { ctx.ui.notify(`Ruby LSP Rails routes failed: ${userFacingError(error)}`, "error"); } return; } ctx.ui.notify("Usage: /ruby-lsp doctor|restart|stop|diagnostics |format [--apply] |rename [--apply] |code-actions [--apply ] [line] [character]|rails routes [filter]", "warning"); }, }); pi.registerTool({ name: "ruby_lsp_doctor", label: "Ruby LSP Doctor", description: "Check Ruby LSP availability and connection status for the current project.", promptSnippet: "Check Ruby LSP availability and connection status", promptGuidelines: [ "Use ruby_lsp_doctor when Ruby LSP tools fail or before relying on Ruby semantic tooling in a new project.", ], parameters: Type.Object({}), async execute(_toolCallId, _params, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const doctor = await operations.doctor(ctx); return { content: [{ type: "text", text: asJson(doctor) }], details: doctor, }; }, }); pi.registerTool({ name: "ruby_lsp_diagnostics", label: "Ruby LSP Diagnostics", description: "Get Ruby LSP diagnostics for a Ruby file. Use after editing Ruby files or before refactors.", promptSnippet: "Get Ruby LSP diagnostics for Ruby files", promptGuidelines: [ "Use ruby_lsp_diagnostics after editing Ruby files before declaring the task done.", "Use ruby_lsp_diagnostics before large Ruby refactors to understand existing errors.", ], parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), }), async execute(_toolCallId, params: PathToolParams, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.diagnostics(ctx, params.path); const summary = diagnosticSummary(result.diagnostics); return { content: [ { type: "text", text: result.diagnostics.length ? `${summary}\n${asJson(result.diagnostics)}` : `No Ruby LSP diagnostics for ${params.path}`, }, ], details: result, }; }, }); pi.registerTool({ name: "ruby_lsp_symbols", label: "Ruby LSP Symbols", description: "List document symbols for a Ruby file: classes, modules, methods, constants, and related structure.", promptSnippet: "List Ruby classes, modules, methods, and constants in a file", promptGuidelines: [ "Use ruby_lsp_symbols before editing unfamiliar Ruby files to understand their structure without reading excessive context.", ], parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), }), async execute(_toolCallId, params: PathToolParams, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.symbols(ctx, params); return { content: [{ type: "text", text: asJson(result.symbols) }], details: result, }; }, }); pi.registerTool({ name: "ruby_lsp_definition", label: "Ruby LSP Definition", description: "Find the definition of the Ruby symbol at a zero-based line and character position.", promptSnippet: "Find the definition of a Ruby symbol", parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), line: Type.Number({ description: "Zero-based line number" }), character: Type.Number({ description: "Zero-based character offset" }), }), async execute(_toolCallId, params: PositionToolParams, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.definition(ctx, params); return { content: [{ type: "text", text: asJson(result.locations) }], details: result, }; }, }); pi.registerTool({ name: "ruby_lsp_hover", label: "Ruby LSP Hover", description: "Get Ruby LSP hover information at a zero-based line and character position.", promptSnippet: "Get Ruby documentation or type/context info at a cursor position", parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), line: Type.Number({ description: "Zero-based line number" }), character: Type.Number({ description: "Zero-based character offset" }), }), async execute(_toolCallId, params: PositionToolParams, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.hover(ctx, params); return { content: [{ type: "text", text: asJson(result.hover) }], details: result, }; }, }); pi.registerTool({ name: "ruby_lsp_references", label: "Ruby LSP References", description: "Find references to the Ruby symbol at a zero-based line and character position.", promptSnippet: "Find references to a Ruby symbol", parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), line: Type.Number({ description: "Zero-based line number" }), character: Type.Number({ description: "Zero-based character offset" }), includeDeclaration: Type.Optional(Type.Boolean({ description: "Whether to include the declaration in results" })), }), async execute( _toolCallId, params: PositionToolParams & { includeDeclaration?: boolean }, signal, _onUpdate, ctx, ) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.references(ctx, params); return { content: [{ type: "text", text: asJson(result.references) }], details: result, }; }, }); pi.registerTool({ name: "ruby_lsp_format", label: "Ruby LSP Format", description: "Preview or apply Ruby LSP formatting edits for a Ruby file. Defaults to preview; set apply true to write changes.", promptSnippet: "Format a Ruby file with Ruby LSP", promptGuidelines: ["Use ruby_lsp_format after Ruby edits to preview formatter changes before applying them."], parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), apply: Type.Optional(Type.Boolean({ description: "Apply edits to disk; defaults to false preview mode" })), }), async execute(_toolCallId, params: PathToolParams & ApplyableToolParams, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.format(ctx, params); return { content: [{ type: "text", text: asJson(result) }], details: result }; }, }); pi.registerTool({ name: "ruby_lsp_rename", label: "Ruby LSP Rename", description: "Preview or apply an LSP-backed symbol rename at a zero-based file position. Prefer this over manual multi-file edits.", promptSnippet: "Rename a Ruby symbol safely with Ruby LSP", promptGuidelines: ["Call with apply false first to preview all affected files unless the user explicitly asked to apply."], parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), line: Type.Number({ description: "Zero-based line number" }), character: Type.Number({ description: "Zero-based character offset" }), newName: Type.String({ description: "New symbol name" }), apply: Type.Optional(Type.Boolean({ description: "Apply workspace edits to disk; defaults to false preview mode" })), }), async execute(_toolCallId, params: PositionToolParams & { newName: string } & ApplyableToolParams, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.rename(ctx, params); return { content: [{ type: "text", text: asJson(result) }], details: result }; }, }); pi.registerTool({ name: "ruby_lsp_code_actions", label: "Ruby LSP Code Actions", description: "List Ruby LSP code actions for a file/range, optionally applying an edit-based action by index. Defaults to preview only.", promptSnippet: "List Ruby quick fixes and refactors from Ruby LSP", promptGuidelines: ["Use code actions to fix diagnostics before attempting manual edits.", "Only set apply true with actionIndex after inspecting the preview or when explicitly requested."], parameters: Type.Object({ path: Type.String({ description: "Path to a Ruby file" }), startLine: Type.Optional(Type.Number({ description: "Zero-based start line" })), startCharacter: Type.Optional(Type.Number({ description: "Zero-based start character" })), endLine: Type.Optional(Type.Number({ description: "Zero-based end line" })), endCharacter: Type.Optional(Type.Number({ description: "Zero-based end character" })), kind: Type.Optional(Type.String({ description: "Optional kind filter, e.g. quickfix, refactor, source" })), apply: Type.Optional(Type.Boolean({ description: "Apply selected action; requires actionIndex" })), actionIndex: Type.Optional(Type.Number({ description: "Index of action to apply" })), }), async execute(_toolCallId, params: CodeActionsParams, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.codeActions(ctx, params); return { content: [{ type: "text", text: asJson(result) }], details: result }; }, }); pi.registerTool({ name: "ruby_lsp_rails_routes", label: "Ruby LSP Rails Routes", description: "List Rails routes for a Rails project using bin/rails routes or bundle exec rails routes, with optional filter.", promptSnippet: "List Rails routes", parameters: Type.Object({ filter: Type.Optional(Type.String({ description: "Optional route grep/filter string" })), }), async execute(_toolCallId, params: { filter?: string }, signal, _onUpdate, ctx) { if (signal?.aborted) return { content: [{ type: "text", text: "Cancelled" }], details: { cancelled: true } }; const result = await operations.railsRoutes(ctx, params.filter); return { content: [{ type: "text", text: asJson(result) }], details: result }; }, }); pi.on("tool_result", async (event, ctx) => { if (event.toolName !== "write" && event.toolName !== "edit") return; if (event.isError) return; const input = event.input as { path?: unknown }; if (typeof input.path !== "string") return; const path = input.path; if (!isRubyPath(path)) return; void (async () => { try { await runDiagnostics(ctx, path); } catch (error) { if (ctx.hasUI) ctx.ui.setStatus("ruby-lsp", `Ruby LSP: ${userFacingError(error)}`); } })(); }); }