Compare commits
5 Commits
62fc80c70f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2da4103cd7 | |||
| 8cfe604de7 | |||
| 071c87d3c1 | |||
| 3f3cb4cdbf | |||
| 14749a6449 |
70
index.ts
70
index.ts
@@ -18,8 +18,28 @@ import {
|
|||||||
UnsupportedExtensionError,
|
UnsupportedExtensionError,
|
||||||
} from "./src/types.ts";
|
} 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.
|
// Format Hover - Turn an LSP hover response into readable text.
|
||||||
function formatHover(result: unknown): string {
|
function formatHover(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || typeof result !== "object") return "(no hover info)";
|
if (!result || typeof result !== "object") return "(no hover info)";
|
||||||
const hover = result as { contents?: unknown };
|
const hover = result as { contents?: unknown };
|
||||||
if (!hover.contents) return "(empty)";
|
if (!hover.contents) return "(empty)";
|
||||||
@@ -49,6 +69,7 @@ function formatHover(result: unknown): string {
|
|||||||
|
|
||||||
// Format Definition - Turn definition locations into readable text.
|
// Format Definition - Turn definition locations into readable text.
|
||||||
function formatDefinition(result: unknown): string {
|
function formatDefinition(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result) return "(no definition found)";
|
if (!result) return "(no definition found)";
|
||||||
const locations = Array.isArray(result) ? result : [result];
|
const locations = Array.isArray(result) ? result : [result];
|
||||||
if (locations.length === 0) return "(no definition found)";
|
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.
|
// Format References - Turn reference locations into readable text.
|
||||||
function formatReferences(result: unknown): string {
|
function formatReferences(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || !Array.isArray(result)) return "(no references found)";
|
if (!result || !Array.isArray(result)) return "(no references found)";
|
||||||
if (result.length === 0) 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.
|
// Format Completions - Turn completion items into readable text.
|
||||||
function formatCompletions(result: unknown): string {
|
function formatCompletions(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result) return "(no completions)";
|
if (!result) return "(no completions)";
|
||||||
|
|
||||||
// Resolve to CompletionItem[] if it's a CompletionList
|
// 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.
|
// Format Document Symbols - Turn symbols into a tree outline.
|
||||||
function formatDocumentSymbols(result: unknown): string {
|
function formatDocumentSymbols(result: unknown): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || !Array.isArray(result)) return "(no symbols)";
|
if (!result || !Array.isArray(result)) return "(no symbols)";
|
||||||
const symbols = result as any[];
|
const symbols = result as any[];
|
||||||
if (symbols.length === 0) return "(no symbols)";
|
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
|
// { [serverId]: { uri, diagnostics[] } }. Single-server results omit the
|
||||||
// header to avoid noise.
|
// header to avoid noise.
|
||||||
function formatDiagnostics(result: unknown, limit = 20): string {
|
function formatDiagnostics(result: unknown, limit = 20): string {
|
||||||
|
if (isLspUnavailable(result)) return formatUnavailable(result);
|
||||||
if (!result || typeof result !== "object") return "(no diagnostics)";
|
if (!result || typeof result !== "object") return "(no diagnostics)";
|
||||||
|
|
||||||
const grouped = result as Record<string, any>;
|
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
|
// Disabled Servers - In-memory set of disabled server IDs. Scoped to this
|
||||||
// extension process; the shared daemon is never mutated by disable/enable.
|
// extension process; the shared daemon is never mutated by disable/enable.
|
||||||
const lspToolNames = [
|
const lspToolNames = [
|
||||||
@@ -301,14 +354,12 @@ async function runLsp(
|
|||||||
// touching the daemon so other pi instances sharing it are unaffected.
|
// touching the daemon so other pi instances sharing it are unaffected.
|
||||||
const server = pickServer(filePath);
|
const server = pickServer(filePath);
|
||||||
if (disabledServers.has(server.id)) {
|
if (disabledServers.has(server.id)) {
|
||||||
throw new Error(
|
return unavailableForFile(filePath);
|
||||||
`LSP server "${server.id}" is disabled. Use /lsp-enable ${server.id} to re-enable.`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return await daemonRequest(filePath, server.id, method, params);
|
return await daemonRequest(filePath, server.id, method, params);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedError(error)) {
|
if (isExpectedError(error)) {
|
||||||
return undefined;
|
return unavailableForFile(filePath);
|
||||||
}
|
}
|
||||||
// Daemon-wrapped errors (plain Error with expected message) are also
|
// Daemon-wrapped errors (plain Error with expected message) are also
|
||||||
// expected — the daemon catches pickServer() throws and returns them
|
// 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("No LSP server registered") ||
|
||||||
error.message.includes("not found on PATH"))
|
error.message.includes("not found on PATH"))
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return unavailableForFile(filePath);
|
||||||
}
|
}
|
||||||
throw error;
|
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
|
// 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> {
|
async function runDiagnostics(filePath: string): Promise<unknown> {
|
||||||
try {
|
try {
|
||||||
const serverIds = pickDiagnosticServers(filePath);
|
const serverIds = pickDiagnosticServers(filePath);
|
||||||
if (serverIds.length === 0) return undefined;
|
if (serverIds.length === 0) return unavailableForFile(filePath, true);
|
||||||
return await daemonDiagnostics(filePath, serverIds, 1500);
|
return await daemonDiagnostics(filePath, serverIds, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isExpectedError(error)) {
|
if (isExpectedError(error)) {
|
||||||
return undefined;
|
return unavailableForFile(filePath, true);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
(error.message.includes("No LSP server registered") ||
|
(error.message.includes("No LSP server registered") ||
|
||||||
error.message.includes("not found on PATH"))
|
error.message.includes("not found on PATH"))
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return unavailableForFile(filePath, true);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -572,6 +623,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// Run LSP diagnostics with timeout - Prevent the auto-check hook from
|
// Run LSP diagnostics with timeout - Prevent the auto-check hook from
|
||||||
// blocking pi if the daemon or LSP server is slow or unresponsive.
|
// blocking pi if the daemon or LSP server is slow or unresponsive.
|
||||||
const result = await withTimeout(runDiagnostics(absolutePath), 3000);
|
const result = await withTimeout(runDiagnostics(absolutePath), 3000);
|
||||||
|
if (isLspUnavailable(result)) return;
|
||||||
const formatted = formatDiagnostics(result, 10);
|
const formatted = formatDiagnostics(result, 10);
|
||||||
|
|
||||||
// Only send a message if there are actual diagnostics
|
// Only send a message if there are actual diagnostics
|
||||||
|
|||||||
@@ -247,6 +247,14 @@ export class LspClient {
|
|||||||
return uri;
|
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.
|
// Send Raw LSP Request - Passthrough used by the command dispatcher.
|
||||||
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
|
sendRequest<R = unknown>(method: string, params: unknown): Promise<R> {
|
||||||
return this.conn.sendRequest(method, params) as Promise<R>;
|
return this.conn.sendRequest(method, params) as Promise<R>;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as net from "node:net";
|
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 { LspClient } from "./client.ts";
|
import { LspClient } from "./client.ts";
|
||||||
import { findRoot, findServerById, pathToUri } from "./root.ts";
|
import { findRoot, findServerById, pathToUri } from "./root.ts";
|
||||||
import type { ServerConfig } from "./types.ts";
|
import type { ServerConfig } from "./types.ts";
|
||||||
@@ -20,6 +21,10 @@ 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 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
|
// Client Entry - One LspClient per (server.id, rootDir), plus the bookkeeping
|
||||||
// needed to keep files in sync and evict on idleness.
|
// needed to keep files in sync and evict on idleness.
|
||||||
@@ -40,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>();
|
||||||
@@ -93,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 {
|
||||||
@@ -109,8 +116,34 @@ async function getOrCreateEntry(
|
|||||||
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
|
// Attach Watcher - Registration can happen during initialize, before the daemon subscribes.
|
||||||
async function attachWatcher(entry: ClientEntry): Promise<void> {
|
async function attachWatcher(entry: ClientEntry): Promise<void> {
|
||||||
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
if (process.env.PI_LSP_DISABLE_WATCHERS) return;
|
||||||
const sync = async () => {
|
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void refreshWatcher(entry));
|
||||||
const patterns = entry.client.getFileWatchers();
|
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 (patterns.length === 0 && !entry.watcher) return;
|
||||||
if (!entry.watcher) {
|
if (!entry.watcher) {
|
||||||
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
|
entry.watcher = new WorkspaceWatcher(entry.rootDir, (events) =>
|
||||||
@@ -123,9 +156,6 @@ async function attachWatcher(entry: ClientEntry): Promise<void> {
|
|||||||
}
|
}
|
||||||
entry.watcher.setPatterns(patterns);
|
entry.watcher.setPatterns(patterns);
|
||||||
if (patterns.length > 0) await waitForWatcherReady(entry);
|
if (patterns.length > 0) await waitForWatcherReady(entry);
|
||||||
};
|
|
||||||
entry.unsubscribeWatchers = entry.client.onWatchersChanged(() => void sync());
|
|
||||||
await sync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForWatcherReady(entry: ClientEntry): Promise<void> {
|
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 {
|
function forwardEvents(entry: ClientEntry, events: FileEvent[]): void {
|
||||||
try {
|
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) {
|
if (process.env.LSP_DEBUG) {
|
||||||
log(`watcher fire`, entry.server.id, JSON.stringify(events));
|
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", {
|
entry.client.sendNotification("workspace/didChangeWatchedFiles", {
|
||||||
changes: events,
|
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) {
|
} catch (err) {
|
||||||
log(`watcher send failed`, entry.server.id, (err as Error).message);
|
log(`watcher send failed`, entry.server.id, (err as Error).message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ interface DiagResult {
|
|||||||
[serverId: string]: { diagnostics?: { message: string }[] };
|
[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 tmpDir: string;
|
||||||
let mainFile: string;
|
let mainFile: string;
|
||||||
let helperFile: 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)}`,
|
`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)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
143
test/integration/watcher-typescript.test.ts
Normal file
143
test/integration/watcher-typescript.test.ts
Normal 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)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user