commit 61bca87bba76757fb9bbff1a293733447d7b64e6 Author: Evan Reichard Date: Sat Apr 25 21:06:15 2026 -0400 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..e325922 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# @pi/lsp + +LSP extension for pi coding agent. Provides LSP tools that the LLM can use to query language servers, plus automatic diagnostics after edit/write operations. + +## Features + +### LSP Tools (callable by LLM) + +- `lsp_hover` - Get hover documentation for a symbol +- `lsp_definition` - Find the definition of a symbol +- `lsp_references` - Find all references to a symbol +- `lsp_completion` - Get completion suggestions +- `lsp_documentSymbol` - Get the symbol outline of a file +- `lsp_diagnostics` - Get lint/type-check diagnostics + +### Auto-Check + +Automatically runs LSP diagnostics after `edit` or `write` tool calls. If issues are found, sends a message with the diagnostics to the LLM. + +**Enable/disable:** +```bash +pi --lsp-auto-check=false # Disable auto-check +pi --lsp-auto-check=true # Enable (default) +``` + +### Manual Check Command + +Run diagnostics manually on specific files: +```bash +/lsp-check main.go utils.go +``` + +## Install + +```bash +cd ~/.pi/extensions/lsp +npm install +``` + +## CLI Usage (for development/testing) + +``` +tsx ./cli.ts +``` + +`req_data_json` is the raw LSP params for the command, minus +`textDocument.uri` (we inject that from ``). + +### Commands + +- `hover` +- `definition` +- `references` +- `completion` +- `documentSymbol` +- `diagnostics` (waits briefly for the first `publishDiagnostics`) + +### Examples + +```bash +# Hover at line 224, col 23 (LSP is 0-indexed, so subtract 1) +npm run lsp -- backend/api/server.go hover \ + '{"position":{"line":223,"character":22}}' + +# Go to definition +npm run lsp -- backend/api/server.go definition \ + '{"position":{"line":223,"character":22}}' + +# Document symbols (no params needed) +npm run lsp -- backend/api/server.go documentSymbol '{}' + +# Diagnostics +npm run lsp -- backend/api/server.go diagnostics '{}' +``` + +Set `LSP_DEBUG=1` to forward server stderr. + +## Adding A Server + +Edit `server.ts`: + +```ts +{ + id: "rust-analyzer", + match: ["rs"], + command: "rust-analyzer", + args: [], + rootMarkers: ["Cargo.toml"], + languageId: "rust", +} +``` + +## Adding A Command + +1. Add to the `LspCommand` union in `src/types.ts`. +2. Add a handler in `src/commands.ts`. + +## Future + +- **Daemon with TTL** - `ServerConfig.idleTtlMs` is reserved for a future + daemon that keeps language servers alive per `(server.id, rootUri)` to + avoid cold-start latency. Not implemented; the CLI is short-lived and + spawns fresh each invocation. diff --git a/cli.ts b/cli.ts new file mode 100755 index 0000000..88d2b2b --- /dev/null +++ b/cli.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env -S npx tsx +import * as path from "node:path"; +import { startClientForFile } from "./src/client.ts"; +import { isLspCommand, listCommands, runCommand } from "./src/commands.ts"; +import { pickServer } from "./src/root.ts"; + +// Usage +function usage(): never { + process.stderr.write( + `Usage: cli.ts \n` + + `Commands: ${listCommands().join(", ")}\n` + + `Example:\n` + + ` cli.ts backend/api/server.go hover '{"position":{"line":223,"character":22}}'\n`, + ); + process.exit(2); +} + +async function main() { + const [, , fileArg, cmdArg, jsonArg] = process.argv; + if (!fileArg || !cmdArg || jsonArg === undefined) usage(); + + if (!isLspCommand(cmdArg)) { + process.stderr.write( + `Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`, + ); + process.exit(2); + } + + // Parse Request JSON + let params: Record; + try { + params = jsonArg.trim() === "" ? {} : JSON.parse(jsonArg); + } catch (err) { + process.stderr.write( + `Invalid JSON for req_data_json: ${(err as Error).message}\n`, + ); + process.exit(2); + } + + const filePath = path.resolve(fileArg); + const server = pickServer(filePath); + const { client, uri } = await startClientForFile(server, filePath); + + try { + const result = await runCommand(cmdArg, client, uri, params); + process.stdout.write(JSON.stringify(result, null, 2) + "\n"); + } finally { + // Fire-And-Forget Shutdown - With `gopls -remote=auto` (and similar) + // the spawned process is a thin client to a background daemon; a + // graceful shutdown can hang the parent. Kick it off but don't wait. + void client.dispose(); + } + // Hard Exit - Any lingering handles (LSP stdio, daemon stubs) would keep + // the event loop alive. For a short-lived CLI we just exit. + process.exit(0); +} + +main().catch((err) => { + process.stderr.write(`${(err as Error).stack ?? err}\n`); + process.exit(1); +}); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..16ef18f --- /dev/null +++ b/index.ts @@ -0,0 +1,484 @@ +// LSP Extension - Registers tools that let the LLM query language servers +// for hover, definition, references, completions, document symbols, and +// diagnostics. Each tool spawns a short-lived server, runs one request, +// and tears it down (same lifecycle as the CLI). +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "typebox"; +import * as path from "node:path"; +import { LspClient, uriToPath } from "./src/client.ts"; +import { pickServer, findRoot } from "./src/root.ts"; + +// Format Hover - Turn an LSP hover response into readable text. +function formatHover(result: unknown): string { + if (!result || typeof result !== "object") return "(no hover info)"; + const hover = result as { contents?: unknown }; + if (!hover.contents) return "(empty)"; + + // MarkupContent + if ( + "value" in hover.contents && + typeof (hover.contents as any).value === "string" + ) { + return (hover.contents as any).value; + } + // MarkedString | MarkedString[] + if (Array.isArray(hover.contents)) { + return hover.contents + .map((s: any) => (typeof s === "string" ? s : (s?.value ?? ""))) + .join("\n"); + } + if ( + "value" in hover.contents && + typeof (hover.contents as any).language === "string" + ) { + const ms = hover.contents as any; + return `\`\`\`${ms.language}\n${ms.value}\n\`\`\``; + } + return JSON.stringify(result, null, 2); +} + +// Format Definition - Turn definition locations into readable text. +function formatDefinition(result: unknown): string { + if (!result) return "(no definition found)"; + const locations = Array.isArray(result) ? result : [result]; + if (locations.length === 0) return "(no definition found)"; + + return locations + .map((loc: any, i: number) => { + const file = uriToPath(loc.uri); + const range = loc.range; + return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`; + }) + .join("\n"); +} + +// Format References - Turn reference locations into readable text. +function formatReferences(result: unknown): string { + if (!result || !Array.isArray(result)) return "(no references found)"; + if (result.length === 0) return "(no references found)"; + + return result + .map((loc: any, i: number) => { + const file = uriToPath(loc.uri); + const range = loc.range; + return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`; + }) + .join("\n"); +} + +// Format Completions - Turn completion items into readable text. +function formatCompletions(result: unknown): string { + if (!result) return "(no completions)"; + + // Resolve to CompletionItem[] if it's a CompletionList + let items: any[]; + if (Array.isArray(result)) { + items = result; + } else if ("items" in result && Array.isArray((result as any).items)) { + items = (result as any).items; + } else { + return JSON.stringify(result, null, 2); + } + + if (items.length === 0) return "(no completions)"; + + // Limit to top 30 to avoid flooding context + const limited = items.slice(0, 30); + const lines = limited.map((item: any) => { + let label = item.label; + if (item.kind) { + const kindNames: Record = { + 1: "text", + 2: "method", + 3: "function", + 4: "constructor", + 5: "field", + 6: "variable", + 7: "class", + 8: "interface", + 9: "module", + 10: "property", + 11: "unit", + 12: "value", + 13: "enum", + 14: "keyword", + 15: "snippet", + 16: "color", + 17: "file", + 18: "reference", + 19: "folder", + 20: "enumMember", + 21: "constant", + 22: "struct", + 23: "event", + 24: "operator", + 25: "typeParameter", + }; + label += ` (${kindNames[item.kind] ?? item.kind})`; + } + if (item.detail) label += ` — ${item.detail}`; + return `• ${label}`; + }); + + if (items.length > 30) { + lines.push(`\n... and ${items.length - 30} more`); + } + return lines.join("\n"); +} + +// Format Document Symbols - Turn symbols into a tree outline. +function formatDocumentSymbols(result: unknown): string { + if (!result || !Array.isArray(result)) return "(no symbols)"; + const symbols = result as any[]; + if (symbols.length === 0) return "(no symbols)"; + + const kindNames: Record = { + 1: "File", + 2: "Module", + 3: "Namespace", + 4: "Package", + 5: "Class", + 6: "Method", + 7: "Property", + 8: "Field", + 9: "Constructor", + 10: "Enum", + 11: "Interface", + 12: "Function", + 13: "Variable", + 14: "Constant", + 15: "String", + 16: "Number", + 17: "Boolean", + 18: "Array", + 19: "Object", + 20: "Key", + 21: "Null", + 22: "EnumMember", + 23: "Struct", + 24: "Event", + 25: "Operator", + 26: "TypeParameter", + }; + + function renderSymbol(sym: any, indent = 0): string { + const pad = " ".repeat(indent); + const kind = kindNames[sym.kind] ?? `kind:${sym.kind}`; + const line = sym.range?.start?.line ? sym.range.start.line + 1 : "?"; + let text = `${pad}${sym.name} [${kind}] :${line}`; + if (sym.children && Array.isArray(sym.children)) { + for (const child of sym.children) { + text += "\n" + renderSymbol(child, indent + 1); + } + } + return text; + } + + return symbols.map((s) => renderSymbol(s)).join("\n"); +} + +// Format Diagnostics - Turn diagnostic messages into readable text. +function formatDiagnostics(result: unknown): string { + if (!result || !("diagnostics" in result)) return "(no diagnostics)"; + const diags = (result as any).diagnostics; + if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)"; + + const severityNames: Record = { + 1: "Error", + 2: "Warning", + 3: "Info", + 4: "Hint", + }; + + return diags + .map((d: any, i: number) => { + const sev = severityNames[d.severity] ?? `sev:${d.severity}`; + const range = d.range; + const line = range?.start?.line != null ? range.start.line + 1 : "?"; + const col = + range?.start?.character != null ? range.start.character + 1 : "?"; + return `${i + 1}. [${sev}] ${d.message} (line ${line}, col ${col})`; + }) + .join("\n"); +} + +// Run LSP Request - Spawn a server, open the file, run one request, dispose. +// Mirrors the CLI lifecycle: fresh server per request. +async function runLsp( + filePath: string, + method: string, + params: Record, +): Promise { + const server = pickServer(filePath); + const rootDir = findRoot(filePath, server.rootMarkers); + const client = new LspClient(server); + + try { + await client.start(rootDir); + const uri = client.openDocument(filePath); + await client.waitForReady(); + // Populate textDocument.uri if the params have a textDocument field + if (params.textDocument && typeof params.textDocument === "object") { + params.textDocument = { ...params.textDocument, uri }; + } + return client.sendRequest(method, params); + } finally { + // Fire-and-forget shutdown; don't wait for graceful exit. + void client.dispose(); + } +} + +// Run LSP Diagnostics - Diagnostics arrive as a notification, so we use +// the dedicated waitForDiagnostics helper instead of sendRequest. +async function runDiagnostics(filePath: string): Promise { + const server = pickServer(filePath); + const rootDir = findRoot(filePath, server.rootMarkers); + const client = new LspClient(server); + + try { + await client.start(rootDir); + const uri = client.openDocument(filePath); + await client.waitForReady(); + return client.waitForDiagnostics(uri, 1500); + } finally { + void client.dispose(); + } +} + +// Shared Parameters Schema - All position-based tools accept file + optional +// position. The "file" field is required; position defaults to (0, 0). +const PositionSchema = Type.Object({ + file: Type.String({ description: "Path to the file to query" }), + line: Type.Optional( + Type.Integer({ description: "0-indexed line number (default: 0)" }), + ), + character: Type.Optional( + Type.Integer({ description: "0-indexed character offset (default: 0)" }), + ), +}); + +export default function (pi: ExtensionAPI) { + // Hover - Get hover documentation for the symbol at a position. + pi.registerTool({ + name: "lsp_hover", + label: "LSP Hover", + description: "Get hover documentation for a symbol at a position in a file", + promptSnippet: "Query LSP server for hover docs at a cursor position", + parameters: PositionSchema, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const filePath = path.resolve(ctx.cwd, params.file); + const lspParams = { + textDocument: {}, + position: { line: params.line ?? 0, character: params.character ?? 0 }, + }; + const result = await runLsp(filePath, "textDocument/hover", lspParams); + return { + content: [{ type: "text", text: formatHover(result) }], + details: { raw: result }, + }; + }, + }); + + // Definition - Find the definition location of a symbol. + pi.registerTool({ + name: "lsp_definition", + label: "LSP Definition", + description: "Find the definition of a symbol at a position in a file", + promptSnippet: "Query LSP server for symbol definition locations", + parameters: PositionSchema, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const filePath = path.resolve(ctx.cwd, params.file); + const lspParams = { + textDocument: {}, + position: { line: params.line ?? 0, character: params.character ?? 0 }, + }; + const result = await runLsp( + filePath, + "textDocument/definition", + lspParams, + ); + return { + content: [{ type: "text", text: formatDefinition(result) }], + details: { raw: result }, + }; + }, + }); + + // References - Find all references to a symbol. + pi.registerTool({ + name: "lsp_references", + label: "LSP References", + description: "Find all references to a symbol at a position in a file", + promptSnippet: "Query LSP server for all references to a symbol", + parameters: PositionSchema, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const filePath = path.resolve(ctx.cwd, params.file); + const lspParams = { + textDocument: {}, + position: { line: params.line ?? 0, character: params.character ?? 0 }, + context: { includeDeclaration: true }, + }; + const result = await runLsp( + filePath, + "textDocument/references", + lspParams, + ); + return { + content: [{ type: "text", text: formatReferences(result) }], + details: { raw: result }, + }; + }, + }); + + // Completion - Get completion suggestions at a position. + pi.registerTool({ + name: "lsp_completion", + label: "LSP Completion", + description: "Get completion suggestions at a position in a file", + promptSnippet: "Query LSP server for completion items at a cursor position", + parameters: PositionSchema, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const filePath = path.resolve(ctx.cwd, params.file); + const lspParams = { + textDocument: {}, + position: { line: params.line ?? 0, character: params.character ?? 0 }, + }; + const result = await runLsp( + filePath, + "textDocument/completion", + lspParams, + ); + return { + content: [{ type: "text", text: formatCompletions(result) }], + details: { raw: result }, + }; + }, + }); + + // Document Symbol - Get the outline/symbol tree of a file. + pi.registerTool({ + name: "lsp_documentSymbol", + label: "LSP Document Symbols", + description: "Get the symbol outline (classes, functions, etc.) of a file", + promptSnippet: "Query LSP server for document symbol outline", + parameters: Type.Object({ + file: Type.String({ description: "Path to the file" }), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const filePath = path.resolve(ctx.cwd, params.file); + const result = await runLsp(filePath, "textDocument/documentSymbol", { + textDocument: {}, + }); + return { + content: [{ type: "text", text: formatDocumentSymbols(result) }], + details: { raw: result }, + }; + }, + }); + + // Diagnostics - Get lint/type-check diagnostics for a file. + pi.registerTool({ + name: "lsp_diagnostics", + label: "LSP Diagnostics", + description: + "Get diagnostics (errors, warnings) for a file from its LSP server", + promptSnippet: "Query LSP server for file diagnostics (errors, warnings)", + parameters: Type.Object({ + file: Type.String({ description: "Path to the file" }), + }), + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const filePath = path.resolve(ctx.cwd, params.file); + const result = await runDiagnostics(filePath); + return { + content: [{ type: "text", text: formatDiagnostics(result) }], + details: { raw: result }, + }; + }, + }); + + // Auto-Check Flag - Enable automatic diagnostics after edit/write + pi.registerFlag("lsp-auto-check", { + description: + "Automatically run LSP diagnostics after edit/write operations", + type: "boolean", + default: true, + }); + + // Auto-Check After Edit/Write - Run diagnostics automatically + pi.on("tool_result", async (event, ctx) => { + // Check Enabled + if (!pi.getFlag("lsp-auto-check")) return; + + // Edit & Write Only + if (!["edit", "write"].includes(event.toolName)) return; + + // Skip Errors + if (event.isError) return; + + // Resolve Filepath + const filePath = event.input?.path; + if (!filePath || typeof filePath !== "string") return; + const absolutePath = path.resolve(ctx.cwd, filePath); + + try { + // Run LSP diagnostics + const result = await runDiagnostics(absolutePath); + const formatted = formatDiagnostics(result); + + // Only send a message if there are actual diagnostics + if (formatted !== "(no diagnostics)") { + pi.sendMessage( + { + customType: "lsp-auto-check", + content: `LSP found issues in ${filePath}:\n${formatted}`, + display: true, + details: { filePath, diagnostics: result }, + }, + { deliverAs: "steer" }, + ); + } + } catch (error) { + // Silently fail - don't interrupt the flow + // Only log if there's an actual error we care about + if (error && typeof error === "object" && "message" in error) { + const msg = (error as { message: string }).message; + if (!msg.includes("not found on PATH")) { + console.error("LSP auto-check failed:", msg); + } + } + } + }); + + // Manual Check Command - Run diagnostics on specific files + pi.registerCommand("lsp-check", { + description: "Run LSP diagnostics on one or more files", + handler: async (args, ctx) => { + const files = args ? args.split(/\s+/) : []; + + if (files.length === 0) { + ctx.ui.notify("Usage: /lsp-check [file2...]", "error"); + return; + } + + for (const file of files) { + const absolutePath = path.resolve(ctx.cwd, file); + try { + const result = await runDiagnostics(absolutePath); + const formatted = formatDiagnostics(result); + + if (formatted === "(no diagnostics)") { + ctx.ui.notify(`${file}: ✓ No issues`, "info"); + } else { + ctx.ui.notify(`${file}:\n${formatted}`, "warning"); + } + } catch (error) { + const msg = + error && typeof error === "object" && "message" in error + ? (error as { message: string }).message + : "Unknown error"; + ctx.ui.notify(`${file}: Failed to check - ${msg}`, "error"); + } + } + }, + }); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f77cae3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,631 @@ +{ + "name": "@pi/lsp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@pi/lsp", + "version": "0.1.0", + "dependencies": { + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver-protocol": "^3.17.5" + }, + "bin": { + "pi-lsp": "cli.ts" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c84fdfb --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "@pi/lsp", + "version": "0.1.0", + "private": true, + "description": "LSP tools for pi: hover, definition, references, completions, symbols, diagnostics.", + "pi": { + "extensions": ["./index.ts"] + }, + "type": "module", + "bin": { + "pi-lsp": "./cli.ts" + }, + "scripts": { + "lsp": "tsx ./cli.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver-protocol": "^3.17.5" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^6.0.3" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..2ce376d --- /dev/null +++ b/server.ts @@ -0,0 +1,33 @@ +// Server Registry - Maps a language id (or file extension) to an LSP server +// launcher plus the root markers that identify a project boundary. +// +// Add new servers here. `match` is a list of file extensions (no dot) OR +// language ids; either matches. +import type { ServerConfig } from "./src/types.ts"; + +export const servers: ServerConfig[] = [ + { + id: "gopls", + match: ["go"], + command: "gopls", + args: ["-remote=auto"], + rootMarkers: ["go.work", "go.mod"], + languageId: "go", + }, + { + id: "typescript-language-server", + match: ["ts", "tsx", "js", "jsx", "mts", "cts"], + command: "typescript-language-server", + args: ["--stdio"], + rootMarkers: ["pnpm-workspace.yaml", "tsconfig.json", "package.json"], + languageId: "typescript", + }, + { + id: "pyright", + match: ["py"], + command: "pyright-langserver", + args: ["--stdio"], + rootMarkers: ["pyproject.toml", "setup.py", "setup.cfg"], + languageId: "python", + }, +]; diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..1bcb76a --- /dev/null +++ b/src/client.ts @@ -0,0 +1,258 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import * as fs from "node:fs"; +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, + type MessageConnection, +} from "vscode-jsonrpc/node.js"; +import type { + InitializeParams, + PublishDiagnosticsParams, +} from "vscode-languageserver-protocol"; +import * as path from "node:path"; +import type { ServerConfig } from "./types.ts"; +import { findRoot, pathToUri, uriToPath } from "./root.ts"; + +// Is On PATH - Returns true if `cmd` resolves to an executable via the +// current PATH. Absolute/relative paths are checked directly. +function isOnPath(cmd: string): boolean { + const isExec = (p: string) => { + try { + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } + }; + if (cmd.includes(path.sep)) return isExec(cmd); + const exts = + process.platform === "win32" + ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") + : [""]; + for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { + if (!dir) continue; + for (const ext of exts) { + if (isExec(path.join(dir, cmd + ext))) return true; + } + } + return false; +} + +// LspClient - Thin wrapper that spawns a language server, performs the +// initialize handshake, auto-opens a file, and exposes sendRequest so the +// command dispatcher can forward raw LSP method calls. +// +// Method Names As Strings - We use plain method strings rather than the +// typed ProtocolRequestType constants to sidestep a version skew between +// vscode-jsonrpc and the copy re-exported by vscode-languageserver-protocol. +// +// Lifetime - One client per CLI invocation. On dispose() we do a best-effort +// shutdown/exit. A future daemon will reuse a client per (server.id, rootUri). +export class LspClient { + private proc!: ChildProcessWithoutNullStreams; + private conn!: MessageConnection; + private diagnostics = new Map(); + private diagListeners = new Set<(p: PublishDiagnosticsParams) => void>(); + // Progress Tracking - Servers like gopls aren't ready to serve requests + // until they finish their initial workspace load. They announce this via + // `$/progress` end notifications. We track outstanding tokens so callers + // can await readiness. + private progressTokens = new Set(); + private progressListeners = new Set<() => void>(); + + constructor(private readonly server: ServerConfig) {} + + // Start - Spawns the server process and wires up JSON-RPC. + async start(rootDir: string): Promise { + // Verify Binary On PATH - Fail fast with a clear message instead of + // letting spawn ENOENT surface as a generic error. It's the user's + // responsibility to have the server installed & on PATH. + if (!isOnPath(this.server.command)) { + throw new Error( + `LSP server binary "${this.server.command}" not found on PATH. ` + + `Install it and ensure it's on your PATH (required by server "${this.server.id}").`, + ); + } + this.proc = spawn(this.server.command, this.server.args, { + stdio: ["pipe", "pipe", "pipe"], + cwd: rootDir, + }); + this.proc.on("error", (err) => { + process.stderr.write( + `[lsp:${this.server.id}] spawn error: ${err.message}\n`, + ); + }); + // Drain Server Stderr - Many servers log verbosely; only forward when + // LSP_DEBUG is set so normal output stays clean. + this.proc.stderr.on("data", (chunk) => { + if (process.env.LSP_DEBUG) process.stderr.write(chunk); + }); + + this.conn = createMessageConnection( + new StreamMessageReader(this.proc.stdout), + new StreamMessageWriter(this.proc.stdin), + ); + + // Capture Diagnostics - Stored by URI, also fanned out to listeners so + // the diagnostics command can await the first publish. + this.conn.onNotification( + "textDocument/publishDiagnostics", + (p: PublishDiagnosticsParams) => { + this.diagnostics.set(p.uri, p); + for (const l of this.diagListeners) l(p); + }, + ); + + // Track Progress Tokens - Count begin/end so we know when the server + // has finished all startup work. We accept every createWorkDoneProgress + // request so servers (gopls) will actually send progress. + this.conn.onRequest( + "window/workDoneProgress/create", + (p: { token: string | number }) => { + this.progressTokens.add(p.token); + return null; + }, + ); + this.conn.onNotification( + "$/progress", + (p: { token: string | number; value: { kind: string } }) => { + if (p.value?.kind === "begin") this.progressTokens.add(p.token); + else if (p.value?.kind === "end") { + this.progressTokens.delete(p.token); + for (const l of this.progressListeners) l(); + } + }, + ); + // Accept Common Server Requests - Return empty/null so servers don't + // stall. Good enough for a CLI; a real client would answer properly. + this.conn.onRequest("workspace/configuration", () => []); + this.conn.onRequest("client/registerCapability", () => null); + this.conn.onRequest("client/unregisterCapability", () => null); + + this.conn.listen(); + + const rootUri = pathToUri(rootDir); + const params: InitializeParams = { + processId: process.pid, + rootUri, + workspaceFolders: [{ uri: rootUri, name: rootDir }], + capabilities: { + textDocument: { + hover: { contentFormat: ["markdown", "plaintext"] }, + definition: { linkSupport: false }, + references: {}, + completion: { completionItem: { snippetSupport: false } }, + documentSymbol: { hierarchicalDocumentSymbolSupport: true }, + publishDiagnostics: {}, + synchronization: { didSave: true }, + }, + workspace: { workspaceFolders: true, configuration: true }, + }, + }; + await this.conn.sendRequest("initialize", { + ...params, + capabilities: { + ...params.capabilities, + window: { workDoneProgress: true }, + }, + }); + this.conn.sendNotification("initialized", {}); + } + + // Wait For Ready - Resolves when there are no outstanding progress + // tokens, or after `timeoutMs`. For servers that never send progress, + // this effectively just waits for `graceMs` of quiet. + async waitForReady(timeoutMs = 15000, graceMs = 300): Promise { + const deadline = Date.now() + timeoutMs; + // Initial Grace - Give the server a moment to announce begin tokens. + await new Promise((r) => setTimeout(r, graceMs)); + while (this.progressTokens.size > 0 && Date.now() < deadline) { + await new Promise((resolve) => { + const onDone = () => { + clearTimeout(timer); + this.progressListeners.delete(onDone); + resolve(); + }; + const timer = setTimeout(onDone, Math.min(500, deadline - Date.now())); + this.progressListeners.add(onDone); + }); + } + } + + // Open Document - Reads the file from disk and sends didOpen. Most + // servers require this before they'll answer hover/definition/etc. + openDocument(filePath: string): string { + const uri = pathToUri(filePath); + const text = fs.readFileSync(filePath, "utf8"); + this.conn.sendNotification("textDocument/didOpen", { + textDocument: { + uri, + languageId: this.server.languageId ?? this.server.match[0], + version: 1, + text, + }, + }); + return uri; + } + + // Send Raw LSP Request - Passthrough used by the command dispatcher. + sendRequest(method: string, params: unknown): Promise { + return this.conn.sendRequest(method, params) as Promise; + } + + // Wait For Diagnostics - Resolves on the first publish for `uri` or + // after `timeoutMs`. Returns whatever we have for that URI. + async waitForDiagnostics( + uri: string, + timeoutMs: number, + ): Promise { + if (this.diagnostics.has(uri)) return this.diagnostics.get(uri)!; + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.diagListeners.delete(listener); + resolve(this.diagnostics.get(uri) ?? { uri, diagnostics: [] }); + }, timeoutMs); + const listener = (p: PublishDiagnosticsParams) => { + if (p.uri !== uri) return; + clearTimeout(timer); + this.diagListeners.delete(listener); + resolve(p); + }; + this.diagListeners.add(listener); + }); + } + + // Dispose - Best-effort shutdown; kills the process if it doesn't exit. + async dispose(): Promise { + if (this.conn) { + try { + await this.conn.sendRequest("shutdown", undefined); + this.conn.sendNotification("exit"); + } catch { + // Ignore - we're tearing down anyway. + } + this.conn.dispose(); + } + if (this.proc && !this.proc.killed) { + this.proc.kill(); + } + } +} + +// Convenience - Build a client, find root, start, and open the file. +export async function startClientForFile( + server: ServerConfig, + filePath: string, +): Promise<{ client: LspClient; uri: string; rootDir: string }> { + const rootDir = findRoot(filePath, server.rootMarkers); + const client = new LspClient(server); + await client.start(rootDir); + const uri = client.openDocument(filePath); + // Wait For Workspace Load - gopls & friends reject requests with errors + // like "no views" until their initial load completes. + await client.waitForReady(); + return { client, uri, rootDir }; +} + +export { uriToPath }; diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..d22d670 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,59 @@ +import type { LspClient } from "./client.ts"; +import type { LspCommand } from "./types.ts"; + +// Command Dispatcher - Each entry takes the user-supplied raw LSP params +// (minus textDocument.uri, which we inject from the opened file) and +// forwards to the appropriate LSP method. +// +// To add a new command: extend the LspCommand union in types.ts and add a +// handler here. +type Handler = ( + client: LspClient, + uri: string, + params: Record, +) => Promise; + +// Inject textDocument.uri - Saves callers from repeating it; they just +// pass e.g. {"position": {"line": 10, "character": 4}}. +function withDoc( + uri: string, + params: Record, +): Record { + const existing = (params.textDocument as Record) ?? {}; + return { ...params, textDocument: { uri, ...existing } }; +} + +const handlers: Record = { + hover: (c, uri, p) => c.sendRequest("textDocument/hover", withDoc(uri, p)), + definition: (c, uri, p) => + c.sendRequest("textDocument/definition", withDoc(uri, p)), + references: (c, uri, p) => { + const merged = withDoc(uri, p); + if (!("context" in merged)) merged.context = { includeDeclaration: true }; + return c.sendRequest("textDocument/references", merged); + }, + completion: (c, uri, p) => + c.sendRequest("textDocument/completion", withDoc(uri, p)), + documentSymbol: (c, uri, p) => + c.sendRequest("textDocument/documentSymbol", withDoc(uri, p)), + // Diagnostics - Pseudo-command; diagnostics arrive as a notification, so + // we wait briefly for the first publish after didOpen. + diagnostics: async (c, uri) => c.waitForDiagnostics(uri, 1500), +}; + +export function isLspCommand(v: string): v is LspCommand { + return v in handlers; +} + +export function listCommands(): LspCommand[] { + return Object.keys(handlers) as LspCommand[]; +} + +export function runCommand( + cmd: LspCommand, + client: LspClient, + uri: string, + params: Record, +): Promise { + return handlers[cmd](client, uri, params); +} diff --git a/src/root.ts b/src/root.ts new file mode 100644 index 0000000..050490e --- /dev/null +++ b/src/root.ts @@ -0,0 +1,40 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { servers } from "../server.ts"; +import type { ServerConfig } from "./types.ts"; + +// Resolve File URI To Local Path +export function uriToPath(uri: string): string { + return fileURLToPath(uri); +} + +// Resolve Local Path To File URI +export function pathToUri(p: string): string { + return pathToFileURL(path.resolve(p)).toString(); +} + +// Pick Server By File Extension - match[] entries are matched against the +// file's extension (no dot). First server in the registry wins. +export function pickServer(filePath: string): ServerConfig { + const ext = path.extname(filePath).replace(/^\./, ""); + const hit = servers.find((s) => s.match.includes(ext)); + if (!hit) { + throw new Error(`No LSP server registered for extension ".${ext}"`); + } + return hit; +} + +// Find Project Root By Walking Upward - stops at the first directory +// containing any rootMarker. Falls back to the file's directory. +export function findRoot(filePath: string, markers: string[]): string { + let dir = path.dirname(path.resolve(filePath)); + const { root } = path.parse(dir); + while (true) { + for (const m of markers) { + if (fs.existsSync(path.join(dir, m))) return dir; + } + if (dir === root) return path.dirname(path.resolve(filePath)); + dir = path.dirname(dir); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..cf5e266 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,28 @@ +export interface ServerConfig { + // Stable identifier (useful for logs and future daemon cache keys). + id: string; + // File extensions (no dot) or LSP language ids this server handles. + match: string[]; + // Executable to spawn. + command: string; + // Arguments passed to the executable. + args: string[]; + // Files/directories whose presence marks the project root. Searched + // upward from the target file; first match wins. + rootMarkers: string[]; + // LSP languageId sent in didOpen. Defaults to match[0] if omitted. + languageId?: string; + // TTL Planning - When we eventually add a daemon, servers will be kept + // alive per (id, rootUri) for this many ms of idleness. Not used yet. + idleTtlMs?: number; +} + +// Supported high-level commands exposed via the CLI. Extend this union +// (and the dispatcher in src/commands.ts) to add more. +export type LspCommand = + | "hover" + | "definition" + | "references" + | "completion" + | "documentSymbol" + | "diagnostics"; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3b67c97 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["cli.ts", "server.ts", "src/**/*.ts"] +}