UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

1,605 lines • 72.9 kB
/**
 * LSP Client for pi-lens
 *
 * Handles JSON-RPC communication with language servers:
 * - Initialize/shutdown lifecycle
 * - Document synchronization (didOpen, didChange)
 * - Diagnostics with debouncing
 * - Request/response handling
 */
import { spawn as nodeSpawn } from "node:child_process";
import { EventEmitter } from "node:events";
import { access, readFile } from "node:fs/promises";
import * as os from "node:os";
import { pathToFileURL } from "node:url";
import { withTimeout } from "../deadline-utils.js";
import { logLatency } from "../latency-logger.js";
// vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
// `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
import { getAmbientAbortSignal } from "../safe-spawn.js";
import { applyWorkspaceEdit } from "./edits.js";
import { recordLspChild, removeLspChild } from "../instance-registry.js";
import { normalizeMapKey, uriToPath } from "./path-utils.js";
import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
import { getStrategy } from "./server-strategies.js";
import { WatchedFilesQueue } from "./watch-queue.js";
// Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
// hot-path cost. Surfaces each server's publish behavior (version + count) to
// diagnose the clean-file affirmative-signal question (#240): which servers
// publish an empty-with-version set on a clean scan vs go silent.
const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
/**
 * #472/#449: extract a per-spawn-unique "marker" from an LSP server's resolved
 * args, for the instance registry's command-line re-identification fallback
 * (used when a recorded child's pid is dead/recycled but its process tree
 * grandchild — e.g. ast-grep's native exe behind a dead node wrapper — is
 * still alive under a different pid).
 *
 * Generalized, NOT ast-grep-specific (uniformity requirement — no per-server
 * special casing): the value immediately following a `--config`/`-c` flag, if
 * that value looks like a path under a temp directory (`os.tmpdir()`). This
 * covers ast-grep's `lsp --config <tmp sgconfig path>` (clients/sgconfig.ts)
 * today, and any other server later launched with a temp-file `--config`/`-c`
 * argument, without new server-specific code.
 */
function extractSpawnMarker(args) {
    const tmpDir = os.tmpdir();
    for (let i = 0; i < args.length - 1; i++) {
        const flag = args[i];
        if (flag === "--config" || flag === "-c") {
            const value = args[i + 1];
            if (value?.startsWith(tmpDir))
                return value;
        }
    }
    return undefined;
}
// --- Constants ---
const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
/**
 * The client capabilities advertised in every `initialize`. The textDocument set
 * is intentionally COMPLETE and spec-compliant: servers built on
 * OmniSharp.Extensions.LanguageServer (PowerShell Editor Services, #278)
 * dereference these sub-capabilities while handling `initialize` and throw a
 * NullReferenceException when an expected one is absent, hanging the handshake. A
 * partial textDocument object (the old `synchronization: {didOpen, didChange}` —
 * not even valid TextDocumentSyncClientCapabilities fields) triggered exactly
 * that. Declaring the full set is harmless to other servers (they act only on the
 * requests we actually send), so this is the single, server-agnostic shape.
 * Exported for the regression guard in client-internals tests.
 */
