fix(watcher): derive fallback file patterns

This commit is contained in:
2026-05-20 06:53:35 -04:00
parent 3f3cb4cdbf
commit 071c87d3c1
2 changed files with 55 additions and 8 deletions

View File

@@ -7,7 +7,7 @@ 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 type { FileSystemWatcher } from "vscode-languageserver-protocol";
import { LspClient } from "./client.ts"; 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 type { ServerConfig } from "./types.ts";
import { WorkspaceWatcher, type FileEvent } from "./watcher.ts"; import { WorkspaceWatcher, type FileEvent } from "./watcher.ts";
import { import {
@@ -22,6 +22,8 @@ import {
const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000; const DEFAULT_IDLE_TTL_MS = 5 * 60 * 1000;
const WATCHER_READY_TIMEOUT_MS = 5000; const WATCHER_READY_TIMEOUT_MS = 5000;
const FILE_CHANGE_DELETED = 3; const FILE_CHANGE_DELETED = 3;
const WATCH_KIND_CREATE = 1;
const WATCH_KIND_CHANGE = 2;
const WATCH_KIND_DELETE = 4; 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
@@ -43,6 +45,7 @@ interface ClientEntry {
lastUsed: number; lastUsed: number;
watcher: WorkspaceWatcher | null; watcher: WorkspaceWatcher | null;
unsubscribeWatchers: (() => void) | null; unsubscribeWatchers: (() => void) | null;
usesDerivedWatchers: boolean;
} }
const entries = new Map<string, ClientEntry>(); const entries = new Map<string, ClientEntry>();
@@ -96,6 +99,7 @@ async function getOrCreateEntry(
lastUsed: Date.now(), lastUsed: Date.now(),
watcher: null, watcher: null,
unsubscribeWatchers: null, unsubscribeWatchers: null,
usesDerivedWatchers: false,
}; };
entries.set(key, entry); entries.set(key, entry);
try { try {
@@ -118,11 +122,23 @@ async function attachWatcher(entry: ClientEntry): Promise<void> {
function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] { function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] {
const registered = entry.client.getFileWatchers(); const registered = entry.client.getFileWatchers();
const openedDeletes = [...entry.opened.keys()].map((uri) => ({ entry.usesDerivedWatchers = registered.length === 0;
globPattern: uriToPath(uri).split(path.sep).join("/"), return registered.length > 0 ? registered : derivedServerWatchers(entry.server);
kind: WATCH_KIND_DELETE, }
}));
return [...registered, ...openedDeletes]; 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> { async function refreshWatcher(entry: ClientEntry): Promise<void> {
@@ -178,6 +194,9 @@ function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
entry.client.sendNotification("workspace/didChangeWatchedFiles", { entry.client.sendNotification("workspace/didChangeWatchedFiles", {
changes: events, changes: events,
}); });
if (entry.usesDerivedWatchers) {
evict(entry, "derived watcher event");
}
} catch (err) { } catch (err) {
log(`watcher send failed`, entry.server.id, (err as Error).message); log(`watcher send failed`, entry.server.id, (err as Error).message);
} }
@@ -220,7 +239,6 @@ async function syncFile(
if (prev === undefined) { if (prev === undefined) {
entry.client.openDocument(filePath); entry.client.openDocument(filePath);
entry.opened.set(uri, stat.mtimeMs); entry.opened.set(uri, stat.mtimeMs);
await refreshWatcher(entry);
return { uri, changed: true }; return { uri, changed: true };
} else if (prev !== stat.mtimeMs) { } else if (prev !== stat.mtimeMs) {
entry.client.notifyChange(filePath); entry.client.notifyChange(filePath);

View File

@@ -32,7 +32,7 @@ interface DiagResult {
[serverId: string]: { diagnostics?: { message: string }[] }; [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 tmpDir: string;
let mainFile: string; let mainFile: string;
let helperFile: 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 }); 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 () => { it("reports missing module after an opened imported file is deleted", async () => {
const initial = (await runCliJson( const initial = (await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":5000}'], [mainFile, "diagnostics", '{"timeoutMs":5000}'],