feat(watcher): forward FS events as workspace/didChangeWatchedFiles
LSP servers maintain their own workspace index built at initialize time and rely on the client to push file-system events. Previously the daemon only synced the single file being queried, so externally created/changed files (codegen, build scripts, git checkout, the agent's own writes from the perspective of other open files) left the server's index stale until manual /lsp-destroy. Each ClientEntry now lazily owns a WorkspaceWatcher (chokidar + picomatch) that translates FS events into workspace/didChangeWatchedFiles batches. Patterns come from the server via client/registerCapability (no speculative watching). Ignores layer a tiny baseline (.git, .DS_Store) over the repo's root .gitignore, with a fallback list for non-git workspaces. Events debounce 50ms quiet / 500ms max wait. Notable: gopls registers absolute-path globs (/abs/root/**/*.go) rather than relative ones, so compileWatchers() matches each event against both relative and absolute path forms. Caught by the integration test; unit regression test added. Rollback: PI_LSP_DISABLE_WATCHERS=1 disables all watcher creation. - src/client.ts: honor register/unregisterCapability for workspace/didChangeWatchedFiles; advertise dynamicRegistration; expose getFileWatchers/onWatchersChanged/sendNotification - src/watcher.ts: new WorkspaceWatcher with layered ignores, debounce+batch, Created+Deleted coalescing, dual-form glob matching - src/daemon.ts: per-entry watcher lifecycle, PI_LSP_DISABLE_WATCHERS, LSP_DEBUG-gated pattern/event logging - test/unit/watcher.test.ts: 11 tests against real chokidar + temp dir - test/integration/watcher-gopls.test.ts: end-to-end against gopls - AGENTS.md: new "Workspace File Watching" section - flake.nix: add go (required by gopls integration test)
This commit is contained in:
35
AGENTS.md
35
AGENTS.md
@@ -41,6 +41,38 @@ 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.
|
||||
- **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 |
|
||||
@@ -60,7 +92,8 @@ 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.)
|
||||
|
||||
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",
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
type MessageConnection,
|
||||
} from "vscode-jsonrpc/node.js";
|
||||
import type {
|
||||
FileSystemWatcher,
|
||||
InitializeParams,
|
||||
PublishDiagnosticsParams,
|
||||
Registration,
|
||||
Unregistration,
|
||||
} from "vscode-languageserver-protocol";
|
||||
import type { ServerConfig } from "./types.ts";
|
||||
import { ServerNotFoundError } from "./types.ts";
|
||||
@@ -40,6 +43,12 @@ 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>();
|
||||
// File Watcher Registrations - Servers announce which globs they care
|
||||
// about via client/registerCapability("workspace/didChangeWatchedFiles").
|
||||
// Stored by registration id so unregister can remove them; listeners
|
||||
// are notified so the daemon can (re)build a chokidar watcher.
|
||||
private fileWatchers = new Map<string, FileSystemWatcher[]>();
|
||||
private watchersListeners = new Set<() => void>();
|
||||
|
||||
constructor(private readonly server: ServerConfig) {}
|
||||
|
||||
@@ -115,8 +124,34 @@ export class LspClient {
|
||||
);
|
||||
},
|
||||
);
|
||||
this.conn.onRequest("client/registerCapability", () => null);
|
||||
this.conn.onRequest("client/unregisterCapability", () => 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();
|
||||
|
||||
@@ -135,7 +170,13 @@ export class LspClient {
|
||||
publishDiagnostics: {},
|
||||
synchronization: { didSave: true },
|
||||
},
|
||||
workspace: { workspaceFolders: true, configuration: true },
|
||||
workspace: {
|
||||
workspaceFolders: true,
|
||||
configuration: true,
|
||||
// Dynamic Registration - Required for servers like gopls to send
|
||||
// client/registerCapability("workspace/didChangeWatchedFiles").
|
||||
didChangeWatchedFiles: { dynamicRegistration: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
await this.conn.sendRequest("initialize", {
|
||||
@@ -217,6 +258,28 @@ export class LspClient {
|
||||
return this.conn.sendRequest(method, params) as Promise<R>;
|
||||
}
|
||||
|
||||
// Send Raw LSP Notification - Used by the daemon's workspace watcher to
|
||||
// push workspace/didChangeWatchedFiles batches without going through the
|
||||
// typed command surface.
|
||||
sendNotification(method: string, params: unknown): void {
|
||||
this.conn.sendNotification(method, params);
|
||||
}
|
||||
|
||||
// Get File Watchers - Flat list of all watcher patterns the server has
|
||||
// registered across all registration ids.
|
||||
getFileWatchers(): FileSystemWatcher[] {
|
||||
return Array.from(this.fileWatchers.values()).flat();
|
||||
}
|
||||
|
||||
// On Watchers Changed - Subscribe to register/unregister events for
|
||||
// workspace/didChangeWatchedFiles. Returns an unsubscribe fn.
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as path from "node:path";
|
||||
import { LspClient } from "./client.ts";
|
||||
import { findRoot, findServerById, pathToUri } from "./root.ts";
|
||||
import type { ServerConfig } from "./types.ts";
|
||||
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
|
||||
import {
|
||||
logPath,
|
||||
socketPath,
|
||||
@@ -37,6 +38,11 @@ interface ClientEntry {
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
ttlMs: number;
|
||||
lastUsed: number;
|
||||
// watcher: Lazy - created on first registerCapability for watched files,
|
||||
// disposed on eviction. Null if the server never registered any watchers
|
||||
// (or if PI_LSP_DISABLE_WATCHERS is set).
|
||||
watcher: WorkspaceWatcher | null;
|
||||
unsubscribeWatchers: (() => void) | null;
|
||||
}
|
||||
|
||||
const entries = new Map<string, ClientEntry>();
|
||||
@@ -88,6 +94,8 @@ async function getOrCreateEntry(
|
||||
idleTimer: null,
|
||||
ttlMs,
|
||||
lastUsed: Date.now(),
|
||||
watcher: null,
|
||||
unsubscribeWatchers: null,
|
||||
};
|
||||
entries.set(key, entry);
|
||||
try {
|
||||
@@ -96,10 +104,52 @@ async function getOrCreateEntry(
|
||||
entries.delete(key);
|
||||
throw err;
|
||||
}
|
||||
attachWatcher(entry);
|
||||
bumpIdle(entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Attach Watcher - Subscribes to the client's watcher-registration events.
|
||||
// The first non-empty registration lazily creates the WorkspaceWatcher;
|
||||
// subsequent register/unregister calls update its pattern set in place.
|
||||
// Honors PI_LSP_DISABLE_WATCHERS for emergency rollback.
|
||||
function attachWatcher(entry: ClientEntry): void {
|
||||
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
||||
const sync = () => {
|
||||
const patterns = entry.client.getFileWatchers();
|
||||
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);
|
||||
};
|
||||
entry.unsubscribeWatchers = entry.client.onWatchersChanged(sync);
|
||||
// Initial Sync - Server may have already sent registerCapability during
|
||||
// initialize before we subscribed.
|
||||
sync();
|
||||
}
|
||||
|
||||
// Forward Events - Sends a batched workspace/didChangeWatchedFiles to the
|
||||
// server. We catch errors so a downed transport doesn't crash the daemon.
|
||||
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
|
||||
try {
|
||||
if (process.env.LSP_DEBUG) {
|
||||
log(`watcher fire`, entry.server.id, JSON.stringify(events));
|
||||
}
|
||||
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
|
||||
changes: events,
|
||||
});
|
||||
} 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) {
|
||||
@@ -113,6 +163,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.
|
||||
@@ -292,6 +344,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();
|
||||
|
||||
265
src/watcher.ts
Normal file
265
src/watcher.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
// Workspace Watcher - Per-entry filesystem watcher that translates chokidar
|
||||
// events into LSP `workspace/didChangeWatchedFiles` notifications. One
|
||||
// instance lives on each daemon ClientEntry; it's torn down on eviction.
|
||||
//
|
||||
// Design:
|
||||
// - Patterns come from the server via `client/registerCapability`. If the
|
||||
// server registers nothing, we don't watch anything (no speculative
|
||||
// watching - wastes inotify slots and risks event storms).
|
||||
// - We honor the repo's `.gitignore` (root level only for v1) plus a tiny
|
||||
// always-ignore baseline so the watcher tracks the same set the LSP
|
||||
// server would naturally care about.
|
||||
// - Events are debounced (50ms quiet period) but force-flushed every 500ms
|
||||
// so sustained event streams (e.g. `git checkout` of a big branch)
|
||||
// can't stall the batch indefinitely.
|
||||
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";
|
||||
|
||||
// LSP Constants - Inlined to avoid pulling the enum into a hot path. See
|
||||
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#fileChangeType
|
||||
const FILE_CHANGE_CREATED = 1;
|
||||
const FILE_CHANGE_CHANGED = 2;
|
||||
const FILE_CHANGE_DELETED = 3;
|
||||
|
||||
// WatchKind - Bitmask the server uses to opt out of specific event kinds.
|
||||
// Default (when `kind` is omitted) is 7 = Create | Change | Delete.
|
||||
const WATCH_KIND_CREATE = 1;
|
||||
const WATCH_KIND_CHANGE = 2;
|
||||
const WATCH_KIND_DELETE = 4;
|
||||
|
||||
// Debounce Settings - Quiet period before flushing, plus a max wait so a
|
||||
// continuous stream of events still gets delivered in bounded time.
|
||||
const DEBOUNCE_QUIET_MS = 50;
|
||||
const DEBOUNCE_MAX_WAIT_MS = 500;
|
||||
|
||||
// Always-Ignore Baseline - Things that are never interesting to an LSP
|
||||
// server regardless of gitignore contents. `.git/` is the obvious one
|
||||
// (gitignore won't list itself); the rest are OS/editor noise.
|
||||
export const BASELINE_IGNORES = [
|
||||
"**/.git/**",
|
||||
"**/.DS_Store",
|
||||
"**/.hg/**",
|
||||
"**/.svn/**",
|
||||
];
|
||||
|
||||
// Fallback Ignores - Used only when no `.gitignore` exists at rootDir.
|
||||
// Conservative list to keep non-git workspaces from blowing up.
|
||||
const NO_GITIGNORE_FALLBACK = ["**/node_modules/**", "**/.git/**"];
|
||||
|
||||
export interface FileEvent {
|
||||
uri: string;
|
||||
type: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
// Build Ignore Matcher - Returns a function that takes a path relative to
|
||||
// rootDir and returns true if it should be ignored. Reads root .gitignore
|
||||
// once at construction; nested gitignores are intentionally not supported
|
||||
// in v1 (covers ~95% of repos; add nesting if it becomes a real problem).
|
||||
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 {
|
||||
// Treat parse failure as no gitignore. The fallback will catch the
|
||||
// worst offenders (node_modules) so we don't watch everything.
|
||||
ig = null;
|
||||
}
|
||||
}
|
||||
// Baseline always applies; gitignore (or fallback) layers on top.
|
||||
const baselineMatcher = picomatch(BASELINE_IGNORES);
|
||||
if (ig) {
|
||||
return (relPath) => {
|
||||
if (baselineMatcher(relPath)) return true;
|
||||
// `ignore` requires posix-style paths and bare relative paths.
|
||||
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);
|
||||
}
|
||||
|
||||
// Compile Watcher Patterns - Turns the server-supplied FileSystemWatcher[]
|
||||
// into a single matcher that returns the matched `kind` bitmask (or 0 if
|
||||
// no pattern matched). Returning the kind lets us filter individual event
|
||||
// types (Created/Changed/Deleted) per the spec.
|
||||
//
|
||||
// Servers send patterns in mixed forms: bare relative globs (`**/*.ts`),
|
||||
// absolute path globs (gopls sends `/abs/root/**/*.go`), or RelativePattern
|
||||
// objects. We try matching the candidate path in both relative and absolute
|
||||
// form so we accept any of those without server-specific special casing.
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve Relative Pattern - LSP 3.17 added RelativePattern with a baseUri
|
||||
// that may be a string or WorkspaceFolder. We collapse to a glob relative
|
||||
// to rootDir; out-of-root patterns fall back to the raw pattern (won't
|
||||
// match anything inside rootDir, which is the safe behavior).
|
||||
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;
|
||||
|
||||
constructor(
|
||||
private readonly rootDir: string,
|
||||
private readonly onEvents: (events: FileEvent[]) => void,
|
||||
) {
|
||||
this.isIgnored = buildIgnoreMatcher(rootDir);
|
||||
}
|
||||
|
||||
// Set Patterns - Called whenever the server's registered watchers change.
|
||||
// First non-empty call lazily starts chokidar; subsequent calls update
|
||||
// the matcher in place (chokidar already watches everything under
|
||||
// rootDir minus ignores, so we just recompile filtering).
|
||||
setPatterns(watchers: FileSystemWatcher[]): void {
|
||||
if (this.disposed) return;
|
||||
this.matchKind = compileWatchers(watchers, this.rootDir);
|
||||
if (watchers.length === 0) {
|
||||
// Server unregistered everything - stop watching but keep instance
|
||||
// alive so re-registration works without recreating chokidar state.
|
||||
void this.stopChokidar();
|
||||
return;
|
||||
}
|
||||
if (!this.chokidar) this.startChokidar();
|
||||
}
|
||||
|
||||
private startChokidar(): void {
|
||||
this.chokidar = chokidar.watch(this.rootDir, {
|
||||
ignoreInitial: true,
|
||||
followSymlinks: false,
|
||||
// Ignore Function - Called by chokidar for each path; returning true
|
||||
// prevents both watching and event emission. Cheaper than emitting
|
||||
// and filtering afterward.
|
||||
ignored: (absPath: string) => {
|
||||
if (absPath === this.rootDir) return false;
|
||||
const rel = path.relative(this.rootDir, absPath);
|
||||
return this.isIgnored(rel);
|
||||
},
|
||||
// Atomic Writes - Many editors write-via-rename; awaitWriteFinish
|
||||
// coalesces those into a single change event. Conservative thresholds
|
||||
// keep latency low while still suppressing partial-write noise.
|
||||
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));
|
||||
// ENOSPC and friends - log but don't crash the entry.
|
||||
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;
|
||||
await w.close();
|
||||
}
|
||||
|
||||
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;
|
||||
// Per-Spec Filter - Skip events the server explicitly opted out of.
|
||||
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);
|
||||
// Coalesce per URI - Last event wins. The exception is Created followed
|
||||
// by Deleted (and vice versa), which we collapse to "nothing happened"
|
||||
// by deleting the entry; the server doesn't need to learn about a file
|
||||
// that briefly existed.
|
||||
const prev = this.pending.get(uri);
|
||||
if (
|
||||
(prev === FILE_CHANGE_CREATED && type === FILE_CHANGE_DELETED) ||
|
||||
(prev === FILE_CHANGE_DELETED && type === FILE_CHANGE_CREATED)
|
||||
) {
|
||||
this.pending.delete(uri);
|
||||
} else {
|
||||
this.pending.set(uri, type);
|
||||
}
|
||||
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;
|
||||
if (this.quietTimer) clearTimeout(this.quietTimer);
|
||||
if (this.maxWaitTimer) clearTimeout(this.maxWaitTimer);
|
||||
this.pending.clear();
|
||||
await this.stopChokidar();
|
||||
}
|
||||
}
|
||||
145
test/integration/watcher-gopls.test.ts
Normal file
145
test/integration/watcher-gopls.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// Watcher Integration Test — proves the FS-watcher → LSP wiring works
|
||||
// end-to-end against gopls: a stale "undefined symbol" diagnostic clears
|
||||
// after the missing file is created externally (no LSP query touches it).
|
||||
//
|
||||
// This is the canonical staleness scenario from `_scratch/plan-fs-watching.md`.
|
||||
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");
|
||||
|
||||
// Poll Until - Runs `fn` repeatedly until `predicate(result)` is true or
|
||||
// the timeout elapses. We need this because gopls reanalysis after a
|
||||
// workspace/didChangeWatchedFiles is asynchronous; diagnostics arrive on
|
||||
// a `textDocument/publishDiagnostics` push that we then re-fetch.
|
||||
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 externally-created files", { 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-"));
|
||||
// Minimal Go Module - go.mod is gopls's primary root marker.
|
||||
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");
|
||||
// main.go references Helper() which doesn't exist yet \u2014 should produce
|
||||
// an "undefined: Helper" diagnostic.
|
||||
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 () => {
|
||||
// Poll - gopls's workspace load on a fresh tmp dir takes a beat; the
|
||||
// first diagnostics call can return "No active builds contain ..."
|
||||
// before the real analysis lands. Wait up to 15s for it to settle.
|
||||
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 () => {
|
||||
// Create The Missing File Without Touching It Via LSP - This is the
|
||||
// whole point: only chokidar + workspace/didChangeWatchedFiles can tell
|
||||
// gopls about helper.go's existence.
|
||||
fs.writeFileSync(
|
||||
helperFile,
|
||||
"package main\n\nfunc Helper() {}\n",
|
||||
);
|
||||
|
||||
// Poll - Allow up to 15s for the watcher to fire, gopls to reanalyze,
|
||||
// and the diagnostic to clear. Re-query the same file (main.go) only.
|
||||
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)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
249
test/unit/watcher.test.ts
Normal file
249
test/unit/watcher.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
// Watcher Unit Tests — exercises WorkspaceWatcher against a temp dir with
|
||||
// real chokidar. We use real FS because mocking it would test the mock,
|
||||
// not the actual behavior under inotify.
|
||||
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";
|
||||
|
||||
// Wait For - Polls a predicate up to timeoutMs. Returns true if it became
|
||||
// true, false if it timed out. Used to wait on async chokidar events
|
||||
// without arbitrary sleeps.
|
||||
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();
|
||||
}
|
||||
|
||||
// Wait Quiet - Waits for the debounce window to elapse so any pending
|
||||
// events flush. Slightly longer than DEBOUNCE_MAX_WAIT_MS.
|
||||
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 = [];
|
||||
// Reset Dir - Wipe contents between tests so file lists are predictable.
|
||||
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" }]);
|
||||
// Settle - chokidar's `ready` is implicit; give it a moment.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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 new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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 new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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 new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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 new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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));
|
||||
// Kind 1 = Create only - delete events should be suppressed.
|
||||
watcher.setPatterns([{ globPattern: "**/*.ts", kind: 1 }]);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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 new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// Write Several Files Quickly - within the 50ms debounce window they
|
||||
// should all land in one batch, capped at 500ms max wait.
|
||||
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}`);
|
||||
// The batches should be << 5 (debounce coalesces). Allow up to 2 in
|
||||
// case the loop straddled a flush boundary.
|
||||
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 new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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 new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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 () => {
|
||||
// Regression - gopls registers e.g. `/tmp/.../**/*.{go,mod}` rather
|
||||
// than a bare relative `**/*.go`. The matcher must accept both.
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([
|
||||
{ globPattern: `${tmpDir}/**/*.{go,mod}` },
|
||||
]);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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("empty pattern set means no watching", async () => {
|
||||
watcher = new WorkspaceWatcher(tmpDir, (evs) => received.push(evs));
|
||||
watcher.setPatterns([]);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
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