Compare commits

..

5 Commits

Author SHA1 Message Date
2da4103cd7 fix: report unavailable LSP servers explicitly 2026-05-31 20:07:17 -04:00
8cfe604de7 fix(daemon): clear diagnostics and reload TypeScript projects on watcher events
Evicting the entire entry on derived watcher events was overly aggressive.
Instead, clear cached diagnostics and send a reloadProjects command to the
TypeScript language server so it picks up workspace changes without losing
state.
2026-05-24 11:57:01 -04:00
071c87d3c1 fix(watcher): derive fallback file patterns 2026-05-20 06:53:35 -04:00
3f3cb4cdbf fix(watcher): close deleted opened documents 2026-05-20 06:38:21 -04:00
14749a6449 fix(watcher): close deleted open documents 2026-05-20 00:22:30 -04:00
5 changed files with 317 additions and 27 deletions

View File

@@ -18,8 +18,28 @@ import {
UnsupportedExtensionError,
} from "./src/types.ts";
type LspUnavailable = { piLspUnavailable: true; message: string };
function lspUnavailable(message: string): LspUnavailable {
return { piLspUnavailable: true, message };
}
function isLspUnavailable(result: unknown): result is LspUnavailable {
return Boolean(
result &&
typeof result === "object" &&
"piLspUnavailable" in result &&
(result as { piLspUnavailable?: unknown }).piLspUnavailable === true,
);
}
function formatUnavailable(result: LspUnavailable): string {
return `(LSP unavailable: ${result.message})`;
}
// Format Hover - Turn an LSP hover response into readable text.
function formatHover(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || typeof result !== "object") return "(no hover info)";
const hover = result as { contents?: unknown };
if (!hover.contents) return "(empty)";
@@ -49,6 +69,7 @@ function formatHover(result: unknown): string {
// Format Definition - Turn definition locations into readable text.
function formatDefinition(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result) return "(no definition found)";
const locations = Array.isArray(result) ? result : [result];
if (locations.length === 0) return "(no definition found)";
@@ -64,6 +85,7 @@ function formatDefinition(result: unknown): string {
// Format References - Turn reference locations into readable text.
function formatReferences(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || !Array.isArray(result)) return "(no references found)";
if (result.length === 0) return "(no references found)";
@@ -85,6 +107,7 @@ function formatReferences(result: unknown): string {
// Format Completions - Turn completion items into readable text.
function formatCompletions(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result) return "(no completions)";
// Resolve to CompletionItem[] if it's a CompletionList
@@ -150,6 +173,7 @@ function formatCompletions(result: unknown): string {
// Format Document Symbols - Turn symbols into a tree outline.
function formatDocumentSymbols(result: unknown): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || !Array.isArray(result)) return "(no symbols)";
const symbols = result as any[];
if (symbols.length === 0) return "(no symbols)";
@@ -235,6 +259,7 @@ function formatServerDiagnostics(diags: any[], limit: number): string {
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
// header to avoid noise.
function formatDiagnostics(result: unknown, limit = 20): string {
if (isLspUnavailable(result)) return formatUnavailable(result);
if (!result || typeof result !== "object") return "(no diagnostics)";
const grouped = result as Record<string, any>;
@@ -275,6 +300,34 @@ function isExpectedError(error: unknown): boolean {
);
}
function unavailableForFile(filePath: string, includeDiagnosticsOnly = false): LspUnavailable {
const ext = path.extname(filePath).replace(/^\./, "");
const label = ext ? `.${ext}` : "files without an extension";
const matches = getServersForPath(filePath).filter((server) =>
server.match.includes(ext) && (includeDiagnosticsOnly || !server.diagnosticsOnly)
);
if (matches.length === 0) {
return lspUnavailable(`no LSP server is registered for ${label}`);
}
const disabled = matches.filter((server) => disabledServers.has(server.id));
if (disabled.length === matches.length) {
return lspUnavailable(
`matching LSP server(s) are disabled: ${disabled.map((s) => s.id).join(", ")}`,
);
}
const unavailable = matches.filter((server) => !isServerAvailable(server));
if (unavailable.length > 0) {
return lspUnavailable(
`matching LSP server(s) are not on PATH: ${unavailable.map((s) => s.command).join(", ")}`,
);
}
return lspUnavailable(`no applicable LSP server is available for ${label}`);
}
// Disabled Servers - In-memory set of disabled server IDs. Scoped to this
// extension process; the shared daemon is never mutated by disable/enable.
const lspToolNames = [
@@ -301,14 +354,12 @@ async function runLsp(
// touching the daemon so other pi instances sharing it are unaffected.
const server = pickServer(filePath);
if (disabledServers.has(server.id)) {
throw new Error(
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
);
return unavailableForFile(filePath);
}
return await daemonRequest(filePath, server.id, method, params);
} catch (error) {
if (isExpectedError(error)) {
return undefined;
return unavailableForFile(filePath);
}
// Daemon-wrapped errors (plain Error with expected message) are also
// expected — the daemon catches pickServer() throws and returns them
@@ -318,7 +369,7 @@ async function runLsp(
(error.message.includes("No LSP server registered") ||
error.message.includes("not found on PATH"))
) {
return undefined;
return unavailableForFile(filePath);
}
throw error;
}
@@ -347,22 +398,22 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
}
// Run LSP Diagnostics - Fans out to all matching servers in a single
// daemon call. Returns the grouped result map or undefined if no servers.
// daemon call. Returns an explicit unavailable result if no server applies.
async function runDiagnostics(filePath: string): Promise<unknown> {
try {
const serverIds = pickDiagnosticServers(filePath);
if (serverIds.length === 0) return undefined;
if (serverIds.length === 0) return unavailableForFile(filePath, true);
return await daemonDiagnostics(filePath, serverIds, 1500);
} catch (error) {
if (isExpectedError(error)) {
return undefined;
return unavailableForFile(filePath, true);
}
if (
error instanceof Error &&
(error.message.includes("No LSP server registered") ||
error.message.includes("not found on PATH"))
) {
return undefined;
return unavailableForFile(filePath, true);
}
throw error;
}
@@ -572,6 +623,7 @@ export default function (pi: ExtensionAPI) {
// Run LSP diagnostics with timeout - Prevent the auto-check hook from
// blocking pi if the daemon or LSP server is slow or unresponsive.
const result = await withTimeout(runDiagnostics(absolutePath), 3000);
if (isLspUnavailable(result)) return;
const formatted = formatDiagnostics(result, 10);
// Only send a message if there are actual diagnostics

View File

@@ -247,6 +247,14 @@ export class LspClient {
return uri;
}
closeDocument(uri: string): void {
this.versions.delete(uri);
this.diagnostics.delete(uri);
this.conn.sendNotification("textDocument/didClose", {
textDocument: { uri },
});
}
// Send Raw LSP Request - Passthrough used by the command dispatcher.
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
return this.conn.sendRequest(method, params) as Promise<R>;

View File

@@ -5,6 +5,7 @@
import * as fs from "node:fs";
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 } from "./root.ts";
import type { ServerConfig } from "./types.ts";
@@ -20,6 +21,10 @@ 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
// needed to keep files in sync and evict on idleness.
@@ -40,6 +45,7 @@ interface ClientEntry {
lastUsed: number;
watcher: WorkspaceWatcher | null;
unsubscribeWatchers: (() => void) | null;
usesDerivedWatchers: boolean;
}
const entries = new Map<string, ClientEntry>();
@@ -93,6 +99,7 @@ async function getOrCreateEntry(
lastUsed: Date.now(),
watcher: null,
unsubscribeWatchers: null,
usesDerivedWatchers: false,
};
entries.set(key, entry);
try {
@@ -109,8 +116,34 @@ async function getOrCreateEntry(
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
async function attachWatcher(entry: ClientEntry): Promise<void> {
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
const sync = async () => {
const patterns = entry.client.getFileWatchers();
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void refreshWatcher(entry));
await refreshWatcher(entry);
}
function watcherPatterns(entry: ClientEntry): FileSystemWatcher[] {
const registered = entry.client.getFileWatchers();
entry.usesDerivedWatchers = registered.length === 0;
return registered.length > 0 ? registered : derivedServerWatchers(entry.server);
}
function derivedServerWatchers(server: ServerConfig): FileSystemWatcher[] {
const extensions = server.match
.map((m) => m.replace(/^\./, ""))
.filter((m) => /^[A-Za-z0-9_-]+$/.test(m));
if (extensions.length === 0) return [];
const globPattern = extensions.length === 1
? `**/*.${extensions[0]}`
: `**/*.{${extensions.join(",")}}`;
return [{
globPattern,
kind: WATCH_KIND_CREATE | WATCH_KIND_CHANGE | WATCH_KIND_DELETE,
}];
}
async function refreshWatcher(entry: ClientEntry): Promise<void> {
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
const patterns = watcherPatterns(entry);
if (patterns.length === 0 && !entry.watcher) return;
if (!entry.watcher) {
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
@@ -123,9 +156,6 @@ async function attachWatcher(entry: ClientEntry): Promise<void> {
}
entry.watcher.setPatterns(patterns);
if (patterns.length > 0) await waitForWatcherReady(entry);
};
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void sync());
await sync();
}
async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
@@ -152,12 +182,25 @@ async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
try {
for (const event of events) {
if (event.type !== FILE_CHANGE_DELETED || !entry.opened.has(event.uri)) continue;
entry.client.closeDocument(event.uri);
entry.opened.delete(event.uri);
void refreshWatcher(entry);
}
if (process.env.LSP_DEBUG) {
log(`watcher fire`, entry.server.id, JSON.stringify(events));
}
for (const uri of entry.opened.keys()) entry.client.clearDiagnostics(uri);
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
changes: events,
});
if (entry.usesDerivedWatchers && entry.server.id === "typescript-language-server") {
void entry.client.sendRequest("workspace/executeCommand", {
command: "typescript.tsserverRequest",
arguments: ["reloadProjects"],
}).catch((err) => log("typescript reloadProjects failed", (err as Error).message));
}
} catch (err) {
log(`watcher send failed`, entry.server.id, (err as Error).message);
}

View File

@@ -32,7 +32,7 @@ interface DiagResult {
[serverId: string]: { diagnostics?: { message: string }[] };
}
describe("watcher: gopls picks up externally-created files", { skip: skip ?? undefined }, () => {
describe("watcher: gopls picks up external file changes", { skip: skip ?? undefined }, () => {
let tmpDir: string;
let mainFile: string;
let helperFile: string;
@@ -122,4 +122,48 @@ describe("watcher: gopls picks up externally-created files", { skip: skip ?? und
`Expected diagnostic to clear after creating helper.go, still got: ${JSON.stringify(finalDiags)}`,
);
});
it("closes an opened file when it is deleted externally", async () => {
fs.writeFileSync(
helperFile,
"package main\n\nfunc Helper(x int) {}\n",
);
await runCliJson([helperFile, "diagnostics", '{"timeoutMs":3000}'], env);
await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return diags.some((d) => d.message.includes("not enough arguments"));
},
15000,
500,
);
fs.rmSync(helperFile);
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["gopls"]?.diagnostics ?? [];
return diags.some((d) => d.message.toLowerCase().includes("undefined"));
},
15000,
500,
);
const finalDiags = result["gopls"]?.diagnostics ?? [];
assert.ok(
finalDiags.some((d) => d.message.toLowerCase().includes("undefined")),
`Expected undefined-symbol diagnostic after deleting opened helper.go, got: ${JSON.stringify(finalDiags)}`,
);
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, before, after } from "node:test";
import * as assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
setTestSocket,
stopTestDaemon,
runCliJson,
requireServer,
} from "../helpers.ts";
const skip = requireServer("typescript-language-server");
async function pollUntil<T>(
fn: () => Promise<T>,
predicate: (v: T) => boolean,
timeoutMs: number,
intervalMs = 250,
): Promise<T> {
const deadline = Date.now() + timeoutMs;
let last: T = await fn();
while (Date.now() < deadline) {
if (predicate(last)) return last;
await new Promise((r) => setTimeout(r, intervalMs));
last = await fn();
}
return last;
}
interface DiagResult {
[serverId: string]: { diagnostics?: { message: string }[] };
}
describe("watcher: typescript handles derived file patterns", { skip: skip ?? undefined }, () => {
let tmpDir: string;
let mainFile: string;
let helperFile: string;
const env = { ...process.env };
let cleanup: () => void;
before(async () => {
delete env.NODE_OPTIONS;
cleanup = setTestSocket(env);
await stopTestDaemon(env);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-lsp-ts-watch-"));
fs.writeFileSync(
path.join(tmpDir, ".pi-lsp.json"),
JSON.stringify({ disable: ["oxlint"] }),
);
fs.writeFileSync(
path.join(tmpDir, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
target: "ES2022",
module: "ESNext",
moduleResolution: "Bundler",
strict: true,
noEmit: true,
},
include: ["*.ts"],
},
null,
2,
),
);
mainFile = path.join(tmpDir, "main.ts");
helperFile = path.join(tmpDir, "helper.ts");
fs.writeFileSync(
mainFile,
'import { helper } from "./helper";\n\nconsole.log(helper());\n',
);
fs.writeFileSync(helperFile, "export function helper(): number {\n return 1;\n}\n");
});
after(async () => {
await stopTestDaemon(env);
cleanup();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it("clears missing module after an unopened imported file is created", async () => {
fs.rmSync(helperFile);
const missing = (await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":5000}'],
env,
)) as DiagResult;
assert.ok(
(missing["typescript-language-server"]?.diagnostics ?? []).some((d) =>
d.message.includes("Cannot find module './helper'")
),
);
fs.writeFileSync(helperFile, "export function helper(): number {\n return 1;\n}\n");
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => (r["typescript-language-server"]?.diagnostics ?? []).length === 0,
15000,
500,
);
assert.deepEqual(result["typescript-language-server"]?.diagnostics ?? [], []);
});
it("reports missing module after an opened imported file is deleted", async () => {
const initial = (await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":5000}'],
env,
)) as DiagResult;
assert.deepEqual(initial["typescript-language-server"]?.diagnostics ?? [], []);
await runCliJson([helperFile, "diagnostics", '{"timeoutMs":5000}'], env);
fs.rmSync(helperFile);
const result = await pollUntil(
async () =>
(await runCliJson(
[mainFile, "diagnostics", '{"timeoutMs":3000}'],
env,
)) as DiagResult,
(r) => {
const diags = r["typescript-language-server"]?.diagnostics ?? [];
return diags.some((d) => d.message.includes("Cannot find module './helper'"));
},
15000,
500,
);
const diags = result["typescript-language-server"]?.diagnostics ?? [];
assert.ok(
diags.some((d) => d.message.includes("Cannot find module './helper'")),
`Expected missing-module diagnostic after deleting opened helper.ts, got: ${JSON.stringify(diags)}`,
);
});
});