Compare commits
24 Commits
4b486b2464
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2da4103cd7 | |||
| 8cfe604de7 | |||
| 071c87d3c1 | |||
| 3f3cb4cdbf | |||
| 14749a6449 | |||
| 62fc80c70f | |||
| b7e421483d | |||
| 0aa44bedc4 | |||
| 77876264ee | |||
| e143e05758 | |||
| 46e3cc4ccd | |||
| 0b23e203f4 | |||
| 9e5a0677c8 | |||
| 99ce79ac88 | |||
| 81ab984a86 | |||
| e40c93fc80 | |||
| b9808a8b1f | |||
| d24e2e94f4 | |||
| 630226a00a | |||
| f811efef68 | |||
| 01ab10a7d9 | |||
| 99525ad0ee | |||
| 04fd520438 | |||
| 306771f92a |
69
AGENTS.md
69
AGENTS.md
@@ -21,14 +21,14 @@ The extension is **stateless** — it opens a fresh socket connection per reques
|
||||
|
||||
Communication is **newline-delimited JSON (NDJSON)** over a Unix socket at `$XDG_RUNTIME_DIR/pi-lsp-$UID.sock`. Each line is one independent request/response pair with an `id` field for matching. See `src/daemonProtocol.ts` for the type definitions (`DaemonRequest`, `DaemonResponse`).
|
||||
|
||||
Current ops: `request`, `diagnostics`, `status`, `shutdown`.
|
||||
Current ops: `request`, `diagnostics`, `status`, `shutdown`, `destroy_server`. `request` and `diagnostics` include a `launch` context with the caller env. The env is used only when spawning a new server for `(server.id, rootDir)`; existing running servers keep their original process env until idle eviction or manual destroy/restart.
|
||||
|
||||
### Server Lifecycle
|
||||
|
||||
1. First LSP tool call for a file triggers `getOrCreateEntry()` in the daemon
|
||||
2. `pickServer()` matches the file extension against `server.ts` registry
|
||||
3. `findRoot()` walks upward looking for root markers (e.g., `go.mod`, `tsconfig.json`)
|
||||
4. A new `LspClient` is spawned, initialized via LSP `initialize`/`initialized`, and waited on (`waitForReady()`)
|
||||
4. A new `LspClient` is spawned with the caller/session environment from the daemon request, initialized via LSP `initialize`/`initialized`, and waited on (`waitForReady()`)
|
||||
5. The file is synced via `didOpen` or `didChange` (based on mtime comparison)
|
||||
6. On idle timeout (default 5 min), the entry is evicted and the server process killed
|
||||
|
||||
@@ -41,6 +41,40 @@ The daemon tracks opened files per-entry in a `Map<uri, mtimeMs>`. On each reque
|
||||
|
||||
A per-entry `serializer` promise chain prevents concurrent syncs from racing.
|
||||
|
||||
### Workspace File Watching
|
||||
|
||||
Each `ClientEntry` lazily owns a `WorkspaceWatcher` (`src/watcher.ts`,
|
||||
chokidar + picomatch) that translates filesystem events into
|
||||
`workspace/didChangeWatchedFiles` notifications. This keeps the server's
|
||||
workspace index fresh when files are created/changed/deleted **outside** of
|
||||
LSP tool calls (build scripts, codegen, `git checkout`, the agent's own
|
||||
file writes).
|
||||
|
||||
Non-obvious bits:
|
||||
|
||||
- **Patterns come from the server.** We honor `client/registerCapability`
|
||||
for `workspace/didChangeWatchedFiles` and store the registrations on the
|
||||
`LspClient`. **Don't re-stub those handlers**; they look harmless but
|
||||
break the entire feature. If a server doesn't register, we don't watch.
|
||||
- **Servers send mixed pattern forms.** Gopls registers absolute-path
|
||||
globs (`/abs/root/**/*.go`); others send relative (`**/*.ts`) or
|
||||
`RelativePattern` objects. `compileWatchers()` tries both relative and
|
||||
absolute matching against each event so we accept all forms.
|
||||
- **Ignore layering.** Always-ignore baseline (`.git/`, `.DS_Store`) +
|
||||
root `.gitignore` parsed via the `ignore` package + a small fallback
|
||||
for non-git workspaces. Nested gitignores aren't supported yet.
|
||||
- **Startup readiness.** The daemon waits for chokidar's initial scan, capped
|
||||
at 5s, so first requests don't hang indefinitely on huge workspaces.
|
||||
- **Debounce.** 50ms quiet period, capped at 500ms max wait so sustained
|
||||
event streams (branch switches) still flush in bounded time.
|
||||
- **Watcher and mtime-sync coexist.** When the agent edits a file we'll
|
||||
emit `didChangeWatchedFiles` *and* the next request's `syncFile` will
|
||||
send a `didChange`. Servers treat the two as orthogonal (workspace
|
||||
index vs. editor buffer) and dedupe internally. This matches VS Code.
|
||||
- **Rollback.** `PI_LSP_DISABLE_WATCHERS=1` short-circuits all watcher
|
||||
creation — if something goes wrong in a real workspace, this restores
|
||||
the prior "only the queried file is synced" behavior.
|
||||
|
||||
### Extension vs Daemon Responsibilities
|
||||
|
||||
| Concern | Where |
|
||||
@@ -55,22 +89,43 @@ A per-entry `serializer` promise chain prevents concurrent syncs from racing.
|
||||
|
||||
```
|
||||
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)
|
||||
daemon.ts — Entrypoint that starts the daemon process
|
||||
|
||||
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, file-watcher registrations
|
||||
watcher.ts — WorkspaceWatcher: chokidar + picomatch → workspace/didChangeWatchedFiles batches
|
||||
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.)
|
||||
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
|
||||
```
|
||||
|
||||
## 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
|
||||
{
|
||||
|
||||
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
|
||||
|
||||
1. Add to the `LspCommand` union in `src/types.ts`.
|
||||
|
||||
13
cli.ts
13
cli.ts
@@ -2,7 +2,7 @@
|
||||
import * as path from "node:path";
|
||||
import { startClientForFile } from "./src/client.ts";
|
||||
import { isLspCommand, listCommands, runCommand } from "./src/commands.ts";
|
||||
import { pickServer } from "./src/root.ts";
|
||||
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
|
||||
import {
|
||||
daemonDiagnostics,
|
||||
daemonRequest,
|
||||
@@ -104,14 +104,21 @@ async function runViaDaemon(
|
||||
const filePath = path.resolve(fileArg);
|
||||
let result: unknown;
|
||||
if (cmdArg === "diagnostics") {
|
||||
result = await daemonDiagnostics(filePath);
|
||||
// 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 serverIds = getServersForPath(filePath)
|
||||
.filter((s) => s.match.includes(ext) && isServerAvailable(s))
|
||||
.map((s) => s.id);
|
||||
result = await daemonDiagnostics(filePath, serverIds);
|
||||
} else if (cmdArg in methodMap) {
|
||||
const server = pickServer(filePath);
|
||||
// References Default - Match commands.ts: include declaration unless
|
||||
// caller explicitly overrode `context`.
|
||||
if (cmdArg === "references" && !("context" in params)) {
|
||||
params.context = { includeDeclaration: true };
|
||||
}
|
||||
result = await daemonRequest(filePath, methodMap[cmdArg], params);
|
||||
result = await daemonRequest(filePath, server.id, methodMap[cmdArg], params);
|
||||
} else {
|
||||
process.stderr.write(
|
||||
`Unknown command "${cmdArg}". Known: ${listCommands().join(", ")}\n`,
|
||||
|
||||
@@ -20,8 +20,14 @@
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
oxlint
|
||||
nodejs_22
|
||||
typescript-language-server
|
||||
|
||||
# Tests
|
||||
go
|
||||
gopls
|
||||
pyright
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
259
index.ts
259
index.ts
@@ -12,25 +12,45 @@ import {
|
||||
daemonRequest,
|
||||
daemonStatus,
|
||||
} from "./src/daemonClient.ts";
|
||||
import { pickServer } from "./src/root.ts";
|
||||
import { servers } from "./server.ts";
|
||||
import { pickServer, isServerAvailable, getServersForPath } from "./src/root.ts";
|
||||
import {
|
||||
ServerNotFoundError,
|
||||
UnsupportedExtensionError,
|
||||
} from "./src/types.ts";
|
||||
|
||||
type LspUnavailable = { piLspUnavailable: true; message: string };
|
||||
|
||||
function lspUnavailable(message: string): LspUnavailable {
|
||||
return { piLspUnavailable: true, message };
|
||||
}
|
||||
|
||||
function isLspUnavailable(result: unknown): result is LspUnavailable {
|
||||
return Boolean(
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"piLspUnavailable" in result &&
|
||||
(result as { piLspUnavailable?: unknown }).piLspUnavailable === true,
|
||||
);
|
||||
}
|
||||
|
||||
function formatUnavailable(result: LspUnavailable): string {
|
||||
return `(LSP unavailable: ${result.message})`;
|
||||
}
|
||||
|
||||
// Format Hover - Turn an LSP hover response into readable text.
|
||||
function formatHover(result: unknown): string {
|
||||
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||
if (!result || typeof result !== "object") return "(no hover info)";
|
||||
const hover = result as { contents?: unknown };
|
||||
if (!hover.contents) return "(empty)";
|
||||
|
||||
// MarkupContent
|
||||
const contents = hover.contents as Record<string, unknown>;
|
||||
if (
|
||||
"value" in hover.contents &&
|
||||
typeof (hover.contents as any).value === "string"
|
||||
"value" in contents &&
|
||||
typeof contents.value === "string"
|
||||
) {
|
||||
return (hover.contents as any).value;
|
||||
return contents.value;
|
||||
}
|
||||
// MarkedString | MarkedString[]
|
||||
if (Array.isArray(hover.contents)) {
|
||||
@@ -39,17 +59,17 @@ function formatHover(result: unknown): string {
|
||||
.join("\n");
|
||||
}
|
||||
if (
|
||||
"value" in hover.contents &&
|
||||
typeof (hover.contents as any).language === "string"
|
||||
"value" in contents &&
|
||||
typeof contents.language === "string"
|
||||
) {
|
||||
const ms = hover.contents as any;
|
||||
return `\`\`\`${ms.language}\n${ms.value}\n\`\`\``;
|
||||
return `\`\`\`${contents.language}\n${contents.value}\n\`\`\``;
|
||||
}
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
// Format Definition - Turn definition locations into readable text.
|
||||
function formatDefinition(result: unknown): string {
|
||||
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||
if (!result) return "(no definition found)";
|
||||
const locations = Array.isArray(result) ? result : [result];
|
||||
if (locations.length === 0) return "(no definition found)";
|
||||
@@ -65,27 +85,41 @@ function formatDefinition(result: unknown): string {
|
||||
|
||||
// Format References - Turn reference locations into readable text.
|
||||
function formatReferences(result: unknown): string {
|
||||
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||
if (!result || !Array.isArray(result)) return "(no references found)";
|
||||
if (result.length === 0) return "(no references found)";
|
||||
|
||||
return result
|
||||
const limit = 30;
|
||||
const shown = result.slice(0, limit);
|
||||
const formatted = shown
|
||||
.map((loc: any, i: number) => {
|
||||
const file = uriToPath(loc.uri);
|
||||
const range = loc.range;
|
||||
return `${i + 1}. ${file} (${range.start.line + 1}:${range.start.character + 1})`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
if (result.length > limit) {
|
||||
return `${formatted}\n\n... and ${result.length - limit} more references (showing first ${limit})`;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
// Format Completions - Turn completion items into readable text.
|
||||
function formatCompletions(result: unknown): string {
|
||||
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||
if (!result) return "(no completions)";
|
||||
|
||||
// Resolve to CompletionItem[] if it's a CompletionList
|
||||
let items: any[];
|
||||
if (Array.isArray(result)) {
|
||||
items = result;
|
||||
} else if ("items" in result && Array.isArray((result as any).items)) {
|
||||
} else if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"items" in result &&
|
||||
Array.isArray((result as any).items)
|
||||
) {
|
||||
items = (result as any).items;
|
||||
} else {
|
||||
return JSON.stringify(result, null, 2);
|
||||
@@ -139,6 +173,7 @@ function formatCompletions(result: unknown): string {
|
||||
|
||||
// Format Document Symbols - Turn symbols into a tree outline.
|
||||
function formatDocumentSymbols(result: unknown): string {
|
||||
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||
if (!result || !Array.isArray(result)) return "(no symbols)";
|
||||
const symbols = result as any[];
|
||||
if (symbols.length === 0) return "(no symbols)";
|
||||
@@ -189,11 +224,8 @@ function formatDocumentSymbols(result: unknown): string {
|
||||
}
|
||||
|
||||
// Format Diagnostics - Turn diagnostic messages into readable text.
|
||||
function formatDiagnostics(result: unknown): string {
|
||||
if (!result || !("diagnostics" in result)) return "(no diagnostics)";
|
||||
const diags = (result as any).diagnostics;
|
||||
if (!Array.isArray(diags) || diags.length === 0) return "(no diagnostics)";
|
||||
|
||||
// Format Single Server Diagnostics - Renders one server's diagnostics list.
|
||||
function formatServerDiagnostics(diags: any[], limit: number): string {
|
||||
const severityNames: Record<number, string> = {
|
||||
1: "Error",
|
||||
2: "Warning",
|
||||
@@ -201,7 +233,12 @@ function formatDiagnostics(result: unknown): string {
|
||||
4: "Hint",
|
||||
};
|
||||
|
||||
return diags
|
||||
// Sort By Severity - Errors first, then warnings, info, hints. Ensures
|
||||
// the most actionable issues survive truncation.
|
||||
diags.sort((a: any, b: any) => (a.severity ?? 99) - (b.severity ?? 99));
|
||||
|
||||
const shown = diags.slice(0, limit);
|
||||
const formatted = shown
|
||||
.map((d: any, i: number) => {
|
||||
const sev = severityNames[d.severity] ?? `sev:${d.severity}`;
|
||||
const range = d.range;
|
||||
@@ -211,6 +248,46 @@ function formatDiagnostics(result: unknown): string {
|
||||
return `${i + 1}. [${sev}] ${d.message} (line ${line}, col ${col})`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
if (diags.length > limit) {
|
||||
return `${formatted}\n\n... and ${diags.length - limit} more (showing first ${limit})`;
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
// Format Diagnostics - Handles the grouped result map from the daemon:
|
||||
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
|
||||
// header to avoid noise.
|
||||
function formatDiagnostics(result: unknown, limit = 20): string {
|
||||
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||
if (!result || typeof result !== "object") return "(no diagnostics)";
|
||||
|
||||
const grouped = result as Record<string, any>;
|
||||
const serverIds = Object.keys(grouped);
|
||||
if (serverIds.length === 0) return "(no diagnostics)";
|
||||
|
||||
// Collect Servers With Diagnostics
|
||||
const sections: { id: string; diags: any[] }[] = [];
|
||||
for (const id of serverIds) {
|
||||
const entry = grouped[id];
|
||||
const diags = entry?.diagnostics;
|
||||
if (Array.isArray(diags) && diags.length > 0) {
|
||||
sections.push({ id, diags });
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length === 0) return "(no diagnostics)";
|
||||
|
||||
// Single Server - Skip header for brevity.
|
||||
if (sections.length === 1) {
|
||||
return formatServerDiagnostics(sections[0].diags, limit);
|
||||
}
|
||||
|
||||
// Multiple Servers - Group with headers.
|
||||
const perServer = Math.max(5, Math.floor(limit / sections.length));
|
||||
return sections
|
||||
.map((s) => `## ${s.id}\n${formatServerDiagnostics(s.diags, perServer)}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
// Is Expected Error - Returns true if the error is an expected condition
|
||||
@@ -223,6 +300,34 @@ function isExpectedError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function unavailableForFile(filePath: string, includeDiagnosticsOnly = false): LspUnavailable {
|
||||
const ext = path.extname(filePath).replace(/^\./, "");
|
||||
const label = ext ? `.${ext}` : "files without an extension";
|
||||
const matches = getServersForPath(filePath).filter((server) =>
|
||||
server.match.includes(ext) && (includeDiagnosticsOnly || !server.diagnosticsOnly)
|
||||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return lspUnavailable(`no LSP server is registered for ${label}`);
|
||||
}
|
||||
|
||||
const disabled = matches.filter((server) => disabledServers.has(server.id));
|
||||
if (disabled.length === matches.length) {
|
||||
return lspUnavailable(
|
||||
`matching LSP server(s) are disabled: ${disabled.map((s) => s.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const unavailable = matches.filter((server) => !isServerAvailable(server));
|
||||
if (unavailable.length > 0) {
|
||||
return lspUnavailable(
|
||||
`matching LSP server(s) are not on PATH: ${unavailable.map((s) => s.command).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lspUnavailable(`no applicable LSP server is available for ${label}`);
|
||||
}
|
||||
|
||||
// Disabled Servers - In-memory set of disabled server IDs. Scoped to this
|
||||
// extension process; the shared daemon is never mutated by disable/enable.
|
||||
const lspToolNames = [
|
||||
@@ -249,14 +354,12 @@ async function runLsp(
|
||||
// touching the daemon so other pi instances sharing it are unaffected.
|
||||
const server = pickServer(filePath);
|
||||
if (disabledServers.has(server.id)) {
|
||||
throw new Error(
|
||||
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
|
||||
);
|
||||
return unavailableForFile(filePath);
|
||||
}
|
||||
return await daemonRequest(filePath, method, params);
|
||||
return await daemonRequest(filePath, server.id, method, params);
|
||||
} catch (error) {
|
||||
if (isExpectedError(error)) {
|
||||
return undefined;
|
||||
return unavailableForFile(filePath);
|
||||
}
|
||||
// Daemon-wrapped errors (plain Error with expected message) are also
|
||||
// expected — the daemon catches pickServer() throws and returns them
|
||||
@@ -266,31 +369,51 @@ async function runLsp(
|
||||
(error.message.includes("No LSP server registered") ||
|
||||
error.message.includes("not found on PATH"))
|
||||
) {
|
||||
return undefined;
|
||||
return unavailableForFile(filePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run LSP Diagnostics - Diagnostics arrive as a notification, so the
|
||||
// daemon has a dedicated op that waits for the next publish. Expected
|
||||
// errors (unsupported file type, missing binary) are suppressed.
|
||||
// Pick Diagnostic Servers - Returns all available, non-disabled servers
|
||||
// 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[] {
|
||||
const ext = path.extname(filePath).replace(/^\./, "");
|
||||
return getServersForPath(filePath)
|
||||
.filter((s) => s.match.includes(ext) && isServerAvailable(s) && !disabledServers.has(s.id))
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
// Timeout Wrapper - Rejects a promise after the given number of milliseconds.
|
||||
// Used to prevent async hooks from blocking pi indefinitely.
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms);
|
||||
promise.then(
|
||||
(value) => { clearTimeout(timer); resolve(value); },
|
||||
(reason) => { clearTimeout(timer); reject(reason); },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Run LSP Diagnostics - Fans out to all matching servers in a single
|
||||
// daemon call. Returns an explicit unavailable result if no server applies.
|
||||
async function runDiagnostics(filePath: string): Promise<unknown> {
|
||||
try {
|
||||
return await daemonDiagnostics(filePath, 1500);
|
||||
const serverIds = pickDiagnosticServers(filePath);
|
||||
if (serverIds.length === 0) return unavailableForFile(filePath, true);
|
||||
return await daemonDiagnostics(filePath, serverIds, 1500);
|
||||
} catch (error) {
|
||||
if (isExpectedError(error)) {
|
||||
return undefined;
|
||||
return unavailableForFile(filePath, true);
|
||||
}
|
||||
// Daemon-wrapped errors (plain Error with expected message) are also
|
||||
// expected — the daemon catches pickServer() throws and returns them
|
||||
// as string error messages.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.message.includes("No LSP server registered") ||
|
||||
error.message.includes("not found on PATH"))
|
||||
) {
|
||||
return undefined;
|
||||
return unavailableForFile(filePath, true);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -464,9 +587,12 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
try {
|
||||
const absolutePath = path.resolve(ctx.cwd, filePath);
|
||||
// daemonDiagnostics triggers getOrCreateEntry + syncFile in the daemon.
|
||||
// We don't await it — just fire and forget so the server starts warming up.
|
||||
void daemonDiagnostics(absolutePath).catch(() => {});
|
||||
// Warm Diagnostic Servers - Fire-and-forget so servers are ready by
|
||||
// the time an LSP tool is called.
|
||||
const serverIds = pickDiagnosticServers(absolutePath);
|
||||
if (serverIds.length > 0) {
|
||||
void daemonDiagnostics(absolutePath, serverIds).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore — unsupported file type, missing binary, etc.
|
||||
}
|
||||
@@ -478,7 +604,9 @@ export default function (pi: ExtensionAPI) {
|
||||
if (!pi.getFlag("lsp-auto-check")) return;
|
||||
|
||||
// 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
|
||||
if (!["edit", "write"].includes(event.toolName)) return;
|
||||
@@ -492,9 +620,11 @@ export default function (pi: ExtensionAPI) {
|
||||
const absolutePath = path.resolve(ctx.cwd, filePath);
|
||||
|
||||
try {
|
||||
// Run LSP diagnostics
|
||||
const result = await runDiagnostics(absolutePath);
|
||||
const formatted = formatDiagnostics(result);
|
||||
// Run LSP diagnostics with timeout - Prevent the auto-check hook from
|
||||
// blocking pi if the daemon or LSP server is slow or unresponsive.
|
||||
const result = await withTimeout(runDiagnostics(absolutePath), 3000);
|
||||
if (isLspUnavailable(result)) return;
|
||||
const formatted = formatDiagnostics(result, 10);
|
||||
|
||||
// Only send a message if there are actual diagnostics
|
||||
if (formatted !== "(no diagnostics)") {
|
||||
@@ -556,9 +686,16 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
// --- 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".
|
||||
// Uses process.cwd() since completion handlers don't receive a context;
|
||||
// close enough for argument hints.
|
||||
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),
|
||||
);
|
||||
return ids.length > 0 ? ids.map((id) => ({ value: id, label: id })) : null;
|
||||
@@ -566,16 +703,17 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
// Parse Server IDs - Validates args against registered servers. Bare/empty
|
||||
// or "all" returns every server ID. Throws on unknown names.
|
||||
function parseServerIds(args: string | undefined): string[] {
|
||||
if (!args || !args.trim()) return servers.map((s) => s.id);
|
||||
function parseServerIds(args: string | undefined, cwd: string): string[] {
|
||||
const list = resolveServers(cwd);
|
||||
if (!args || !args.trim()) return list.map((s) => s.id);
|
||||
const ids = args.trim().split(/\s+/);
|
||||
if (ids.includes("all")) return servers.map((s) => s.id);
|
||||
const invalid = ids.filter((id) => !servers.some((s) => s.id === id));
|
||||
if (ids.includes("all")) return list.map((s) => s.id);
|
||||
const invalid = ids.filter((id) => !list.some((s) => s.id === id));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Unknown server(s): ${invalid.join(
|
||||
", ",
|
||||
)}. Available: ${servers.map((s) => s.id).join(", ")}`,
|
||||
)}. Available: ${list.map((s) => s.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
@@ -584,9 +722,10 @@ export default function (pi: ExtensionAPI) {
|
||||
// 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,
|
||||
// restore them. Captures current active tools at toggle time.
|
||||
function updateToolVisibility(): void {
|
||||
const current = pi.getActiveTools().map((t) => t.name);
|
||||
if (servers.every((s) => disabledServers.has(s.id))) {
|
||||
function updateToolVisibility(cwd: string): void {
|
||||
const list = resolveServers(cwd);
|
||||
const current = pi.getActiveTools();
|
||||
if (list.every((s) => disabledServers.has(s.id))) {
|
||||
// All disabled — strip LSP tools
|
||||
pi.setActiveTools(current.filter((name) => !lspToolNames.includes(name)));
|
||||
} else {
|
||||
@@ -636,13 +775,13 @@ export default function (pi: ExtensionAPI) {
|
||||
getArgumentCompletions: serverCompletions,
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const ids = parseServerIds(args);
|
||||
const ids = parseServerIds(args, ctx.cwd);
|
||||
for (const id of ids) {
|
||||
disabledServers.add(id);
|
||||
}
|
||||
updateToolVisibility();
|
||||
const label =
|
||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
||||
updateToolVisibility(ctx.cwd);
|
||||
const total = resolveServers(ctx.cwd).length;
|
||||
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||
ctx.ui.notify(`Disabled: ${label}`, "info");
|
||||
} catch (error) {
|
||||
const msg =
|
||||
@@ -662,13 +801,13 @@ export default function (pi: ExtensionAPI) {
|
||||
getArgumentCompletions: serverCompletions,
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const ids = parseServerIds(args);
|
||||
const ids = parseServerIds(args, ctx.cwd);
|
||||
for (const id of ids) {
|
||||
disabledServers.delete(id);
|
||||
}
|
||||
updateToolVisibility();
|
||||
const label =
|
||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
||||
updateToolVisibility(ctx.cwd);
|
||||
const total = resolveServers(ctx.cwd).length;
|
||||
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||
ctx.ui.notify(`Enabled: ${label}`, "info");
|
||||
} catch (error) {
|
||||
const msg =
|
||||
@@ -688,16 +827,16 @@ export default function (pi: ExtensionAPI) {
|
||||
getArgumentCompletions: serverCompletions,
|
||||
handler: async (args, ctx) => {
|
||||
try {
|
||||
const ids = parseServerIds(args);
|
||||
if (ids.length === servers.length) {
|
||||
const ids = parseServerIds(args, ctx.cwd);
|
||||
const total = resolveServers(ctx.cwd).length;
|
||||
if (ids.length === total) {
|
||||
await daemonDestroyServer();
|
||||
} else {
|
||||
for (const id of ids) {
|
||||
await daemonDestroyServer(id);
|
||||
}
|
||||
}
|
||||
const label =
|
||||
ids.length === servers.length ? "all servers" : ids.join(", ");
|
||||
const label = ids.length === total ? "all servers" : ids.join(", ");
|
||||
ctx.ui.notify(`Destroyed: ${label}`, "info");
|
||||
} catch (error) {
|
||||
const msg =
|
||||
|
||||
52
package-lock.json
generated
52
package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "@evan/pi-lsp",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"picomatch": "^4.0.4",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"vscode-languageserver-protocol": "^3.17.5"
|
||||
},
|
||||
@@ -17,6 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.72.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"oxlint": "^1.62.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typebox": "^1.1.37",
|
||||
@@ -2789,6 +2793,13 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
||||
@@ -2967,6 +2978,21 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-highlight": {
|
||||
"version": "2.1.11",
|
||||
"resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz",
|
||||
@@ -3642,7 +3668,6 @@
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
@@ -4085,6 +4110,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
@@ -4180,6 +4217,19 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
|
||||
@@ -21,12 +21,16 @@
|
||||
"test:integration": "NODE_OPTIONS='--import=tsx' node --test test/integration/**/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"ignore": "^7.0.5",
|
||||
"picomatch": "^4.0.4",
|
||||
"vscode-jsonrpc": "^8.2.1",
|
||||
"vscode-languageserver-protocol": "^3.17.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.72.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/picomatch": "^4.0.3",
|
||||
"oxlint": "^1.62.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typebox": "^1.1.37",
|
||||
|
||||
125
server.ts
125
server.ts
@@ -3,8 +3,45 @@
|
||||
//
|
||||
// Add new servers here. `match` is a list of file extensions (no dot) OR
|
||||
// language ids; either matches.
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ServerConfig } from "./src/types.ts";
|
||||
|
||||
// Resolve Python Path - Prefer the project's virtualenv when present so
|
||||
// Pyright sees the same interpreter/dependencies as project commands.
|
||||
function resolvePythonPath(rootDir: string): string | undefined {
|
||||
const candidates = [
|
||||
path.join(rootDir, ".venv", "bin", "python"),
|
||||
path.join(rootDir, "venv", "bin", "python"),
|
||||
];
|
||||
return candidates.find((candidate) => fs.existsSync(candidate));
|
||||
}
|
||||
|
||||
// Pyright Settings - Minimal editor settings needed for diagnostics and import
|
||||
// resolution. Shared by workspace/configuration and didChangeConfiguration.
|
||||
function pyrightSettings(rootDir: string): {
|
||||
pythonPath: string | undefined;
|
||||
analysis: {
|
||||
diagnosticMode: string;
|
||||
typeCheckingMode: string;
|
||||
autoSearchPaths: boolean;
|
||||
useLibraryCodeForTypes: boolean;
|
||||
};
|
||||
} {
|
||||
return {
|
||||
pythonPath: resolvePythonPath(rootDir),
|
||||
analysis: {
|
||||
diagnosticMode: "openFilesOnly",
|
||||
typeCheckingMode: "basic",
|
||||
autoSearchPaths: true,
|
||||
useLibraryCodeForTypes: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Global Root Markers — appended to every server's rootMarkers list
|
||||
export const globalRootMarkers = [".git"];
|
||||
|
||||
export const servers: ServerConfig[] = [
|
||||
{
|
||||
id: "gopls",
|
||||
@@ -22,6 +59,20 @@ export const servers: ServerConfig[] = [
|
||||
rootMarkers: ["pnpm-workspace.yaml", "tsconfig.json", "package.json"],
|
||||
languageId: "typescript",
|
||||
},
|
||||
{
|
||||
id: "svelteserver",
|
||||
match: ["svelte"],
|
||||
command: "svelteserver",
|
||||
args: ["--stdio"],
|
||||
rootMarkers: [
|
||||
"svelte.config.js",
|
||||
"svelte.config.ts",
|
||||
"svelte.config.cjs",
|
||||
"svelte.config.mjs",
|
||||
"package.json",
|
||||
],
|
||||
languageId: "svelte",
|
||||
},
|
||||
{
|
||||
id: "pyright",
|
||||
match: ["py"],
|
||||
@@ -29,5 +80,79 @@ export const servers: ServerConfig[] = [
|
||||
args: ["--stdio"],
|
||||
rootMarkers: ["pyproject.toml", "setup.py", "setup.cfg"],
|
||||
languageId: "python",
|
||||
workspaceConfiguration: {
|
||||
initialSettings: ({ rootDir }) => ({ python: pyrightSettings(rootDir) }),
|
||||
getSection: (section, { rootDir }) => {
|
||||
const settings = pyrightSettings(rootDir);
|
||||
if (section === "python") return settings;
|
||||
if (section === "python.analysis") return settings.analysis;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "lua-language-server",
|
||||
match: ["lua"],
|
||||
command: "lua-language-server",
|
||||
args: [],
|
||||
rootMarkers: [".luarc.json"],
|
||||
languageId: "lua",
|
||||
},
|
||||
{
|
||||
id: "vscode-html-language-server",
|
||||
match: ["html"],
|
||||
command: "vscode-html-language-server",
|
||||
args: ["--stdio"],
|
||||
rootMarkers: ["package.json"],
|
||||
languageId: "html",
|
||||
},
|
||||
{
|
||||
id: "vscode-css-language-server",
|
||||
match: ["css", "scss", "less"],
|
||||
command: "vscode-css-language-server",
|
||||
args: ["--stdio"],
|
||||
rootMarkers: ["package.json"],
|
||||
languageId: "css",
|
||||
},
|
||||
{
|
||||
id: "vscode-json-language-server",
|
||||
match: ["json", "jsonc", "jsonl"],
|
||||
command: "vscode-json-language-server",
|
||||
args: ["--stdio"],
|
||||
rootMarkers: ["package.json"],
|
||||
languageId: "json",
|
||||
},
|
||||
{
|
||||
id: "bash-language-server",
|
||||
match: ["sh", "bash"],
|
||||
command: "bash-language-server",
|
||||
args: ["start"],
|
||||
rootMarkers: [".git"],
|
||||
languageId: "shellscript",
|
||||
},
|
||||
{
|
||||
id: "sqls",
|
||||
match: ["sql"],
|
||||
command: "sqls",
|
||||
args: [],
|
||||
rootMarkers: [".git"],
|
||||
languageId: "sql",
|
||||
},
|
||||
{
|
||||
id: "nil",
|
||||
match: ["nix"],
|
||||
command: "nil",
|
||||
args: [],
|
||||
rootMarkers: ["flake.nix"],
|
||||
languageId: "nix",
|
||||
},
|
||||
{
|
||||
id: "oxlint",
|
||||
match: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
||||
command: "oxlint",
|
||||
args: ["--lsp"],
|
||||
rootMarkers: [".oxlintrc.json", "oxlint.config.json"],
|
||||
languageId: "typescript",
|
||||
diagnosticsOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
129
src/client.ts
129
src/client.ts
@@ -7,38 +7,16 @@ import {
|
||||
type MessageConnection,
|
||||
} from "vscode-jsonrpc/node.js";
|
||||
import type {
|
||||
FileSystemWatcher,
|
||||
InitializeParams,
|
||||
PublishDiagnosticsParams,
|
||||
Registration,
|
||||
Unregistration,
|
||||
} from "vscode-languageserver-protocol";
|
||||
import * as path from "node:path";
|
||||
import type { ServerConfig } from "./types.ts";
|
||||
import { ServerNotFoundError } from "./types.ts";
|
||||
import { findRoot, pathToUri, uriToPath } from "./root.ts";
|
||||
|
||||
// Is On PATH - Returns true if `cmd` resolves to an executable via the
|
||||
// current PATH. Absolute/relative paths are checked directly.
|
||||
function isOnPath(cmd: string): boolean {
|
||||
const isExec = (p: string) => {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (cmd.includes(path.sep)) return isExec(cmd);
|
||||
const exts =
|
||||
process.platform === "win32"
|
||||
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
|
||||
: [""];
|
||||
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const ext of exts) {
|
||||
if (isExec(path.join(dir, cmd + ext))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
import { isOnPath } from "./util.ts";
|
||||
|
||||
// LspClient - Thin wrapper that spawns a language server, performs the
|
||||
// initialize handshake, auto-opens a file, and exposes sendRequest so the
|
||||
@@ -65,20 +43,23 @@ export class LspClient {
|
||||
// version numbers in didOpen/didChange. We track them so the daemon
|
||||
// can resync files via notifyChange after on-disk edits.
|
||||
private versions = new Map<string, number>();
|
||||
private fileWatchers = new Map<string, FileSystemWatcher[]>();
|
||||
private watchersListeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly server: ServerConfig) {}
|
||||
|
||||
// Start - Spawns the server process and wires up JSON-RPC.
|
||||
async start(rootDir: string): Promise<void> {
|
||||
async start(rootDir: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
// Verify Binary On PATH - Fail fast with a clear message instead of
|
||||
// letting spawn ENOENT surface as a generic error. It's the user's
|
||||
// responsibility to have the server installed & on PATH.
|
||||
if (!isOnPath(this.server.command)) {
|
||||
// letting spawn ENOENT surface as a generic error. Resolution uses the
|
||||
// caller/session env, not the daemon's launch-time env.
|
||||
if (!isOnPath(this.server.command, env)) {
|
||||
throw new ServerNotFoundError(this.server.command);
|
||||
}
|
||||
this.proc = spawn(this.server.command, this.server.args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: rootDir,
|
||||
env,
|
||||
});
|
||||
this.proc.on("error", (err) => {
|
||||
process.stderr.write(
|
||||
@@ -126,11 +107,47 @@ export class LspClient {
|
||||
}
|
||||
},
|
||||
);
|
||||
// Accept Common Server Requests - Return empty/null so servers don't
|
||||
// stall. Good enough for a CLI; a real client would answer properly.
|
||||
this.conn.onRequest("workspace/configuration", () => []);
|
||||
this.conn.onRequest("client/registerCapability", () => null);
|
||||
this.conn.onRequest("client/unregisterCapability", () => null);
|
||||
// Accept Common Server Requests - Return one configuration response per
|
||||
// requested item. Server-specific settings live in server.ts so adding
|
||||
// another picky server doesn't grow conditionals in this transport layer.
|
||||
this.conn.onRequest(
|
||||
"workspace/configuration",
|
||||
(params: { items?: { section?: string }[] }) => {
|
||||
const items = params.items ?? [];
|
||||
const config = this.server.workspaceConfiguration;
|
||||
return items.map((item) =>
|
||||
config?.getSection?.(item.section, { rootDir, env }) ?? null,
|
||||
);
|
||||
},
|
||||
);
|
||||
this.conn.onRequest(
|
||||
"client/registerCapability",
|
||||
(params: { registrations?: Registration[] }) => {
|
||||
let changed = false;
|
||||
for (const reg of params.registrations ?? []) {
|
||||
if (reg.method !== "workspace/didChangeWatchedFiles") continue;
|
||||
const opts = reg.registerOptions as
|
||||
| { watchers?: FileSystemWatcher[] }
|
||||
| undefined;
|
||||
this.fileWatchers.set(reg.id, opts?.watchers ?? []);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) for (const l of this.watchersListeners) l();
|
||||
return null;
|
||||
},
|
||||
);
|
||||
this.conn.onRequest(
|
||||
"client/unregisterCapability",
|
||||
(params: { unregisterations?: Unregistration[] }) => {
|
||||
let changed = false;
|
||||
for (const unreg of params.unregisterations ?? []) {
|
||||
if (unreg.method !== "workspace/didChangeWatchedFiles") continue;
|
||||
if (this.fileWatchers.delete(unreg.id)) changed = true;
|
||||
}
|
||||
if (changed) for (const l of this.watchersListeners) l();
|
||||
return null;
|
||||
},
|
||||
);
|
||||
|
||||
this.conn.listen();
|
||||
|
||||
@@ -149,7 +166,11 @@ export class LspClient {
|
||||
publishDiagnostics: {},
|
||||
synchronization: { didSave: true },
|
||||
},
|
||||
workspace: { workspaceFolders: true, configuration: true },
|
||||
workspace: {
|
||||
workspaceFolders: true,
|
||||
configuration: true,
|
||||
didChangeWatchedFiles: { dynamicRegistration: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.conn.sendRequest("initialize", {
|
||||
@@ -160,6 +181,16 @@ export class LspClient {
|
||||
},
|
||||
});
|
||||
this.conn.sendNotification("initialized", {});
|
||||
|
||||
// Push Configuration - Some servers do not always request workspace/configuration,
|
||||
// but still consume settings delivered through didChangeConfiguration.
|
||||
const settings = this.server.workspaceConfiguration?.initialSettings?.({
|
||||
rootDir,
|
||||
env,
|
||||
});
|
||||
if (settings !== undefined && settings !== null) {
|
||||
this.conn.sendNotification("workspace/didChangeConfiguration", { settings });
|
||||
}
|
||||
}
|
||||
|
||||
// Wait For Ready - Resolves when there are no outstanding progress
|
||||
@@ -216,11 +247,34 @@ export class LspClient {
|
||||
return uri;
|
||||
}
|
||||
|
||||
closeDocument(uri: string): void {
|
||||
this.versions.delete(uri);
|
||||
this.diagnostics.delete(uri);
|
||||
this.conn.sendNotification("textDocument/didClose", {
|
||||
textDocument: { uri },
|
||||
});
|
||||
}
|
||||
|
||||
// Send Raw LSP Request - Passthrough used by the command dispatcher.
|
||||
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
|
||||
return this.conn.sendRequest(method, params) as Promise<R>;
|
||||
}
|
||||
|
||||
sendNotification(method: string, params: unknown): void {
|
||||
this.conn.sendNotification(method, params);
|
||||
}
|
||||
|
||||
getFileWatchers(): FileSystemWatcher[] {
|
||||
return Array.from(this.fileWatchers.values()).flat();
|
||||
}
|
||||
|
||||
onWatchersChanged(listener: () => void): () => void {
|
||||
this.watchersListeners.add(listener);
|
||||
return () => {
|
||||
this.watchersListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
// Clear Diagnostics - Drops the cached diagnostics for a URI so callers
|
||||
// can force waitForDiagnostics to await a fresh publish after didChange.
|
||||
clearDiagnostics(uri: string): void {
|
||||
@@ -277,10 +331,11 @@ export class LspClient {
|
||||
export async function startClientForFile(
|
||||
server: ServerConfig,
|
||||
filePath: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<{ client: LspClient; uri: string; rootDir: string }> {
|
||||
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||
const client = new LspClient(server);
|
||||
await client.start(rootDir);
|
||||
await client.start(rootDir, env);
|
||||
const uri = client.openDocument(filePath);
|
||||
// Wait For Workspace Load - gopls & friends reject requests with errors
|
||||
// like "no views" until their initial load completes.
|
||||
|
||||
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();
|
||||
}
|
||||
157
src/daemon.ts
157
src/daemon.ts
@@ -5,19 +5,26 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as net from "node:net";
|
||||
import * as path from "node:path";
|
||||
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
|
||||
import { LspClient } from "./client.ts";
|
||||
import { findRoot, pickServer, pathToUri } from "./root.ts";
|
||||
import { findRoot, findServerById, pathToUri } from "./root.ts";
|
||||
import type { ServerConfig } from "./types.ts";
|
||||
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
|
||||
import {
|
||||
logPath,
|
||||
socketPath,
|
||||
tryConnect,
|
||||
type DaemonRequest,
|
||||
type DaemonResponse,
|
||||
type LaunchContext,
|
||||
} from "./daemonProtocol.ts";
|
||||
|
||||
// Default Idle TTL - 5 minutes. Per-server overrides via ServerConfig.idleTtlMs.
|
||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
||||
const WATCHER_READY_TIMEOUT_MS = 5000;
|
||||
const FILE_CHANGE_DELETED = 3;
|
||||
const WATCH_KIND_CREATE = 1;
|
||||
const WATCH_KIND_CHANGE = 2;
|
||||
const WATCH_KIND_DELETE = 4;
|
||||
|
||||
// Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping
|
||||
// needed to keep files in sync and evict on idleness.
|
||||
@@ -36,6 +43,9 @@ interface ClientEntry {
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
ttlMs: number;
|
||||
lastUsed: number;
|
||||
watcher: WorkspaceWatcher | null;
|
||||
unsubscribeWatchers: (() => void) | null;
|
||||
usesDerivedWatchers: boolean;
|
||||
}
|
||||
|
||||
const entries = new Map<string, ClientEntry>();
|
||||
@@ -49,11 +59,17 @@ function log(...args: unknown[]) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get Or Create Entry - Looks up the cached client for a file, spawning a
|
||||
// fresh LspClient if needed. The returned entry is guaranteed to have its
|
||||
// `ready` promise resolved before the caller uses it.
|
||||
async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
|
||||
const server = pickServer(filePath);
|
||||
// Get Or Create Entry - Looks up the cached client for a server+file,
|
||||
// spawning a fresh LspClient if needed. The returned entry is guaranteed
|
||||
// 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(
|
||||
filePath: string,
|
||||
serverId: string,
|
||||
launch: LaunchContext,
|
||||
): Promise<ClientEntry> {
|
||||
const server = findServerById(filePath, serverId);
|
||||
const rootDir = findRoot(filePath, server.rootMarkers);
|
||||
const key = `${server.id}::${rootDir}`;
|
||||
const existing = entries.get(key);
|
||||
@@ -72,7 +88,7 @@ async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
|
||||
client,
|
||||
ready: (async () => {
|
||||
log(`spawn`, server.id, rootDir);
|
||||
await client.start(rootDir);
|
||||
await client.start(rootDir, launch.env);
|
||||
await client.waitForReady();
|
||||
log(`ready`, server.id);
|
||||
})(),
|
||||
@@ -81,6 +97,9 @@ async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
|
||||
idleTimer: null,
|
||||
ttlMs,
|
||||
lastUsed: Date.now(),
|
||||
watcher: null,
|
||||
unsubscribeWatchers: null,
|
||||
usesDerivedWatchers: false,
|
||||
};
|
||||
entries.set(key, entry);
|
||||
try {
|
||||
@@ -89,10 +108,104 @@ async function getOrCreateEntry(filePath: string): Promise<ClientEntry> {
|
||||
entries.delete(key);
|
||||
throw err;
|
||||
}
|
||||
await attachWatcher(entry);
|
||||
bumpIdle(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
|
||||
async function attachWatcher(entry: ClientEntry): Promise<void> {
|
||||
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
||||
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void refreshWatcher(entry));
|
||||
await refreshWatcher(entry);
|
||||
}
|
||||
|
||||
function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] {
|
||||
const registered = entry.client.getFileWatchers();
|
||||
entry.usesDerivedWatchers = registered.length === 0;
|
||||
return registered.length > 0 ? registered : derivedServerWatchers(entry.server);
|
||||
}
|
||||
|
||||
function derivedServerWatchers(server: ServerConfig): FileSystemWatcher[] {
|
||||
const extensions = server.match
|
||||
.map((m) => m.replace(/^\./, ""))
|
||||
.filter((m) => /^[A-Za-z0-9_-]+$/.test(m));
|
||||
if (extensions.length === 0) return [];
|
||||
|
||||
const globPattern = extensions.length === 1
|
||||
? `**/*.${extensions[0]}`
|
||||
: `**/*.{${extensions.join(",")}}`;
|
||||
return [{
|
||||
globPattern,
|
||||
kind: WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE,
|
||||
}];
|
||||
}
|
||||
|
||||
async function refreshWatcher(entry: ClientEntry): Promise<void> {
|
||||
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
||||
const patterns = watcherPatterns(entry);
|
||||
if (patterns.length === 0 && !entry.watcher) return;
|
||||
if (!entry.watcher) {
|
||||
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
|
||||
forwardEvents(entry, events),
|
||||
);
|
||||
log(`watcher`, entry.server.id, entry.rootDir, `patterns=${patterns.length}`);
|
||||
}
|
||||
if (process.env.LSP_DEBUG) {
|
||||
log(`watcher patterns`, entry.server.id, JSON.stringify(patterns));
|
||||
}
|
||||
entry.watcher.setPatterns(patterns);
|
||||
if (patterns.length > 0) await waitForWatcherReady(entry);
|
||||
}
|
||||
|
||||
async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
|
||||
if (!entry.watcher) return;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let timedOut = false;
|
||||
try {
|
||||
await Promise.race([
|
||||
entry.watcher.ready(),
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve();
|
||||
}, WATCHER_READY_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
if (timedOut) {
|
||||
log(`watcher ready timeout`, entry.server.id, entry.rootDir);
|
||||
}
|
||||
}
|
||||
|
||||
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
|
||||
try {
|
||||
for (const event of events) {
|
||||
if (event.type !== FILE_CHANGE_DELETED || !entry.opened.has(event.uri)) continue;
|
||||
entry.client.closeDocument(event.uri);
|
||||
entry.opened.delete(event.uri);
|
||||
void refreshWatcher(entry);
|
||||
}
|
||||
if (process.env.LSP_DEBUG) {
|
||||
log(`watcher fire`, entry.server.id, JSON.stringify(events));
|
||||
}
|
||||
for (const uri of entry.opened.keys()) entry.client.clearDiagnostics(uri);
|
||||
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: events,
|
||||
});
|
||||
if (entry.usesDerivedWatchers && entry.server.id === "typescript-language-server") {
|
||||
void entry.client.sendRequest("workspace/executeCommand", {
|
||||
command: "typescript.tsserverRequest",
|
||||
arguments: ["reloadProjects"],
|
||||
}).catch((err) => log("typescript reloadProjects failed", (err as Error).message));
|
||||
}
|
||||
} catch (err) {
|
||||
log(`watcher send failed`, entry.server.id, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// Bump Idle - Resets the idle eviction timer. Called on every request that
|
||||
// touches the entry. We log evictions so the daemon's behavior is visible.
|
||||
function bumpIdle(entry: ClientEntry) {
|
||||
@@ -106,6 +219,8 @@ function evict(entry: ClientEntry, reason: string) {
|
||||
log(`evict`, entry.key, reason);
|
||||
entries.delete(entry.key);
|
||||
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
||||
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
|
||||
void entry.watcher?.dispose();
|
||||
void entry.client.dispose();
|
||||
// Auto Shutdown - If this was the last entry, there's nothing left to
|
||||
// manage. Tear down the daemon so it doesn't sit idle forever.
|
||||
@@ -157,7 +272,7 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
switch (req.op) {
|
||||
case "request": {
|
||||
const filePath = path.resolve(req.file);
|
||||
const entry = await getOrCreateEntry(filePath);
|
||||
const entry = await getOrCreateEntry(filePath, req.serverId, req.launch);
|
||||
const { uri } = await syncFile(entry, filePath);
|
||||
bumpIdle(entry);
|
||||
const result = await entry.client.sendRequest(
|
||||
@@ -168,15 +283,27 @@ async function handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
}
|
||||
case "diagnostics": {
|
||||
const filePath = path.resolve(req.file);
|
||||
const entry = await getOrCreateEntry(filePath);
|
||||
const timeoutMs = req.timeoutMs ?? 1500;
|
||||
|
||||
// Fan-Out - Run diagnostics against all requested servers in
|
||||
// parallel. Individual failures are captured, not thrown.
|
||||
const results: Record<string, unknown> = {};
|
||||
const settled = await Promise.allSettled(
|
||||
req.serverIds.map(async (serverId) => {
|
||||
const entry = await getOrCreateEntry(filePath, serverId, req.launch);
|
||||
const { uri, changed } = await syncFile(entry, filePath);
|
||||
bumpIdle(entry);
|
||||
if (changed) entry.client.clearDiagnostics(uri);
|
||||
const result = await entry.client.waitForDiagnostics(
|
||||
uri,
|
||||
req.timeoutMs ?? 1500,
|
||||
const diag = await entry.client.waitForDiagnostics(uri, timeoutMs);
|
||||
return { serverId, diag };
|
||||
}),
|
||||
);
|
||||
return { id: req.id, ok: true, result };
|
||||
for (const outcome of settled) {
|
||||
if (outcome.status === "fulfilled") {
|
||||
results[outcome.value.serverId] = outcome.value.diag;
|
||||
}
|
||||
}
|
||||
return { id: req.id, ok: true, result: results };
|
||||
}
|
||||
case "status": {
|
||||
const result = {
|
||||
@@ -273,6 +400,8 @@ function shutdownDaemon(reason: string) {
|
||||
if (server) server.close();
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
||||
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
|
||||
void entry.watcher?.dispose();
|
||||
void entry.client.dispose();
|
||||
}
|
||||
entries.clear();
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
// Why Not One Persistent Socket - For now we open a fresh connection per
|
||||
// request. The cost is negligible (Unix socket, same machine) compared to
|
||||
// the LSP request itself, and it keeps client code stateless.
|
||||
import { sendOnce, type DaemonResponse } from "./daemonProtocol.ts";
|
||||
import {
|
||||
buildLaunchContext,
|
||||
sendOnce,
|
||||
type DaemonResponse,
|
||||
} from "./daemonProtocol.ts";
|
||||
|
||||
// Unwrap - Throws on { ok: false }, returns result on { ok: true }. All
|
||||
// callers want the result-or-throw shape, so we centralize it.
|
||||
@@ -18,19 +22,40 @@ function unwrap(resp: DaemonResponse): unknown {
|
||||
// daemon injects textDocument.uri from `file`, so callers omit it.
|
||||
export async function daemonRequest(
|
||||
file: string,
|
||||
serverId: string,
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
return unwrap(await sendOnce({ op: "request", file, method, params }));
|
||||
return unwrap(
|
||||
await sendOnce({
|
||||
op: "request",
|
||||
file,
|
||||
serverId,
|
||||
method,
|
||||
params,
|
||||
launch: buildLaunchContext(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Wait For Diagnostics - Diagnostics arrive as a notification, not a
|
||||
// response, so the daemon has a dedicated op that awaits the next publish.
|
||||
// Accepts an array of server IDs; daemon fans out in parallel and returns
|
||||
// a grouped map: { [serverId]: { uri, diagnostics[] } }.
|
||||
export async function daemonDiagnostics(
|
||||
file: string,
|
||||
serverIds: string[],
|
||||
timeoutMs = 1500,
|
||||
): Promise<unknown> {
|
||||
return unwrap(await sendOnce({ op: "diagnostics", file, timeoutMs }));
|
||||
return unwrap(
|
||||
await sendOnce({
|
||||
op: "diagnostics",
|
||||
file,
|
||||
serverIds,
|
||||
timeoutMs,
|
||||
launch: buildLaunchContext(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Status - Lists currently-cached LSP servers (id, root, opened files,
|
||||
|
||||
@@ -10,16 +10,47 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
// Launch Context - Captures the caller/session environment used when the
|
||||
// daemon spawns a new language server. Running servers keep their original
|
||||
// process env; later requests for the same root reuse the existing server.
|
||||
export interface LaunchContext {
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
// Build Launch Context - Convert Node's optional-valued process.env into the
|
||||
// concrete string map accepted by child_process.spawn(). Env contents are
|
||||
// sensitive: keep them internal to requests and never log or expose them.
|
||||
export function buildLaunchContext(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): LaunchContext {
|
||||
return {
|
||||
env: Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => {
|
||||
return typeof entry[1] === "string";
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Request Shapes - Sent client -> daemon.
|
||||
export type DaemonRequest =
|
||||
| {
|
||||
id: number;
|
||||
op: "request";
|
||||
file: string;
|
||||
serverId: string;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
launch: LaunchContext;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
op: "diagnostics";
|
||||
file: string;
|
||||
serverIds: string[];
|
||||
timeoutMs?: number;
|
||||
launch: LaunchContext;
|
||||
}
|
||||
| { id: number; op: "diagnostics"; file: string; timeoutMs?: number }
|
||||
| { id: number; op: "status" }
|
||||
| { id: number; op: "shutdown" }
|
||||
| { id: number; op: "destroy_server"; serverId?: string };
|
||||
@@ -28,10 +59,18 @@ export type DaemonRequestWithoutId =
|
||||
| {
|
||||
op: "request";
|
||||
file: string;
|
||||
serverId: string;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
launch: LaunchContext;
|
||||
}
|
||||
| {
|
||||
op: "diagnostics";
|
||||
file: string;
|
||||
serverIds: string[];
|
||||
timeoutMs?: number;
|
||||
launch: LaunchContext;
|
||||
}
|
||||
| { op: "diagnostics"; file: string; timeoutMs?: number }
|
||||
| { op: "status" }
|
||||
| { op: "shutdown" }
|
||||
| { op: "destroy_server"; serverId?: string };
|
||||
|
||||
43
src/root.ts
43
src/root.ts
@@ -1,9 +1,15 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { pathToFileURL, fileURLToPath } from "node:url";
|
||||
import { servers } 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 {
|
||||
@@ -15,24 +21,53 @@ export function pathToUri(p: string): string {
|
||||
return pathToFileURL(path.resolve(p)).toString();
|
||||
}
|
||||
|
||||
// Server Availability Cache - Checked once per process lifetime per server.
|
||||
// Avoids repeated filesystem lookups on every tool call.
|
||||
const serverAvailability = new Map<string, boolean>();
|
||||
|
||||
// Is Server Available - Returns true if the server binary is on PATH.
|
||||
// Result is cached for the lifetime of this process.
|
||||
export function isServerAvailable(server: ServerConfig): boolean {
|
||||
if (serverAvailability.has(server.id)) return serverAvailability.get(server.id)!;
|
||||
const available = isOnPath(server.command, process.env);
|
||||
serverAvailability.set(server.id, available);
|
||||
return available;
|
||||
}
|
||||
|
||||
// Pick Server By File Extension - match[] entries are matched against the
|
||||
// file's extension (no dot). First server in the registry 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 {
|
||||
const ext = path.extname(filePath).replace(/^\./, "");
|
||||
const hit = servers.find((s) => s.match.includes(ext));
|
||||
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 {
|
||||
let dir = path.dirname(path.resolve(filePath));
|
||||
const { root } = path.parse(dir);
|
||||
const allMarkers = [...markers, ...globalRootMarkers];
|
||||
while (true) {
|
||||
for (const m of markers) {
|
||||
for (const m of allMarkers) {
|
||||
if (fs.existsSync(path.join(dir, m))) return dir;
|
||||
}
|
||||
if (dir === root) return path.dirname(path.resolve(filePath));
|
||||
|
||||
23
src/types.ts
23
src/types.ts
@@ -17,6 +17,22 @@ export class ServerNotFoundError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceConfigurationContext {
|
||||
rootDir: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export interface ServerWorkspaceConfiguration {
|
||||
// Initial Settings - Optional payload pushed via workspace/didChangeConfiguration
|
||||
// after initialize/initialized for servers that don't always request config.
|
||||
initialSettings?: (ctx: WorkspaceConfigurationContext) => unknown;
|
||||
// Section Settings - Optional handler for workspace/configuration requests.
|
||||
getSection?: (
|
||||
section: string | undefined,
|
||||
ctx: WorkspaceConfigurationContext,
|
||||
) => unknown;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
// Stable identifier (useful for logs and future daemon cache keys).
|
||||
id: string;
|
||||
@@ -34,6 +50,13 @@ export interface ServerConfig {
|
||||
// Idle TTL - Daemon keeps one server alive per (id, rootDir) and evicts
|
||||
// it after this many ms of inactivity. Defaults to 5 minutes.
|
||||
idleTtlMs?: number;
|
||||
// Diagnostics Only - When true, this server is excluded from
|
||||
// hover/definition/references/completion/documentSymbol but included
|
||||
// in lsp_diagnostics and auto-check.
|
||||
diagnosticsOnly?: boolean;
|
||||
// Workspace Configuration - Optional server-specific settings exposed through
|
||||
// workspace/configuration and workspace/didChangeConfiguration.
|
||||
workspaceConfiguration?: ServerWorkspaceConfiguration;
|
||||
}
|
||||
|
||||
// Supported high-level commands exposed via the CLI. Extend this union
|
||||
|
||||
27
src/util.ts
Normal file
27
src/util.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
// Is On PATH - Returns true if `cmd` resolves to an executable via the
|
||||
// supplied PATH. Absolute/relative paths are checked directly.
|
||||
export function isOnPath(cmd: string, env: NodeJS.ProcessEnv): boolean {
|
||||
const isExec = (p: string) => {
|
||||
try {
|
||||
fs.accessSync(p, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (cmd.includes(path.sep)) return isExec(cmd);
|
||||
const exts =
|
||||
process.platform === "win32"
|
||||
? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")
|
||||
: [""];
|
||||
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const ext of exts) {
|
||||
if (isExec(path.join(dir, cmd + ext))) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
237
src/watcher.ts
Normal file
237
src/watcher.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import chokidar, { type FSWatcher } from "chokidar";
|
||||
import ignore, { type Ignore } from "ignore";
|
||||
import picomatch from "picomatch";
|
||||
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
|
||||
import { pathToUri } from "./root.ts";
|
||||
|
||||
const FILE_CHANGE_CREATED = 1;
|
||||
const FILE_CHANGE_CHANGED = 2;
|
||||
const FILE_CHANGE_DELETED = 3;
|
||||
|
||||
const WATCH_KIND_CREATE = 1;
|
||||
const WATCH_KIND_CHANGE = 2;
|
||||
const WATCH_KIND_DELETE = 4;
|
||||
|
||||
const DEBOUNCE_QUIET_MS = 50;
|
||||
const DEBOUNCE_MAX_WAIT_MS = 500;
|
||||
|
||||
export const BASELINE_IGNORES = [
|
||||
"**/.git/**",
|
||||
"**/.DS_Store",
|
||||
"**/.hg/**",
|
||||
"**/.svn/**",
|
||||
];
|
||||
|
||||
const NO_GITIGNORE_FALLBACK = ["**/node_modules/**", "**/.git/**"];
|
||||
|
||||
export interface FileEvent {
|
||||
uri: string;
|
||||
type: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
function buildIgnoreMatcher(rootDir: string): (relPath: string) => boolean {
|
||||
const gitignorePath = path.join(rootDir, ".gitignore");
|
||||
let ig: Ignore | null = null;
|
||||
if (fs.existsSync(gitignorePath)) {
|
||||
try {
|
||||
ig = ignore().add(fs.readFileSync(gitignorePath, "utf8"));
|
||||
} catch {
|
||||
ig = null;
|
||||
}
|
||||
}
|
||||
|
||||
const baselineMatcher = picomatch(BASELINE_IGNORES);
|
||||
if (ig) {
|
||||
return (relPath) => {
|
||||
if (baselineMatcher(relPath)) return true;
|
||||
const posixRel = relPath.split(path.sep).join("/");
|
||||
if (!posixRel || posixRel === ".") return false;
|
||||
return ig.ignores(posixRel);
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackMatcher = picomatch(NO_GITIGNORE_FALLBACK);
|
||||
return (relPath) => baselineMatcher(relPath) || fallbackMatcher(relPath);
|
||||
}
|
||||
|
||||
function compileWatchers(
|
||||
watchers: FileSystemWatcher[],
|
||||
rootDir: string,
|
||||
): (relPath: string, absPath: string) => number {
|
||||
const compiled = watchers.map((w) => {
|
||||
const pattern = typeof w.globPattern === "string"
|
||||
? w.globPattern
|
||||
: resolveRelativePattern(w.globPattern, rootDir);
|
||||
return {
|
||||
match: picomatch(pattern, { dot: true }),
|
||||
kind: w.kind ?? (WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE),
|
||||
};
|
||||
});
|
||||
|
||||
return (relPath, absPath) => {
|
||||
const posixRel = relPath.split(path.sep).join("/");
|
||||
const posixAbs = absPath.split(path.sep).join("/");
|
||||
let kind = 0;
|
||||
for (const c of compiled) {
|
||||
if (c.match(posixRel) || c.match(posixAbs)) kind |= c.kind;
|
||||
}
|
||||
return kind;
|
||||
};
|
||||
}
|
||||
|
||||
// Relative Pattern - Servers may send baseUri as a string or WorkspaceFolder.
|
||||
function resolveRelativePattern(
|
||||
rp: { baseUri: string | { uri: string }; pattern: string },
|
||||
rootDir: string,
|
||||
): string {
|
||||
const baseUri = typeof rp.baseUri === "string" ? rp.baseUri : rp.baseUri.uri;
|
||||
if (!baseUri.startsWith("file://")) return rp.pattern;
|
||||
const basePath = decodeURIComponent(baseUri.slice("file://".length));
|
||||
const relBase = path.relative(rootDir, basePath);
|
||||
if (relBase.startsWith("..") || path.isAbsolute(relBase)) return rp.pattern;
|
||||
return relBase ? `${relBase.split(path.sep).join("/")}/${rp.pattern}` : rp.pattern;
|
||||
}
|
||||
|
||||
export class WorkspaceWatcher {
|
||||
private chokidar: FSWatcher | null = null;
|
||||
private isIgnored: (relPath: string) => boolean;
|
||||
private matchKind: (relPath: string, absPath: string) => number = () => 0;
|
||||
private pending = new Map<string, 1 | 2 | 3>();
|
||||
private quietTimer: NodeJS.Timeout | null = null;
|
||||
private maxWaitTimer: NodeJS.Timeout | null = null;
|
||||
private disposed = false;
|
||||
private readyPromise: Promise<void> = Promise.resolve();
|
||||
private resolveReady: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly rootDir: string,
|
||||
private readonly onEvents: (events: FileEvent[]) => void,
|
||||
) {
|
||||
this.isIgnored = buildIgnoreMatcher(rootDir);
|
||||
}
|
||||
|
||||
setPatterns(watchers: FileSystemWatcher[]): void {
|
||||
if (this.disposed) return;
|
||||
this.matchKind = compileWatchers(watchers, this.rootDir);
|
||||
if (watchers.length === 0) {
|
||||
this.cancelPending();
|
||||
void this.stopChokidar();
|
||||
return;
|
||||
}
|
||||
if (!this.chokidar) this.startChokidar();
|
||||
}
|
||||
|
||||
ready(): Promise<void> {
|
||||
return this.readyPromise;
|
||||
}
|
||||
|
||||
private startChokidar(): void {
|
||||
this.readyPromise = new Promise<void>((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
this.chokidar = chokidar.watch(this.rootDir, {
|
||||
ignoreInitial: true,
|
||||
followSymlinks: false,
|
||||
ignored: (absPath: string) => {
|
||||
if (absPath === this.rootDir) return false;
|
||||
const rel = path.relative(this.rootDir, absPath);
|
||||
return this.isIgnored(rel);
|
||||
},
|
||||
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 },
|
||||
});
|
||||
this.chokidar.on("add", (p) => this.queue(p, FILE_CHANGE_CREATED));
|
||||
this.chokidar.on("change", (p) => this.queue(p, FILE_CHANGE_CHANGED));
|
||||
this.chokidar.on("unlink", (p) => this.queue(p, FILE_CHANGE_DELETED));
|
||||
this.chokidar.on("ready", () => this.resolveReady?.());
|
||||
this.chokidar.on("error", (err) => {
|
||||
process.stderr.write(`[pi-lsp:watcher] ${this.rootDir}: ${String(err)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
private async stopChokidar(): Promise<void> {
|
||||
if (!this.chokidar) return;
|
||||
const w = this.chokidar;
|
||||
this.chokidar = null;
|
||||
this.resolveReady?.();
|
||||
this.resolveReady = null;
|
||||
this.readyPromise = Promise.resolve();
|
||||
await w.close();
|
||||
}
|
||||
|
||||
private cancelPending(): void {
|
||||
if (this.quietTimer) clearTimeout(this.quietTimer);
|
||||
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
|
||||
this.quietTimer = null;
|
||||
this.maxWaitTimer = null;
|
||||
this.pending.clear();
|
||||
}
|
||||
|
||||
private queue(absPath: string, type: 1 | 2 | 3): void {
|
||||
const rel = path.relative(this.rootDir, absPath);
|
||||
const kind = this.matchKind(rel, absPath);
|
||||
if (kind === 0) return;
|
||||
if (type === FILE_CHANGE_CREATED && !(kind & WATCH_KIND_CREATE)) return;
|
||||
if (type === FILE_CHANGE_CHANGED && !(kind & WATCH_KIND_CHANGE)) return;
|
||||
if (type === FILE_CHANGE_DELETED && !(kind & WATCH_KIND_DELETE)) return;
|
||||
|
||||
const uri = pathToUri(absPath);
|
||||
const prev = this.pending.get(uri);
|
||||
const next = coalesce(prev, type);
|
||||
if (next === null) this.pending.delete(uri);
|
||||
else this.pending.set(uri, next);
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.quietTimer) clearTimeout(this.quietTimer);
|
||||
this.quietTimer = setTimeout(() => this.flush(), DEBOUNCE_QUIET_MS);
|
||||
if (!this.maxWaitTimer) {
|
||||
this.maxWaitTimer = setTimeout(() => this.flush(), DEBOUNCE_MAX_WAIT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.quietTimer) clearTimeout(this.quietTimer);
|
||||
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
|
||||
this.quietTimer = null;
|
||||
this.maxWaitTimer = null;
|
||||
if (this.pending.size === 0) return;
|
||||
|
||||
const events: FileEvent[] = Array.from(this.pending, ([uri, type]) => ({
|
||||
uri,
|
||||
type,
|
||||
}));
|
||||
this.pending.clear();
|
||||
try {
|
||||
this.onEvents(events);
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[pi-lsp:watcher] onEvents threw: ${(err as Error).message}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.cancelPending();
|
||||
await this.stopChokidar();
|
||||
}
|
||||
}
|
||||
|
||||
function coalesce(
|
||||
prev: 1 | 2 | 3 | undefined,
|
||||
next: 1 | 2 | 3,
|
||||
): 1 | 2 | 3 | null {
|
||||
if (prev === undefined) return next;
|
||||
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_DELETED) return null;
|
||||
if (prev === FILE_CHANGE_DELETED && next === FILE_CHANGE_CREATED) {
|
||||
return FILE_CHANGE_CHANGED;
|
||||
}
|
||||
if (prev === FILE_CHANGE_CREATED && next === FILE_CHANGE_CHANGED) {
|
||||
return FILE_CHANGE_CREATED;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -25,10 +25,11 @@ export const tsx = path.resolve(
|
||||
"cli.mjs",
|
||||
);
|
||||
|
||||
// Unique Test Socket — each test run gets its own Unix socket so we don't
|
||||
// touch any real session daemon.
|
||||
// Unique Test Socket — each suite gets its own Unix socket so parallel
|
||||
// integration tests don't race through the same daemon.
|
||||
export function testSocket(): string {
|
||||
return path.join(os.tmpdir(), `pi-lsp-test-${process.pid}.sock`);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-test-"));
|
||||
return path.join(dir, "daemon.sock");
|
||||
}
|
||||
|
||||
// Set Test Socket — sets PI_LSP_SOCKET_PATH for the current process and
|
||||
@@ -39,7 +40,7 @@ export function setTestSocket(env: Record<string, string | undefined>): () => vo
|
||||
return () => {
|
||||
delete env.PI_LSP_SOCKET_PATH;
|
||||
try {
|
||||
fs.unlinkSync(sock);
|
||||
fs.rmSync(path.dirname(sock), { recursive: true, force: true });
|
||||
} catch {
|
||||
// Socket may not exist — that's fine.
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ describe("cli daemon lifecycle", () => {
|
||||
const env = { ...process.env };
|
||||
let cleanup: () => void;
|
||||
|
||||
before(() => {
|
||||
before(async () => {
|
||||
cleanup = setTestSocket(env);
|
||||
// Stop any stale daemon on this socket before tests run.
|
||||
stopTestDaemon(env);
|
||||
await stopTestDaemon(env);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
after(async () => {
|
||||
// Tear down daemon and clean up socket after all tests.
|
||||
stopTestDaemon(env);
|
||||
await stopTestDaemon(env);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
|
||||
169
test/integration/watcher-gopls.test.ts
Normal file
169
test/integration/watcher-gopls.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, it, before, after } 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 {
|
||||
setTestSocket,
|
||||
stopTestDaemon,
|
||||
runCliJson,
|
||||
requireServer,
|
||||
} from "../helpers.ts";
|
||||
|
||||
const skip = requireServer("gopls");
|
||||
|
||||
async function pollUntil<T>(
|
||||
fn: () => Promise<T>,
|
||||
predicate: (v: T) => boolean,
|
||||
timeoutMs: number,
|
||||
intervalMs = 250,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: T = await fn();
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate(last)) return last;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await fn();
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
interface DiagResult {
|
||||
[serverId: string]: { diagnostics?: { message: string }[] };
|
||||
}
|
||||
|
||||
describe("watcher: gopls picks up external file changes", { skip: skip ?? undefined }, () => {
|
||||
let tmpDir: string;
|
||||
let mainFile: string;
|
||||
let helperFile: string;
|
||||
const env = { ...process.env };
|
||||
let cleanup: () => void;
|
||||
|
||||
before(async () => {
|
||||
cleanup = setTestSocket(env);
|
||||
await stopTestDaemon(env);
|
||||
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-gopls-watch-"));
|
||||
fs.writeFileSync(path.join(tmpDir, "go.mod"), "module example.com/wtest\n\ngo 1.21\n");
|
||||
mainFile = path.join(tmpDir, "main.go");
|
||||
helperFile = path.join(tmpDir, "helper.go");
|
||||
fs.writeFileSync(
|
||||
mainFile,
|
||||
"package main\n\nfunc main() {\n\tHelper()\n}\n",
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopTestDaemon(env);
|
||||
cleanup();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("initially reports undefined symbol", async () => {
|
||||
const result = await pollUntil(
|
||||
async () =>
|
||||
(await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
|
||||
env,
|
||||
)) as DiagResult,
|
||||
(r) => {
|
||||
const diags = r["gopls"]?.diagnostics ?? [];
|
||||
return diags.some(
|
||||
(d) =>
|
||||
d.message.toLowerCase().includes("undefined") ||
|
||||
d.message.includes("Helper"),
|
||||
);
|
||||
},
|
||||
15000,
|
||||
500,
|
||||
);
|
||||
const diags = result["gopls"]?.diagnostics ?? [];
|
||||
const hasUndefined = diags.some((d) =>
|
||||
d.message.toLowerCase().includes("undefined") || d.message.includes("Helper"),
|
||||
);
|
||||
assert.ok(
|
||||
hasUndefined,
|
||||
`Expected undefined-symbol diagnostic, got: ${JSON.stringify(diags)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the diagnostic after helper.go is created externally", async () => {
|
||||
fs.writeFileSync(
|
||||
helperFile,
|
||||
"package main\n\nfunc Helper() {}\n",
|
||||
);
|
||||
|
||||
const result = await pollUntil(
|
||||
async () =>
|
||||
(await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
|
||||
env,
|
||||
)) as DiagResult,
|
||||
(r) => {
|
||||
const diags = r["gopls"]?.diagnostics ?? [];
|
||||
return !diags.some(
|
||||
(d) =>
|
||||
d.message.toLowerCase().includes("undefined") ||
|
||||
d.message.includes("Helper"),
|
||||
);
|
||||
},
|
||||
15000,
|
||||
500,
|
||||
);
|
||||
|
||||
const finalDiags = result["gopls"]?.diagnostics ?? [];
|
||||
const stillUndefined = finalDiags.some(
|
||||
(d) =>
|
||||
d.message.toLowerCase().includes("undefined") ||
|
||||
d.message.includes("Helper"),
|
||||
);
|
||||
assert.ok(
|
||||
!stillUndefined,
|
||||
`Expected diagnostic to clear after creating helper.go, still got: ${JSON.stringify(finalDiags)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("closes an opened file when it is deleted externally", async () => {
|
||||
fs.writeFileSync(
|
||||
helperFile,
|
||||
"package main\n\nfunc Helper(x int) {}\n",
|
||||
);
|
||||
await runCliJson([helperFile, "diagnostics", '{"timeoutMs":3000}'], env);
|
||||
|
||||
await pollUntil(
|
||||
async () =>
|
||||
(await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
|
||||
env,
|
||||
)) as DiagResult,
|
||||
(r) => {
|
||||
const diags = r["gopls"]?.diagnostics ?? [];
|
||||
return diags.some((d) => d.message.includes("not enough arguments"));
|
||||
},
|
||||
15000,
|
||||
500,
|
||||
);
|
||||
|
||||
fs.rmSync(helperFile);
|
||||
|
||||
const result = await pollUntil(
|
||||
async () =>
|
||||
(await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
|
||||
env,
|
||||
)) as DiagResult,
|
||||
(r) => {
|
||||
const diags = r["gopls"]?.diagnostics ?? [];
|
||||
return diags.some((d) => d.message.toLowerCase().includes("undefined"));
|
||||
},
|
||||
15000,
|
||||
500,
|
||||
);
|
||||
|
||||
const finalDiags = result["gopls"]?.diagnostics ?? [];
|
||||
assert.ok(
|
||||
finalDiags.some((d) => d.message.toLowerCase().includes("undefined")),
|
||||
`Expected undefined-symbol diagnostic after deleting opened helper.go, got: ${JSON.stringify(finalDiags)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
143
test/integration/watcher-typescript.test.ts
Normal file
143
test/integration/watcher-typescript.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, before, after } 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 {
|
||||
setTestSocket,
|
||||
stopTestDaemon,
|
||||
runCliJson,
|
||||
requireServer,
|
||||
} from "../helpers.ts";
|
||||
|
||||
const skip = requireServer("typescript-language-server");
|
||||
|
||||
async function pollUntil<T>(
|
||||
fn: () => Promise<T>,
|
||||
predicate: (v: T) => boolean,
|
||||
timeoutMs: number,
|
||||
intervalMs = 250,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: T = await fn();
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate(last)) return last;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
last = await fn();
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
interface DiagResult {
|
||||
[serverId: string]: { diagnostics?: { message: string }[] };
|
||||
}
|
||||
|
||||
describe("watcher: typescript handles derived file patterns", { skip: skip ?? undefined }, () => {
|
||||
let tmpDir: string;
|
||||
let mainFile: string;
|
||||
let helperFile: string;
|
||||
const env = { ...process.env };
|
||||
let cleanup: () => void;
|
||||
|
||||
before(async () => {
|
||||
delete env.NODE_OPTIONS;
|
||||
cleanup = setTestSocket(env);
|
||||
await stopTestDaemon(env);
|
||||
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-ts-watch-"));
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, ".pi-lsp.json"),
|
||||
JSON.stringify({ disable: ["oxlint"] }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, "tsconfig.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "ESNext",
|
||||
moduleResolution: "Bundler",
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
},
|
||||
include: ["*.ts"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
mainFile = path.join(tmpDir, "main.ts");
|
||||
helperFile = path.join(tmpDir, "helper.ts");
|
||||
fs.writeFileSync(
|
||||
mainFile,
|
||||
'import { helper } from "./helper";\n\nconsole.log(helper());\n',
|
||||
);
|
||||
fs.writeFileSync(helperFile, "export function helper(): number {\n return 1;\n}\n");
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopTestDaemon(env);
|
||||
cleanup();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("clears missing module after an unopened imported file is created", async () => {
|
||||
fs.rmSync(helperFile);
|
||||
|
||||
const missing = (await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":5000}'],
|
||||
env,
|
||||
)) as DiagResult;
|
||||
assert.ok(
|
||||
(missing["typescript-language-server"]?.diagnostics ?? []).some((d) =>
|
||||
d.message.includes("Cannot find module './helper'")
|
||||
),
|
||||
);
|
||||
|
||||
fs.writeFileSync(helperFile, "export function helper(): number {\n return 1;\n}\n");
|
||||
|
||||
const result = await pollUntil(
|
||||
async () =>
|
||||
(await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
|
||||
env,
|
||||
)) as DiagResult,
|
||||
(r) => (r["typescript-language-server"]?.diagnostics ?? []).length === 0,
|
||||
15000,
|
||||
500,
|
||||
);
|
||||
|
||||
assert.deepEqual(result["typescript-language-server"]?.diagnostics ?? [], []);
|
||||
});
|
||||
|
||||
it("reports missing module after an opened imported file is deleted", async () => {
|
||||
const initial = (await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":5000}'],
|
||||
env,
|
||||
)) as DiagResult;
|
||||
assert.deepEqual(initial["typescript-language-server"]?.diagnostics ?? [], []);
|
||||
|
||||
await runCliJson([helperFile, "diagnostics", '{"timeoutMs":5000}'], env);
|
||||
fs.rmSync(helperFile);
|
||||
|
||||
const result = await pollUntil(
|
||||
async () =>
|
||||
(await runCliJson(
|
||||
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
|
||||
env,
|
||||
)) as DiagResult,
|
||||
(r) => {
|
||||
const diags = r["typescript-language-server"]?.diagnostics ?? [];
|
||||
return diags.some((d) => d.message.includes("Cannot find module './helper'"));
|
||||
},
|
||||
15000,
|
||||
500,
|
||||
);
|
||||
|
||||
const diags = result["typescript-language-server"]?.diagnostics ?? [];
|
||||
assert.ok(
|
||||
diags.some((d) => d.message.includes("Cannot find module './helper'")),
|
||||
`Expected missing-module diagnostic after deleting opened helper.ts, got: ${JSON.stringify(diags)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
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"));
|
||||
});
|
||||
});
|
||||
293
test/unit/watcher.test.ts
Normal file
293
test/unit/watcher.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, before, after, beforeEach } 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 { WorkspaceWatcher, type FileEvent } from "../../src/watcher.ts";
|
||||
import { pathToUri } from "../../src/root.ts";
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 2000,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (predicate()) return true;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
const FLUSH_WAIT_MS = 700;
|
||||
|
||||
describe("WorkspaceWatcher", () => {
|
||||
let tmpDir: string;
|
||||
let received: FileEvent[][] = [];
|
||||
let watcher: WorkspaceWatcher | null = null;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-watcher-"));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (watcher) {
|
||||
await watcher.dispose();
|
||||
watcher = null;
|
||||
}
|
||||
received = [];
|
||||
for (const entry of fs.readdirSync(tmpDir)) {
|
||||
fs.rmSync(path.join(tmpDir, entry), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("emits Created event for new file matching pattern", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, "foo.ts"), "x");
|
||||
|
||||
const ok = await waitFor(() => received.length > 0);
|
||||
assert.ok(ok, "Expected at least one batch");
|
||||
const all = received.flat();
|
||||
const match = all.find((e) => e.uri === pathToUri(path.join(tmpDir, "foo.ts")));
|
||||
assert.ok(match, `Expected event for foo.ts, got ${JSON.stringify(all)}`);
|
||||
assert.strictEqual(match.type, 1);
|
||||
});
|
||||
|
||||
it("emits Deleted event when file removed", async () => {
|
||||
const file = path.join(tmpDir, "del.ts");
|
||||
fs.writeFileSync(file, "x");
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.unlinkSync(file);
|
||||
|
||||
const ok = await waitFor(() =>
|
||||
received.flat().some((e) => e.type === 3 && e.uri === pathToUri(file)),
|
||||
);
|
||||
assert.ok(ok, `Expected delete event, got ${JSON.stringify(received)}`);
|
||||
});
|
||||
|
||||
it("emits Changed event when file content changes", async () => {
|
||||
const file = path.join(tmpDir, "chg.ts");
|
||||
fs.writeFileSync(file, "x");
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.writeFileSync(file, "y");
|
||||
|
||||
const ok = await waitFor(() =>
|
||||
received.flat().some((e) => e.type === 2 && e.uri === pathToUri(file)),
|
||||
);
|
||||
assert.ok(ok, `Expected change event, got ${JSON.stringify(received)}`);
|
||||
});
|
||||
|
||||
it("skips files not matching the pattern", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, "ignored.txt"), "x");
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
assert.strictEqual(
|
||||
all.length,
|
||||
0,
|
||||
`Expected no events for .txt, got ${JSON.stringify(all)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("honors .gitignore at root", async () => {
|
||||
fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored-dir/\n");
|
||||
fs.mkdirSync(path.join(tmpDir, "ignored-dir"));
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, "ignored-dir", "x.ts"), "x");
|
||||
fs.writeFileSync(path.join(tmpDir, "watched.ts"), "x");
|
||||
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
const uris = all.map((e) => e.uri);
|
||||
assert.ok(
|
||||
uris.includes(pathToUri(path.join(tmpDir, "watched.ts"))),
|
||||
"watched.ts should fire",
|
||||
);
|
||||
assert.ok(
|
||||
!uris.some((u) => u.includes("ignored-dir")),
|
||||
`ignored-dir/x.ts should be filtered, got ${JSON.stringify(uris)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("respects WatchKind to filter event types", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
|
||||
await watcher.ready();
|
||||
|
||||
const file = path.join(tmpDir, "only-create.ts");
|
||||
fs.writeFileSync(file, "x");
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
fs.unlinkSync(file);
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
assert.ok(
|
||||
all.some((e) => e.type === 1),
|
||||
"Expected create event",
|
||||
);
|
||||
assert.ok(
|
||||
!all.some((e) => e.type === 3),
|
||||
`Did not expect delete events, got ${JSON.stringify(all)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("batches multiple rapid events into one onEvents call", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fs.writeFileSync(path.join(tmpDir, `f${i}.ts`), "x");
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
assert.strictEqual(all.length, 5, `Expected 5 events, got ${all.length}`);
|
||||
assert.ok(
|
||||
received.length <= 2,
|
||||
`Expected <=2 batches, got ${received.length}: ${JSON.stringify(received)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores .git/ even when not in gitignore", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.mkdirSync(path.join(tmpDir, ".git"));
|
||||
fs.writeFileSync(path.join(tmpDir, ".git", "HEAD"), "ref: foo");
|
||||
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
assert.ok(
|
||||
!all.some((e) => e.uri.includes("/.git/")),
|
||||
`.git contents should be ignored, got ${JSON.stringify(all)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("stops emitting after dispose", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
await watcher.dispose();
|
||||
watcher = null;
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, "post-dispose.ts"), "x");
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
assert.strictEqual(all.length, 0, `Expected no events after dispose`);
|
||||
});
|
||||
|
||||
it("matches absolute-path glob patterns (gopls-style)", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([
|
||||
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
|
||||
]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, "helper.go"), "package x\n");
|
||||
|
||||
const ok = await waitFor(() =>
|
||||
received
|
||||
.flat()
|
||||
.some((e) => e.uri === pathToUri(path.join(tmpDir, "helper.go"))),
|
||||
);
|
||||
assert.ok(ok, `Expected event for absolute glob, got ${JSON.stringify(received)}`);
|
||||
});
|
||||
|
||||
it("coalesces Created+Deleted to no-op", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
const file = path.join(tmpDir, "transient.ts");
|
||||
fs.writeFileSync(file, "x");
|
||||
fs.unlinkSync(file);
|
||||
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
assert.ok(
|
||||
!all.some((e) => e.uri === pathToUri(file)),
|
||||
`Transient file should not surface, got ${JSON.stringify(all)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("coalesces Deleted+Created (replacement) to Changed", async () => {
|
||||
const file = path.join(tmpDir, "replace.ts");
|
||||
fs.writeFileSync(file, "v1");
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.unlinkSync(file);
|
||||
fs.writeFileSync(file, "v2");
|
||||
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
const all = received.flat();
|
||||
const events = all.filter((e) => e.uri === pathToUri(file));
|
||||
assert.ok(events.length > 0, `Expected an event for replaced file`);
|
||||
const types = events.map((e) => e.type).sort();
|
||||
const acceptable =
|
||||
JSON.stringify(types) === JSON.stringify([2]) ||
|
||||
JSON.stringify(types) === JSON.stringify([1, 3]);
|
||||
assert.ok(
|
||||
acceptable,
|
||||
`Expected [2] or [1,3], got ${JSON.stringify(types)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops pending events when patterns are cleared", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts" }]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, "pending.ts"), "x");
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
watcher.setPatterns([]);
|
||||
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
assert.strictEqual(
|
||||
received.length,
|
||||
0,
|
||||
`Pending batch should be dropped, got ${JSON.stringify(received)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("empty pattern set means no watching", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([]);
|
||||
await watcher.ready();
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, "no-patterns.ts"), "x");
|
||||
await new Promise((r) => setTimeout(r, FLUSH_WAIT_MS));
|
||||
|
||||
assert.strictEqual(received.length, 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user