feat(config): add per-repo .pi-lsp.json server overrides
Users can now drop a .pi-lsp.json at any ancestor of their working files to add new LSP servers, override built-in ones, or disable servers entirely. The nearest config (walking upward) wins. - New src/config.ts: walks upward for .pi-lsp.json, parses, and merges with the built-in registry. Cached per config-file path with mtime invalidation. Falls back to built-ins on parse error. - Merge rules: matching id shallow-merges (user wins); new id appends (must include match/command/args/rootMarkers); `disable` filters at the end. - src/root.ts: pickServer() now resolves servers via the per-repo registry. Adds findServerById(filePath, id) and re-exports getServersForPath() for callers. - src/daemon.ts: getOrCreateEntry() resolves serverId against the filePath's config so spawned servers reflect repo overrides. - index.ts and cli.ts: replace direct `servers` imports with path-aware getServersForPath() lookups. - Tests: 9 new unit tests covering merge semantics, walk-up discovery, mtime invalidation, and graceful fallback. - Docs: README "Per-Repo Config" section + AGENTS.md updates.
This commit is contained in:
141
src/config.ts
Normal file
141
src/config.ts
Normal file
@@ -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<Partial<ServerConfig> & { id: string }>;
|
||||
disable?: string[];
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
mtimeMs: number;
|
||||
resolved: ServerConfig[];
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
// 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<string, ServerConfig>(
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user