export const CLIENT_CAPABILITIES = {
    general: { positionEncodings: ADVERTISED_POSITION_ENCODINGS },
    window: { workDoneProgress: true },
    workspace: {
        workspaceFolders: true,
        configuration: true,
        didChangeWatchedFiles: { dynamicRegistration: true },
    },
    textDocument: {
        synchronization: {
            dynamicRegistration: false,
            willSave: false,
            willSaveWaitUntil: false,
            didSave: true,
        },
        completion: {
            dynamicRegistration: false,
            completionItem: { snippetSupport: false },
        },
        hover: { dynamicRegistration: false },
        signatureHelp: { dynamicRegistration: false },
        definition: { dynamicRegistration: false },
        typeDefinition: { dynamicRegistration: false },
        implementation: { dynamicRegistration: false },
        references: { dynamicRegistration: false },
        documentSymbol: { dynamicRegistration: false },
        codeAction: { dynamicRegistration: false },
        rename: { dynamicRegistration: false },
        publishDiagnostics: {
            relatedInformation: true,
            versionSupport: true,
        },
    },
};
const NAV_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_NAV_REQUEST_TIMEOUT_MS", 10_000); // 10s — per-request ceiling; prevents heavy servers (vue, svelte) from hanging
const DIAGNOSTICS_WAIT_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_DIAGNOSTICS_WAIT_MS", 10_000);
const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_RETRY_INTERVAL_MS", 250);
// Per-request ceiling for pull diagnostics (textDocument/diagnostic), mirroring
// NAV_REQUEST_TIMEOUT_MS. safeSendRequest only settles on a reply or a *destroyed*
// stream, so a pull-mode server that is alive but hung (accepts the request, never
// replies) would await forever — hanging clientWaitForDiagnostics and, upstream,
// the diagnostics flush. On timeout the request is treated as `unavailable`, which
// (per #240) is NOT read as clean and falls through to the bounded push backstop.
const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
// Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
// (30s): the command is mutating and legitimately long-running (a real server
// refactor / organize-imports), so this must not truncate valid work — it only
// stops a hung server from blocking the caller forever. On timeout the command
// may still be applying server-side; we surface that rather than pretend it ran.
const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
const LSP_CRASH_CODES = new Set([
    "ERR_STREAM_DESTROYED",
    "ERR_STREAM_WRITE_AFTER_END",
    "EPIPE",
    "ECONNRESET",
]);
let crashGuardInstalled = false;
function isIgnorableLspRuntimeCrash(err) {
    if (!(err instanceof Error))
        return false;
    const code = err.code;
    if (code && LSP_CRASH_CODES.has(code))
        return true;
    const msg = err.message.toLowerCase();
    const stack = (err.stack ?? "").toLowerCase();
    return (msg.includes("stream") ||
        msg.includes("write after end") ||
        stack.includes("vscode-jsonrpc/lib/node/ril.js"));
}
function installCrashGuard() {
    if (crashGuardInstalled)
        return;
    crashGuardInstalled = true;
    process.on("uncaughtException", (err) => {
        if (isIgnorableLspRuntimeCrash(err)) {
            return;
        }
        throw err;
    });
    process.on("unhandledRejection", (reason) => {
        if (isIgnorableLspRuntimeCrash(reason)) {
            return;
        }
        throw reason instanceof Error ? reason : new Error(String(reason));
    });
}
function isClientAlive(state) {
    return (state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed);
}
function disposeClientConnection(state) {
    if (state.connectionDisposed)
        return;
    state.connectionDisposed = true;
    try {
        state.connection.dispose();
    }
    catch {
        // ignore
    }
}
export async function killProcessTree(proc, pid, options = {}) {
    // If our child has already exited, its PID is dead and the OS may have
    // RECYCLED it. The Windows `taskkill /F /T` below force-kills the PID's whole
    // tree, so on a recycled PID it would kill an unrelated process (in the test
    // suite this occasionally nuked a vitest worker fork → "Worker exited
    // unexpectedly" with no fatal dump). There is nothing left for us to kill, and
    // the handle-based proc.kill() below is moot, so return early.
    if ((proc.exitCode != null || proc.signalCode != null) &&
        !options.processExiting) {
        proc.unref?.();
        return;
    }
    if (process.platform === "win32" && pid > 0) {
        // Host process is exiting (loop already closing): never spawn a child here —
        // the spawn's uv_async_send on the closing loop-wakeup handle hard-aborts
        // (src\win\async.c). Kill the direct child via the handle we already hold
        // (TerminateProcess; synchronous, no async handle).
        //
        // #472 CORRECTION of a prior false claim here ("orphaned grandchildren are
        // reaped by the OS as the host exits"): Windows does NOT kill children when
        // a parent dies. For shell/.cmd-wrapped servers the direct child is
        // cmd.exe, so this path only ever kills the wrapper — the actual server
        // (its grandchild) survives by design whenever it doesn't independently
        // exit. It relies entirely on best-effort backstops instead: (1) the
        // server observing stdin EOF once the wrapper's pipes close, (2) LSP
        // `initialize.processId: process.pid` (some servers self-watchdog on that
        // pid dying — typescript-language-server does, ast-grep's native binary
        // does not, an upstream spec violation), and (3) the #449/#472
        // cross-process instance registry's orphan reaper, which is the only
        // mechanism that works regardless of why a pipe write-end stayed open
        // (e.g. Windows handle-inheritance capture by a long-lived process). This
        // is why registering every LSP child at spawn matters uniformly — do NOT
        // weaken this direct-child-only kill to try to chase grandchildren here;
        // spawning taskkill in this branch is exactly the libuv hazard above.
        if (options.processExiting) {
            try {
                proc.kill();
            }
            catch {
                // best-effort
            }
            proc.unref?.();
            return;
        }
        try {
            // Absolute path avoids PATH-resolution: SystemRoot is set by Windows itself.
            const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
            const killer = nodeSpawn(taskkill, ["/F", "/T", "/PID", String(pid)], {
                shell: false,
                windowsHide: true,
                stdio: "ignore",
                detached: !!options.fast,
            });
            if (options.fast) {
                killer.unref();
                proc.unref?.();
                return;
            }
            await new Promise((resolve) => {
                killer.once("close", () => resolve());
                killer.once("error", () => resolve());
            });
        }
        catch {
            // ignore
        }
        return;
    }
    const killPosixProcessGroup = (signal) => {
        if (pid <= 0)
            return false;
        try {
            process.kill(-pid, signal);
            return true;
        }
        catch {
            return false;
        }
    };
    const killDirectChild = (signal) => {
        try {
            proc.kill(signal);
        }
        catch {
            // best-effort
        }
    };
    try {
        if (!killPosixProcessGroup("SIGTERM")) {
            killDirectChild("SIGTERM");
        }
        if (options.fast) {
            const timer = setTimeout(() => {
                if (!proc.killed) {
                    if (!killPosixProcessGroup("SIGKILL")) {
                        killDirectChild("SIGKILL");
                    }
                }
            }, 1500);
            timer.unref?.();
            proc.unref?.();
            return;
        }
        // SIGTERM → 1.5s → SIGKILL escalation.
        // SIGTERM alone can leave zombie processes if the server hangs.
        await new Promise((resolve) => setTimeout(resolve, 1500));
        if (!proc.killed) {
            if (!killPosixProcessGroup("SIGKILL")) {
                killDirectChild("SIGKILL");
            }
        }
    }
    catch {
        // ignore
    }
}
export function stripDiagnosticNoiseLines(message) {
    const cleaned = message
        .split(/\r?\n/)
        .filter((line) => {
        const trimmed = line.trim();
        if (/^for further information visit\b/i.test(trimmed))
            return false;
        if (/^https?:\/\/\S+$/i.test(trimmed))
            return false;
        return true;
    })
        .join("\n")
        .trim();
    return cleaned || message.trim() || message;
}
function normalizeLspDiagnostic(diagnostic) {
    const message = stripDiagnosticNoiseLines(diagnostic.message);
    return message === diagnostic.message
        ? diagnostic
        : { ...diagnostic, message };
}
function normalizeLspDiagnostics(diagnostics) {
    return diagnostics.map(normalizeLspDiagnostic);
}
function mergeDiagnosticLists(push, pull) {
    const merged = [];
    const seen = new Set();
    for (const diagnostic of [...(push ?? []), ...(pull ?? [])]) {
        const key = [
            diagnostic.range.start.line,
            diagnostic.range.start.character,
            diagnostic.range.end.line,
            diagnostic.range.end.character,
            diagnostic.code ?? "",
            diagnostic.source ?? "",
            diagnostic.message,
        ].join(":");
        if (seen.has(key))
            continue;
        seen.add(key);
        merged.push(diagnostic);
    }
    return merged;
}
function getMergedDiagnosticsForPath(state, normalizedPath) {
    const legacy = state;
    return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
        legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
}
function clearDiagnosticsForPath(state, normalizedPath) {
    const legacy = state;
    state.pushDiagnostics?.delete(normalizedPath);
    state.pushDiagnosticTimestamps?.delete(normalizedPath);
    state.documentPullDiagnostics?.delete(normalizedPath);
    state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
    state.diagnosticDocVersions?.delete(normalizedPath);
    legacy.diagnostics?.delete(normalizedPath);
    legacy.diagnosticTimestamps?.delete(normalizedPath);
}
// Methods that can be registered dynamically and map to operationSupport keys
const DYNAMIC_OPERATION_METHOD_MAP = {
    "textDocument/definition": "definition",
    "textDocument/typeDefinition": "typeDefinition",
    "textDocument/declaration": "declaration",
    "textDocument/references": "references",
    "textDocument/hover": "hover",
    "textDocument/signatureHelp": "signatureHelp",
    "textDocument/documentSymbol": "documentSymbol",
    "workspace/symbol": "workspaceSymbol",
    "textDocument/codeAction": "codeAction",
    "textDocument/rename": "rename",
    "textDocument/implementation": "implementation",
    "textDocument/prepareCallHierarchy": "callHierarchy",
};
export function applyDynamicCapabilities(state) {
    const registeredMethods = new Set(state.dynamicRegistrations.values());
    const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
        registeredMethods.has("workspace/diagnostic");
    if (hasDynamicPull) {
        state.workspaceDiagnosticsSupport = {
            advertised: true,
            mode: "pull",
            workspaceDiagnostics: registeredMethods.has("workspace/diagnostic"),
            diagnosticProviderKind: "dynamic",
        };
    }
    else if (state.staticDiagnosticsMode === "push-only" &&
        state.workspaceDiagnosticsSupport.diagnosticProviderKind === "dynamic") {
        // Was only dynamically registered, now unregistered — revert to push-only
        state.workspaceDiagnosticsSupport = {
            advertised: false,
            mode: "push-only",
            workspaceDiagnostics: false,
            diagnosticProviderKind: "none",
        };
    }
    for (const [method, key] of Object.entries(DYNAMIC_OPERATION_METHOD_MAP)) {
        if (registeredMethods.has(method)) {
            state.operationSupport[key] = true;
        }
    }
}
// Exported (only) so tests can invoke the publishDiagnostics notification
// handler directly against a mock LSPClientState/connection without spawning
// a real language server. Not part of the public client API surface.
export function setupIncomingHandlers(state, initialization) {
    state.connection.onNotification("textDocument/publishDiagnostics", (params) => {
        const filePath = uriToPath(params.uri);
        const normalizedPath = normalizeMapKey(filePath);
        const newDiags = normalizeLspDiagnostics(params.diagnostics || []);
        const docVersion = params.version;
        if (PUB_DEBUG) {
            console.error(`[lsp-pub] server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`);
        }
        const strategy = getStrategy(state.serverId);
        // Record the document version these diagnostics were computed against
        // (when the server reports it) so waitForDiagnostics can reject results
        // that lag behind the latest didChange instead of serving them as fresh.
        const recordDocVersion = () => {
            if (docVersion !== undefined) {
                state.diagnosticDocVersions.set(normalizedPath, docVersion);
            }
        };
        // Late/superseded-push guard: if the server stamped this push with a
        // version and that version already lags the latest didChange we sent,
        // this is analysis for an edit that's since been overtaken — caching it
        // would let getDiagnostics()/getAllDiagnostics()/pruneDiagnostics() (none
        // of which consult isVersionStale — that check only gates the *wait*
        // helper below) serve stale results as current until the next genuinely
        // fresh push overwrites them. Drop it before it reaches the cache instead.
        // Checked at write time (not at notification-receipt time) so a push that
        // arrives fresh but whose debounce timer fires after a later didChange is
        // still caught. Version-less servers (docVersion undefined) are
        // unaffected — that's an intentional, separate tradeoff (see
        // isVersionStale below), not something this guard touches.
        //
        // Known, deliberately out-of-scope gaps: the pull-diagnostics path
        // (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
        // version stamp to compare against in this codebase's current handling,
        // so nothing analogous is applied there. And diagnosticsVersion is a
        // single global counter rather than per-path, so an unrelated path's
        // fresh push can still satisfy a wait for this path's version bump —
        // both are separate, larger changes.
        const isSupersededPush = () => {
            if (docVersion === undefined)
                return false;
            const currentVersion = state.documentVersions.get(normalizedPath);
            return currentVersion !== undefined && docVersion < currentVersion;
        };
        // Seed on first push for servers whose first push is known complete.
        // Bypasses the debounce timer entirely — resolves waiting promises immediately.
        if (strategy.seedFirstPush &&
            !state.pushDiagnostics.has(normalizedPath)) {
            if (isSupersededPush())
                return;
            state.pushDiagnostics.set(normalizedPath, newDiags);
            state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
            recordDocVersion();
            state.diagnosticsVersion += 1;
            state.diagnosticEmitter.emit("diagnostics", normalizedPath);
            return;
        }
        const existingTimer = state.pendingDiagnostics.get(normalizedPath);
        if (existingTimer)
            clearTimeout(existingTimer);
        const timer = setTimeout(() => {
            state.pendingDiagnostics.delete(normalizedPath);
            if (isSupersededPush())
                return;
            state.pushDiagnostics.set(normalizedPath, newDiags);
            state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
            recordDocVersion();
            state.diagnosticsVersion += 1;
            state.diagnosticEmitter.emit("diagnostics", normalizedPath);
        }, strategy.debounceMs);
        state.pendingDiagnostics.set(normalizedPath, timer);
    });
    state.connection.onRequest("workspace/workspaceFolders", () => [
        { name: "workspace", uri: pathToFileURL(state.root).href },
    ]);
    state.connection.onRequest("client/registerCapability", async (params) => {
        for (const reg of params?.registrations ?? []) {
            if (reg.id && reg.method) {
                state.dynamicRegistrations.set(reg.id, reg.method);
            }
            // executeCommand commands can arrive dynamically too — merge them
            // into the allowlist so dynamically-registered commands are runnable.
            if (reg.method === "workspace/executeCommand" &&
                Array.isArray(reg.registerOptions?.commands)) {
                for (const cmd of reg.registerOptions.commands) {
                    if (typeof cmd === "string")
                        state.advertisedCommands.add(cmd);
                }
            }
        }
        applyDynamicCapabilities(state);
    });
    state.connection.onRequest("client/unregisterCapability", async (params) => {
        for (const unreg of params?.unregisterations ?? []) {
            if (unreg.id) {
                state.dynamicRegistrations.delete(unreg.id);
            }
        }
        applyDynamicCapabilities(state);
    });
    // Server-initiated edits (the mutation vector for executeCommand). Honored
    // ONLY while an explicit executeCommand is in flight (serverEditsAllowed > 0);
    // an unsolicited applyEdit outside that window is refused so a server can't
    // push edits to disk at will. Applied through the same applyWorkspaceEdit path
    // as every other edit.
    state.connection.onRequest("workspace/applyEdit", async (params) => {
        if (state.serverEditsAllowed <= 0 || !params?.edit) {
            return { applied: false, failureReason: "edit not solicited" };
        }
        try {
            await applyWorkspaceEdit(params.edit, state.root);
            return { applied: true };
        }
        catch (err) {
            return {
                applied: false,
                failureReason: err instanceof Error ? err.message : String(err),
            };
        }
    });
    state.connection.onRequest("workspace/configuration", async () => [
        initialization ?? {},
    ]);
    state.connection.onRequest("window/workDoneProgress/create", async () => { });
}
function setupConnectionLifecycle(state, recentStderr) {
    state.connection.onError(([error]) => {
        state.lastError = error instanceof Error ? error : new Error(String(error));
        state.isConnected = false;
        state.isDestroyed = true;
        disposeClientConnection(state);
    });
    state.connection.onClose(() => {
        state.isConnected = false;
        state.isDestroyed = true;
        disposeClientConnection(state);
    });
    state.lspProcess.process.on("exit", (code, signal) => {
        // Gate on shutdownRequested (our own clientShutdown() call), not
        // isConnected: a genuine crash's connection.onClose/onError handler above
        // can fire and flip isConnected false BEFORE this 'exit' event arrives,
        // which used to make the old `wasConnected` check silently swallow every
        // crash whose transport died before the process itself reported exiting
        // (previously: 5 ast-grep deaths during a dogfooding sweep logged only
        // "respawn, uptime=Xms" — no exit code, no signal, no stderr — because
        // none of them tripped this log).
        const wasIntentional = state.shutdownRequested;
        state.isConnected = false;
        state.isDestroyed = true;
        disposeClientConnection(state);
        if (!wasIntentional) {
            logLatency({
                type: "phase",
                phase: "lsp_server_unexpected_exit",
                filePath: state.root,
                durationMs: 0,
                metadata: {
                    serverId: state.serverId,
                    pid: state.lspProcess.pid,
                    exitCode: code ?? null,
                    exitSignal: signal ?? null,
                    stderrTail: recentStderr(20),
                },
            });
        }
    });
}
async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
    if (!isClientAlive(state))
        return { status: "unavailable" };
    const uri = pathToFileURL(filePath).href;
    try {
        // withTimeout is the backstop against a hung pull-mode server: without it
        // this await never settles unless the stream is destroyed. Bounded by the
        // smaller of the absolute ceiling and the caller's remaining wait budget.
        // On timeout the caught error yields `unavailable` below (never a false
        // `clean`), so it falls through to the push-wait/timeout backstop.
        const report = await withTimeout(safeSendRequest(state.connection, "textDocument/diagnostic", { textDocument: { uri } }), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
        if (!report)
            return { status: "unavailable" };
        const normalizedPath = normalizeMapKey(filePath);
        const primaryItems = normalizeLspDiagnostics(report.items ?? []);
        const now = Date.now();
        state.documentPullDiagnostics.set(normalizedPath, primaryItems);
        state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
        state.diagnosticsVersion += 1;
        let totalCount = primaryItems.length;
        if (report.relatedDocuments) {
            for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
                const relatedPath = uriToPath(relatedUri);
                const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
                state.documentPullDiagnostics.set(normalizeMapKey(relatedPath), relatedItems);
                state.documentPullDiagnosticTimestamps.set(normalizeMapKey(relatedPath), now);
                totalCount += relatedItems.length;
            }
        }
        state.diagnosticEmitter.emit("diagnostics", normalizedPath);
        return totalCount > 0
            ? { status: "found", count: totalCount }
            : { status: "clean" };
    }
    catch {
        return { status: "unavailable" };
    }
}
/**
 * One project-wide `workspace/diagnostic` pull — a single request that returns
 * diagnostics for every document the server knows, instead of opening N files.
 * Returns per-file reports, or `undefined` on unsupported/dead/timeout/malformed
 * (caller falls back to the per-file path). `unchanged`-kind items carry no
 * diagnostics and are skipped, so a file absent from the result is "clean".
 */
