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

130
test/unit/config.test.ts Normal file
View File

@@ -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"));
});
});