import { type EditToolDetails, type EditToolInput, type ExtensionAPI, isEditToolResult, } from "@elyracode/coding-agent"; import { type ChildProcess, execSync, spawn } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { Type } from "typebox"; // ── LSP Types ─────────────────────────────────────────────────────────────── interface LspPosition { line: number; character: number; } interface LspRange { start: LspPosition; end: LspPosition; } interface LspLocation { uri: string; range: LspRange; } interface LspDiagnostic { range: LspRange; severity?: number; message: string; source?: string; code?: string | number; } interface LspHoverResult { contents: string | { kind: string; value: string } | Array; range?: LspRange; } interface PendingRequest { resolve: (value: unknown) => void; reject: (reason: unknown) => void; } // ── Helpers ───────────────────────────────────────────────────────────────── const SEVERITY_LABELS: Record = { 1: "Error", 2: "Warning", 3: "Information", 4: "Hint", }; function fileUri(filePath: string): string { return `file://${filePath}`; } function uriToPath(uri: string): string { return uri.replace(/^file:\/\//, ""); } function positionFromLineCol(line: number, col: number): LspPosition { return { line: line - 1, character: col - 1 }; } function languageIdForPath(filePath: string): string { if (filePath.endsWith(".tsx")) return "typescriptreact"; if (filePath.endsWith(".jsx")) return "javascriptreact"; if (filePath.endsWith(".js") || filePath.endsWith(".mjs") || filePath.endsWith(".cjs")) return "javascript"; return "typescript"; } // ── Symbol-aware auto-context ──────────────────────────────────────────── // After an edit, proactively resolve unfamiliar-looking type/class names // referenced in the new code via hover, and append a short summary to the // edit's own tool result. Saves a definitions/hover round trip for the // common case of "I just referenced a type, is this the shape I think it is". /** Common built-ins that rarely need an explanation. */ const TS_SYMBOL_STOPLIST = new Set([ "String", "Number", "Boolean", "Array", "Object", "Promise", "Error", "Map", "Set", "Date", "RegExp", "JSON", "Symbol", "Math", "Function", "Record", "Partial", "Readonly", "Pick", "Omit", "Required", "Awaited", "Buffer", "ArrayBuffer", "Uint8Array", "Console", ]); /** Extract candidate PascalCase type/class identifiers from a snippet of code. */ function extractCandidateSymbols(text: string, stoplist: Set, max: number): string[] { const found = new Set(); const re = /\b[A-Z][A-Za-z0-9_]*\b/g; let m: RegExpExecArray | null = re.exec(text); while (m && found.size < max * 3) { if (!stoplist.has(m[0])) found.add(m[0]); m = re.exec(text); } return [...found].slice(0, max); } /** Find the (1-based) line/column of the first whole-word match of `symbol`, searching from `fromLine` first. */ function findSymbolPosition( fileText: string, symbol: string, fromLine: number, ): { line: number; column: number } | undefined { const lines = fileText.split("\n"); const re = new RegExp(`\\b${symbol}\\b`); const start = Math.max(0, fromLine - 1); for (let i = start; i < lines.length; i++) { const idx = lines[i].search(re); if (idx >= 0) return { line: i + 1, column: idx + 1 }; } for (let i = 0; i < start; i++) { const idx = lines[i].search(re); if (idx >= 0) return { line: i + 1, column: idx + 1 }; } return undefined; } /** Reduce a hover result to a short, single-line summary. */ function summarizeHover(result: unknown): string | undefined { if (!result) return undefined; const hover = result as LspHoverResult; let text: string; if (typeof hover.contents === "string") text = hover.contents; else if (Array.isArray(hover.contents)) { text = hover.contents.map((c) => (typeof c === "string" ? c : c.value)).join("\n"); } else text = hover.contents.value; const firstLine = text .split("\n") .map((l) => l.trim()) .find((l) => l && !l.startsWith("```")); if (!firstLine) return undefined; return firstLine.length > 160 ? `${firstLine.slice(0, 160)}\u2026` : firstLine; } // LSP SymbolKind values that can meaningfully own a blast radius: // Class, Method, Constructor, Enum, Interface, Function, Variable, Constant, Struct. const BLAST_SYMBOL_KINDS = new Set([5, 6, 9, 10, 11, 12, 13, 14, 23]); interface FlatSymbol { name: string; kind: number; startLine: number; endLine: number; depth: number; /** 0-based position of the symbol's name (for references lookups). */ selLine: number; selChar: number; } /** Flatten a documentSymbol response (hierarchical DocumentSymbol[] or flat SymbolInformation[]). */ function flattenSymbols(result: unknown): FlatSymbol[] { if (!Array.isArray(result)) return []; const out: FlatSymbol[] = []; const walk = (nodes: unknown[], depth: number): void => { for (const n of nodes) { if (!n || typeof n !== "object") continue; const sym = n as { name?: unknown; kind?: unknown; range?: LspRange; selectionRange?: LspRange; location?: { range?: LspRange }; children?: unknown[]; }; const range = sym.range ?? sym.location?.range; if (typeof sym.name === "string" && typeof sym.kind === "number" && range) { const sel = sym.selectionRange ?? range; out.push({ name: sym.name, kind: sym.kind, startLine: range.start.line, endLine: range.end.line, depth, selLine: sel.start.line, selChar: sel.start.character, }); } if (Array.isArray(sym.children)) walk(sym.children, depth + 1); } }; walk(result, 0); return out; } /** Find the innermost symbol (by line span, then nesting depth) containing a 0-based line. */ function findEnclosingSymbol(symbols: FlatSymbol[], line0: number): FlatSymbol | undefined { let best: FlatSymbol | undefined; for (const s of symbols) { if (!BLAST_SYMBOL_KINDS.has(s.kind)) continue; if (line0 < s.startLine || line0 > s.endLine) continue; if (!best) { best = s; continue; } const bestSpan = best.endLine - best.startLine; const span = s.endLine - s.startLine; if (span < bestSpan || (span === bestSpan && s.depth > best.depth)) best = s; } return best; } /** True when the edit tool's own result already contains a diagnostics section. */ function hasExistingDiagnostics(content: ReadonlyArray): boolean { return content.some((c) => { if (!c || typeof c !== "object") return false; const item = c as { type?: unknown; text?: unknown }; return item.type === "text" && typeof item.text === "string" && item.text.includes("\nDiagnostics ("); }); } function findBinary(workingDir: string): string | undefined { const local = join(workingDir, "node_modules", ".bin", "typescript-language-server"); if (existsSync(local)) return local; try { const global = execSync("which typescript-language-server", { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }).trim(); if (global) return global; } catch { // not found globally } return undefined; } // ── Extension ─────────────────────────────────────────────────────────────── export default function (elyra: ExtensionAPI): void { let lspProcess: ChildProcess | null = null; let requestId = 0; const pendingRequests = new Map(); let buffer = Buffer.alloc(0); let initialized = false; let cwd = ""; const openedFiles = new Set(); const diagnosticsByUri = new Map(); const diagnosticsWaiters = new Map void>>(); // ── JSON-RPC Client ───────────────────────────────────────────────── function sendRequest(method: string, params: unknown): Promise { if (!lspProcess?.stdin) { return Promise.reject(new Error("LSP server not running")); } const id = ++requestId; const body = JSON.stringify({ jsonrpc: "2.0", id, method, params }); const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; lspProcess.stdin.write(message); return new Promise((res, rej) => { const timer = setTimeout(() => { pendingRequests.delete(id); rej(new Error(`LSP request "${method}" timed out after 10s`)); }, 10_000); pendingRequests.set(id, { resolve: (value: unknown) => { clearTimeout(timer); res(value); }, reject: (reason: unknown) => { clearTimeout(timer); rej(reason); }, }); }); } function sendNotification(method: string, params: unknown): void { if (!lspProcess?.stdin) return; const body = JSON.stringify({ jsonrpc: "2.0", method, params }); const message = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; lspProcess.stdin.write(message); } function handleData(chunk: Buffer): void { buffer = Buffer.concat([buffer, chunk]); for (;;) { const headerEnd = buffer.indexOf("\r\n\r\n"); if (headerEnd === -1) break; const header = buffer.subarray(0, headerEnd).toString("utf-8"); const match = /Content-Length:\s*(\d+)/i.exec(header); if (!match) { buffer = buffer.subarray(headerEnd + 4); continue; } const contentLength = parseInt(match[1], 10); const bodyStart = headerEnd + 4; if (buffer.length < bodyStart + contentLength) break; const bodyStr = buffer.subarray(bodyStart, bodyStart + contentLength).toString("utf-8"); buffer = buffer.subarray(bodyStart + contentLength); let parsed: unknown; try { parsed = JSON.parse(bodyStr); } catch { continue; } if (!parsed || typeof parsed !== "object") continue; const msg = parsed as Record; // Response to a request if ("id" in msg && typeof msg.id === "number") { const pending = pendingRequests.get(msg.id); if (pending) { pendingRequests.delete(msg.id); if ("error" in msg && msg.error) { const err = msg.error as { code: number; message: string }; pending.reject(new Error(`LSP error ${err.code}: ${err.message}`)); } else { pending.resolve(msg.result); } } } // Server notification if ("method" in msg && typeof msg.method === "string") { if (msg.method === "textDocument/publishDiagnostics" && msg.params) { const p = msg.params as { uri: string; diagnostics: LspDiagnostic[] }; diagnosticsByUri.set(p.uri, p.diagnostics); const waiters = diagnosticsWaiters.get(p.uri); if (waiters) { diagnosticsWaiters.delete(p.uri); for (const w of waiters) w(); } } } } } // ── File helpers ──────────────────────────────────────────────────── function openFile(filePath: string): void { const absPath = resolve(cwd, filePath); const uri = fileUri(absPath); if (openedFiles.has(uri)) return; let text: string; try { text = readFileSync(absPath, "utf-8"); } catch { return; } sendNotification("textDocument/didOpen", { textDocument: { uri, languageId: languageIdForPath(filePath), version: 1, text, }, }); openedFiles.add(uri); } /** Force the server to see the current on-disk content (e.g. right after our own edit). */ function syncFile(filePath: string): void { const absPath = resolve(cwd, filePath); const uri = fileUri(absPath); if (openedFiles.has(uri)) { sendNotification("textDocument/didClose", { textDocument: { uri } }); openedFiles.delete(uri); } openFile(filePath); } /** Resolve when the server publishes diagnostics for `uri`, or after `timeoutMs`. */ function waitForDiagnostics(uri: string, timeoutMs: number): Promise { return new Promise((resolveWait) => { const waiter = () => { clearTimeout(timer); resolveWait(); }; const timer = setTimeout(() => { const arr = diagnosticsWaiters.get(uri); if (arr) { const remaining = arr.filter((w) => w !== waiter); if (remaining.length > 0) diagnosticsWaiters.set(uri, remaining); else diagnosticsWaiters.delete(uri); } resolveWait(); }, timeoutMs); const list = diagnosticsWaiters.get(uri) ?? []; list.push(waiter); diagnosticsWaiters.set(uri, list); }); } function formatLocations(result: unknown, workingDir: string): string { if (!result) return "No results found."; const locations: LspLocation[] = Array.isArray(result) ? result : [result as LspLocation]; if (locations.length === 0) return "No results found."; const prefix = workingDir + "/"; return locations .map((loc) => { const p = uriToPath(loc.uri); const rel = p.startsWith(prefix) ? p.slice(prefix.length) : p; return `${rel}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`; }) .join("\n"); } // ── Lifecycle ─────────────────────────────────────────────────────── elyra.on("session_start", async (_event, ctx) => { cwd = ctx.cwd; if (!existsSync(join(cwd, "tsconfig.json"))) return; const binary = findBinary(cwd); if (!binary) return; lspProcess = spawn(binary, ["--stdio"], { cwd }); lspProcess.stdout?.on("data", (chunk: Buffer) => handleData(chunk)); lspProcess.stderr?.on("data", () => { /* ignore stderr */ }); lspProcess.on("exit", () => { lspProcess = null; initialized = false; }); try { await sendRequest("initialize", { processId: process.pid, capabilities: {}, rootUri: fileUri(cwd), workspaceFolders: [{ uri: fileUri(cwd), name: "workspace" }], }); sendNotification("initialized", {}); initialized = true; } catch { lspProcess?.kill(); lspProcess = null; return; } // ── Tool: lsp_definitions ─────────────────────────────────────── elyra.registerTool({ name: "lsp_definitions", label: "LSP Go to Definition", description: "Go to the definition of a symbol at a given position. Returns the file path and " + "line where the symbol is defined. More precise than grep for finding where functions, " + "classes, types, and variables are declared.", promptSnippet: "Go to definition of a symbol at a file position", parameters: Type.Object({ file: Type.String({ description: "Relative file path from project root" }), line: Type.Number({ description: "Line number (1-based)" }), column: Type.Number({ description: "Column number (1-based)" }), }), execute: async (_toolCallId, params) => { if (!initialized) { return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} }; } const absPath = resolve(cwd, params.file); openFile(params.file); try { const result = await sendRequest("textDocument/definition", { textDocument: { uri: fileUri(absPath) }, position: positionFromLineCol(params.line, params.column), }); return { content: [{ type: "text", text: formatLocations(result, cwd) }], details: {} }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: [{ type: "text", text: `Error: ${message}` }], details: {} }; } }, }); // ── Tool: lsp_references ──────────────────────────────────────── elyra.registerTool({ name: "lsp_references", label: "LSP Find References", description: "Find all references to a symbol at a given position. Returns every location where " + "the symbol is used across the project. More precise than grep for understanding usage patterns.", promptSnippet: "Find all references to a symbol at a file position", parameters: Type.Object({ file: Type.String({ description: "Relative file path from project root" }), line: Type.Number({ description: "Line number (1-based)" }), column: Type.Number({ description: "Column number (1-based)" }), }), execute: async (_toolCallId, params) => { if (!initialized) { return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} }; } const absPath = resolve(cwd, params.file); openFile(params.file); try { const result = await sendRequest("textDocument/references", { textDocument: { uri: fileUri(absPath) }, position: positionFromLineCol(params.line, params.column), context: { includeDeclaration: true }, }); return { content: [{ type: "text", text: formatLocations(result, cwd) }], details: {} }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: [{ type: "text", text: `Error: ${message}` }], details: {} }; } }, }); // ── Tool: lsp_diagnostics ─────────────────────────────────────── elyra.registerTool({ name: "lsp_diagnostics", label: "LSP Diagnostics", description: "Get TypeScript compilation errors and warnings for a file without running the full " + "type checker. Returns diagnostics with line numbers, severity, and messages.", promptSnippet: "Get TypeScript errors and warnings for a file", parameters: Type.Object({ file: Type.String({ description: "Relative file path from project root" }), }), execute: async (_toolCallId, params) => { if (!initialized) { return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} }; } const absPath = resolve(cwd, params.file); openFile(params.file); const uri = fileUri(absPath); // Allow the server time to publish diagnostics after opening the file await new Promise((r) => setTimeout(r, 1000)); const diagnostics = diagnosticsByUri.get(uri) ?? []; if (diagnostics.length === 0) { return { content: [{ type: "text", text: "No diagnostics found." }], details: {} }; } const lines = diagnostics.map((d) => { const severity = SEVERITY_LABELS[d.severity ?? 1] ?? "Unknown"; const loc = `${params.file}:${d.range.start.line + 1}:${d.range.start.character + 1}`; const code = d.code ? ` [${d.code}]` : ""; return `${severity}${code} ${loc}: ${d.message}`; }); return { content: [{ type: "text", text: lines.join("\n") }], details: {} }; }, }); // ── Tool: lsp_hover ───────────────────────────────────────────── elyra.registerTool({ name: "lsp_hover", label: "LSP Hover", description: "Get type information and documentation for a symbol at a given position. Shows the " + "resolved type signature and JSDoc comments.", promptSnippet: "Get type info and docs for a symbol at a file position", parameters: Type.Object({ file: Type.String({ description: "Relative file path from project root" }), line: Type.Number({ description: "Line number (1-based)" }), column: Type.Number({ description: "Column number (1-based)" }), }), execute: async (_toolCallId, params) => { if (!initialized) { return { content: [{ type: "text", text: "Error: LSP server is not running." }], details: {} }; } const absPath = resolve(cwd, params.file); openFile(params.file); try { const result = await sendRequest("textDocument/hover", { textDocument: { uri: fileUri(absPath) }, position: positionFromLineCol(params.line, params.column), }); if (!result) { return { content: [{ type: "text", text: "No hover information available." }], details: {} }; } const hover = result as LspHoverResult; let text: string; if (typeof hover.contents === "string") { text = hover.contents; } else if (Array.isArray(hover.contents)) { text = hover.contents.map((c) => (typeof c === "string" ? c : c.value)).join("\n\n"); } else { text = hover.contents.value; } return { content: [{ type: "text", text: text || "No hover information available." }], details: {}, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: [{ type: "text", text: `Error: ${message}` }], details: {} }; } }, }); // ── Hook: auto-context, auto-diagnostics, and blast radius on edit ── elyra.on("tool_result", async (event) => { if (!initialized || !isEditToolResult(event) || event.isError) return undefined; const input = event.input as unknown as EditToolInput; if (typeof input?.path !== "string" || !Array.isArray(input.edits)) return undefined; if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(input.path)) return undefined; try { syncFile(input.path); const absPath = resolve(cwd, input.path); const fileText = readFileSync(absPath, "utf-8"); const details = event.details as EditToolDetails | undefined; const fromLine = details?.firstChangedLine ?? 1; const uri = fileUri(absPath); const sections: string[] = []; // 1. Related symbols: hover summaries for referenced types. const candidates = new Set(); for (const edit of input.edits) { for (const s of extractCandidateSymbols(edit.newText, TS_SYMBOL_STOPLIST, 4)) candidates.add(s); } const lookups = [...candidates].slice(0, 5).map(async (symbol) => { const pos = findSymbolPosition(fileText, symbol, fromLine); if (!pos) return undefined; try { const result = await sendRequest("textDocument/hover", { textDocument: { uri }, position: positionFromLineCol(pos.line, pos.column), }); const summary = summarizeHover(result); return summary ? `- ${symbol}: ${summary}` : undefined; } catch { return undefined; } }); // 2. Blast radius: references to the edited symbol outside this file. const blastRadius = (async () => { try { const symbolResult = await sendRequest("textDocument/documentSymbol", { textDocument: { uri }, }); const enclosing = findEnclosingSymbol(flattenSymbols(symbolResult), fromLine - 1); if (!enclosing) return undefined; const refsResult = await sendRequest("textDocument/references", { textDocument: { uri }, position: { line: enclosing.selLine, character: enclosing.selChar }, context: { includeDeclaration: false }, }); if (!Array.isArray(refsResult)) return undefined; const external = (refsResult as LspLocation[]).filter((loc) => uriToPath(loc.uri) !== absPath); if (external.length === 0) return undefined; const prefix = `${cwd}/`; const shown = external.slice(0, 8).map((loc) => { const p = uriToPath(loc.uri); const rel = p.startsWith(prefix) ? p.slice(prefix.length) : p; return `${rel}:${loc.range.start.line + 1}`; }); const more = external.length > shown.length ? ` (+${external.length - shown.length} more)` : ""; return `${enclosing.name} is referenced in ${external.length} place${external.length === 1 ? "" : "s"} outside this file:\n${shown.map((s) => `- ${s}`).join("\n")}${more}`; } catch { return undefined; } })(); // 3. Fresh diagnostics for the edited file (skip if the edit tool already ran tsc). const freshDiagnostics = (async () => { if (hasExistingDiagnostics(event.content)) return undefined; try { await waitForDiagnostics(uri, 1500); const errors = (diagnosticsByUri.get(uri) ?? []).filter((d) => (d.severity ?? 1) === 1); if (errors.length === 0) return undefined; const shown = errors.slice(0, 8).map((d) => { const code = d.code ? ` [${d.code}]` : ""; return `- ${input.path}:${d.range.start.line + 1}:${d.range.start.character + 1}${code}: ${d.message.split("\n")[0]}`; }); const more = errors.length > shown.length ? ` (+${errors.length - shown.length} more)` : ""; return `${errors.length} error${errors.length === 1 ? "" : "s"} in this file after the edit:\n${shown.join("\n")}${more}`; } catch { return undefined; } })(); const [hoverLines, blast, diag] = await Promise.all([Promise.all(lookups), blastRadius, freshDiagnostics]); const related = hoverLines.filter((l): l is string => !!l); if (diag) sections.push(`[Diagnostics \u2014 auto-checked via LSP]\n${diag}`); if (blast) sections.push(`[Blast radius \u2014 auto-resolved via LSP]\n${blast}`); if (related.length > 0) { sections.push(`[Related symbols \u2014 auto-resolved via LSP, no extra tool call needed]\n${related.join("\n")}`); } if (sections.length === 0) return undefined; const contextBlock = `\n\n${sections.join("\n\n")}`; return { content: [...event.content, { type: "text" as const, text: contextBlock }] }; } catch { return undefined; } }); }); // ── Shutdown ──────────────────────────────────────────── elyra.on("session_shutdown", async () => { if (lspProcess && initialized) { try { await sendRequest("shutdown", null); sendNotification("exit", null); } catch { // ignore errors during shutdown } } if (lspProcess) { lspProcess.kill(); lspProcess = null; } initialized = false; openedFiles.clear(); diagnosticsByUri.clear(); buffer = Buffer.alloc(0); requestId = 0; for (const pending of pendingRequests.values()) { pending.reject(new Error("LSP server shutting down")); } pendingRequests.clear(); }); }