diff --git a/AGENTS.md b/AGENTS.md index e77923c..56b82c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,38 @@ The daemon tracks opened files per-entry in a `Map`. 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.) diff --git a/flake.nix b/flake.nix index 1298cb6..71791ba 100644 --- a/flake.nix +++ b/flake.nix @@ -25,6 +25,7 @@ typescript-language-server # Tests + go gopls pyright ]; diff --git a/package-lock.json b/package-lock.json index 1546af2..1e8a6ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0d43bc0..d4939f4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/client.ts b/src/client.ts index 6e23893..0bbf91f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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(); + // 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(); + 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; } + // 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 { diff --git a/src/daemon.ts b/src/daemon.ts index a7f3729..d4f01fa 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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(); @@ -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(); diff --git a/src/watcher.ts b/src/watcher.ts new file mode 100644 index 0000000..75119c6 --- /dev/null +++ b/src/watcher.ts @@ -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(); + 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 { + 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 { + 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(); + } +} diff --git a/test/integration/watcher-gopls.test.ts b/test/integration/watcher-gopls.test.ts new file mode 100644 index 0000000..c3cb812 --- /dev/null +++ b/test/integration/watcher-gopls.test.ts @@ -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( + fn: () => Promise, + predicate: (v: T) => boolean, + timeoutMs: number, + intervalMs = 250, +): Promise { + 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)}`, + ); + }); +}); diff --git a/test/unit/watcher.test.ts b/test/unit/watcher.test.ts new file mode 100644 index 0000000..4ec8a92 --- /dev/null +++ b/test/unit/watcher.test.ts @@ -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 { + 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); + }); +});