diff --git a/src/daemon.ts b/src/daemon.ts index 3394e2d..2fec016 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -7,7 +7,7 @@ import * as net from "node:net"; import * as path from "node:path"; import type { FileSystemWatcher } from "vscode-languageserver-protocol"; import { LspClient } from "./client.ts"; -import { findRoot, findServerById, pathToUri, uriToPath } from "./root.ts"; +import { findRoot, findServerById, pathToUri } from "./root.ts"; import type { ServerConfig } from "./types.ts"; import { WorkspaceWatcher, type FileEvent } from "./watcher.ts"; import { @@ -22,6 +22,8 @@ import { 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 @@ -43,6 +45,7 @@ interface ClientEntry { lastUsed: number; watcher: WorkspaceWatcher | null; unsubscribeWatchers: (() => void) | null; + usesDerivedWatchers: boolean; } const entries = new Map(); @@ -96,6 +99,7 @@ async function getOrCreateEntry( lastUsed: Date.now(), watcher: null, unsubscribeWatchers: null, + usesDerivedWatchers: false, }; entries.set(key, entry); try { @@ -118,11 +122,23 @@ async function attachWatcher(entry: ClientEntry): Promise { function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] { const registered = entry.client.getFileWatchers(); - const openedDeletes = [...entry.opened.keys()].map((uri) => ({ - globPattern: uriToPath(uri).split(path.sep).join("/"), - kind: WATCH_KIND_DELETE, - })); - return [...registered, ...openedDeletes]; + 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 { @@ -178,6 +194,9 @@ function forwardEvents(entry: ClientEntry, events: FileEvent[]): void { entry.client.sendNotification("workspace/didChangeWatchedFiles", { changes: events, }); + if (entry.usesDerivedWatchers) { + evict(entry, "derived watcher event"); + } } catch (err) { log(`watcher send failed`, entry.server.id, (err as Error).message); } @@ -220,7 +239,6 @@ async function syncFile( if (prev === undefined) { entry.client.openDocument(filePath); entry.opened.set(uri, stat.mtimeMs); - await refreshWatcher(entry); return { uri, changed: true }; } else if (prev !== stat.mtimeMs) { entry.client.notifyChange(filePath); diff --git a/test/integration/watcher-typescript.test.ts b/test/integration/watcher-typescript.test.ts index 5475ff1..30cec50 100644 --- a/test/integration/watcher-typescript.test.ts +++ b/test/integration/watcher-typescript.test.ts @@ -32,7 +32,7 @@ interface DiagResult { [serverId: string]: { diagnostics?: { message: string }[] }; } -describe("watcher: typescript handles deleted opened files", { skip: skip ?? undefined }, () => { +describe("watcher: typescript handles derived file patterns", { skip: skip ?? undefined }, () => { let tmpDir: string; let mainFile: string; let helperFile: string; @@ -81,6 +81,35 @@ describe("watcher: typescript handles deleted opened files", { skip: skip ?? und 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}'],