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.
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
// 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"));
|
|
});
|
|
});
|