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:
2026-05-07 22:43:41 -04:00
parent 0b23e203f4
commit 46e3cc4ccd
8 changed files with 400 additions and 49 deletions

141
src/config.ts Normal file
View 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();
}

View File

@@ -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<ClientEntry> {
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);

View File

@@ -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 {