Compare commits
9 Commits
e143e05758
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2da4103cd7 | |||
| 8cfe604de7 | |||
| 071c87d3c1 | |||
| 3f3cb4cdbf | |||
| 14749a6449 | |||
| 62fc80c70f | |||
| b7e421483d | |||
| 0aa44bedc4 | |||
| 77876264ee |
37
AGENTS.md
37
AGENTS.md
@@ -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.
|
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
|
### Extension vs Daemon Responsibilities
|
||||||
|
|
||||||
| Concern | Where |
|
| Concern | Where |
|
||||||
@@ -60,7 +94,8 @@ cli.ts — CLI for testing/debugging (daemon-aware or --no-daemon)
|
|||||||
daemon.ts — Entrypoint that starts the daemon process
|
daemon.ts — Entrypoint that starts the daemon process
|
||||||
|
|
||||||
src/
|
src/
|
||||||
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync
|
client.ts — LspClient: spawns a language server, JSON-RPC handshake, file sync, file-watcher registrations
|
||||||
|
watcher.ts — WorkspaceWatcher: chokidar + picomatch → workspace/didChangeWatchedFiles batches
|
||||||
commands.ts — CLI command dispatcher (maps command names → LSP methods)
|
commands.ts — CLI command dispatcher (maps command names → LSP methods)
|
||||||
config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache
|
config.ts — Per-repo `.pi-lsp.json` loader: walk-up + merge with built-ins, mtime cache
|
||||||
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)
|
daemonClient.ts — High-level helpers (daemonRequest, daemonDiagnostics, etc.)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
typescript-language-server
|
typescript-language-server
|
||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
|
go
|
||||||
gopls
|
gopls
|
||||||
pyright
|
pyright
|
||||||
];
|
];
|
||||||
|
|||||||
70
index.ts
70
index.ts
@@ -18,8 +18,28 @@ import {
|
|||||||
UnsupportedExtensionError,
|
UnsupportedExtensionError,
|
||||||
} from "./src/types.ts";
|
} 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.
|
// Format Hover - Turn an LSP hover response into readable text.
|
||||||
function formatHover(result: unknown): string {
|
function formatHover(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || typeof result !== "object") return "(no hover info)";
|
if (!result || typeof result !== "object") return "(no hover info)";
|
||||||
const hover = result as { contents?: unknown };
|
const hover = result as { contents?: unknown };
|
||||||
if (!hover.contents) return "(empty)";
|
if (!hover.contents) return "(empty)";
|
||||||
@@ -49,6 +69,7 @@ function formatHover(result: unknown): string {
|
|||||||
|
|
||||||
// Format Definition - Turn definition locations into readable text.
|
// Format Definition - Turn definition locations into readable text.
|
||||||
function formatDefinition(result: unknown): string {
|
function formatDefinition(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result) return "(no definition found)";
|
if (!result) return "(no definition found)";
|
||||||
const locations = Array.isArray(result) ? result : [result];
|
const locations = Array.isArray(result) ? result : [result];
|
||||||
if (locations.length === 0) return "(no definition found)";
|
if (locations.length === 0) return "(no definition found)";
|
||||||
@@ -64,6 +85,7 @@ function formatDefinition(result: unknown): string {
|
|||||||
|
|
||||||
// Format References - Turn reference locations into readable text.
|
// Format References - Turn reference locations into readable text.
|
||||||
function formatReferences(result: unknown): string {
|
function formatReferences(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || !Array.isArray(result)) return "(no references found)";
|
if (!result || !Array.isArray(result)) return "(no references found)";
|
||||||
if (result.length === 0) return "(no references found)";
|
if (result.length === 0) return "(no references found)";
|
||||||
|
|
||||||
@@ -85,6 +107,7 @@ function formatReferences(result: unknown): string {
|
|||||||
|
|
||||||
// Format Completions - Turn completion items into readable text.
|
// Format Completions - Turn completion items into readable text.
|
||||||
function formatCompletions(result: unknown): string {
|
function formatCompletions(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result) return "(no completions)";
|
if (!result) return "(no completions)";
|
||||||
|
|
||||||
// Resolve to CompletionItem[] if it's a CompletionList
|
// Resolve to CompletionItem[] if it's a CompletionList
|
||||||
@@ -150,6 +173,7 @@ function formatCompletions(result: unknown): string {
|
|||||||
|
|
||||||
// Format Document Symbols - Turn symbols into a tree outline.
|
// Format Document Symbols - Turn symbols into a tree outline.
|
||||||
function formatDocumentSymbols(result: unknown): string {
|
function formatDocumentSymbols(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || !Array.isArray(result)) return "(no symbols)";
|
if (!result || !Array.isArray(result)) return "(no symbols)";
|
||||||
const symbols = result as any[];
|
const symbols = result as any[];
|
||||||
if (symbols.length === 0) return "(no symbols)";
|
if (symbols.length === 0) return "(no symbols)";
|
||||||
@@ -235,6 +259,7 @@ function formatServerDiagnostics(diags: any[], limit: number): string {
|
|||||||
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
|
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
|
||||||
// header to avoid noise.
|
// header to avoid noise.
|
||||||
function formatDiagnostics(result: unknown, limit = 20): string {
|
function formatDiagnostics(result: unknown, limit = 20): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || typeof result !== "object") return "(no diagnostics)";
|
if (!result || typeof result !== "object") return "(no diagnostics)";
|
||||||
|
|
||||||
const grouped = result as Record<string, any>;
|
const grouped = result as Record<string, any>;
|
||||||
@@ -275,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
|
// Disabled Servers - In-memory set of disabled server IDs. Scoped to this
|
||||||
// extension process; the shared daemon is never mutated by disable/enable.
|
// extension process; the shared daemon is never mutated by disable/enable.
|
||||||
const lspToolNames = [
|
const lspToolNames = [
|
||||||
@@ -301,14 +354,12 @@ async function runLsp(
|
|||||||
// touching the daemon so other pi instances sharing it are unaffected.
|
// touching the daemon so other pi instances sharing it are unaffected.
|
||||||
const server = pickServer(filePath);
|
const server = pickServer(filePath);
|
||||||
if (disabledServers.has(server.id)) {
|
if (disabledServers.has(server.id)) {
|
||||||
throw new Error(
|
return unavailableForFile(filePath);
|
||||||
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return await daemonRequest(filePath, server.id, method, params);
|
return await daemonRequest(filePath, server.id, method, params);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedError(error)) {
|
if (isExpectedError(error)) {
|
||||||
return undefined;
|
return unavailableForFile(filePath);
|
||||||
}
|
}
|
||||||
// Daemon-wrapped errors (plain Error with expected message) are also
|
// Daemon-wrapped errors (plain Error with expected message) are also
|
||||||
// expected — the daemon catches pickServer() throws and returns them
|
// expected — the daemon catches pickServer() throws and returns them
|
||||||
@@ -318,7 +369,7 @@ async function runLsp(
|
|||||||
(error.message.includes("No LSP server registered") ||
|
(error.message.includes("No LSP server registered") ||
|
||||||
error.message.includes("not found on PATH"))
|
error.message.includes("not found on PATH"))
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return unavailableForFile(filePath);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -347,22 +398,22 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run LSP Diagnostics - Fans out to all matching servers in a single
|
// Run LSP Diagnostics - Fans out to all matching servers in a single
|
||||||
// daemon call. Returns the grouped result map or undefined if no servers.
|
// daemon call. Returns an explicit unavailable result if no server applies.
|
||||||
async function runDiagnostics(filePath: string): Promise<unknown> {
|
async function runDiagnostics(filePath: string): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
const serverIds = pickDiagnosticServers(filePath);
|
const serverIds = pickDiagnosticServers(filePath);
|
||||||
if (serverIds.length === 0) return undefined;
|
if (serverIds.length === 0) return unavailableForFile(filePath, true);
|
||||||
return await daemonDiagnostics(filePath, serverIds, 1500);
|
return await daemonDiagnostics(filePath, serverIds, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedError(error)) {
|
if (isExpectedError(error)) {
|
||||||
return undefined;
|
return unavailableForFile(filePath, true);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
(error.message.includes("No LSP server registered") ||
|
(error.message.includes("No LSP server registered") ||
|
||||||
error.message.includes("not found on PATH"))
|
error.message.includes("not found on PATH"))
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return unavailableForFile(filePath, true);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -572,6 +623,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// Run LSP diagnostics with timeout - Prevent the auto-check hook from
|
// Run LSP diagnostics with timeout - Prevent the auto-check hook from
|
||||||
// blocking pi if the daemon or LSP server is slow or unresponsive.
|
// blocking pi if the daemon or LSP server is slow or unresponsive.
|
||||||
const result = await withTimeout(runDiagnostics(absolutePath), 3000);
|
const result = await withTimeout(runDiagnostics(absolutePath), 3000);
|
||||||
|
if (isLspUnavailable(result)) return;
|
||||||
const formatted = formatDiagnostics(result, 10);
|
const formatted = formatDiagnostics(result, 10);
|
||||||
|
|
||||||
// Only send a message if there are actual diagnostics
|
// Only send a message if there are actual diagnostics
|
||||||
|
|||||||
52
package-lock.json
generated
52
package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "@evan/pi-lsp",
|
"name": "@evan/pi-lsp",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
"vscode-jsonrpc": "^8.2.1",
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
"vscode-languageserver-protocol": "^3.17.5"
|
"vscode-languageserver-protocol": "^3.17.5"
|
||||||
},
|
},
|
||||||
@@ -17,6 +20,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "^0.72.0",
|
"@mariozechner/pi-coding-agent": "^0.72.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/picomatch": "^4.0.3",
|
||||||
"oxlint": "^1.62.0",
|
"oxlint": "^1.62.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typebox": "^1.1.37",
|
"typebox": "^1.1.37",
|
||||||
@@ -2789,6 +2793,13 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/retry": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
|
||||||
@@ -2967,6 +2978,21 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/cli-highlight": {
|
||||||
"version": "2.1.11",
|
"version": "2.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz",
|
||||||
@@ -3642,7 +3668,6 @@
|
|||||||
"version": "7.0.5",
|
"version": "7.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
@@ -4085,6 +4110,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/proper-lockfile": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||||
@@ -4180,6 +4217,19 @@
|
|||||||
"once": "^1.3.1"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"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"
|
"test:integration": "NODE_OPTIONS='--import=tsx' node --test test/integration/**/*.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
|
"ignore": "^7.0.5",
|
||||||
|
"picomatch": "^4.0.4",
|
||||||
"vscode-jsonrpc": "^8.2.1",
|
"vscode-jsonrpc": "^8.2.1",
|
||||||
"vscode-languageserver-protocol": "^3.17.5"
|
"vscode-languageserver-protocol": "^3.17.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "^0.72.0",
|
"@mariozechner/pi-coding-agent": "^0.72.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/picomatch": "^4.0.3",
|
||||||
"oxlint": "^1.62.0",
|
"oxlint": "^1.62.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typebox": "^1.1.37",
|
"typebox": "^1.1.37",
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import {
|
|||||||
type MessageConnection,
|
type MessageConnection,
|
||||||
} from "vscode-jsonrpc/node.js";
|
} from "vscode-jsonrpc/node.js";
|
||||||
import type {
|
import type {
|
||||||
|
FileSystemWatcher,
|
||||||
InitializeParams,
|
InitializeParams,
|
||||||
PublishDiagnosticsParams,
|
PublishDiagnosticsParams,
|
||||||
|
Registration,
|
||||||
|
Unregistration,
|
||||||
} from "vscode-languageserver-protocol";
|
} from "vscode-languageserver-protocol";
|
||||||
import type { ServerConfig } from "./types.ts";
|
import type { ServerConfig } from "./types.ts";
|
||||||
import { ServerNotFoundError } from "./types.ts";
|
import { ServerNotFoundError } from "./types.ts";
|
||||||
@@ -40,6 +43,8 @@ export class LspClient {
|
|||||||
// version numbers in didOpen/didChange. We track them so the daemon
|
// version numbers in didOpen/didChange. We track them so the daemon
|
||||||
// can resync files via notifyChange after on-disk edits.
|
// can resync files via notifyChange after on-disk edits.
|
||||||
private versions = new Map<string, number>();
|
private versions = new Map<string, number>();
|
||||||
|
private fileWatchers = new Map<string, FileSystemWatcher[]>();
|
||||||
|
private watchersListeners = new Set<() => void>();
|
||||||
|
|
||||||
constructor(private readonly server: ServerConfig) {}
|
constructor(private readonly server: ServerConfig) {}
|
||||||
|
|
||||||
@@ -115,8 +120,34 @@ export class LspClient {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
this.conn.onRequest("client/registerCapability", () => null);
|
this.conn.onRequest(
|
||||||
this.conn.onRequest("client/unregisterCapability", () => null);
|
"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();
|
this.conn.listen();
|
||||||
|
|
||||||
@@ -135,7 +166,11 @@ export class LspClient {
|
|||||||
publishDiagnostics: {},
|
publishDiagnostics: {},
|
||||||
synchronization: { didSave: true },
|
synchronization: { didSave: true },
|
||||||
},
|
},
|
||||||
workspace: { workspaceFolders: true, configuration: true },
|
workspace: {
|
||||||
|
workspaceFolders: true,
|
||||||
|
configuration: true,
|
||||||
|
didChangeWatchedFiles: { dynamicRegistration: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await this.conn.sendRequest("initialize", {
|
await this.conn.sendRequest("initialize", {
|
||||||
@@ -212,11 +247,34 @@ export class LspClient {
|
|||||||
return uri;
|
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.
|
// Send Raw LSP Request - Passthrough used by the command dispatcher.
|
||||||
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
|
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
|
||||||
return this.conn.sendRequest(method, params) as 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
|
// Clear Diagnostics - Drops the cached diagnostics for a URI so callers
|
||||||
// can force waitForDiagnostics to await a fresh publish after didChange.
|
// can force waitForDiagnostics to await a fresh publish after didChange.
|
||||||
clearDiagnostics(uri: string): void {
|
clearDiagnostics(uri: string): void {
|
||||||
|
|||||||
112
src/daemon.ts
112
src/daemon.ts
@@ -5,9 +5,11 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as net from "node:net";
|
import * as net from "node:net";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import type { FileSystemWatcher } from "vscode-languageserver-protocol";
|
||||||
import { LspClient } from "./client.ts";
|
import { LspClient } from "./client.ts";
|
||||||
import { findRoot, findServerById, pathToUri } from "./root.ts";
|
import { findRoot, findServerById, pathToUri } from "./root.ts";
|
||||||
import type { ServerConfig } from "./types.ts";
|
import type { ServerConfig } from "./types.ts";
|
||||||
|
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
|
||||||
import {
|
import {
|
||||||
logPath,
|
logPath,
|
||||||
socketPath,
|
socketPath,
|
||||||
@@ -17,8 +19,12 @@ import {
|
|||||||
type LaunchContext,
|
type LaunchContext,
|
||||||
} from "./daemonProtocol.ts";
|
} from "./daemonProtocol.ts";
|
||||||
|
|
||||||
// Default Idle TTL - 5 minutes. Per-server overrides via ServerConfig.idleTtlMs.
|
|
||||||
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
|
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
|
// Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping
|
||||||
// needed to keep files in sync and evict on idleness.
|
// needed to keep files in sync and evict on idleness.
|
||||||
@@ -37,6 +43,9 @@ interface ClientEntry {
|
|||||||
idleTimer: NodeJS.Timeout | null;
|
idleTimer: NodeJS.Timeout | null;
|
||||||
ttlMs: number;
|
ttlMs: number;
|
||||||
lastUsed: number;
|
lastUsed: number;
|
||||||
|
watcher: WorkspaceWatcher | null;
|
||||||
|
unsubscribeWatchers: (() => void) | null;
|
||||||
|
usesDerivedWatchers: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = new Map<string, ClientEntry>();
|
const entries = new Map<string, ClientEntry>();
|
||||||
@@ -88,6 +97,9 @@ async function getOrCreateEntry(
|
|||||||
idleTimer: null,
|
idleTimer: null,
|
||||||
ttlMs,
|
ttlMs,
|
||||||
lastUsed: Date.now(),
|
lastUsed: Date.now(),
|
||||||
|
watcher: null,
|
||||||
|
unsubscribeWatchers: null,
|
||||||
|
usesDerivedWatchers: false,
|
||||||
};
|
};
|
||||||
entries.set(key, entry);
|
entries.set(key, entry);
|
||||||
try {
|
try {
|
||||||
@@ -96,10 +108,104 @@ async function getOrCreateEntry(
|
|||||||
entries.delete(key);
|
entries.delete(key);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
await attachWatcher(entry);
|
||||||
bumpIdle(entry);
|
bumpIdle(entry);
|
||||||
return 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
|
// 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.
|
// touches the entry. We log evictions so the daemon's behavior is visible.
|
||||||
function bumpIdle(entry: ClientEntry) {
|
function bumpIdle(entry: ClientEntry) {
|
||||||
@@ -113,6 +219,8 @@ function evict(entry: ClientEntry, reason: string) {
|
|||||||
log(`evict`, entry.key, reason);
|
log(`evict`, entry.key, reason);
|
||||||
entries.delete(entry.key);
|
entries.delete(entry.key);
|
||||||
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
||||||
|
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
|
||||||
|
void entry.watcher?.dispose();
|
||||||
void entry.client.dispose();
|
void entry.client.dispose();
|
||||||
// Auto Shutdown - If this was the last entry, there's nothing left to
|
// 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.
|
// manage. Tear down the daemon so it doesn't sit idle forever.
|
||||||
@@ -292,6 +400,8 @@ function shutdownDaemon(reason: string) {
|
|||||||
if (server) server.close();
|
if (server) server.close();
|
||||||
for (const entry of entries.values()) {
|
for (const entry of entries.values()) {
|
||||||
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
||||||
|
if (entry.unsubscribeWatchers) entry.unsubscribeWatchers();
|
||||||
|
void entry.watcher?.dispose();
|
||||||
void entry.client.dispose();
|
void entry.client.dispose();
|
||||||
}
|
}
|
||||||
entries.clear();
|
entries.clear();
|
||||||
|
|||||||
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",
|
"cli.mjs",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unique Test Socket — each test run gets its own Unix socket so we don't
|
// Unique Test Socket — each suite gets its own Unix socket so parallel
|
||||||
// touch any real session daemon.
|
// integration tests don't race through the same daemon.
|
||||||
export function testSocket(): string {
|
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
|
// 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 () => {
|
return () => {
|
||||||
delete env.PI_LSP_SOCKET_PATH;
|
delete env.PI_LSP_SOCKET_PATH;
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(sock);
|
fs.rmSync(path.dirname(sock), { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// Socket may not exist — that's fine.
|
// Socket may not exist — that's fine.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ describe("cli daemon lifecycle", () => {
|
|||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
let cleanup: () => void;
|
let cleanup: () => void;
|
||||||
|
|
||||||
before(() => {
|
before(async () => {
|
||||||
cleanup = setTestSocket(env);
|
cleanup = setTestSocket(env);
|
||||||
// Stop any stale daemon on this socket before tests run.
|
// 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.
|
// Tear down daemon and clean up socket after all tests.
|
||||||
stopTestDaemon(env);
|
await stopTestDaemon(env);
|
||||||
cleanup();
|
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)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
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