// 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(); }