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

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 {