/** * jdtls Extension for pi * * Spawns Eclipse JDT Language Server (jdtls) and exposes its Java intelligence * as native pi tools so the LLM can query diagnostics, search symbols, hover for * type info, rename, and reformat without leaving the chat. * * This is the alternative to pi-idea for when IntelliJ IDEA is not available. * * Placement: extensions/jdtls/index.ts (part of the t1/tdder pi package) */ import type { AgentToolResult, ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { setToolsActive } from "./vendor/tool-activation.ts"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { Type } from "typebox"; import { DiagnosticsCollector, formatDiagnostics, type LspDiagnostic, } from "./diagnostics-collector.ts"; import { findJdtls, isJavaProject, JdtlsServer, type ServerStatus } from "./jdtls-server.ts"; import { formatHover, formatSymbols, type LspSymbolInformation, } from "./lsp-format.ts"; import { formatCodeActions, formatSourcePaths, isCommand, type LspAction, type LspSourcePath, } from "./code-action.ts"; import { applyTextEdits, applyWorkspaceEdit, formatApplyResult, type TextEdit, type WorkspaceEdit, } from "./workspace-edit.ts"; const FOOTER_KEY = "jdtls"; const DIAGNOSTICS_QUIET_MS = 2000; const LSP_INVALID_REQUEST = -32600; const LSP_CODE_ACTION_TRIGGER_REQUESTED = 1; /** Path to the per-project jdtls settings file. */ function settingsPath(cwd: string): string { return join(cwd, ".pi", "settings", "jdtls.json"); } interface JdtlsSettings { enabled?: boolean; } function readSettings(cwd: string): JdtlsSettings | null { const p = settingsPath(cwd); if (!existsSync(p)) return null; try { return JSON.parse(readFileSync(p, "utf-8")); } catch { return null; } } function writeSettings(cwd: string, settings: JdtlsSettings): void { const p = settingsPath(cwd); mkdirSync(join(cwd, ".pi", "settings"), { recursive: true }); writeFileSync(p, JSON.stringify(settings, null, 2) + "\n", "utf-8"); } /** Return shape of every jdtls tool execute callback. */ type JdtlsResult = { content: Array<{ type: "text"; text: string }>; details: undefined; }; /** Typed execute callback — enforces (id, params) => Promise. */ type JdtlsExecute = ( id: string, params: TParams, ) => Promise; /** Wrap a JdtlsExecute into the full pi.registerTool execute signature. */ function wrapExecute>( fn: JdtlsExecute, ): ( toolCallId: string, params: TParams, signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext, ) => Promise> { return (toolCallId, params, _signal, _onUpdate, _ctx) => fn(toolCallId, params); } /** Build a JdtlsResult — keeps `type: "text"` literal via `as const`. */ function result(text: string): JdtlsResult { return { content: [{ type: "text" as const, text }], details: undefined, }; } // Keep in sync with registerJdtlsTool calls below. const JDTLS_TOOL_NAMES = [ "jdtls_get_file_problems", "jdtls_search_symbol", "jdtls_get_symbol_info", "jdtls_rename_refactoring", "jdtls_reformat_file", "jdtls_read_file", "jdtls_code_action", "jdtls_get_project_modules", ]; function footerLabel(status: ServerStatus): string | undefined { switch (status) { case "starting": return "[jdtls ◌ starting…]"; case "ready": return "[jdtls ●]"; case "error": return "[jdtls ⚠]"; case "stopped": return undefined; } } function jdtlsToolName(label: string): string { return `jdtls_${label}`; } export default function (pi: ExtensionAPI): void { let server: JdtlsServer | null = null; let cwd = ""; // ------------------------------------------------------------------------- // Shared helper — open a doc for the duration of a callback, then close it // only if this call was the one that opened it (avoids closing a file that // a concurrent call has open). // ------------------------------------------------------------------------- async function withOpenDoc( srv: JdtlsServer, uri: string, text: string, fn: () => Promise, ): Promise { const wasOpen = srv.isDocOpen(uri); if (!wasOpen) srv.didOpen(uri, text); try { return await fn(); } finally { if (!wasOpen) srv.didClose(uri); } } // ------------------------------------------------------------------------- // Shared helpers // ------------------------------------------------------------------------- function requireReady(): JdtlsServer { if (!server || server.status !== "ready") { throw new Error("jdtls is not ready — wait for 'jdtls ●' in the footer"); } return server; } interface ResolvedFile { absPath: string; text: string; uri: string; } function resolveFile(rawPath: string): ResolvedFile { const absPath = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath); if (!existsSync(absPath)) throw new Error(`File not found: ${absPath}`); const text = readFileSync(absPath, "utf-8"); const uri = pathToFileURL(absPath).toString(); return { absPath, text, uri }; } function simpleRenderCall(label: string, formatArgs?: (args: unknown) => string) { return (args: unknown) => ({ render() { return [formatArgs ? `${label}: ${formatArgs(args)}` : label]; }, invalidate() {}, }); } function registerJdtlsTool>( label: string, description: string, parameters: ReturnType, execute: JdtlsExecute, formatArgs?: (args: unknown) => string, ): void { const wrapped = wrapExecute(execute); const name = jdtlsToolName(label); pi.registerTool({ name, label, description, parameters, renderCall: simpleRenderCall(label, formatArgs), execute: wrapped, }); } // ------------------------------------------------------------------------- // get_file_problems tool // ------------------------------------------------------------------------- registerJdtlsTool<{ path: string }>( "get_file_problems", "Return compiler errors, warnings, and hints for a Java source file. " + "Also includes project-level diagnostics (e.g. JRE mismatch). " + "Backed by Eclipse JDT Language Server.", Type.Object({ path: Type.String({ description: "Absolute path to the Java source file, or a path relative to the project root.", }), }), (async (_id, params) => { const srv = requireReady(); const { path: rawPath } = params; const { absPath, text, uri } = resolveFile(rawPath); // Collect publishDiagnostics notifications using a quiet-period strategy. const collector = new DiagnosticsCollector(DIAGNOSTICS_QUIET_MS); const unsub = srv.addNotificationListener((method, notifParams) => { if (method !== "textDocument/publishDiagnostics") return; const { uri: notifUri, diagnostics } = notifParams as { uri: string; diagnostics: LspDiagnostic[]; }; // Include the requested file and project-level diagnostics (empty URI). if (notifUri === uri || notifUri === "") { collector.feed(notifUri, diagnostics); } }); try { srv.didOpen(uri, text); const collected = await collector.promise; srv.didClose(uri); const output = formatDiagnostics(uri, rawPath, collected); return result(output); } finally { unsub(); } }), (a) => (a as { path: string }).path, ); // ------------------------------------------------------------------------- // search_symbol tool // ------------------------------------------------------------------------- registerJdtlsTool<{ query: string }>( "search_symbol", "Search for Java symbols (classes, methods, fields, …) across the whole workspace by name. " + "Requires full indexing — returns 0 results if called before the index is ready.", Type.Object({ query: Type.String({ description: "Symbol name or prefix to search for (case-insensitive).", }), }), (async (_id, params) => { const srv = requireReady(); const { query } = params; if (!srv.serviceReady) { return result( `Indexing still in progress — workspace/symbol returns no results yet. ` + `Try again in a few seconds.`, ); } const raw = await srv.request("workspace/symbol", { query }); const symbols = (raw ?? []) as LspSymbolInformation[]; return result(formatSymbols(symbols, cwd, query)); }), (a) => (a as { query: string }).query, ); // ------------------------------------------------------------------------- // get_symbol_info tool // ------------------------------------------------------------------------- registerJdtlsTool<{ path: string; line: number; character: number }>( "get_symbol_info", "Return type information and documentation for the symbol at a given position in a file. " + "Line and character are 1-based (as shown by the `read` tool).", Type.Object({ path: Type.String({ description: "Absolute or project-relative path to the Java source file.", }), line: Type.Number({ description: "1-based line number.", }), character: Type.Number({ description: "1-based character offset within the line.", }), }), (async (_id, params) => { const srv = requireReady(); const { path: rawPath, line, character } = params; const { text, uri } = resolveFile(rawPath); // Convert from 1-based (user-facing) to 0-based (LSP) const lspLine = line - 1; const lspChar = character - 1; const hover = await withOpenDoc(srv, uri, text, () => srv.request("textDocument/hover", { textDocument: { uri }, position: { line: lspLine, character: lspChar }, }), ); return result(formatHover(hover)); }), (a) => { const { path: p, line, character } = a as { path: string; line: number; character: number }; return `${p}:${line}:${character}`; }, ); // ------------------------------------------------------------------------- // rename_refactoring tool // ------------------------------------------------------------------------- registerJdtlsTool<{ path: string; line: number; character: number; newName: string }>( "rename_refactoring", "Rename a Java symbol (class, method, field, variable, …) at the given position " + "across the whole project. Only user-defined symbols can be renamed — library types " + "are rejected with a clear error. Line and character are 1-based.", Type.Object({ path: Type.String({ description: "Absolute or project-relative path to the source file." }), line: Type.Number({ description: "1-based line number of the symbol to rename." }), character: Type.Number({ description: "1-based character offset within the line." }), newName: Type.String({ description: "The new name for the symbol." }), }), (async (_id, params) => { const srv = requireReady(); const { path: rawPath, line, character, newName } = params; const { text, uri } = resolveFile(rawPath); const position = { line: line - 1, character: character - 1 }; const results = await withOpenDoc(srv, uri, text, async () => { // Validate with prepareRename first for a better error on library symbols. try { await srv.request("textDocument/prepareRename", { textDocument: { uri }, position, }); } catch (err) { const code = (err as { code?: number }).code; if (code === LSP_INVALID_REQUEST) { throw new Error( "Cannot rename: this symbol is defined in a library and is read-only.", ); } throw err; } const workspaceEdit = await srv.request("textDocument/rename", { textDocument: { uri }, position, newName, }) as WorkspaceEdit; return applyWorkspaceEdit(workspaceEdit); }); return result(formatApplyResult(results, cwd, `Renamed to '${newName}'`)); }), (a) => { const { path: p, line, character, newName } = a as { path: string; line: number; character: number; newName: string }; return `${p}:${line}:${character} → ${newName}`; }, ); // ------------------------------------------------------------------------- // reformat_file tool // ------------------------------------------------------------------------- registerJdtlsTool<{ path: string }>( "reformat_file", "Reformat a Java source file according to the Eclipse/jdtls formatter settings. " + "Edits are applied directly to disk.", Type.Object({ path: Type.String({ description: "Absolute or project-relative path to the Java source file." }), }), (async (_id, params) => { const srv = requireReady(); const { path: rawPath } = params; const { absPath, text, uri } = resolveFile(rawPath); const edits = await withOpenDoc(srv, uri, text, () => srv.request("textDocument/formatting", { textDocument: { uri }, options: { tabSize: 4, insertSpaces: true }, }), ) as TextEdit[] | null; if (!edits || edits.length === 0) { return result(`${rawPath} is already formatted correctly`); } const original = readFileSync(absPath, "utf-8"); const updated = applyTextEdits(original, edits); writeFileSync(absPath, updated, "utf-8"); const results = [{ path: absPath, editsApplied: edits.length }]; return result(formatApplyResult(results, cwd, "Reformatted")); }), (a) => (a as { path: string }).path, ); // ------------------------------------------------------------------------- // read_file tool (jar/class decompilation via jdt:// URIs) // ------------------------------------------------------------------------- registerJdtlsTool<{ uri: string }>( "read_file", "Return the source content of a library class or jar entry using its `jdt://` URI. " + "Obtain the URI from `get_symbol_info` by hovering on a library type reference — " + "jdtls embeds the full URI in the hover response.", Type.Object({ uri: Type.String({ description: "The jdt:// URI of the class file, as returned in a hover response.", }), }), (async (_id, params) => { const srv = requireReady(); const { uri } = params; const content = await srv.request("java/classFileContents", { uri }) as string | null; if (!content) { return result("(no source available for this URI)"); } return result(content); }), (a) => (a as { uri: string }).uri, ); // ------------------------------------------------------------------------- // code_action tool // ------------------------------------------------------------------------- registerJdtlsTool<{ path: string; line: number; character?: number; applyTitle?: string }>( "code_action", "List code actions (quick fixes, refactorings, source actions) available at a position. " + "Without `applyTitle`, lists all actions. With `applyTitle`, applies the matching action. " + "Line and character are 1-based.", Type.Object({ path: Type.String({ description: "Absolute or project-relative path to the Java source file." }), line: Type.Number({ description: "1-based line number." }), character: Type.Optional(Type.Number({ description: "1-based character offset (defaults to 1)." })), applyTitle: Type.Optional(Type.String({ description: "Exact title of the action to apply (from a prior list call)." })), }), (async (_id, params) => { const srv = requireReady(); const { path: rawPath, line, character = 1, applyTitle } = params; const { text, uri } = resolveFile(rawPath); const position = { line: line - 1, character: character - 1 }; const rawActions = await withOpenDoc(srv, uri, text, () => srv.request("textDocument/codeAction", { textDocument: { uri }, range: { start: position, end: position }, context: { diagnostics: [], triggerKind: LSP_CODE_ACTION_TRIGGER_REQUESTED }, }), ) as LspAction[]; const actions: LspAction[] = rawActions ?? []; // List mode if (!applyTitle) { return result(formatCodeActions(actions)); } // Apply mode const action = actions.find((a) => a.title === applyTitle); if (!action) { const available = actions.map((a) => `"${a.title}"`).join(", "); throw new Error( `No code action with title "${applyTitle}". Available: ${available || "(none)"}`, ); } return applyCodeAction(action, srv); }), (a) => { const { path: p, line, character, applyTitle } = a as { path: string; line: number; character?: number; applyTitle?: string }; const suffix = applyTitle ? ` → apply "${applyTitle}"` : " (list)"; return `${p}:${line}:${character ?? 1}${suffix}`; }, ); // ------------------------------------------------------------------------- // code_action helpers // ------------------------------------------------------------------------- async function applyCodeAction( action: LspAction, srv: JdtlsServer, ): Promise { // Prefer a direct WorkspaceEdit if the action carries one. if (!isCommand(action) && action.edit) { const results = applyWorkspaceEdit(action.edit); return result(formatApplyResult(results, cwd, `Applied: ${action.title}`)); } // Otherwise execute the command (which may itself return a WorkspaceEdit). const cmd = isCommand(action) ? action : action.command; if (!cmd) { throw new Error( `Action "${action.title}" has neither an edit nor a command — cannot apply.`, ); } const cmdResult = await srv.request("workspace/executeCommand", { command: cmd.command, arguments: cmd.arguments ?? [], }); if ( cmdResult && typeof cmdResult === "object" && ("changes" in cmdResult || "documentChanges" in cmdResult) ) { const results = applyWorkspaceEdit(cmdResult as WorkspaceEdit); return result(formatApplyResult(results, cwd, `Applied: ${action.title}`)); } return result(`Applied: ${action.title}`); } // ------------------------------------------------------------------------- // get_project_modules tool // ------------------------------------------------------------------------- registerJdtlsTool<{}>( "get_project_modules", "List source and test directories for every module in the workspace. " + "Useful for understanding multi-module project layout.", Type.Object({}), (async (_id, _params) => { const srv = requireReady(); let raw: unknown; try { raw = await srv.request("workspace/executeCommand", { command: "java.project.listSourcePaths", arguments: [], }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error( `get_project_modules: jdtls returned an error — ${msg}. ` + `This command requires jdtls ≥0.57 and a configured workspace.`, ); } const paths = (Array.isArray(raw) ? raw : []) as LspSourcePath[]; return result(formatSourcePaths(paths)); }), ); // ------------------------------------------------------------------------- // Session lifecycle // ------------------------------------------------------------------------- function applyToolActivation(status: ServerStatus): void { setToolsActive(pi, JDTLS_TOOL_NAMES, status === "ready"); } /** Create a fresh JdtlsServer wired to the footer + tool activation, assigned to `server`. */ function createServer(ctx: ExtensionContext): JdtlsServer { return new JdtlsServer((status: ServerStatus) => { ctx.ui.setStatus(FOOTER_KEY, footerLabel(status)); applyToolActivation(status); }); } /** Start the jdtls server (if not already running), surfacing errors via notify. * Returns the resulting server status, or "stopped" on failure. */ async function startServer(ctx: ExtensionContext): Promise { if (server) return server.status; // already running or starting server = createServer(ctx); try { await server.start(ctx.cwd); return server.status; } catch (err) { ctx.ui.notify( `jdtls failed to start: ${(err as Error).message}`, "error", ); await server.shutdown().catch(() => {}); server = null; return "error"; } } /** Stop the jdtls server if running. No-op when nothing is running. */ async function stopServer(ctx: ExtensionContext): Promise { if (!server) return false; await server.shutdown().catch(() => {}); server = null; ctx.ui.setStatus(FOOTER_KEY, undefined); applyToolActivation("stopped"); return true; } /** Force the enable prompt regardless of persisted state — the escape hatch. */ async function askToEnable(ctx: ExtensionContext): Promise { const ok = await ctx.ui.confirm( "Enable jdtls tools?", `Enable Java language server tools (diagnostics, symbol search, rename, format) for this project?`, ); writeSettings(ctx.cwd, { enabled: ok }); if (ok) { await startServer(ctx); } else { await stopServer(ctx); ctx.ui.notify("jdtls disabled — use '/jdtls ask' to re-enable", "info"); } } // ----------------------------------------------------------------------- // /jdtls command // ----------------------------------------------------------------------- const JDTLS_SUBCOMMANDS = ["start", "stop", "status", "ask"] as const; type JdtlsSubcommand = (typeof JDTLS_SUBCOMMANDS)[number]; pi.registerCommand("jdtls", { description: "Manage the jdtls bridge: start | stop | status | ask", getArgumentCompletions: (prefix: string) => { const items = JDTLS_SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map( (s) => ({ value: s, label: s, description: { start: "Start the language server", stop: "Stop the language server", status: "Show connection state", ask: "Re-prompt enable/disable (escape hatch)", }[s], })); return items.length > 0 ? items : null; }, handler: async (args, ctx) => { const sub = (args?.trim() ?? "").split(/\s+/)[0] as JdtlsSubcommand | ""; if (sub === "status") { if (server && server.status === "ready") { ctx.ui.notify("jdtls: running and ready (●)", "info"); } else if (server && server.status === "starting") { ctx.ui.notify("jdtls: starting… (◌)", "info"); } else { ctx.ui.notify("jdtls: not running (use '/jdtls start')", "warning"); } return; } if (sub === "start") { if (server) { ctx.ui.notify("jdtls: already running (●)", "info"); return; } const status = await startServer(ctx); if (status === "ready") ctx.ui.notify("jdtls: started (●)", "info"); return; } if (sub === "stop") { const stopped = await stopServer(ctx); ctx.ui.notify( stopped ? "jdtls: stopped" : "jdtls: not running", stopped ? "info" : "warning", ); return; } if (sub === "ask") { await askToEnable(ctx); return; } ctx.ui.notify( `Usage: /jdtls ${JDTLS_SUBCOMMANDS.join(" | ")}`, "warning", ); }, }); pi.on("session_start", async (_event, ctx) => { cwd = ctx.cwd; // Only consider Java projects that have jdtls installed. if (!isJavaProject(ctx.cwd)) return; const exe = findJdtls(); if (!exe) return; // Check persisted preference: tri-state. // absent -> ask and persist the answer // true -> start immediately without re-asking // false -> stay silent // To flip a persisted choice, use '/jdtls ask' or edit/delete // `.pi/settings/jdtls.json`. const settings = readSettings(ctx.cwd); const enabled = settings?.enabled; if (enabled === undefined) { // Only prompt when an interactive UI is available. In headless/child // sessions (no-op uiContext) ctx.ui.confirm returns false, which would // otherwise persist enabled:false and silently disable jdtls for the // whole repo from a background session. Leave the setting absent so the // next interactive session prompts properly. if (!ctx.hasUI) return; const ok = await ctx.ui.confirm( "Enable jdtls tools?", `This project looks like a Java project and jdtls is installed. Enable Java language server tools (diagnostics, symbol search, rename, format)?`, ); writeSettings(ctx.cwd, { enabled: ok }); if (!ok) return; } else if (!enabled) { return; } server = createServer(ctx); // Start in the background; errors surface via the ⚠ footer indicator. server.start(ctx.cwd).catch((err: unknown) => { console.error("[jdtls] failed to start:", (err as Error).message); }); }); pi.on("session_shutdown", async () => { if (server) { await server.shutdown().catch(() => {}); server = null; } }); }