export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
    if (!isClientAlive(state))
        return undefined;
    if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
        return undefined;
    try {
        const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds: [] }), Math.max(1, budgetMs));
        if (!report || !Array.isArray(report.items))
            return undefined;
        const out = [];
        for (const item of report.items) {
            // Only "full" reports carry items; "unchanged" means "same as last pull"
            // (none, since previousResultIds is empty on this one-shot request).
            if (!item?.uri || item.kind !== "full")
                continue;
            out.push({
                filePath: uriToPath(item.uri),
                diagnostics: normalizeLspDiagnostics(item.items ?? []),
            });
        }
        return out;
    }
    catch {
        return undefined;
    }
}
export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
    const normalizedPath = normalizeMapKey(filePath);
    const minVersion = options.minVersion;
    const hasFreshDiagnostics = () => minVersion === undefined || state.diagnosticsVersion > minVersion;
    // Version coherence: a cached push is "stale" only when the server reported
    // the document version it computed against AND that version lags the latest
    // didChange we sent. This prevents serving diagnostics from a superseded
    // version as fresh (e.g. once the redundant double-push is collapsed and the
    // dispatch wait runs without a push-counter baseline — #203). Unknown version
    // (server omits it) is treated as current so version-less servers are
    // unaffected, and the timeout remains the backstop.
    const isVersionStale = () => {
        const cachedVersion = state.diagnosticDocVersions?.get(normalizedPath);
        if (cachedVersion === undefined)
            return false;
        const currentVersion = state.documentVersions?.get(normalizedPath);
        return currentVersion !== undefined && cachedVersion < currentVersion;
    };
    if (state.workspaceDiagnosticsSupport.mode === "pull") {
        // Pull is authoritative. An AFFIRMATIVE outcome — diagnostics `found`, or
        // an authoritative empty `clean` report — ends the wait. An `unavailable`
        // pull (dead client / no reply / thrown) is NOT clean and must not
        // short-circuit: fall through to the push-wait/timeout backstop. This is
        // the #240 fix — previously the early-return also fired on
        // `hasFreshDiagnostics()`, which is unconditionally true when there is no
        // version baseline (`minVersion === undefined`), so a failed pull returned
        // 0 and was read as a fresh clean.
        let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
        if (outcome.status === "found")
            return;
        let sawClean = outcome.status === "clean";
        const strategy = getStrategy(state.serverId);
        const retryBudgetMs = strategy.pullRetryBudgetMs > 0
            ? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
            : 0;
        const startedAt = Date.now();
        // Retry within budget to catch incremental servers whose first pull is
        // empty while analysis is still running (rust-analyzer). A `clean` seen at
        // any point is a valid affirmative answer for this touch.
        while (outcome.status !== "found" && Date.now() - startedAt < retryBudgetMs) {
            await new Promise((resolve) => setTimeout(resolve, PULL_DIAGNOSTICS_RETRY_INTERVAL_MS));
            outcome = await clientRequestPullDiagnostics(state, filePath, Math.max(0, retryBudgetMs - (Date.now() - startedAt)));
            if (outcome.status === "clean")
                sawClean = true;
        }
        if (outcome.status === "found" || sawClean)
            return;
    }
    if (hasFreshDiagnostics() &&
        !isVersionStale() &&
        getMergedDiagnosticsForPath(state, normalizedPath).length > 0) {
        return;
    }
    return new Promise((resolve) => {
        let debounceTimer;
        const onDiagnostics = (fp) => {
            if (normalizeMapKey(fp) !== normalizedPath)
                return;
            if (!hasFreshDiagnostics() || isVersionStale())
                return;
            if (debounceTimer)
                clearTimeout(debounceTimer);
            // Adaptive debounce: use time since last push to compute remaining
            // wait instead of always waiting the full debounce window.
            const strategy = getStrategy(state.serverId);
            const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
            const timeSincePush = hit ? Date.now() - hit : Infinity;
            const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
            debounceTimer = setTimeout(() => {
                state.diagnosticEmitter.off("diagnostics", onDiagnostics);
                clearTimeout(timeout);
                resolve();
            }, remaining);
        };
        state.diagnosticEmitter.on("diagnostics", onDiagnostics);
        const timeout = setTimeout(() => {
            if (debounceTimer)
                clearTimeout(debounceTimer);
            state.diagnosticEmitter.off("diagnostics", onDiagnostics);
            resolve();
        }, timeoutMs);
    });
}
export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
    if (!isClientAlive(state))
        return;
    const uri = pathToFileURL(filePath).href;
    const normalizedPath = normalizeMapKey(filePath);
    if (state.openDocuments.has(normalizedPath) ||
        state.pendingOpens.has(normalizedPath)) {
        const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
        state.documentVersions.set(normalizedPath, version);
        // preserveDiagnostics: skip cache clear for format-only resyncs so
        // waitForDiagnostics fast-paths instead of waiting up to 5s for TypeScript
        // to re-publish what it already knows (formatting doesn't change semantics).
        if (!preserveDiagnostics) {
            clearDiagnosticsForPath(state, normalizedPath);
        }
        // Scanners that only re-scan on a fresh open (opengrep ignores didChange):
        // close + reopen so the re-edit actually triggers a re-scan instead of
        // silently publishing nothing.
        if (getStrategy(state.serverId).reopenOnResync) {
            await safeSendNotification(state.connection, "textDocument/didClose", {
                textDocument: { uri },
            });
            state.openDocuments.delete(normalizedPath);
            state.documentVersions.set(normalizedPath, 0);
            if (!isClientAlive(state))
                return;
            await safeSendNotification(state.connection, "textDocument/didOpen", {
                textDocument: { uri, languageId, version: 0, text: content },
            });
            state.openDocuments.add(normalizedPath);
            return;
        }
        await safeSendNotification(state.connection, "textDocument/didChange", {
            textDocument: { uri, version },
            contentChanges: [{ text: content }],
        });
        return;
    }
    state.pendingOpens.add(normalizedPath);
    state.documentVersions.set(normalizedPath, 0);
    clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
    // Send workspace notification first (like opencode does).
    // Skipped in silent mode — cascade reads a file for diagnostics,
    // not reporting a real filesystem change. Avoids N project-wide
    // rechecks on push-diagnostics LSPs (TypeScript, Python) per CR-1.
    if (!silent) {
        // Async existence probe (was a synchronous existsSync on the document-open
        // path — a stat that blocks the loop during first-read/warm). The notify
        // type is unchanged: 2 (Changed) when the file exists on disk, else 1
        // (Created). access() rejects when absent.
        let fileExists = true;
        try {
            await access(filePath);
        }
        catch {
            fileExists = false;
        }
        // #271: enqueue instead of sending now — the per-client queue coalesces a
        // turn's file opens into a single notification, so push-diagnostics servers
        // re-analyze the project once per burst rather than once per file. didOpen
        // (below) still carries this file's content immediately, so the open
        // document is analyzed without waiting on the batched watcher notify.
        state.watchQueue.enqueue(uri, fileExists ? 2 : 1);
    }
    if (!isClientAlive(state))
        return;
    await safeSendNotification(state.connection, "textDocument/didOpen", {
        textDocument: { uri, languageId, version: 0, text: content },
    });
    state.pendingOpens.delete(normalizedPath);
    state.openDocuments.add(normalizedPath);
}
export async function handleNotifyChange(state, filePath, content) {
    if (!isClientAlive(state))
        return;
    const uri = pathToFileURL(filePath).href;
    const normalizedPath = normalizeMapKey(filePath);
    if (!state.openDocuments.has(normalizedPath)) {
        // Safety fallback: keep protocol ordering valid even if caller sends
        // didChange before first didOpen for this document.
        await safeSendNotification(state.connection, "textDocument/didOpen", {
            textDocument: { uri, languageId: "plaintext", version: 0, text: content },
        });
        state.documentVersions.set(normalizedPath, 0);
        state.openDocuments.add(normalizedPath);
        return;
    }
    const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
    state.documentVersions.set(normalizedPath, version);
    // Clear stale diagnostics before sending new content so waitForDiagnostics
    // doesn't return immediately with the previous edit's results.
    clearDiagnosticsForPath(state, normalizedPath);
    await safeSendNotification(state.connection, "textDocument/didChange", {
        textDocument: { uri, version },
        contentChanges: [{ text: content }],
    });
}
export async function clientShutdown(state, options = {}) {
    state.shutdownRequested = true;
    state.isConnected = false;
    state.isDestroyed = true;
    for (const timer of state.pendingDiagnostics.values()) {
        clearTimeout(timer);
    }
    state.pendingDiagnostics.clear();
    state.pendingOpens.clear();
    state.openDocuments.clear();
    // #271: drop any pending watched-files batch + its timer (a dying client's
    // queued FS changes are moot, and the timer must not outlive the connection).
    state.watchQueue?.cancel();
    state.diagnosticEmitter.removeAllListeners();
    if (!options.fast) {
        try {
            await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
        }
        catch {
            /* ignore — proceed to exit/kill so shutdown cannot hang the session */
        }
        try {
            await safeSendNotification(state.connection, "exit", {});
        }
        catch {
            /* ignore */
        }
    }
    disposeClientConnection(state);
    const pid = state.lspProcess.pid;
    // #449/#472: deregister this LSP child from the instance registry. Fire-
    // and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
    // including the `processExiting` path where the event loop is closing
    // (#234 forbids spawning here, but a plain fs write/rename is fine; even
    // so, we don't await it to keep this teardown path as fast as before).
    void removeLspChild(pid).catch(() => {
        // best-effort — a stale entry is caught dead-pid by the reaper later
    });
    // On Windows, killing the direct child first can orphan grandchildren before
    // taskkill can traverse the tree. Kill the full tree first and wait briefly.
    await killProcessTree(state.lspProcess.process, pid, options);
}
/**
 * Translate a caller-supplied (UTF-16) `(line, character)` into the position the
 * server expects under its negotiated encoding (#269). UTF-16 is the identity —
 * the common case pays nothing (no I/O). For UTF-8/UTF-32 we read the target
 * line from disk (pi edits files on disk before navigating, so disk == the
 * server's content) and re-measure the character offset; a read failure falls
 * back to the raw offset rather than dropping the request.
 */
