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:
28
AGENTS.md
28
AGENTS.md
@@ -55,22 +55,42 @@ A per-entry `serializer` promise chain prevents concurrent syncs from racing.
|
|||||||
|
|
||||||
```
|
```
|
||||||
index.ts — Extension entry point (tools, commands, auto-check flag)
|
index.ts — Extension entry point (tools, commands, auto-check flag)
|
||||||
server.ts — LSP server registry (gopls, typescript-language-server, pyright)
|
server.ts — Built-in LSP server registry (gopls, typescript-language-server, pyright, ...)
|
||||||
cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon)
|
cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon)
|
||||||
daemon.ts — Entrypoint that starts the daemon process
|
daemon.ts — Entrypoint that starts the daemon process
|
||||||
|
|
||||||
src/
|
src/
|
||||||
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync
|
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync
|
||||||
commands.ts — CLI command dispatcher (maps command names → LSP methods)
|
commands.ts — CLI command dispatcher (maps command names → LSP methods)
|
||||||
|
config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache
|
||||||
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)
|
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)
|
||||||
daemonProtocol.ts — Shared types, socket path, NDJSON send/receive, autospawn logic
|
daemonProtocol.ts — Shared types, socket path, NDJSON send/receive, autospawn logic
|
||||||
root.ts — pickServer(), findRoot(), URI/path conversion
|
root.ts — pickServer(), findServerById(), getServersForPath(), findRoot(), URI/path conversion
|
||||||
types.ts — ServerConfig interface, LspCommand union
|
types.ts — ServerConfig interface, LspCommand union
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding a Server
|
### Per-Repo Config (`.pi-lsp.json`)
|
||||||
|
|
||||||
Edit `server.ts`. Add an entry to the `servers` array:
|
Users can add/override/disable servers without editing `server.ts`. `src/config.ts`
|
||||||
|
walks upward from a given path to find `.pi-lsp.json`, parses it, and merges
|
||||||
|
with the built-in `servers` list:
|
||||||
|
|
||||||
|
- New `id` → appended (must supply `match`, `command`, `args`, `rootMarkers`).
|
||||||
|
- Existing `id` → shallow-merged over the built-in (user fields win).
|
||||||
|
- `disable: []` → filtered out at the end.
|
||||||
|
|
||||||
|
Results are cached per config path, invalidated by mtime. `getServersForPath(p)`
|
||||||
|
is the **single entry point** — don't import the raw `servers` array from
|
||||||
|
`server.ts` outside `src/config.ts`. The daemon resolves servers at
|
||||||
|
`getOrCreateEntry()` time via `findServerById(filePath, id)`, so spawned
|
||||||
|
servers reflect the config of the file being acted on. **Already-running**
|
||||||
|
entries don't see config changes; users must `/lsp-destroy` to respawn.
|
||||||
|
|
||||||
|
## Adding a Server (Built-In)
|
||||||
|
|
||||||
|
For servers shipped with pi-lsp, edit `server.ts`. (For per-repo additions,
|
||||||
|
users should drop a `.pi-lsp.json` at the repo root — see README.) Add an entry
|
||||||
|
to the `servers` array:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -123,6 +123,44 @@ Edit `server.ts`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Per-Repo Config (`.pi-lsp.json`)
|
||||||
|
|
||||||
|
Drop a `.pi-lsp.json` at any ancestor of your working files to add or override
|
||||||
|
servers for that repo. The nearest config (walking upward) wins.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "rust-analyzer",
|
||||||
|
"match": ["rs"],
|
||||||
|
"command": "rust-analyzer",
|
||||||
|
"args": [],
|
||||||
|
"rootMarkers": ["Cargo.toml"],
|
||||||
|
"languageId": "rust"
|
||||||
|
},
|
||||||
|
{ "id": "gopls", "args": ["-remote=auto", "-vv"] }
|
||||||
|
],
|
||||||
|
"disable": ["oxlint"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Merge rules:**
|
||||||
|
|
||||||
|
- Entry with a built-in `id` → fields shallow-merge over the built-in (user wins).
|
||||||
|
- Entry with a new `id` → appended; must include `match`, `command`, `args`, `rootMarkers`.
|
||||||
|
- `disable` → filters out matching ids (built-in or user-defined).
|
||||||
|
|
||||||
|
**Reloading after edits:** Config is re-read on mtime change, so new lookups
|
||||||
|
pick up changes automatically. However, **already-running** language servers in
|
||||||
|
the daemon keep their original spawn args. If you change `command`, `args`, or
|
||||||
|
`rootMarkers`, run `/lsp-destroy` (or `pi-lsp daemon stop`) so they respawn
|
||||||
|
with the new config.
|
||||||
|
|
||||||
|
**Security note:** `.pi-lsp.json` controls what binary pi-lsp spawns. Treat it
|
||||||
|
like `.vscode/settings.json` — don't accept untrusted configs from arbitrary
|
||||||
|
repos.
|
||||||
|
|
||||||
## Adding A Command
|
## Adding A Command
|
||||||
|
|
||||||
1. Add to the `LspCommand` union in `src/types.ts`.
|
1. Add to the `LspCommand` union in `src/types.ts`.
|
||||||
|
|||||||
8
cli.ts
8
cli.ts
@@ -2,8 +2,7 @@
|
|||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { startClientForFile } from "./src/client.ts";
|
import { startClientForFile } from "./src/client.ts";
|
||||||
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
|
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
|
||||||
import { pickServer, isServerAvailable } from "./src/root.ts";
|
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
|
||||||
import { servers } from "./server.ts";
|
|
||||||
import {
|
import {
|
||||||
daemonDiagnostics,
|
daemonDiagnostics,
|
||||||
daemonRequest,
|
daemonRequest,
|
||||||
@@ -105,9 +104,10 @@ async function runViaDaemon(
|
|||||||
const filePath = path.resolve(fileArg);
|
const filePath = path.resolve(fileArg);
|
||||||
let result: unknown;
|
let result: unknown;
|
||||||
if (cmdArg === "diagnostics") {
|
if (cmdArg === "diagnostics") {
|
||||||
// Pick All Available Servers For Diagnostics
|
// Pick All Available Servers For Diagnostics - Resolves against any
|
||||||
|
// `.pi-lsp.json` reachable from the file so per-repo overrides apply.
|
||||||
const ext = path.extname(filePath).replace(/^\./, "");
|
const ext = path.extname(filePath).replace(/^\./, "");
|
||||||
const serverIds = servers
|
const serverIds = getServersForPath(filePath)
|
||||||
.filter((s) => s.match.includes(ext) && isServerAvailable(s))
|
.filter((s) => s.match.includes(ext) && isServerAvailable(s))
|
||||||
.map((s) => s.id);
|
.map((s) => s.id);
|
||||||
result = await daemonDiagnostics(filePath, serverIds);
|
result = await daemonDiagnostics(filePath, serverIds);
|
||||||
|
|||||||
61
index.ts
61
index.ts
@@ -12,8 +12,7 @@ import {
|
|||||||
daemonRequest,
|
daemonRequest,
|
||||||
daemonStatus,
|
daemonStatus,
|
||||||
} from "./src/daemonClient.ts";
|
} from "./src/daemonClient.ts";
|
||||||
import { pickServer, isServerAvailable } from "./src/root.ts";
|
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
|
||||||
import { servers } from "./server.ts";
|
|
||||||
import {
|
import {
|
||||||
ServerNotFoundError,
|
ServerNotFoundError,
|
||||||
UnsupportedExtensionError,
|
UnsupportedExtensionError,
|
||||||
@@ -326,10 +325,11 @@ async function runLsp(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pick Diagnostic Servers - Returns all available, non-disabled servers
|
// Pick Diagnostic Servers - Returns all available, non-disabled servers
|
||||||
// matching the file's extension. Used for fan-out diagnostics.
|
// matching the file's extension. Resolves the per-repo config from the
|
||||||
|
// file's directory so user-defined servers participate in fan-out.
|
||||||
function pickDiagnosticServers(filePath: string): string[] {
|
function pickDiagnosticServers(filePath: string): string[] {
|
||||||
const ext = path.extname(filePath).replace(/^\./, "");
|
const ext = path.extname(filePath).replace(/^\./, "");
|
||||||
return servers
|
return getServersForPath(filePath)
|
||||||
.filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id))
|
.filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id))
|
||||||
.map((s) => s.id);
|
.map((s) => s.id);
|
||||||
}
|
}
|
||||||
@@ -541,7 +541,9 @@ export default function (pi: ExtensionAPI) {
|
|||||||
if (!pi.getFlag("lsp-auto-check")) return;
|
if (!pi.getFlag("lsp-auto-check")) return;
|
||||||
|
|
||||||
// Skip If All Disabled - No LSP server is available for this instance.
|
// Skip If All Disabled - No LSP server is available for this instance.
|
||||||
if (servers.every((s) => disabledServers.has(s.id))) return;
|
// Resolve against ctx.cwd so user-defined servers count toward this check.
|
||||||
|
const cwdServers = getServersForPath(ctx.cwd);
|
||||||
|
if (cwdServers.every((s) => disabledServers.has(s.id))) return;
|
||||||
|
|
||||||
// Edit & Write Only
|
// Edit & Write Only
|
||||||
if (!["edit", "write"].includes(event.toolName)) return;
|
if (!["edit", "write"].includes(event.toolName)) return;
|
||||||
@@ -619,9 +621,16 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
// --- Server Control Commands ---
|
// --- Server Control Commands ---
|
||||||
|
|
||||||
|
// Resolve Active Servers - Per-cwd helper so commands see the same
|
||||||
|
// per-repo overrides the LLM does. We accept an optional cwd because some
|
||||||
|
// closures (e.g. completions) don't get a context.
|
||||||
|
const resolveServers = (cwd?: string) => getServersForPath(cwd ?? process.cwd());
|
||||||
|
|
||||||
// Shared Argument Completions - Suggests registered server IDs plus "all".
|
// Shared Argument Completions - Suggests registered server IDs plus "all".
|
||||||
|
// Uses process.cwd() since completion handlers don't receive a context;
|
||||||
|
// close enough for argument hints.
|
||||||
const serverCompletions = (prefix: string) => {
|
const serverCompletions = (prefix: string) => {
|
||||||
const ids = [...servers.map((s) => s.id), "all"].filter((id) =>
|
const ids = [...resolveServers().map((s) => s.id), "all"].filter((id) =>
|
||||||
id.startsWith(prefix),
|
id.startsWith(prefix),
|
||||||
);
|
);
|
||||||
return ids.length > 0 ? ids.map((id) => ({ value: id, label: id })) : null;
|
return ids.length > 0 ? ids.map((id) => ({ value: id, label: id })) : null;
|
||||||
@@ -629,16 +638,17 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
// Parse Server IDs - Validates args against registered servers. Bare/empty
|
// Parse Server IDs - Validates args against registered servers. Bare/empty
|
||||||
// or "all" returns every server ID. Throws on unknown names.
|
// or "all" returns every server ID. Throws on unknown names.
|
||||||
function parseServerIds(args: string | undefined): string[] {
|
function parseServerIds(args: string | undefined, cwd: string): string[] {
|
||||||
if (!args || !args.trim()) return servers.map((s) => s.id);
|
const list = resolveServers(cwd);
|
||||||
|
if (!args || !args.trim()) return list.map((s) => s.id);
|
||||||
const ids = args.trim().split(/\s+/);
|
const ids = args.trim().split(/\s+/);
|
||||||
if (ids.includes("all")) return servers.map((s) => s.id);
|
if (ids.includes("all")) return list.map((s) => s.id);
|
||||||
const invalid = ids.filter((id) => !servers.some((s) => s.id === id));
|
const invalid = ids.filter((id) => !list.some((s) => s.id === id));
|
||||||
if (invalid.length > 0) {
|
if (invalid.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Unknown server(s): ${invalid.join(
|
`Unknown server(s): ${invalid.join(
|
||||||
", ",
|
", ",
|
||||||
)}. Available: ${servers.map((s) => s.id).join(", ")}`,
|
)}. Available: ${list.map((s) => s.id).join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
@@ -647,9 +657,10 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// Update Tool Visibility - When all servers are disabled, remove LSP tools
|
// Update Tool Visibility - When all servers are disabled, remove LSP tools
|
||||||
// from the active set so the LLM won't attempt them. When any is enabled,
|
// from the active set so the LLM won't attempt them. When any is enabled,
|
||||||
// restore them. Captures current active tools at toggle time.
|
// restore them. Captures current active tools at toggle time.
|
||||||
function updateToolVisibility(): void {
|
function updateToolVisibility(cwd: string): void {
|
||||||
|
const list = resolveServers(cwd);
|
||||||
const current = pi.getActiveTools();
|
const current = pi.getActiveTools();
|
||||||
if (servers.every((s) => disabledServers.has(s.id))) {
|
if (list.every((s) => disabledServers.has(s.id))) {
|
||||||
// All disabled — strip LSP tools
|
// All disabled — strip LSP tools
|
||||||
pi.setActiveTools(current.filter((name) => !lspToolNames.includes(name)));
|
pi.setActiveTools(current.filter((name) => !lspToolNames.includes(name)));
|
||||||
} else {
|
} else {
|
||||||
@@ -699,13 +710,13 @@ export default function (pi: ExtensionAPI) {
|
|||||||
getArgumentCompletions: serverCompletions,
|
getArgumentCompletions: serverCompletions,
|
||||||
handler: async (args, ctx) => {
|
handler: async (args, ctx) => {
|
||||||
try {
|
try {
|
||||||
const ids = parseServerIds(args);
|
const ids = parseServerIds(args, ctx.cwd);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
disabledServers.add(id);
|
disabledServers.add(id);
|
||||||
}
|
}
|
||||||
updateToolVisibility();
|
updateToolVisibility(ctx.cwd);
|
||||||
const label =
|
const total = resolveServers(ctx.cwd).length;
|
||||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||||
ctx.ui.notify(`Disabled: ${label}`, "info");
|
ctx.ui.notify(`Disabled: ${label}`, "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -725,13 +736,13 @@ export default function (pi: ExtensionAPI) {
|
|||||||
getArgumentCompletions: serverCompletions,
|
getArgumentCompletions: serverCompletions,
|
||||||
handler: async (args, ctx) => {
|
handler: async (args, ctx) => {
|
||||||
try {
|
try {
|
||||||
const ids = parseServerIds(args);
|
const ids = parseServerIds(args, ctx.cwd);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
disabledServers.delete(id);
|
disabledServers.delete(id);
|
||||||
}
|
}
|
||||||
updateToolVisibility();
|
updateToolVisibility(ctx.cwd);
|
||||||
const label =
|
const total = resolveServers(ctx.cwd).length;
|
||||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||||
ctx.ui.notify(`Enabled: ${label}`, "info");
|
ctx.ui.notify(`Enabled: ${label}`, "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg =
|
const msg =
|
||||||
@@ -751,16 +762,16 @@ export default function (pi: ExtensionAPI) {
|
|||||||
getArgumentCompletions: serverCompletions,
|
getArgumentCompletions: serverCompletions,
|
||||||
handler: async (args, ctx) => {
|
handler: async (args, ctx) => {
|
||||||
try {
|
try {
|
||||||
const ids = parseServerIds(args);
|
const ids = parseServerIds(args, ctx.cwd);
|
||||||
if (ids.length === servers.length) {
|
const total = resolveServers(ctx.cwd).length;
|
||||||
|
if (ids.length === total) {
|
||||||
await daemonDestroyServer();
|
await daemonDestroyServer();
|
||||||
} else {
|
} else {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
await daemonDestroyServer(id);
|
await daemonDestroyServer(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const label =
|
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
|
||||||
ctx.ui.notify(`Destroyed: ${label}`, "info");
|
ctx.ui.notify(`Destroyed: ${label}`, "info");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg =
|
const msg =
|
||||||
|
|||||||
141
src/config.ts
Normal file
141
src/config.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -6,9 +6,8 @@ import * as fs from "node:fs";
|
|||||||
import * as net from "node:net";
|
import * as net from "node:net";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { LspClient } from "./client.ts";
|
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 type { ServerConfig } from "./types.ts";
|
||||||
import { servers } from "../server.ts";
|
|
||||||
import {
|
import {
|
||||||
logPath,
|
logPath,
|
||||||
socketPath,
|
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,
|
// Get Or Create Entry - Looks up the cached client for a server+file,
|
||||||
// spawning a fresh LspClient if needed. The returned entry is guaranteed
|
// 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(
|
async function getOrCreateEntry(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
launch: LaunchContext,
|
launch: LaunchContext,
|
||||||
): Promise<ClientEntry> {
|
): Promise<ClientEntry> {
|
||||||
const server = findServerById(serverId);
|
const server = findServerById(filePath, serverId);
|
||||||
const rootDir = findRoot(filePath, server.rootMarkers);
|
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||||
const key = `${server.id}::${rootDir}`;
|
const key = `${server.id}::${rootDir}`;
|
||||||
const existing = entries.get(key);
|
const existing = entries.get(key);
|
||||||
|
|||||||
24
src/root.ts
24
src/root.ts
@@ -1,11 +1,16 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
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 type { ServerConfig } from "./types.ts";
|
||||||
import { UnsupportedExtensionError } from "./types.ts";
|
import { UnsupportedExtensionError } from "./types.ts";
|
||||||
import { isOnPath } from "./util.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
|
// Resolve File URI To Local Path
|
||||||
export function uriToPath(uri: string): string {
|
export function uriToPath(uri: string): string {
|
||||||
return fileURLToPath(uri);
|
return fileURLToPath(uri);
|
||||||
@@ -31,15 +36,30 @@ export function isServerAvailable(server: ServerConfig): boolean {
|
|||||||
|
|
||||||
// Pick Server By File Extension - match[] entries are matched against the
|
// Pick Server By File Extension - match[] entries are matched against the
|
||||||
// file's extension (no dot). First available, non-diagnosticsOnly server wins.
|
// 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 {
|
export function pickServer(filePath: string): ServerConfig {
|
||||||
const ext = path.extname(filePath).replace(/^\./, "");
|
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) {
|
if (!hit) {
|
||||||
throw new UnsupportedExtensionError(`.${ext}`);
|
throw new UnsupportedExtensionError(`.${ext}`);
|
||||||
}
|
}
|
||||||
return hit;
|
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
|
// Find Project Root By Walking Upward - stops at the first directory
|
||||||
// containing any rootMarker. Falls back to the file's directory.
|
// containing any rootMarker. Falls back to the file's directory.
|
||||||
export function findRoot(filePath: string, markers: string[]): string {
|
export function findRoot(filePath: string, markers: string[]): string {
|
||||||
|
|||||||
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