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:
130
test/unit/config.test.ts
Normal file
130
test/unit/config.test.ts
Normal 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"));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user