async function toWirePosition(state, filePath, line, character) {
    if (state.positionEncoding === "utf-16")
        return { line, character };
    try {
        const content = await readFile(filePath, "utf8");
        return {
            line,
            character: convertCharacterOffset(state.positionEncoding, lineTextAt(content, line), character),
        };
    }
    catch {
        return { line, character };
    }
}
// #276: drop a navigation result whose document was edited while the request was
// in flight. Mirrors the diagnostics-path staleness check (isVersionStale) which
// compares the version computed-against to the latest didChange. Default on;
// PI_LENS_LSP_NAV_STALE_DROP=0 disables it if it ever over-drops.
function navStaleDropEnabled() {
    return process.env.PI_LENS_LSP_NAV_STALE_DROP !== "0";
}
// Exported for the timeout regression tests (#365). `timeoutMs` overrides the
// per-request ceiling so a test can bound a hung server quickly.
export async function navRequest(state, method, params, 
// When provided, the request is dropped if the document's version advances
// (an edit landed) between send and response. Omit for non-single-file
// requests (workspaceSymbol, call-hierarchy follow-ups) that have no version.
staleCheckPath, timeoutMs = NAV_REQUEST_TIMEOUT_MS, 
// Cancels the in-flight request (LSP `$/cancelRequest`) when the turn is
// abandoned. Defaults to the ambient abort signal set around dispatch/tool
// handling, so callers get cancellation for free without a signature change
// (#238 Item 1). Pass explicitly in tests.
signal = getAmbientAbortSignal()) {
    if (!isClientAlive(state))
        return null;
    const normalizedPath = staleCheckPath !== undefined ? normalizeMapKey(staleCheckPath) : undefined;
    const requestVersion = normalizedPath !== undefined
        ? state.documentVersions.get(normalizedPath)
        : undefined;
    const result = (await withTimeout(safeSendRequest(state.connection, method, params, signal), timeoutMs).catch((err) => {
        if (err instanceof Error && err.message.startsWith("Timeout after")) {
            return undefined;
        }
        throw err;
    }));
    // requestVersion === undefined (never opened, or version-less) → unaffected,
    // matching the diagnostics path; the request timeout remains the backstop.
    if (normalizedPath !== undefined &&
        requestVersion !== undefined &&
        navStaleDropEnabled()) {
        const currentVersion = state.documentVersions.get(normalizedPath);
        if (currentVersion !== undefined && currentVersion > requestVersion) {
            return undefined;
        }
    }
    return result;
}
// Run an advertised server command via workspace/executeCommand, with the
// generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
// hardening invariants: allowlist-by-advertisement (only commands the server
// declared) and the serverEditsAllowed window that gates server-driven
// applyEdit to the duration of an explicit call. Exported with an overridable
// `timeoutMs` for the #365 regression tests.
export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS) {
    if (!isClientAlive(state)) {
        return { executed: false, reason: "lsp client not alive" };
    }
    if (!state.advertisedCommands.has(command)) {
        return {
            executed: false,
            reason: `command "${command}" is not advertised by the ${state.serverId} server`,
        };
    }
    state.serverEditsAllowed += 1;
    try {
        let result;
        try {
            result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
                command,
                arguments: args ?? [],
            }), timeoutMs);
        }
        catch (err) {
            // Generous backstop only: a timeout means the server is hung (or the
            // command is running longer than the ceiling). Surface it honestly — the
            // command may still be applying — instead of hanging the caller. Real
            // (non-timeout) errors still propagate.
            if (err instanceof Error && err.message.startsWith("Timeout after")) {
                return {
                    executed: false,
                    reason: `workspace/executeCommand timed out after ${timeoutMs}ms — the command may still be applying server-side`,
                };
            }
            throw err;
        }
        return { executed: true, result };
    }
    finally {
        state.serverEditsAllowed -= 1;
    }
}
async function resolveCodeActionBestEffort(state, action) {
    if (!isClientAlive(state) || action.edit)
        return action;
    try {
        const resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
        if (!resolved || typeof resolved !== "object")
            return action;
        return { ...action, ...resolved };
    }
    catch {
        // codeAction/resolve is optional. Keep the original lightweight action when
        // the server does not support resolve or fails to populate an edit.
        return action;
    }
}
// --- Client Factory ---
export async function createLSPClient(options) {
    installCrashGuard();
    const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
    // #449/#472: register this LSP child in the cross-process instance registry
    // as soon as we have a live pid — BEFORE `initialize` completes, not after.
    // Registering early means a child that dies/hangs during initialize (the
    // catch block below kills it) is still deregistered by that same path via
    // removeLspChild, and a process that crashes mid-initialize is still
    // visible to the orphan reaper rather than silently untracked. Fire-and-
    // forget: registry I/O must never block or fail LSP startup.
    void recordLspChild({
        pid: lspProcess.pid,
        serverId,
        command: lspProcess.command,
        marker: extractSpawnMarker(lspProcess.args),
    }).catch(() => {
        // best-effort observability — never fail LSP startup over this
    });
    const startupState = {
        exitCode: null,
        exitSignal: null,
        closeCode: null,
        closeSignal: null,
        stderr: "",
    };
    // Persistent stderr ring buffer — captures last ~100 lines for diagnostics.
    // Used in error messages to show what the server said before dying.
    const stderrRing = [];
    const MAX_STDERR_LINES = 100;
    const onStderr = (chunk) => {
        stderrRing.push(chunk.toString());
        if (stderrRing.length > MAX_STDERR_LINES)
            stderrRing.shift();
        // Also capture startup stderr for the initialized-failed error path
        if (startupState.stderr.length < 4096) {
            startupState.stderr += chunk.toString();
        }
    };
    const recentStderr = (lines = 10) => stderrRing.slice(-lines).join("").trim();
    // Pre-request health check — returns error string if process is dead.
    const checkProcessAlive = () => {
        const exited = lspProcess.process.exitCode;
        if (exited !== null) {
            const tail = recentStderr(20);
            return `LSP server ${serverId} exited with code ${exited}${tail ? `. stderr: ${tail}` : ""}`;
        }
        if (lspProcess.process.killed) {
            return `LSP server ${serverId} was killed`;
        }
        return undefined;
    };
    const onProcessExit = (code, signal) => {
        startupState.exitCode = code;
        startupState.exitSignal = signal;
    };
    const onProcessClose = (code, signal) => {
        startupState.closeCode = code;
        startupState.closeSignal = signal;
    };
    lspProcess.stderr.on("data", onStderr);
    lspProcess.process.on("exit", onProcessExit);
    lspProcess.process.on("close", onProcessClose);
    // Attach persistent 'error' listeners to all three stdio streams.
    //
    // Why: when the LSP process exits, Node.js destroys its stdio streams and
    // may emit 'error' (ERR_STREAM_DESTROYED / EPIPE / ECONNRESET) on them.
    // Without a listener that becomes an uncaught exception.
    //
    // vscode-jsonrpc covers stdin/stdout during the connection lifetime but
    // removes its listeners on dispose(). Our permanent listeners cover the gap.
    const streamErrorHandler = (_label) => (err) => {
        if (err.code === "ERR_STREAM_DESTROYED" ||
            err.code === "ERR_STREAM_WRITE_AFTER_END" ||
            err.code === "EPIPE" ||
            err.code === "ECONNRESET")
            return;
    };
    lspProcess.stdin.on("error", streamErrorHandler("stdin"));
    lspProcess.stdout.on("error", streamErrorHandler("stdout"));
    lspProcess.stderr.on("error", streamErrorHandler("stderr"));
    const connection = createMessageConnection(new StreamMessageReader(lspProcess.stdout), new StreamMessageWriter(lspProcess.stdin));
    // Local event emitter — signals waitForDiagnostics when new diagnostics arrive.
    // Scoped to this client instance. setMaxListeners guards against Node.js warning
    // for concurrent waitForDiagnostics calls.
    const diagnosticEmitter = new EventEmitter();
    diagnosticEmitter.setMaxListeners(50);
    const state = {
        isConnected: true,
        isDestroyed: false,
        shutdownRequested: false,
        connectionDisposed: false,
        lastError: undefined,
        connection,
        pushDiagnostics: new Map(),
        pushDiagnosticTimestamps: new Map(),
        documentPullDiagnostics: new Map(),
        documentPullDiagnosticTimestamps: new Map(),
        pendingDiagnostics: new Map(),
        diagnosticEmitter,
        diagnosticsVersion: 0,
        documentVersions: new Map(),
        diagnosticDocVersions: new Map(),
        openDocuments: new Set(),
        pendingOpens: new Set(),
        // these are filled in after initialize — cast to avoid two-phase init
        workspaceDiagnosticsSupport: undefined,
        operationSupport: undefined,
        staticDiagnosticsMode: "push-only",
        positionEncoding: "utf-16",
        dynamicRegistrations: new Map(),
        advertisedCommands: new Set(),
        serverEditsAllowed: 0,
        serverId,
        launchVariant,
        root,
        lspProcess,
        // two-phase: the flush closure needs `state` (below)
        watchQueue: undefined,
    };
    // #271: batch per-file workspace/didChangeWatchedFiles into one notification
    // per debounce window, so an N-file turn re-indexes the server once, not N×.
    state.watchQueue = new WatchedFilesQueue((changes) => {
        if (!isClientAlive(state))
            return;
        void safeSendNotification(state.connection, "workspace/didChangeWatchedFiles", { changes });
    });
    setupIncomingHandlers(state, initialization);
    connection.listen();
    setupConnectionLifecycle(state, recentStderr);
    let initResult;
    try {
        initResult = await withTimeout(safeSendRequest(connection, "initialize", {
            processId: process.pid,
            rootUri: pathToFileURL(root).href,
            workspaceFolders: [
                { name: "workspace", uri: pathToFileURL(root).href },
            ],
            capabilities: CLIENT_CAPABILITIES,
            initializationOptions: initialization,
        }), initializeTimeoutMs);
    }
    catch (err) {
        // Hard-kill the hung process so it doesn't become a zombie.
        // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
        const pid = lspProcess.pid;
        void killProcessTree(lspProcess.process, pid);
        // A child registered above (recordLspChild) but never reaching a healthy
        // createLSPClient return must still be deregistered here — otherwise the
        // registry keeps a stale entry for a process we just killed.
        void removeLspChild(pid).catch(() => {
            // best-effort — a stale registry entry is harmless (the reaper's
            // liveness check will find it dead on the next sweep regardless)
        });
        setTimeout(() => {
            if (!lspProcess.process.killed && process.platform !== "win32") {
                lspProcess.process.kill("SIGKILL");
            }
        }, 2000);
        throw err;
    }
    finally {
        lspProcess.stderr.off("data", onStderr);
    }
    if (initResult === undefined) {
        const compactStderr = startupState.stderr
            .replace(/\s+/g, " ")
            .trim()
            .slice(0, 320);
        const reinstallHint = serverId === "cpp"
            ? "Install clangd (LLVM/clang-tools) and ensure clangd.exe is on PATH."
            : `Try reinstalling: npm install -g ${serverId}-language-server.`;
        const telemetry = [
            `pid=${lspProcess.pid}`,
            `exitCode=${startupState.exitCode ?? "none"}`,
            `exitSignal=${startupState.exitSignal ?? "none"}`,
            `closeCode=${startupState.closeCode ?? "none"}`,
            `closeSignal=${startupState.closeSignal ?? "none"}`,
            `root=${root}`,
            compactStderr ? `stderr=${compactStderr}` : "stderr=<empty>",
        ].join(" ");
        throw new Error(`[lsp] ${serverId} failed to initialize - stream may have been destroyed. ` +
            `The server binary may be missing or crashed immediately. ${reinstallHint} ` +
            `telemetry: ${telemetry}`);
    }
    state.workspaceDiagnosticsSupport =
        detectWorkspaceDiagnosticsSupport(initResult);
    state.operationSupport = detectOperationSupport(initResult);
    state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
    state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
        {}).sort((a, b) => a.localeCompare(b));
    for (const cmd of detectExecuteCommands(initResult)) {
        state.advertisedCommands.add(cmd);
    }
    state.staticDiagnosticsMode = state.workspaceDiagnosticsSupport.mode;
    await safeSendNotification(connection, "initialized", {});
    if (initialization) {
        await safeSendNotification(connection, "workspace/didChangeConfiguration", {
            settings: initialization,
        });
    }
    return {
        serverId,
        root,
        connection,
        isAlive: () => isClientAlive(state),
        /** True if the server process has exited or been killed. */
        processExited: () => lspProcess.process.exitCode !== null ||
            lspProcess.process.killed === true,
        /** Last N lines of server stderr for diagnostics. */
        recentStderr: (lines) => recentStderr(lines),
        /** Pre-request health check — returns error string if dead. */
        checkAlive: () => checkProcessAlive(),
        notify: {
            async open(filePath, content, languageId, preserveDiagnostics, silent) {
                return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent);
            },
            async change(filePath, content) {
                return handleNotifyChange(state, filePath, content);
            },
        },
        getDiagnostics(filePath) {
            return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
        },
        getAllDiagnostics() {
            const result = new Map();
            const keys = new Set([
                ...state.pushDiagnostics.keys(),
                ...state.documentPullDiagnostics.keys(),
            ]);
            for (const key of keys) {
                result.set(key, {
                    diags: getMergedDiagnosticsForPath(state, key),
                    ts: Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0),
                });
            }
            return result;
        },
        getTrackedDiagnosticPaths() {
            return [
                ...new Set([
                    ...state.pushDiagnostics.keys(),
                    ...state.documentPullDiagnostics.keys(),
                ]),
            ];
        },
        pruneDiagnostics(predicate) {
            let removed = 0;
            const keys = new Set([
                ...state.pushDiagnostics.keys(),
                ...state.documentPullDiagnostics.keys(),
            ]);
            for (const key of keys) {
                const diags = getMergedDiagnosticsForPath(state, key);
                const ts = Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0);
                if (!predicate(key, ts, diags))
                    continue;
                clearDiagnosticsForPath(state, key);
                removed++;
            }
            return removed;
        },
        getWorkspaceDiagnosticsSupport() {
            return state.workspaceDiagnosticsSupport;
        },
        requestWorkspaceDiagnostics(budgetMs) {
            return clientRequestWorkspaceDiagnostics(state, budgetMs);
        },
        getOperationSupport() {
            return state.operationSupport;
        },
        getAdvertisedCommands() {
            return [...state.advertisedCommands];
        },
        getRawCapabilityKeys() {
            return state.rawCapabilityKeys ?? [];
        },
        getLaunchVariant() {
            return state.launchVariant;
        },
        async executeCommand(command, args) {
            return runServerCommand(state, command, args);
        },
        get diagnosticsVersion() {
            return state.diagnosticsVersion;
        },
        async waitForDiagnostics(filePath, timeoutMs = DIAGNOSTICS_WAIT_TIMEOUT_MS, options) {
            return clientWaitForDiagnostics(state, filePath, timeoutMs, options);
        },
        async definition(filePath, line, character) {
            const result = await navRequest(state, "textDocument/definition", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
            }, filePath);
            if (!result)
                return [];
            return Array.isArray(result) ? result : [result];
        },
        async typeDefinition(filePath, line, character) {
            const result = await navRequest(state, "textDocument/typeDefinition", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
            }, filePath);
            if (!result)
                return [];
            return Array.isArray(result) ? result : [result];
        },
        async declaration(filePath, line, character) {
            const result = await navRequest(state, "textDocument/declaration", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
            }, filePath);
            if (!result)
                return [];
            return Array.isArray(result) ? result : [result];
        },
        async references(filePath, line, character, includeDeclaration = true) {
            const result = await navRequest(state, "textDocument/references", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
                context: { includeDeclaration },
            }, filePath);
            return result ?? [];
        },
        async hover(filePath, line, character) {
            const result = await navRequest(state, "textDocument/hover", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
            }, filePath);
            return result ?? null;
        },
        async signatureHelp(filePath, line, character) {
            const result = await navRequest(state, "textDocument/signatureHelp", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
            }, filePath);
            return result ?? null;
        },
        async documentSymbol(filePath) {
            const result = await navRequest(state, "textDocument/documentSymbol", { textDocument: { uri: pathToFileURL(filePath).href } }, filePath);
            return result ?? [];
        },
        async workspaceSymbol(query) {
            if (!isClientAlive(state))
                return [];
            // Route through navRequest for the shared withTimeout ceiling — a hung
            // server would otherwise await forever (safeSendRequest only settles on
            // a reply or a destroyed stream). No staleCheckPath: not single-file.
            const result = await navRequest(state, "workspace/symbol", {
                query,
            });
            return result ?? [];
        },
        async codeAction(filePath, line, character, endLine, endCharacter) {
            if (!isClientAlive(state))
                return [];
            const uri = pathToFileURL(filePath).href;
            // navRequest adds the shared withTimeout ceiling + single-file
            // stale-drop (matches documentSymbol); a hung server no longer awaits
            // forever, and code actions computed against superseded content drop.
            const result = await navRequest(state, "textDocument/codeAction", {
                textDocument: { uri },
                range: {
                    start: await toWirePosition(state, filePath, line, character),
                    end: await toWirePosition(state, filePath, endLine, endCharacter),
                },
                context: {
                    diagnostics: getMergedDiagnosticsForPath(state, normalizeMapKey(filePath)),
                },
            }, filePath);
            if (!result || !Array.isArray(result))
                return [];
            const actions = result.filter((item) => typeof item === "object" && item !== null && "title" in item);
            return Promise.all(actions.map((action) => resolveCodeActionBestEffort(state, action)));
        },
        async rename(filePath, line, character, newName) {
            const result = await navRequest(state, "textDocument/rename", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
                newName,
            }, filePath);
            return result ?? null;
        },
        async willRenameFiles(oldFilePath, newFilePath) {
            const result = await navRequest(state, "workspace/willRenameFiles", {
                files: [
                    {
                        oldUri: pathToFileURL(oldFilePath).href,
                        newUri: pathToFileURL(newFilePath).href,
                    },
                ],
            });
            return result ?? null;
        },
        async didRenameFiles(oldFilePath, newFilePath) {
            if (!isClientAlive(state))
                return;
            await safeSendNotification(state.connection, "workspace/didRenameFiles", {
                files: [
                    {
                        oldUri: pathToFileURL(oldFilePath).href,
                        newUri: pathToFileURL(newFilePath).href,
                    },
                ],
            });
        },
        async implementation(filePath, line, character) {
            const result = await navRequest(state, "textDocument/implementation", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
            }, filePath);
            if (!result)
                return [];
            return Array.isArray(result) ? result : [result];
        },
        async prepareCallHierarchy(filePath, line, character) {
            const result = await navRequest(state, "textDocument/prepareCallHierarchy", {
                textDocument: { uri: pathToFileURL(filePath).href },
                position: await toWirePosition(state, filePath, line, character),
            }, filePath);
            if (!result)
                return [];
            return Array.isArray(result) ? result : [result];
        },
        async incomingCalls(item) {
            const result = await navRequest(state, "callHierarchy/incomingCalls", { item });
            return result ?? [];
        },
        async outgoingCalls(item) {
            const result = await navRequest(state, "callHierarchy/outgoingCalls", { item });
            return result ?? [];
        },
        async shutdown(options) {
            return clientShutdown(state, options);
        },
    };
}
// Helper to safely send notifications - catches stream destruction
async function safeSendNotification(connection, method, params) {
    try {
        await connection.sendNotification(method, params);
    }
    catch (err) {
        if (isStreamError(err)) {
            // Silently ignore - stream was destroyed, connection error handlers will update state
            return;
        }
        throw err;
    }
}
// Helper to safely send requests - catches stream destruction
async function safeSendRequest(connection, method, params, 
// When provided, aborting the signal cancels the in-flight request via
// vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
// so a server stops computing a result the agent has already abandoned (#238
// Item 1). The rejection that follows is swallowed (treated as `undefined`).
signal) {
    // Already abandoned before we even sent — don't bother the server.
    if (signal?.aborted)
        return undefined;
    let tokenSource;
    let onAbort;
    if (signal) {
        tokenSource = new CancellationTokenSource();
        onAbort = () => tokenSource?.cancel();
        signal.addEventListener("abort", onAbort, { once: true });
    }
    // Only pass a token when cancellation is wired, so the call shape is unchanged
    // for the (many) requests without a signal.
    const send = () => tokenSource
        ? connection.sendRequest(method, params, tokenSource.token)
        : connection.sendRequest(method, params);
    try {
        // One safe retry on ContentModified (-32801): the document changed under
        // us, so the server discarded the request. A single retry beats returning
        // empty — correctness-under-edit is pi-lens's whole hot path (#238 Item 2).
        const MAX_ATTEMPTS = 2;
        for (let attempt = 1;; attempt++) {
            try {
                return (await send());
            }
            catch (err) {
                if (isStreamError(err) || isCancellationError(err)) {
                    // Stream destroyed, or we cancelled the request on abort — either
                    // way there is no result to return.
                    return undefined;
                }
                if (isContentModifiedError(err)) {
                    // Retry once (unless we've since been aborted); if it's still
                    // ContentModified after that, return empty rather than throwing a
                    // code callers don't understand. RequestFailed (-32803) and other
                    // codes are permanent and fall through to the rethrow below.
                    if (attempt < MAX_ATTEMPTS && !signal?.aborted)
                        continue;
                    return undefined;
                }
                throw err;
            }
        }
    }
    finally {
        if (signal && onAbort)
            signal.removeEventListener("abort", onAbort);
        tokenSource?.dispose();
    }
}
// vscode-jsonrpc rejects a token-cancelled request with a `ResponseError` whose
// code is `RequestCancelled` (-32800) or `ServerCancelled` (-32802). Treat both
// as "no result" rather than a failure. (isStreamError also matches the
// "cancelled" message text; this adds the structured error-code path.)
function isCancellationError(err) {
    const code = err?.code;
    return code === -32800 || code === -32802;
}
// `ContentModified` (-32801): the document changed while the request was in
// flight, so the server couldn't answer against a consistent state. Retryable —
// the only LSP error code worth a second attempt on the edit hot path (#238).
function isContentModifiedError(err) {
    return err?.code === -32801;
}
// Helper to detect stream destruction / connection disposal errors.
// vscode-jsonrpc throws these when the LSP server process exits while
// requests are still in flight:
//   "Connection is disposed."
//   "Pending response rejected since connection got disposed"
// Neither phrase contains "stream", "destroyed", or "closed", which is
// why we must also match "disposed" and "cancelled" here.
function isStreamError(err) {
    if (!(err instanceof Error))
        return false;
    const msg = err.message.toLowerCase();
    return (msg.includes("stream") ||
        msg.includes("destroyed") ||
        msg.includes("closed") ||
        msg.includes("disposed") ||
        msg.includes("cancelled") ||
        err.code === "ERR_STREAM_DESTROYED" ||
        err.code === "ERR_STREAM_WRITE_AFTER_END" ||
        err.code === "EPIPE");
}
// Using shared path utilities from path-utils.ts
function positiveIntFromEnv(name, fallback) {
    const raw = process.env[name];
    if (!raw)
        return fallback;
    const parsed = Number.parseInt(raw, 10);
    if (!Number.isFinite(parsed) || parsed <= 0)
        return fallback;
    return parsed;
}
function detectWorkspaceDiagnosticsSupport(initResult) {
    const capabilities = typeof initResult === "object" && initResult !== null
        ? initResult.capabilities
        : undefined;
    const diagnosticProvider = capabilities?.diagnosticProvider;
    if (!diagnosticProvider) {
        return {
            advertised: false,
            mode: "push-only",
            workspaceDiagnostics: false,
            diagnosticProviderKind: "none",
        };
    }
    if (typeof diagnosticProvider === "boolean") {
        return {
            advertised: diagnosticProvider,
            mode: diagnosticProvider ? "pull" : "push-only",
            // The boolean form of diagnosticProvider only signals document pull.
            workspaceDiagnostics: false,
            diagnosticProviderKind: "boolean",
        };
    }
    if (typeof diagnosticProvider === "object") {
        return {
            advertised: true,
            mode: "pull",
            workspaceDiagnostics: diagnosticProvider
                .workspaceDiagnostics === true,
            diagnosticProviderKind: "object",
        };
    }
    return {
        advertised: false,
        mode: "push-only",
        workspaceDiagnostics: false,
        diagnosticProviderKind: typeof diagnosticProvider,
    };
}
function detectExecuteCommands(initResult) {
    const capabilities = typeof initResult === "object" && initResult !== null
        ? initResult.capabilities
        : undefined;
    const provider = capabilities?.executeCommandProvider;
    if (typeof provider !== "object" || provider === null)
        return [];
    const commands = provider.commands;
    if (!Array.isArray(commands))
        return [];
    return commands.filter((cmd) => typeof cmd === "string");
}
function detectOperationSupport(initResult) {
    const capabilities = typeof initResult === "object" && initResult !== null
        ? initResult.capabilities
        : undefined;
    const hasProvider = (key) => {
        const value = capabilities?.[key];
        if (value === undefined || value === null)
            return false;
        if (typeof value === "boolean")
            return value;
        return true;
    };
    return {
        definition: hasProvider("definitionProvider"),
        typeDefinition: hasProvider("typeDefinitionProvider"),
        declaration: hasProvider("declarationProvider"),
        references: hasProvider("referencesProvider"),
        hover: hasProvider("hoverProvider"),
        signatureHelp: hasProvider("signatureHelpProvider"),
        documentSymbol: hasProvider("documentSymbolProvider"),
        workspaceSymbol: hasProvider("workspaceSymbolProvider"),
        codeAction: hasProvider("codeActionProvider"),
        rename: hasProvider("renameProvider"),
        implementation: hasProvider("implementationProvider"),
        callHierarchy: hasProvider("callHierarchyProvider"),
    };
}