/** * Minimal LSP client for LanguageServer.jl. * * Speaks JSON-RPC 2.0 over stdio with Content-Length framing. * Handles request/response correlation and push notifications (diagnostics). */ import { spawn, type ChildProcess } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; // ─── Types ──────────────────────────────────────────────────────────────────── export interface LspPosition { line: number; // 0-based character: number; // 0-based } export interface LspRange { start: LspPosition; end: LspPosition; } export interface LspDiagnostic { range: LspRange; severity: 1 | 2 | 3 | 4; // Error | Warning | Info | Hint message: string; source?: string; code?: string | number; } export interface LspLocation { uri: string; range: LspRange; } export interface LspCompletionItem { label: string; detail?: string; documentation?: string | { value: string }; kind?: number; } const SEVERITY: Record = { 1: "ERROR", 2: "WARN", 3: "INFO", 4: "HINT" }; // ─── Formatting helpers ─────────────────────────────────────────────────────── export function formatDiagnostics(diags: LspDiagnostic[], filePath?: string): string { if (diags.length === 0) return filePath ? `No diagnostics for ${path.basename(filePath)}.` : "No diagnostics."; return diags .map((d) => { const line = d.range.start.line + 1; const col = d.range.start.character + 1; const sev = SEVERITY[d.severity] ?? "INFO"; return `${sev} [${line}:${col}] ${d.message}`; }) .join("\n"); } export function formatAllDiagnostics(map: Map): string { const lines: string[] = []; for (const [uri, diags] of map) { if (diags.length === 0) continue; const file = uri.replace("file://", ""); lines.push(`### ${path.basename(file)}`); lines.push(formatDiagnostics(diags)); } return lines.length ? lines.join("\n\n") : "No diagnostics in any open file."; } // ─── LspClient ──────────────────────────────────────────────────────────────── export class LspClient { readonly proc: ChildProcess; private buf = Buffer.alloc(0); private pending = new Map void; reject: (e: unknown) => void; }>(); private seq = 1; private diagnostics = new Map(); private openDocs = new Map(); // uri → version constructor(proc: ChildProcess) { this.proc = proc; proc.stdout!.on("data", (chunk: Buffer) => this.onData(chunk)); proc.on("exit", (code) => { for (const { reject } of this.pending.values()) reject(new Error(`LSP process exited (code ${code})`)); this.pending.clear(); }); } // ── I/O ──────────────────────────────────────────────────────────────── private onData(chunk: Buffer): void { this.buf = Buffer.concat([this.buf, chunk]); let msg: unknown; while ((msg = this.shift()) !== null) this.dispatch(msg as Record); } private shift(): unknown | null { const head = this.buf.toString("ascii", 0, Math.min(this.buf.length, 128)); const m = head.match(/Content-Length: (\d+)\r\n\r\n/); if (!m) return null; const hdrEnd = this.buf.indexOf("\r\n\r\n") + 4; const len = parseInt(m[1], 10); if (this.buf.length < hdrEnd + len) return null; const body = this.buf.subarray(hdrEnd, hdrEnd + len).toString("utf-8"); this.buf = this.buf.subarray(hdrEnd + len); return JSON.parse(body); } private write(msg: Record): void { const body = JSON.stringify(msg); const hdr = `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n`; this.proc.stdin!.write(hdr + body); } // ── Dispatch ──────────────────────────────────────────────────────────── private dispatch(msg: Record): void { // Response to a request we sent if (typeof msg.id === "number" && this.pending.has(msg.id)) { const { resolve, reject } = this.pending.get(msg.id)!; this.pending.delete(msg.id); msg.error ? reject(msg.error) : resolve(msg.result ?? null); return; } // Server-initiated notification if (msg.method === "textDocument/publishDiagnostics") { const p = msg.params as { uri: string; diagnostics: LspDiagnostic[] }; this.diagnostics.set(p.uri, p.diagnostics); } // All other server→client notifications ignored (window/logMessage, etc.) } // ── Public API ────────────────────────────────────────────────────────── request(method: string, params: unknown, timeoutMs = 15_000): Promise { const id = this.seq++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.write({ jsonrpc: "2.0", id, method, params }); setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`LSP timeout: ${method}`)); } }, timeoutMs); }); } notify(method: string, params: unknown): void { this.write({ jsonrpc: "2.0", method, params }); } // ── Lifecycle ─────────────────────────────────────────────────────────── async initialize(projectPath: string): Promise { await this.request("initialize", { processId: process.pid, rootUri: pathToUri(projectPath), capabilities: { textDocument: { hover: { contentFormat: ["markdown", "plaintext"] }, completion: { completionItem: { documentationFormat: ["plaintext"] } }, publishDiagnostics: { relatedInformation: false }, definition: { linkSupport: false }, }, workspace: { workspaceFolders: true }, }, workspaceFolders: [ { uri: pathToUri(projectPath), name: path.basename(projectPath) }, ], }, 90_000); // LS can be slow to start — allow 90s this.notify("initialized", {}); } shutdown(): void { try { this.write({ jsonrpc: "2.0", id: this.seq++, method: "shutdown", params: null }); this.notify("exit", null); } catch {} setTimeout(() => { try { this.proc.kill("SIGTERM"); } catch {} }, 1_000); } // ── Document sync ─────────────────────────────────────────────────────── isOpen(filePath: string): boolean { return this.openDocs.has(pathToUri(filePath)); } openDoc(filePath: string): void { const uri = pathToUri(filePath); if (this.openDocs.has(uri)) return; const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf-8") : ""; this.notify("textDocument/didOpen", { textDocument: { uri, languageId: "julia", version: 1, text }, }); this.openDocs.set(uri, 1); } syncDoc(filePath: string): void { const uri = pathToUri(filePath); const version = (this.openDocs.get(uri) ?? 0) + 1; const text = fs.readFileSync(filePath, "utf-8"); this.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }], }); this.openDocs.set(uri, version); } closeDoc(filePath: string): void { const uri = pathToUri(filePath); if (!this.openDocs.has(uri)) return; this.notify("textDocument/didClose", { textDocument: { uri } }); this.openDocs.delete(uri); } // Open an in-memory document using an untitled: URI (no temp file, no disk I/O). // LanguageServer.jl treats untitled docs as a first-class concept and will not // publish diagnostics for them, so callers must NOT use ensureDocOpen after this. openUntitled(name: string, content: string): string { const uri = `untitled:${name}`; this.notify("textDocument/didOpen", { textDocument: { uri, languageId: "julia", version: 1, text: content }, }); this.openDocs.set(uri, 1); return uri; } closeUntitled(uri: string): void { if (!this.openDocs.has(uri)) return; this.notify("textDocument/didClose", { textDocument: { uri } }); this.openDocs.delete(uri); } async hoverByUri(uri: string, line: number, character: number): Promise { const result = await this.request("textDocument/hover", { textDocument: { uri }, position: { line, character }, }) as { contents?: unknown } | null; if (!result?.contents) return null; return extractMarkup(result.contents); } // Fetch a docstring by symbol name without touching the filesystem. // Uses an untitled: document — LanguageServer.jl stores it in memory only. // Diagnostics are never published for untitled docs, so we poll hover directly // until the LS has parsed the content and returns a non-null result. async docByName(symbol: string, timeoutMs = 10_000): Promise { const uri = this.openUntitled(`pi-julia-doc-${Date.now()}.jl`, symbol + "\n"); try { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const result = await this.hoverByUri(uri, 0, 0); if (result !== null) return result; await new Promise((r) => setTimeout(r, 300)); } return null; } finally { this.closeUntitled(uri); } } // ── Diagnostics ───────────────────────────────────────────────────────── getDiagnostics(filePath: string): LspDiagnostic[] { return this.diagnostics.get(pathToUri(filePath)) ?? []; } getAllDiagnostics(): Map { return this.diagnostics; } hasDiagnosticsFor(filePath: string): boolean { return this.diagnostics.has(pathToUri(filePath)); } // ── LSP requests ──────────────────────────────────────────────────────── async hover(filePath: string, line: number, character: number): Promise { const result = await this.request("textDocument/hover", { textDocument: { uri: pathToUri(filePath) }, position: { line, character }, }) as { contents?: unknown } | null; if (!result?.contents) return null; return extractMarkup(result.contents); } async complete( filePath: string, line: number, character: number ): Promise { const result = await this.request("textDocument/completion", { textDocument: { uri: pathToUri(filePath) }, position: { line, character }, context: { triggerKind: 1 }, }) as LspCompletionItem[] | { items: LspCompletionItem[] } | null; if (!result) return []; return Array.isArray(result) ? result : result.items ?? []; } async definition(filePath: string, line: number, character: number): Promise { const result = await this.request("textDocument/definition", { textDocument: { uri: pathToUri(filePath) }, position: { line, character }, }) as LspLocation | LspLocation[] | null; if (!result) return []; return Array.isArray(result) ? result : [result]; } } // ─── Utilities ──────────────────────────────────────────────────────────────── function pathToUri(filePath: string): string { // Ensure absolute path; handle Windows drive letters if ever needed const abs = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); return `file://${abs}`; } function extractMarkup(contents: unknown): string { if (typeof contents === "string") return contents; if (Array.isArray(contents)) return contents.map(extractMarkup).filter(Boolean).join("\n\n"); if (contents && typeof contents === "object") { const c = contents as Record; if (typeof c.value === "string") return c.value; } return String(contents); } // ─── Launch helper ──────────────────────────────────────────────────────────── export function spawnLsp(projectPath: string, lsEnvPath: string): LspClient { // runserver(pipe_in, pipe_out, env_path) — all positional, env_path is NOT a keyword arg. // Pass projectPath as ARGS[1]; runserver's choose_env() picks it up as the first arg. const proc = spawn( "julia", [ "--startup-file=no", `--project=${lsEnvPath}`, "-e", "using LanguageServer; runserver(stdin, stdout, ARGS[1])", projectPath, // → ARGS[1] → env_path ], { stdio: ["pipe", "pipe", "ignore"], // stderr discarded — LS logs go there detached: false, } ); proc.unref(); return new LspClient(proc); } // Find the Julia version string (major.minor) for environment path matching async function juliaVersion( execFn: (cmd: string, args: string[], opts: { timeout: number }) => Promise<{ stdout: string }> ): Promise { const r = await execFn("julia", ["--startup-file=no", "-e", 'v=VERSION; println("$(v.major).$(v.minor)")'], { timeout: 10_000 }); return r.stdout.trim() || "1.12"; } // Locate the LanguageServer.jl environment to use, in priority order: // 1. VS Code Julia extension bundled env (already installed, no download needed) // 2. Julia global environment (requires LanguageServer.jl to be installed there) export async function findLsEnv( execFn: (cmd: string, args: string[], opts: { timeout: number }) => Promise<{ stdout: string; code: number }> ): Promise { const ver = await juliaVersion(execFn); // 1. VS Code extension env (glob for any installed version) const vscodeBase = path.join(os.homedir(), ".vscode", "extensions"); if (fs.existsSync(vscodeBase)) { const entries = fs.readdirSync(vscodeBase) .filter((d) => d.startsWith("julialang.language-julia-")) .sort() .reverse(); // prefer newest version for (const ext of entries) { const envPath = path.join(vscodeBase, ext, "scripts", "environments", "languageserver", `v${ver}`); if (fs.existsSync(envPath)) return envPath; const fallback = path.join(vscodeBase, ext, "scripts", "environments", "languageserver", "fallback"); if (fs.existsSync(fallback)) return fallback; } } // 2. Global env — check if LanguageServer.jl is already there const probe = await execFn( "julia", ["--startup-file=no", "-e", 'using LanguageServer; println("ok")'], { timeout: 30_000 } ); if (probe.stdout.trim() === "ok") return ""; // empty = use default global env // 3. Install into global env const install = await execFn( "julia", ["--startup-file=no", "-e", 'import Pkg; Pkg.add("LanguageServer")'], { timeout: 300_000 } ); return install.code === 0 ? "" : null; }