diff --git a/AGENTS.md b/AGENTS.md index e230368..e77923c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,22 +55,42 @@ A per-entry `serializer` promise chain prevents concurrent syncs from racing. ``` index.ts — Extension entry point (tools, commands, auto-check flag) -server.ts — LSP server registry (gopls, typescript-language-server, pyright) +server.ts — Built-in LSP server registry (gopls, typescript-language-server, pyright, ...) cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon) daemon.ts — Entrypoint that starts the daemon process src/ client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync commands.ts — CLI command dispatcher (maps command names → LSP methods) + config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.) daemonProtocol.ts — Shared types, socket path, NDJSON send/receive, autospawn logic - root.ts — pickServer(), findRoot(), URI/path conversion + root.ts — pickServer(), findServerById(), getServersForPath(), findRoot(), URI/path conversion types.ts — ServerConfig interface, LspCommand union ``` -## Adding a Server +### Per-Repo Config (`.pi-lsp.json`) -Edit `server.ts`. Add an entry to the `servers` array: +Users can add/override/disable servers without editing `server.ts`. `src/config.ts` +walks upward from a given path to find `.pi-lsp.json`, parses it, and merges +with the built-in `servers` list: + +- New `id` → appended (must supply `match`, `command`, `args`, `rootMarkers`). +- Existing `id` → shallow-merged over the built-in (user fields win). +- `disable: []` → filtered out at the end. + +Results are cached per config path, invalidated by mtime. `getServersForPath(p)` +is the **single entry point** — don't import the raw `servers` array from +`server.ts` outside `src/config.ts`. The daemon resolves servers at +`getOrCreateEntry()` time via `findServerById(filePath, id)`, so spawned +servers reflect the config of the file being acted on. **Already-running** +entries don't see config changes; users must `/lsp-destroy` to respawn. + +## Adding a Server (Built-In) + +For servers shipped with pi-lsp, edit `server.ts`. (For per-repo additions, +users should drop a `.pi-lsp.json` at the repo root — see README.) Add an entry +to the `servers` array: ```typescript { diff --git a/README.md b/README.md index 9ac2a0d..6589568 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,44 @@ Edit `server.ts`: } ``` +## Per-Repo Config (`.pi-lsp.json`) + +Drop a `.pi-lsp.json` at any ancestor of your working files to add or override +servers for that repo. The nearest config (walking upward) wins. + +```json +{ + "servers": [ + { + "id": "rust-analyzer", + "match": ["rs"], + "command": "rust-analyzer", + "args": [], + "rootMarkers": ["Cargo.toml"], + "languageId": "rust" + }, + { "id": "gopls", "args": ["-remote=auto", "-vv"] } + ], + "disable": ["oxlint"] +} +``` + +**Merge rules:** + +- Entry with a built-in `id` → fields shallow-merge over the built-in (user wins). +- Entry with a new `id` → appended; must include `match`, `command`, `args`, `rootMarkers`. +- `disable` → filters out matching ids (built-in or user-defined). + +**Reloading after edits:** Config is re-read on mtime change, so new lookups +pick up changes automatically. However, **already-running** language servers in +the daemon keep their original spawn args. If you change `command`, `args`, or +`rootMarkers`, run `/lsp-destroy` (or `pi-lsp daemon stop`) so they respawn +with the new config. + +**Security note:** `.pi-lsp.json` controls what binary pi-lsp spawns. Treat it +like `.vscode/settings.json` — don't accept untrusted configs from arbitrary +repos. + ## Adding A Command 1. Add to the `LspCommand` union in `src/types.ts`. diff --git a/cli.ts b/cli.ts index 24070f3..ee25bf1 100755 --- a/cli.ts +++ b/cli.ts @@ -2,8 +2,7 @@ import * as path from "node:path"; import { startClientForFile } from "./src/client.ts"; import { isLspCommand, listCommands, runCommand } from "./src/commands.ts"; -import { pickServer, isServerAvailable } from "./src/root.ts"; -import { servers } from "./server.ts"; +import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts"; import { daemonDiagnostics, daemonRequest, @@ -105,9 +104,10 @@ async function runViaDaemon( const filePath = path.resolve(fileArg); let result: unknown; if (cmdArg === "diagnostics") { - // Pick All Available Servers For Diagnostics + // Pick All Available Servers For Diagnostics - Resolves against any + // `.pi-lsp.json` reachable from the file so per-repo overrides apply. const ext = path.extname(filePath).replace(/^\./, ""); - const serverIds = servers + const serverIds = getServersForPath(filePath) .filter((s) => s.match.includes(ext) && isServerAvailable(s)) .map((s) => s.id); result = await daemonDiagnostics(filePath, serverIds); diff --git a/index.ts b/index.ts index fda11d8..6a7bc05 100644 --- a/index.ts +++ b/index.ts @@ -12,8 +12,7 @@ import { daemonRequest, daemonStatus, } from "./src/daemonClient.ts"; -import { pickServer, isServerAvailable } from "./src/root.ts"; -import { servers } from "./server.ts"; +import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts"; import { ServerNotFoundError, UnsupportedExtensionError, @@ -326,10 +325,11 @@ async function runLsp( } // Pick Diagnostic Servers - Returns all available, non-disabled servers -// matching the file's extension. Used for fan-out diagnostics. +// matching the file's extension. Resolves the per-repo config from the +// file's directory so user-defined servers participate in fan-out. function pickDiagnosticServers(filePath: string): string[] { const ext = path.extname(filePath).replace(/^\./, ""); - return servers + return getServersForPath(filePath) .filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id)) .map((s) => s.id); } @@ -541,7 +541,9 @@ export default function (pi: ExtensionAPI) { if (!pi.getFlag("lsp-auto-check")) return; // Skip If All Disabled - No LSP server is available for this instance. - if (servers.every((s) => disabledServers.has(s.id))) return; + // Resolve against ctx.cwd so user-defined servers count toward this check. + const cwdServers = getServersForPath(ctx.cwd); + if (cwdServers.every((s) => disabledServers.has(s.id))) return; // Edit & Write Only if (!["edit", "write"].includes(event.toolName)) return; @@ -619,9 +621,16 @@ export default function (pi: ExtensionAPI) { // --- Server Control Commands --- + // Resolve Active Servers - Per-cwd helper so commands see the same + // per-repo overrides the LLM does. We accept an optional cwd because some + // closures (e.g. completions) don't get a context. + const resolveServers = (cwd?: string) => getServersForPath(cwd ?? process.cwd()); + // Shared Argument Completions - Suggests registered server IDs plus "all". + // Uses process.cwd() since completion handlers don't receive a context; + // close enough for argument hints. const serverCompletions = (prefix: string) => { - const ids = [...servers.map((s) => s.id), "all"].filter((id) => + const ids = [...resolveServers().map((s) => s.id), "all"].filter((id) => id.startsWith(prefix), ); return ids.length > 0 ? ids.map((id) => ({ value: id, label: id })) : null; @@ -629,16 +638,17 @@ export default function (pi: ExtensionAPI) { // Parse Server IDs - Validates args against registered servers. Bare/empty // or "all" returns every server ID. Throws on unknown names. - function parseServerIds(args: string | undefined): string[] { - if (!args || !args.trim()) return servers.map((s) => s.id); + function parseServerIds(args: string | undefined, cwd: string): string[] { + const list = resolveServers(cwd); + if (!args || !args.trim()) return list.map((s) => s.id); const ids = args.trim().split(/\s+/); - if (ids.includes("all")) return servers.map((s) => s.id); - const invalid = ids.filter((id) => !servers.some((s) => s.id === id)); + if (ids.includes("all")) return list.map((s) => s.id); + const invalid = ids.filter((id) => !list.some((s) => s.id === id)); if (invalid.length > 0) { throw new Error( `Unknown server(s): ${invalid.join( ", ", - )}. Available: ${servers.map((s) => s.id).join(", ")}`, + )}. Available: ${list.map((s) => s.id).join(", ")}`, ); } return ids; @@ -647,9 +657,10 @@ export default function (pi: ExtensionAPI) { // Update Tool Visibility - When all servers are disabled, remove LSP tools // from the active set so the LLM won't attempt them. When any is enabled, // restore them. Captures current active tools at toggle time. - function updateToolVisibility(): void { + function updateToolVisibility(cwd: string): void { + const list = resolveServers(cwd); const current = pi.getActiveTools(); - if (servers.every((s) => disabledServers.has(s.id))) { + if (list.every((s) => disabledServers.has(s.id))) { // All disabled — strip LSP tools pi.setActiveTools(current.filter((name) => !lspToolNames.includes(name))); } else { @@ -699,13 +710,13 @@ export default function (pi: ExtensionAPI) { getArgumentCompletions: serverCompletions, handler: async (args, ctx) => { try { - const ids = parseServerIds(args); + const ids = parseServerIds(args, ctx.cwd); for (const id of ids) { disabledServers.add(id); } - updateToolVisibility(); - const label = - ids.length === servers.length ? "all servers" : ids.join(", "); + updateToolVisibility(ctx.cwd); + const total = resolveServers(ctx.cwd).length; + const label = ids.length === total ? "all servers" : ids.join(", "); ctx.ui.notify(`Disabled: ${label}`, "info"); } catch (error) { const msg = @@ -725,13 +736,13 @@ export default function (pi: ExtensionAPI) { getArgumentCompletions: serverCompletions, handler: async (args, ctx) => { try { - const ids = parseServerIds(args); + const ids = parseServerIds(args, ctx.cwd); for (const id of ids) { disabledServers.delete(id); } - updateToolVisibility(); - const label = - ids.length === servers.length ? "all servers" : ids.join(", "); + updateToolVisibility(ctx.cwd); + const total = resolveServers(ctx.cwd).length; + const label = ids.length === total ? "all servers" : ids.join(", "); ctx.ui.notify(`Enabled: ${label}`, "info"); } catch (error) { const msg = @@ -751,16 +762,16 @@ export default function (pi: ExtensionAPI) { getArgumentCompletions: serverCompletions, handler: async (args, ctx) => { try { - const ids = parseServerIds(args); - if (ids.length === servers.length) { + const ids = parseServerIds(args, ctx.cwd); + const total = resolveServers(ctx.cwd).length; + if (ids.length === total) { await daemonDestroyServer(); } else { for (const id of ids) { await daemonDestroyServer(id); } } - const label = - ids.length === servers.length ? "all servers" : ids.join(", "); + const label = ids.length === total ? "all servers" : ids.join(", "); ctx.ui.notify(`Destroyed: ${label}`, "info"); } catch (error) { const msg = diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..13f3396 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,141 @@ +// Per-Repo Config - Loads `.pi-lsp.json` from the nearest ancestor of a +// given path and merges it with the built-in server registry. Config is a +// JSON file shaped like: +// +// { +// "servers": [ +// { "id": "rust-analyzer", "match": ["rs"], "command": "rust-analyzer", +// "args": [], "rootMarkers": ["Cargo.toml"], "languageId": "rust" }, +// { "id": "gopls", "args": ["-remote=auto", "-vv"] } +// ], +// "disable": ["oxlint"] +// } +// +// Merge Semantics: +// - Entries whose `id` matches a built-in shallow-merge their fields (user wins). +// - Entries with a new `id` append, and must include all required fields. +// - The `disable` list filters the merged result by id. +// +// Caching: Resolved lists are cached per config-file path and invalidated by +// mtime. If no config file exists, the built-in registry is returned. +import * as fs from "node:fs"; +import * as path from "node:path"; +import { servers as builtinServers } from "../server.ts"; +import type { ServerConfig } from "./types.ts"; + +const CONFIG_FILE = ".pi-lsp.json"; + +interface PiLspConfig { + servers?: Array & { id: string }>; + disable?: string[]; +} + +interface CacheEntry { + mtimeMs: number; + resolved: ServerConfig[]; +} + +const cache = new Map(); + +// Find Config File - Walks upward from `fromDir` looking for `.pi-lsp.json`. +// Returns the absolute path or null if none found before the filesystem root. +function findConfigFile(fromDir: string): string | null { + let dir = path.resolve(fromDir); + const { root } = path.parse(dir); + while (true) { + const candidate = path.join(dir, CONFIG_FILE); + if (fs.existsSync(candidate)) return candidate; + if (dir === root) return null; + dir = path.dirname(dir); + } +} + +// Merge Config - Built-ins by id, user overrides shallow-merge in, new ids +// append (with required-field validation), and `disable` filters the result. +function mergeConfig(config: PiLspConfig, sourcePath: string): ServerConfig[] { + const byId = new Map( + builtinServers.map((s) => [s.id, { ...s }]), + ); + + // Apply Overrides And New Servers + for (const override of config.servers ?? []) { + if (!override.id || typeof override.id !== "string") { + throw new Error( + `pi-lsp config (${sourcePath}): every entry in "servers" must have a string "id"`, + ); + } + const existing = byId.get(override.id); + if (existing) { + byId.set(override.id, { ...existing, ...override } as ServerConfig); + } else { + // New Server - Must include all required spawn fields. + const required = ["match", "command", "args", "rootMarkers"] as const; + const missing = required.filter((k) => override[k] === undefined); + if (missing.length > 0) { + throw new Error( + `pi-lsp config (${sourcePath}): new server "${override.id}" is missing required field(s): ${missing.join(", ")}`, + ); + } + byId.set(override.id, override as ServerConfig); + } + } + + // Apply Disable Filter + const disabled = new Set(config.disable ?? []); + return Array.from(byId.values()).filter((s) => !disabled.has(s.id)); +} + +// Get Servers For Path - Returns the merged ServerConfig list applicable to +// `fromDir` (or the directory containing a file path). Cached per config-file +// path with mtime invalidation. On parse/merge failure, logs to stderr and +// falls back to the built-in registry so a broken config never breaks LSP. +export function getServersForPath(fromPath: string): ServerConfig[] { + // Resolve To Directory - Accept either a file path or a directory. + let dir = path.resolve(fromPath); + try { + const st = fs.statSync(dir); + if (!st.isDirectory()) dir = path.dirname(dir); + } catch { + dir = path.dirname(dir); + } + + const configPath = findConfigFile(dir); + if (!configPath) return builtinServers; + + let mtimeMs: number; + try { + mtimeMs = fs.statSync(configPath).mtimeMs; + } catch { + return builtinServers; + } + + const cached = cache.get(configPath); + if (cached && cached.mtimeMs === mtimeMs) return cached.resolved; + + let parsed: PiLspConfig; + try { + const raw = fs.readFileSync(configPath, "utf8"); + parsed = JSON.parse(raw); + } catch (err) { + process.stderr.write( + `pi-lsp: failed to read/parse ${configPath}: ${(err as Error).message}\n`, + ); + return builtinServers; + } + + let resolved: ServerConfig[]; + try { + resolved = mergeConfig(parsed, configPath); + } catch (err) { + process.stderr.write(`pi-lsp: ${(err as Error).message}\n`); + return builtinServers; + } + + cache.set(configPath, { mtimeMs, resolved }); + return resolved; +} + +// Clear Config Cache - Test hook; not used in production. +export function clearConfigCache(): void { + cache.clear(); +} diff --git a/src/daemon.ts b/src/daemon.ts index 10d39d5..a7f3729 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -6,9 +6,8 @@ import * as fs from "node:fs"; import * as net from "node:net"; import * as path from "node:path"; import { LspClient } from "./client.ts"; -import { findRoot, pathToUri } from "./root.ts"; +import { findRoot, findServerById, pathToUri } from "./root.ts"; import type { ServerConfig } from "./types.ts"; -import { servers } from "../server.ts"; import { logPath, socketPath, @@ -51,25 +50,17 @@ function log(...args: unknown[]) { ); } -// Find Server By ID - Looks up a ServerConfig from the registry by ID. -// Throws if the ID is not registered. -function findServerById(serverId: string): ServerConfig { - const server = servers.find((s) => s.id === serverId); - if (!server) { - throw new Error(`Unknown server ID: "${serverId}". Registered: ${servers.map((s) => s.id).join(", ")}`); - } - return server; -} - // Get Or Create Entry - Looks up the cached client for a server+file, // spawning a fresh LspClient if needed. The returned entry is guaranteed -// to have its `ready` promise resolved before the caller uses it. +// to have its `ready` promise resolved before the caller uses it. The +// server registry is resolved against any `.pi-lsp.json` reachable from +// `filePath`, so per-repo config overrides take effect at spawn time. async function getOrCreateEntry( filePath: string, serverId: string, launch: LaunchContext, ): Promise { - const server = findServerById(serverId); + const server = findServerById(filePath, serverId); const rootDir = findRoot(filePath, server.rootMarkers); const key = `${server.id}::${rootDir}`; const existing = entries.get(key); diff --git a/src/root.ts b/src/root.ts index 09e8edd..53288a7 100644 --- a/src/root.ts +++ b/src/root.ts @@ -1,11 +1,16 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { pathToFileURL, fileURLToPath } from "node:url"; -import { servers, globalRootMarkers } from "../server.ts"; +import { globalRootMarkers } from "../server.ts"; +import { getServersForPath } from "./config.ts"; import type { ServerConfig } from "./types.ts"; import { UnsupportedExtensionError } from "./types.ts"; import { isOnPath } from "./util.ts"; +// Re-Export - Centralizes the path-aware registry helper so callers can +// import it from `./root.ts` alongside pickServer/findRoot. +export { getServersForPath }; + // Resolve File URI To Local Path export function uriToPath(uri: string): string { return fileURLToPath(uri); @@ -31,15 +36,30 @@ export function isServerAvailable(server: ServerConfig): boolean { // Pick Server By File Extension - match[] entries are matched against the // file's extension (no dot). First available, non-diagnosticsOnly server wins. +// Resolves the per-repo config from the file's directory before matching. export function pickServer(filePath: string): ServerConfig { const ext = path.extname(filePath).replace(/^\./, ""); - const hit = servers.find((s) => s.match.includes(ext) && !s.diagnosticsOnly && isServerAvailable(s)); + const list = getServersForPath(filePath); + const hit = list.find((s) => s.match.includes(ext) && !s.diagnosticsOnly && isServerAvailable(s)); if (!hit) { throw new UnsupportedExtensionError(`.${ext}`); } return hit; } +// Find Server By ID - Looks up a ServerConfig by id within the registry +// resolved for the given path. Throws if the id is not registered. +export function findServerById(filePath: string, id: string): ServerConfig { + const list = getServersForPath(filePath); + const hit = list.find((s) => s.id === id); + if (!hit) { + throw new Error( + `Unknown server ID: "${id}". Registered: ${list.map((s) => s.id).join(", ")}`, + ); + } + 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 { diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts new file mode 100644 index 0000000..37f5d8b --- /dev/null +++ b/test/unit/config.test.ts @@ -0,0 +1,130 @@ +// Config Unit Tests — getServersForPath() merge semantics, mtime caching, +// and graceful fallback on parse errors. +import { describe, it, beforeEach, afterEach } from "node:test"; +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getServersForPath, clearConfigCache } from "../../src/config.ts"; +import { servers as builtinServers } from "../../server.ts"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-config-test-")); + clearConfigCache(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + clearConfigCache(); +}); + +function writeConfig(dir: string, content: unknown): void { + fs.writeFileSync(path.join(dir, ".pi-lsp.json"), JSON.stringify(content)); +} + +describe("getServersForPath", () => { + it("returns built-in servers when no config file is found", () => { + // tmpDir lives under /tmp; no ancestor will have .pi-lsp.json. + const result = getServersForPath(tmpDir); + assert.deepStrictEqual( + result.map((s) => s.id), + builtinServers.map((s) => s.id), + ); + }); + + it("appends a new server entry from config", () => { + writeConfig(tmpDir, { + servers: [ + { + id: "rust-analyzer", + match: ["rs"], + command: "rust-analyzer", + args: [], + rootMarkers: ["Cargo.toml"], + languageId: "rust", + }, + ], + }); + const result = getServersForPath(tmpDir); + const rust = result.find((s) => s.id === "rust-analyzer"); + assert.ok(rust, "rust-analyzer should be present"); + assert.deepStrictEqual(rust!.match, ["rs"]); + // Built-ins are still there + assert.ok(result.find((s) => s.id === "gopls")); + }); + + it("shallow-merges overrides into existing built-in servers", () => { + writeConfig(tmpDir, { + servers: [{ id: "gopls", args: ["-vv"] }], + }); + const result = getServersForPath(tmpDir); + const gopls = result.find((s) => s.id === "gopls")!; + assert.deepStrictEqual(gopls.args, ["-vv"]); + // Other fields preserved from built-in + assert.strictEqual(gopls.command, "gopls"); + assert.deepStrictEqual(gopls.match, ["go"]); + }); + + it("filters out servers listed in `disable`", () => { + writeConfig(tmpDir, { disable: ["oxlint", "gopls"] }); + const result = getServersForPath(tmpDir); + assert.ok(!result.some((s) => s.id === "oxlint")); + assert.ok(!result.some((s) => s.id === "gopls")); + assert.ok(result.some((s) => s.id === "pyright")); + }); + + it("walks upward to find config in an ancestor directory", () => { + writeConfig(tmpDir, { disable: ["oxlint"] }); + const sub = path.join(tmpDir, "a", "b", "c"); + fs.mkdirSync(sub, { recursive: true }); + const result = getServersForPath(sub); + assert.ok(!result.some((s) => s.id === "oxlint")); + }); + + it("accepts a file path and walks from its directory", () => { + writeConfig(tmpDir, { disable: ["oxlint"] }); + const file = path.join(tmpDir, "src", "main.go"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, "package main"); + const result = getServersForPath(file); + assert.ok(!result.some((s) => s.id === "oxlint")); + }); + + it("falls back to built-ins on invalid JSON", () => { + fs.writeFileSync(path.join(tmpDir, ".pi-lsp.json"), "{ not valid"); + const result = getServersForPath(tmpDir); + assert.deepStrictEqual( + result.map((s) => s.id), + builtinServers.map((s) => s.id), + ); + }); + + it("falls back to built-ins when a new server is missing required fields", () => { + writeConfig(tmpDir, { + servers: [{ id: "incomplete", match: ["foo"] }], + }); + const result = getServersForPath(tmpDir); + assert.deepStrictEqual( + result.map((s) => s.id), + builtinServers.map((s) => s.id), + ); + }); + + it("invalidates cache on mtime change", () => { + writeConfig(tmpDir, { disable: ["oxlint"] }); + let result = getServersForPath(tmpDir); + assert.ok(!result.some((s) => s.id === "oxlint")); + + // Rewrite With Different Content And Bump mtime + const cfgPath = path.join(tmpDir, ".pi-lsp.json"); + fs.writeFileSync(cfgPath, JSON.stringify({ disable: ["gopls"] })); + const future = new Date(Date.now() + 5000); + fs.utimesSync(cfgPath, future, future); + + result = getServersForPath(tmpDir); + assert.ok(result.some((s) => s.id === "oxlint")); + assert.ok(!result.some((s) => s.id === "gopls")); + }); +});