refactor(coding-agent): reduced lsp index to a composition barrel
- src/lsp/index.ts is the explicit ./lsp package entry, yet held 2821 lines of warmup, config caching, diagnostics, external build-command workspace diagnostics, the writethrough batching subsystem and the LspTool class. - Those are now servers, diagnostics, workspace-diagnostics, writethrough and tool modules; index.ts is 22 lines and re-exports the same public surface. - configCache and writethroughBatches remain single instances and every tuned diagnostics timing constant moved verbatim.
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { formatPathRelativeToCwd } from "../tools/path-utils";
|
||||
import { throwIfAborted } from "../tools/tool-errors";
|
||||
import { getOrCreateClient, sendRequest, supportsDocumentDiagnostics, waitForProjectLoaded } from "./client";
|
||||
import { getLinterClient } from "./clients";
|
||||
import { hasRootMarkerAncestor } from "./config";
|
||||
import { applyTextEditsToString } from "./edits";
|
||||
import { resolveFormatOptions } from "./format-options";
|
||||
import { isProjectAwareLspServer } from "./servers";
|
||||
import type {
|
||||
Diagnostic,
|
||||
Location,
|
||||
LocationLink,
|
||||
LspClient,
|
||||
Position,
|
||||
PublishedDiagnostics,
|
||||
ServerConfig,
|
||||
TextEdit,
|
||||
} from "./types";
|
||||
import {
|
||||
fileToUri,
|
||||
formatDiagnostic,
|
||||
formatDiagnosticsSummary,
|
||||
formatLocation,
|
||||
readLocationContext,
|
||||
sortDiagnostics,
|
||||
uriToFile,
|
||||
} from "./utils";
|
||||
|
||||
const DIAGNOSTIC_MESSAGE_LIMIT = 50;
|
||||
export const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
|
||||
export const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
|
||||
const DIAGNOSTICS_POLL_MS = 100;
|
||||
const DIAGNOSTICS_SETTLE_MS = 250;
|
||||
/**
|
||||
* How long the edit/write writethrough blocks inline waiting for fresh
|
||||
* diagnostics before handing slow servers off to the deferred late-injection
|
||||
* channel. Keeps the common fast-server case inline while letting an edit
|
||||
* return promptly when a server (e.g. a large-monorepo tsserver) is slow to
|
||||
* publish fresh diagnostics.
|
||||
*/
|
||||
export const INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 500;
|
||||
/**
|
||||
* Inner per-server diagnostics wait budget for the background/deferred fetch.
|
||||
* Longer than the inline cap (and the old 3s default) so a slow server still
|
||||
* delivers late instead of giving up before it ever publishes.
|
||||
*/
|
||||
export const DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS = 12_000;
|
||||
export const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
|
||||
export const WORKSPACE_SYMBOL_LIMIT = 200;
|
||||
export const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
|
||||
"definition",
|
||||
"type_definition",
|
||||
"implementation",
|
||||
"references",
|
||||
"rename",
|
||||
"hover",
|
||||
]);
|
||||
|
||||
const RUST_WORKSPACE_MARKERS = ["Cargo.toml", "rust-analyzer.toml"] as const;
|
||||
|
||||
export function hasRustWorkspaceAncestor(filePath: string): boolean {
|
||||
let dir = path.dirname(filePath);
|
||||
while (true) {
|
||||
for (const marker of RUST_WORKSPACE_MARKERS) {
|
||||
if (fs.existsSync(path.join(dir, marker))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
return false;
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
export function limitDiagnosticMessages(messages: string[]): string[] {
|
||||
if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
|
||||
return messages;
|
||||
}
|
||||
return messages.slice(0, DIAGNOSTIC_MESSAGE_LIMIT);
|
||||
}
|
||||
|
||||
const ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES: Record<number, true> = {
|
||||
1375: true,
|
||||
1378: true,
|
||||
2307: true,
|
||||
2580: true,
|
||||
2591: true,
|
||||
2792: true,
|
||||
2867: true,
|
||||
};
|
||||
|
||||
function diagnosticCodeNumber(diagnostic: Diagnostic): number | null {
|
||||
if (typeof diagnostic.code === "number") return diagnostic.code;
|
||||
if (typeof diagnostic.code === "string" && /^\d+$/.test(diagnostic.code)) return Number(diagnostic.code);
|
||||
return null;
|
||||
}
|
||||
function isTypeScriptProjectDiagnostic(serverName: string, diagnostic: Diagnostic): boolean {
|
||||
if (diagnostic.source !== "typescript" && !serverName.toLowerCase().includes("typescript")) {
|
||||
return false;
|
||||
}
|
||||
const code = diagnosticCodeNumber(diagnostic);
|
||||
return code !== null && ORPHAN_TYPESCRIPT_PROJECT_DIAGNOSTIC_CODES[code] === true;
|
||||
}
|
||||
|
||||
function filterOrphanProjectDiagnostics(
|
||||
absolutePath: string,
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
diagnostics: Diagnostic[],
|
||||
): Diagnostic[] {
|
||||
if (!serverConfig.rootMarkers.length || hasRootMarkerAncestor(absolutePath, serverConfig.rootMarkers)) {
|
||||
return diagnostics;
|
||||
}
|
||||
return diagnostics.filter(diagnostic => !isTypeScriptProjectDiagnostic(serverName, diagnostic));
|
||||
}
|
||||
|
||||
const LOCATION_CONTEXT_LINES = 1;
|
||||
export const REFERENCE_CONTEXT_LIMIT = 50;
|
||||
|
||||
export const REFERENCES_RETRY_COUNT = 2;
|
||||
export const REFERENCES_RETRY_DELAY_MS = 250;
|
||||
|
||||
function comparePosition(a: Position, b: Position): number {
|
||||
return a.line === b.line ? a.character - b.character : a.line - b.line;
|
||||
}
|
||||
|
||||
function rangeContainsPosition(range: Location["range"], position: Position): boolean {
|
||||
return comparePosition(range.start, position) <= 0 && comparePosition(position, range.end) <= 0;
|
||||
}
|
||||
|
||||
export function isOnlyQueriedDeclaration(locations: Location[], uri: string, position: Position): boolean {
|
||||
return locations.length === 1 && locations[0]?.uri === uri && rangeContainsPosition(locations[0].range, position);
|
||||
}
|
||||
|
||||
export function normalizeLocationResult(
|
||||
result: Location | Location[] | LocationLink | LocationLink[] | null,
|
||||
): Location[] {
|
||||
if (!result) return [];
|
||||
const raw = Array.isArray(result) ? result : [result];
|
||||
return raw.flatMap(loc => {
|
||||
if ("uri" in loc) {
|
||||
return [loc as Location];
|
||||
}
|
||||
if ("targetUri" in loc) {
|
||||
const link = loc as LocationLink;
|
||||
return [{ uri: link.targetUri, range: link.targetSelectionRange ?? link.targetRange }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
export async function formatLocationWithContext(location: Location, cwd: string): Promise<string> {
|
||||
const header = ` ${formatLocation(location, cwd)}`;
|
||||
const context = await readLocationContext(
|
||||
uriToFile(location.uri),
|
||||
location.range.start.line + 1,
|
||||
LOCATION_CONTEXT_LINES,
|
||||
);
|
||||
if (context.length === 0) {
|
||||
return header;
|
||||
}
|
||||
return `${header}\n${context.map(lineText => ` ${lineText}`).join("\n")}`;
|
||||
}
|
||||
|
||||
interface WaitForDiagnosticsOptions {
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
minVersion?: number;
|
||||
expectedDocumentVersion?: number;
|
||||
/**
|
||||
* Quiescence window (ms). typescript-language-server never echoes the document
|
||||
* version (issue #983) and emits diagnostics from several sources at different
|
||||
* times, so there is no single "complete, version-matched" publish to gate on.
|
||||
* When the server does not exact-version-match, accept the latest publish only
|
||||
* after no newer one has arrived for this long, letting an in-flight pre-edit
|
||||
* publish be superseded by the fresh one.
|
||||
*/
|
||||
settleMs?: number;
|
||||
}
|
||||
|
||||
function requestDocumentDiagnostics(
|
||||
client: LspClient,
|
||||
uri: string,
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<Diagnostic[] | undefined> {
|
||||
return sendRequest(client, "textDocument/diagnostic", { textDocument: { uri } }, signal, timeoutMs)
|
||||
.then(report => {
|
||||
if (!report || typeof report !== "object" || !("kind" in report) || report.kind !== "full") {
|
||||
return undefined;
|
||||
}
|
||||
if (!("items" in report) || !Array.isArray(report.items)) return undefined;
|
||||
return report.items;
|
||||
})
|
||||
.catch(err => {
|
||||
if (!signal?.aborted) {
|
||||
logger.debug("LSP document diagnostic pull failed", { server: client.name, uri, error: String(err) });
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForDiagnostics(
|
||||
client: LspClient,
|
||||
uri: string,
|
||||
options: WaitForDiagnosticsOptions = {},
|
||||
): Promise<Diagnostic[]> {
|
||||
const { timeoutMs = 3000, signal, minVersion, expectedDocumentVersion, settleMs = DIAGNOSTICS_SETTLE_MS } = options;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let pullAttempted = false;
|
||||
let pullResultPromise: Promise<{ diagnostics: Diagnostic[] | undefined }> | undefined;
|
||||
let pulled: Diagnostic[] | undefined;
|
||||
let settledRef: PublishedDiagnostics | undefined;
|
||||
let settledAt = 0;
|
||||
while (Date.now() < deadline) {
|
||||
throwIfAborted(signal);
|
||||
if (!pullAttempted && supportsDocumentDiagnostics(client)) {
|
||||
pullAttempted = true;
|
||||
pullResultPromise = requestDocumentDiagnostics(client, uri, signal, Math.max(1, deadline - Date.now())).then(
|
||||
diagnostics => ({ diagnostics }),
|
||||
);
|
||||
}
|
||||
|
||||
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
|
||||
const published = client.diagnostics.get(uri);
|
||||
if (published && versionOk) {
|
||||
// Server honored our exact document version → authoritative, accept now.
|
||||
if (expectedDocumentVersion !== undefined && published.version === expectedDocumentVersion) {
|
||||
return published.diagnostics;
|
||||
}
|
||||
// Unversioned/mismatched publish: wait for the stream to go quiet so an
|
||||
// in-flight publish for the pre-edit content is superseded by the fresh one.
|
||||
if (published !== settledRef) {
|
||||
settledRef = published;
|
||||
settledAt = Date.now();
|
||||
} else if (Date.now() - settledAt >= settleMs) {
|
||||
return published.diagnostics;
|
||||
}
|
||||
}
|
||||
|
||||
const pollMs = Math.min(DIAGNOSTICS_POLL_MS, Math.max(0, deadline - Date.now()));
|
||||
if (!pullResultPromise) {
|
||||
await Bun.sleep(pollMs);
|
||||
continue;
|
||||
}
|
||||
const pullResult = await Promise.race([pullResultPromise, Bun.sleep(pollMs).then(() => undefined)]);
|
||||
if (pullResult) {
|
||||
pullResultPromise = undefined;
|
||||
pulled = pullResult.diagnostics;
|
||||
if (pulled !== undefined) break;
|
||||
}
|
||||
}
|
||||
|
||||
const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion;
|
||||
const published = client.diagnostics.get(uri);
|
||||
if (published && versionOk) {
|
||||
return published.diagnostics;
|
||||
}
|
||||
if (pullResultPromise) {
|
||||
pulled = (await pullResultPromise).diagnostics;
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
if (pulled === undefined) return [];
|
||||
client.diagnostics.set(uri, {
|
||||
diagnostics: pulled,
|
||||
version: expectedDocumentVersion ?? client.openFiles.get(uri)?.version ?? null,
|
||||
});
|
||||
client.diagnosticsVersion += 1;
|
||||
return pulled;
|
||||
}
|
||||
|
||||
/** Result from getDiagnosticsForFile */
|
||||
export interface FileDiagnosticsResult {
|
||||
/** Name of the LSP server used (if available) */
|
||||
server?: string;
|
||||
/** Formatted diagnostic messages */
|
||||
messages: string[];
|
||||
/** Summary string (e.g., "2 error(s), 1 warning(s)") */
|
||||
summary: string;
|
||||
/** Whether there are any errors (severity 1) */
|
||||
errored: boolean;
|
||||
/** Whether the file was formatted */
|
||||
formatter?: FileFormatResult;
|
||||
}
|
||||
|
||||
export type ServerVersionMap = Map<string, number>;
|
||||
|
||||
interface GetDiagnosticsForFileOptions {
|
||||
signal?: AbortSignal;
|
||||
minVersions?: ServerVersionMap;
|
||||
expectedDocumentVersions?: ServerVersionMap;
|
||||
/** Per-server wait budget (ms). Defaults to {@link SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS}. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture current diagnostic versions for all LSP servers.
|
||||
* Call this BEFORE syncing content to detect stale diagnostics later.
|
||||
*/
|
||||
export async function captureDiagnosticVersions(
|
||||
cwd: string,
|
||||
servers: Array<[string, ServerConfig]>,
|
||||
initTimeoutMs?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ServerVersionMap> {
|
||||
const versions = new Map<string, number>();
|
||||
await Promise.allSettled(
|
||||
servers.map(async ([serverName, serverConfig]) => {
|
||||
if (serverConfig.createClient) return;
|
||||
const client = await getOrCreateClient(serverConfig, cwd, initTimeoutMs, signal);
|
||||
versions.set(serverName, client.diagnosticsVersion);
|
||||
}),
|
||||
);
|
||||
return versions;
|
||||
}
|
||||
|
||||
export async function captureOpenFileVersions(
|
||||
absolutePath: string,
|
||||
cwd: string,
|
||||
servers: Array<[string, ServerConfig]>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ServerVersionMap> {
|
||||
const uri = fileToUri(absolutePath);
|
||||
const versions = new Map<string, number>();
|
||||
await Promise.allSettled(
|
||||
servers.map(async ([serverName, serverConfig]) => {
|
||||
const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
|
||||
const version = client.openFiles.get(uri)?.version;
|
||||
if (version !== undefined) {
|
||||
versions.set(serverName, version);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return versions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostics for a file using LSP or custom linter client.
|
||||
*
|
||||
* @param absolutePath - Absolute path to the file
|
||||
* @param cwd - Working directory for LSP config resolution
|
||||
* @param servers - Servers to query diagnostics for
|
||||
* @param minVersions - Minimum diagnostic versions per server (to detect stale results)
|
||||
* @returns Diagnostic results or undefined if no servers
|
||||
*/
|
||||
export async function getDiagnosticsForFile(
|
||||
absolutePath: string,
|
||||
cwd: string,
|
||||
servers: Array<[string, ServerConfig]>,
|
||||
options: GetDiagnosticsForFileOptions = {},
|
||||
): Promise<FileDiagnosticsResult | undefined> {
|
||||
const { signal, minVersions, expectedDocumentVersions, timeoutMs } = options;
|
||||
if (servers.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const uri = fileToUri(absolutePath);
|
||||
const relPath = formatPathRelativeToCwd(absolutePath, cwd);
|
||||
const allDiagnostics: Diagnostic[] = [];
|
||||
const serverNames: string[] = [];
|
||||
|
||||
// Wait for diagnostics from all servers in parallel
|
||||
const results = await Promise.allSettled(
|
||||
servers.map(async ([serverName, serverConfig]) => {
|
||||
throwIfAborted(signal);
|
||||
// Use custom linter client if configured
|
||||
if (serverConfig.createClient) {
|
||||
const linterClient = getLinterClient(serverName, serverConfig, cwd);
|
||||
const diagnostics = await linterClient.lint(absolutePath);
|
||||
return { serverName, serverConfig, diagnostics };
|
||||
}
|
||||
|
||||
// Default: use LSP
|
||||
const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
|
||||
throwIfAborted(signal);
|
||||
if (isProjectAwareLspServer(serverConfig)) {
|
||||
await waitForProjectLoaded(client, signal);
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
// Content already synced + didSave sent, wait for fresh diagnostics
|
||||
const minVersion = minVersions?.get(serverName);
|
||||
const expectedDocumentVersion = expectedDocumentVersions?.get(serverName);
|
||||
const diagnostics = await waitForDiagnostics(client, uri, {
|
||||
timeoutMs: timeoutMs ?? SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
signal,
|
||||
minVersion,
|
||||
expectedDocumentVersion,
|
||||
});
|
||||
return { serverName, serverConfig, diagnostics };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
serverNames.push(result.value.serverName);
|
||||
allDiagnostics.push(
|
||||
...filterOrphanProjectDiagnostics(
|
||||
absolutePath,
|
||||
result.value.serverName,
|
||||
result.value.serverConfig,
|
||||
result.value.diagnostics,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (allDiagnostics.length === 0) {
|
||||
return {
|
||||
server: serverNames.join(", "),
|
||||
messages: [],
|
||||
summary: "OK",
|
||||
errored: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Deduplicate diagnostics by range + message (different servers might report similar issues)
|
||||
const seen = new Set<string>();
|
||||
const uniqueDiagnostics: Diagnostic[] = [];
|
||||
for (const d of allDiagnostics) {
|
||||
const key = `${d.range.start.line}:${d.range.start.character}:${d.range.end.line}:${d.range.end.character}:${d.message}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueDiagnostics.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
sortDiagnostics(uniqueDiagnostics);
|
||||
const formatted = uniqueDiagnostics.map(d => formatDiagnostic(d, relPath));
|
||||
const limited = limitDiagnosticMessages(formatted);
|
||||
const summary = formatDiagnosticsSummary(uniqueDiagnostics);
|
||||
const hasErrors = uniqueDiagnostics.some(d => d.severity === 1);
|
||||
|
||||
return {
|
||||
server: serverNames.join(", "),
|
||||
messages: limited,
|
||||
summary,
|
||||
errored: hasErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export enum FileFormatResult {
|
||||
UNCHANGED = "unchanged",
|
||||
FORMATTED = "formatted",
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content using LSP or custom linter client.
|
||||
*
|
||||
* @param absolutePath - Absolute path (for URI)
|
||||
* @param content - Content to format
|
||||
* @param cwd - Working directory for LSP config resolution
|
||||
* @param servers - Servers to try formatting with
|
||||
* @returns Formatted content, or original if no formatter available
|
||||
*/
|
||||
export async function formatContent(
|
||||
absolutePath: string,
|
||||
content: string,
|
||||
cwd: string,
|
||||
servers: Array<[string, ServerConfig]>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
if (servers.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const uri = fileToUri(absolutePath);
|
||||
|
||||
for (const [serverName, serverConfig] of servers) {
|
||||
try {
|
||||
throwIfAborted(signal);
|
||||
// Use custom linter client if configured
|
||||
if (serverConfig.createClient) {
|
||||
const linterClient = getLinterClient(serverName, serverConfig, cwd);
|
||||
return await linterClient.format(absolutePath, content);
|
||||
}
|
||||
|
||||
// Default: use LSP
|
||||
const client = await getOrCreateClient(serverConfig, cwd, undefined, signal);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const caps = client.serverCapabilities;
|
||||
if (!caps?.documentFormattingProvider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Request formatting (content already synced)
|
||||
const edits = (await sendRequest(
|
||||
client,
|
||||
"textDocument/formatting",
|
||||
{
|
||||
textDocument: { uri },
|
||||
options: resolveFormatOptions(absolutePath, content),
|
||||
},
|
||||
signal,
|
||||
)) as TextEdit[] | null;
|
||||
|
||||
if (!edits || edits.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Apply edits in-memory and return
|
||||
return applyTextEditsToString(content, edits);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { throwIfAborted } from "../tools/tool-errors";
|
||||
import {
|
||||
getActiveClients,
|
||||
getActiveOrPendingClient,
|
||||
getOrCreateClient,
|
||||
type LspServerStatus,
|
||||
notifySaved,
|
||||
sendNotification,
|
||||
sendRequest,
|
||||
setIdleTimeout,
|
||||
shutdownClientInstance,
|
||||
syncContent,
|
||||
WARMUP_TIMEOUT_MS,
|
||||
} from "./client";
|
||||
import { getServersForFile, type LspConfig, loadConfig } from "./config";
|
||||
import { MUX_RESTART_METHOD } from "./mux/protocol";
|
||||
import type { LspClient, ServerConfig } from "./types";
|
||||
|
||||
/**
|
||||
* LSP actions that do not mutate the workspace or language-server state.
|
||||
* Anything not in this set (rename, code_actions with apply, rename_file,
|
||||
* reload, raw request, etc.) is classified as write-tier.
|
||||
*/
|
||||
export const LSP_READONLY_ACTIONS: ReadonlySet<string> = new Set([
|
||||
"diagnostics",
|
||||
"definition",
|
||||
"type_definition",
|
||||
"implementation",
|
||||
"references",
|
||||
"hover",
|
||||
"symbols",
|
||||
"status",
|
||||
"capabilities",
|
||||
]);
|
||||
|
||||
export interface LspStartupServerInfo {
|
||||
name: string;
|
||||
status: "connecting" | "ready" | "error" | "available";
|
||||
fileTypes: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result from warming up LSP servers */
|
||||
export interface LspWarmupResult {
|
||||
servers: Array<LspStartupServerInfo & { status: "ready" | "error" }>;
|
||||
}
|
||||
|
||||
/** Options for warming up LSP servers */
|
||||
export interface LspWarmupOptions {
|
||||
/** Called when starting to connect to servers */
|
||||
onConnecting?: (serverNames: string[]) => void;
|
||||
}
|
||||
|
||||
export function discoverStartupLspServers(
|
||||
cwd: string,
|
||||
status: LspStartupServerInfo["status"] = "connecting",
|
||||
): LspStartupServerInfo[] {
|
||||
const config = loadConfig(cwd);
|
||||
return getLspServers(config).map(([name, serverConfig]) => ({
|
||||
name,
|
||||
status,
|
||||
fileTypes: serverConfig.fileTypes,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Warm up LSP servers for a directory by connecting to all detected servers.
|
||||
* This should be called at startup to avoid cold-start delays.
|
||||
*
|
||||
* @param cwd - Working directory to detect and start servers for
|
||||
* @param options - Optional callbacks for progress reporting
|
||||
* @returns Status of each server that was started
|
||||
*/
|
||||
export async function warmupLspServers(cwd: string, options?: LspWarmupOptions): Promise<LspWarmupResult> {
|
||||
const config = loadConfig(cwd);
|
||||
setIdleTimeout(config.idleTimeoutMs);
|
||||
const servers: LspWarmupResult["servers"] = [];
|
||||
const lspServers = getLspServers(config);
|
||||
|
||||
// Notify caller which servers we're connecting to
|
||||
if (lspServers.length > 0 && options?.onConnecting) {
|
||||
options.onConnecting(lspServers.map(([name]) => name));
|
||||
}
|
||||
|
||||
// Start all detected servers in parallel with a short timeout
|
||||
// Servers that don't respond quickly will be initialized lazily on first use
|
||||
const results = await Promise.allSettled(
|
||||
lspServers.map(async ([name, serverConfig]) => {
|
||||
const client = await getOrCreateClient(serverConfig, cwd, serverConfig.warmupTimeoutMs ?? WARMUP_TIMEOUT_MS);
|
||||
return { name, client, fileTypes: serverConfig.fileTypes };
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i];
|
||||
const [name, serverConfig] = lspServers[i];
|
||||
if (result.status === "fulfilled") {
|
||||
servers.push({
|
||||
name: result.value.name,
|
||||
status: "ready",
|
||||
fileTypes: result.value.fileTypes,
|
||||
});
|
||||
} else {
|
||||
const errorMsg = result.reason?.message ?? String(result.reason);
|
||||
logger.warn("LSP server failed to start", { server: name, error: errorMsg });
|
||||
servers.push({
|
||||
name,
|
||||
status: "error",
|
||||
fileTypes: serverConfig.fileTypes,
|
||||
error: errorMsg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { servers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of currently active LSP servers.
|
||||
*/
|
||||
export function getLspStatus(): LspServerStatus[] {
|
||||
return getActiveClients();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync in-memory file content to all applicable LSP servers.
|
||||
* Sends didOpen (if new) or didChange (if already open).
|
||||
*
|
||||
* @param absolutePath - Absolute path to the file
|
||||
* @param content - The new file content
|
||||
* @param cwd - Working directory for LSP config resolution
|
||||
* @param servers - Servers to sync to
|
||||
*/
|
||||
export async function syncFileContent(
|
||||
absolutePath: string,
|
||||
content: string,
|
||||
cwd: string,
|
||||
servers: Array<[string, ServerConfig]>,
|
||||
signal?: AbortSignal,
|
||||
createMissing = true,
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal);
|
||||
await Promise.allSettled(
|
||||
servers.map(async ([_serverName, serverConfig]) => {
|
||||
throwIfAborted(signal);
|
||||
if (serverConfig.createClient) {
|
||||
return;
|
||||
}
|
||||
const client = createMissing
|
||||
? await getOrCreateClient(serverConfig, cwd, undefined, signal)
|
||||
: await getActiveOrPendingClient(serverConfig, cwd, signal);
|
||||
if (!client) return;
|
||||
throwIfAborted(signal);
|
||||
await syncContent(client, absolutePath, content, signal);
|
||||
}),
|
||||
);
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all LSP servers that a file was saved.
|
||||
* Assumes content was already synced via syncFileContent.
|
||||
*
|
||||
* @param absolutePath - Absolute path to the file
|
||||
* @param cwd - Working directory for LSP config resolution
|
||||
* @param servers - Servers to notify
|
||||
*/
|
||||
export async function notifyFileSaved(
|
||||
absolutePath: string,
|
||||
cwd: string,
|
||||
servers: Array<[string, ServerConfig]>,
|
||||
signal?: AbortSignal,
|
||||
createMissing = true,
|
||||
): Promise<void> {
|
||||
throwIfAborted(signal);
|
||||
await Promise.allSettled(
|
||||
servers.map(async ([_serverName, serverConfig]) => {
|
||||
throwIfAborted(signal);
|
||||
if (serverConfig.createClient) {
|
||||
return;
|
||||
}
|
||||
const client = createMissing
|
||||
? await getOrCreateClient(serverConfig, cwd, undefined, signal)
|
||||
: await getActiveOrPendingClient(serverConfig, cwd, signal);
|
||||
if (!client) return;
|
||||
await notifySaved(client, absolutePath, signal);
|
||||
}),
|
||||
);
|
||||
throwIfAborted(signal);
|
||||
}
|
||||
|
||||
// Cache config per cwd to avoid repeated file I/O
|
||||
export const configCache = new Map<string, LspConfig>();
|
||||
|
||||
export function getConfig(cwd: string): LspConfig {
|
||||
let config = configCache.get(cwd);
|
||||
if (!config) {
|
||||
config = loadConfig(cwd);
|
||||
setIdleTimeout(config.idleTimeoutMs);
|
||||
configCache.set(cwd, config);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function isCustomLinter(serverConfig: ServerConfig): boolean {
|
||||
return Boolean(serverConfig.createClient);
|
||||
}
|
||||
|
||||
export function splitServers(servers: Array<[string, ServerConfig]>): {
|
||||
lspServers: Array<[string, ServerConfig]>;
|
||||
customLinterServers: Array<[string, ServerConfig]>;
|
||||
} {
|
||||
const lspServers: Array<[string, ServerConfig]> = [];
|
||||
const customLinterServers: Array<[string, ServerConfig]> = [];
|
||||
for (const entry of servers) {
|
||||
if (isCustomLinter(entry[1])) {
|
||||
customLinterServers.push(entry);
|
||||
} else {
|
||||
lspServers.push(entry);
|
||||
}
|
||||
}
|
||||
return { lspServers, customLinterServers };
|
||||
}
|
||||
|
||||
export function getLspServers(config: LspConfig): Array<[string, ServerConfig]> {
|
||||
return (Object.entries(config.servers) as Array<[string, ServerConfig]>).filter(
|
||||
([, serverConfig]) => !isCustomLinter(serverConfig),
|
||||
);
|
||||
}
|
||||
|
||||
export function getLspServersForFile(config: LspConfig, filePath: string): Array<[string, ServerConfig]> {
|
||||
return getServersForFile(config, filePath).filter(([, serverConfig]) => !isCustomLinter(serverConfig));
|
||||
}
|
||||
|
||||
export function getLspServerForFile(config: LspConfig, filePath: string): [string, ServerConfig] | null {
|
||||
const servers = getLspServersForFile(config, filePath);
|
||||
return servers.length > 0 ? servers[0] : null;
|
||||
}
|
||||
|
||||
export function isProjectAwareLspServer(serverConfig: ServerConfig): boolean {
|
||||
return !serverConfig.createClient && !serverConfig.isLinter;
|
||||
}
|
||||
|
||||
/** True when an LSP error indicates the server doesn't implement the requested method. */
|
||||
export function isMethodNotFoundError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("method not found") ||
|
||||
msg.includes("unhandled method") ||
|
||||
msg.includes("not supported") ||
|
||||
msg.includes("-32601")
|
||||
);
|
||||
}
|
||||
|
||||
export async function reloadServer(client: LspClient, serverName: string, signal?: AbortSignal): Promise<string> {
|
||||
throwIfAborted(signal);
|
||||
// rust-analyzer exposes a real reload request. Every other server rejects it
|
||||
// with method-not-found — that alone justifies the generic fallback. A caller
|
||||
// cancel or tool timeout must propagate, never be mistaken for an unsupported
|
||||
// method and swallowed into a bogus "Restarted" (issue #6369).
|
||||
try {
|
||||
await sendRequest(client, "rust-analyzer/reloadWorkspace", null, signal);
|
||||
return `Reloaded ${serverName}`;
|
||||
} catch (err) {
|
||||
throwIfAborted(signal);
|
||||
if (!isMethodNotFoundError(err)) throw err;
|
||||
// Method not supported — fall through to the generic reload.
|
||||
}
|
||||
// workspace/didChangeConfiguration is a notification per spec; sending it
|
||||
// as a request hangs until the tool deadline on servers that route it to
|
||||
// the notification handler and never respond.
|
||||
try {
|
||||
await sendNotification(client, "workspace/didChangeConfiguration", { settings: {} }, signal);
|
||||
return `Reloaded ${serverName}`;
|
||||
} catch {
|
||||
throwIfAborted(signal);
|
||||
// The reload notification could not be delivered — the connection is
|
||||
// wedged or the process already died. Tear the client down (removing it
|
||||
// from the registry by identity and awaiting confirmed process exit) so
|
||||
// the next request cold-starts a fresh client. A kill that never confirms
|
||||
// exit is not a restart: surface the teardown failure truthfully.
|
||||
//
|
||||
// On a broker-shared link a per-session teardown only detaches this
|
||||
// process while the wedged server keeps serving everyone else — ask the
|
||||
// mux to kill the shared server first (best-effort; it also severs us).
|
||||
if (client.proc.sharedMux) {
|
||||
await sendNotification(client, MUX_RESTART_METHOD, undefined, AbortSignal.timeout(2_000)).catch(() => {});
|
||||
}
|
||||
if (!(await shutdownClientInstance(client))) {
|
||||
throw new Error(`Failed to restart ${serverName}: server process did not exit after kill`);
|
||||
}
|
||||
return `Restarted ${serverName}`;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
import * as fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
|
||||
|
||||
/** Project type detection result */
|
||||
interface ProjectType {
|
||||
type: "rust" | "typescript" | "go" | "python" | "unknown";
|
||||
command?: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Convert a `go.work` use directory into the package pattern `go build` needs. */
|
||||
function goWorkspaceBuildPattern(diskPath: string): string | null {
|
||||
const trimmed = diskPath.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const isAbsolute = path.isAbsolute(trimmed) || path.win32.isAbsolute(trimmed);
|
||||
const normalized = trimmed.replaceAll("\\", "/").replace(/\/+$/, "");
|
||||
const dir = normalized || ".";
|
||||
if (dir === ".") return "./...";
|
||||
if (dir.endsWith("/...")) return dir;
|
||||
if (isAbsolute || dir.startsWith("./") || dir.startsWith("../")) return `${dir}/...`;
|
||||
return `./${dir}/...`;
|
||||
}
|
||||
|
||||
/** Parse `go work edit -json` output into per-module package patterns. */
|
||||
function parseGoWorkspaceBuildPatterns(output: string): string[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || !("Use" in parsed) || !Array.isArray(parsed.Use)) return [];
|
||||
|
||||
const patterns = new Set<string>();
|
||||
for (const entry of parsed.Use) {
|
||||
if (!entry || typeof entry !== "object" || !("DiskPath" in entry) || typeof entry.DiskPath !== "string") {
|
||||
continue;
|
||||
}
|
||||
const pattern = goWorkspaceBuildPattern(entry.DiskPath);
|
||||
if (pattern) patterns.add(pattern);
|
||||
}
|
||||
return [...patterns];
|
||||
}
|
||||
|
||||
/** Resolve the `go build` command for a `go.work` workspace. */
|
||||
async function resolveGoWorkspaceDiagnosticsCommand(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
||||
const fallback = ["go", "build", "./..."];
|
||||
try {
|
||||
const proc = Bun.spawn(["go", "work", "edit", "-json"], {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
});
|
||||
const abortHandler = () => {
|
||||
proc.kill();
|
||||
};
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const [stdout] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const exitCode = await proc.exited;
|
||||
throwIfAborted(signal);
|
||||
if (exitCode !== 0) return fallback;
|
||||
const patterns = parseGoWorkspaceBuildPatterns(stdout);
|
||||
return patterns.length > 0 ? ["go", "build", ...patterns] : fallback;
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
} catch {
|
||||
if (signal?.aborted) {
|
||||
throw new ToolAbortError();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detect project type from root markers */
|
||||
async function detectProjectType(cwd: string, signal?: AbortSignal): Promise<ProjectType> {
|
||||
// Check for Rust (Cargo.toml)
|
||||
if (fs.existsSync(path.join(cwd, "Cargo.toml"))) {
|
||||
return { type: "rust", command: ["cargo", "check", "--message-format=short"], description: "Rust (cargo check)" };
|
||||
}
|
||||
|
||||
// Check for TypeScript (tsconfig.json)
|
||||
if (fs.existsSync(path.join(cwd, "tsconfig.json"))) {
|
||||
return { type: "typescript", command: ["npx", "tsc", "--noEmit"], description: "TypeScript (tsc --noEmit)" };
|
||||
}
|
||||
|
||||
// Check for Go workspaces before single-module Go projects.
|
||||
if (fs.existsSync(path.join(cwd, "go.work"))) {
|
||||
return {
|
||||
type: "go",
|
||||
command: await resolveGoWorkspaceDiagnosticsCommand(cwd, signal),
|
||||
description: "Go workspace (go build)",
|
||||
};
|
||||
}
|
||||
|
||||
// Check for Go (go.mod)
|
||||
if (fs.existsSync(path.join(cwd, "go.mod"))) {
|
||||
return { type: "go", command: ["go", "build", "./..."], description: "Go (go build)" };
|
||||
}
|
||||
|
||||
// Check for Python (pyproject.toml or pyrightconfig.json)
|
||||
if (fs.existsSync(path.join(cwd, "pyproject.toml")) || fs.existsSync(path.join(cwd, "pyrightconfig.json"))) {
|
||||
return { type: "python", command: ["pyright"], description: "Python (pyright)" };
|
||||
}
|
||||
|
||||
return { type: "unknown", description: "Unknown project type" };
|
||||
}
|
||||
|
||||
/** Run workspace diagnostics command and parse output */
|
||||
export async function runWorkspaceDiagnostics(
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ output: string; projectType: ProjectType }> {
|
||||
throwIfAborted(signal);
|
||||
const projectType = await detectProjectType(cwd, signal);
|
||||
if (!projectType.command) {
|
||||
return {
|
||||
output: `Cannot detect project type. Supported: Rust (Cargo.toml), TypeScript (tsconfig.json), Go (go.work/go.mod), Python (pyproject.toml)`,
|
||||
projectType,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const proc = Bun.spawn(projectType.command, {
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
});
|
||||
const abortHandler = () => {
|
||||
proc.kill();
|
||||
};
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const [stdout, stderr] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
]);
|
||||
await proc.exited;
|
||||
throwIfAborted(signal);
|
||||
const combined = (stdout + stderr).trim();
|
||||
if (!combined) {
|
||||
return { output: "No issues found", projectType };
|
||||
}
|
||||
// Limit output length
|
||||
const lines = combined.split("\n");
|
||||
if (lines.length > 50) {
|
||||
return { output: `${lines.slice(0, 50).join("\n")}\n[…${lines.length - 50}ln elided…]`, projectType };
|
||||
}
|
||||
return { output: combined, projectType };
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) {
|
||||
throw new ToolAbortError();
|
||||
}
|
||||
return { output: `Failed to run ${projectType.command.join(" ")}: ${e}`, projectType };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import * as fs from "node:fs";
|
||||
import { isEnoent, logger, once, untilAborted } from "@oh-my-pi/pi-utils";
|
||||
import type { BunFile } from "bun";
|
||||
import { FileChangeType, notifyWorkspaceWatchedFiles } from "./client";
|
||||
import { getServersForFile } from "./config";
|
||||
import {
|
||||
captureDiagnosticVersions,
|
||||
captureOpenFileVersions,
|
||||
DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
type FileDiagnosticsResult,
|
||||
FileFormatResult,
|
||||
formatContent,
|
||||
getDiagnosticsForFile,
|
||||
INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
limitDiagnosticMessages,
|
||||
type ServerVersionMap,
|
||||
} from "./diagnostics";
|
||||
import { getConfig, notifyFileSaved, splitServers, syncFileContent } from "./servers";
|
||||
import type { ServerConfig } from "./types";
|
||||
import { summarizeDiagnosticMessages } from "./utils";
|
||||
|
||||
/** Options for creating the LSP writethrough callback */
|
||||
export interface WritethroughOptions {
|
||||
/** Whether to format the file using LSP after writing */
|
||||
enableFormat?: boolean;
|
||||
/** Whether to get LSP diagnostics after writing */
|
||||
enableDiagnostics?: boolean;
|
||||
/** Called when diagnostics arrive after the main timeout. */
|
||||
onDeferredDiagnostics?: (diagnostics: FileDiagnosticsResult) => void;
|
||||
/** Signal to cancel a pending deferred diagnostics fetch. */
|
||||
deferredSignal?: AbortSignal;
|
||||
/** Transform diagnostics before surfacing them after a successful fetch. */
|
||||
transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
|
||||
}
|
||||
|
||||
/** Internal resolved form of {@link WritethroughOptions} that the writethrough machinery operates on. */
|
||||
type ResolvedWritethroughOptions = {
|
||||
enableFormat: boolean;
|
||||
enableDiagnostics: boolean;
|
||||
transformDiagnostics?: (absPath: string, result: FileDiagnosticsResult) => FileDiagnosticsResult;
|
||||
};
|
||||
|
||||
/** Per-file deferred LSP diagnostics wiring for {@link WritethroughCallback}. */
|
||||
export type WritethroughDeferredHandle = {
|
||||
onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
|
||||
signal: AbortSignal;
|
||||
finalize: (diagnostics: FileDiagnosticsResult | undefined) => void;
|
||||
};
|
||||
|
||||
/** Callback type for the LSP writethrough */
|
||||
export type WritethroughCallback = (
|
||||
dst: string,
|
||||
content: string,
|
||||
signal?: AbortSignal,
|
||||
file?: BunFile,
|
||||
batch?: LspWritethroughBatchRequest,
|
||||
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
|
||||
) => Promise<FileDiagnosticsResult | undefined>;
|
||||
|
||||
/** No-op writethrough callback */
|
||||
export async function writethroughNoop(
|
||||
dst: string,
|
||||
content: string,
|
||||
_signal?: AbortSignal,
|
||||
file?: BunFile,
|
||||
_batch?: LspWritethroughBatchRequest,
|
||||
_getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
|
||||
): Promise<FileDiagnosticsResult | undefined> {
|
||||
if (file) {
|
||||
await file.write(content);
|
||||
} else {
|
||||
await Bun.write(dst, content);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface PendingWritethrough {
|
||||
dst: string;
|
||||
file?: BunFile;
|
||||
changeType: FileChangeType;
|
||||
}
|
||||
|
||||
interface RunLspWritethroughOptions {
|
||||
contentAlreadyWritten?: boolean;
|
||||
}
|
||||
|
||||
interface LspWritethroughBatchRequest {
|
||||
id: string;
|
||||
flush: boolean;
|
||||
}
|
||||
|
||||
interface LspWritethroughBatchState {
|
||||
entries: Map<string, PendingWritethrough>;
|
||||
options: ResolvedWritethroughOptions;
|
||||
}
|
||||
|
||||
const writethroughBatches = new Map<string, LspWritethroughBatchState>();
|
||||
|
||||
function getOrCreateWritethroughBatch(id: string, options: ResolvedWritethroughOptions): LspWritethroughBatchState {
|
||||
const existing = writethroughBatches.get(id);
|
||||
if (existing) {
|
||||
existing.options.enableFormat ||= options.enableFormat;
|
||||
existing.options.enableDiagnostics ||= options.enableDiagnostics;
|
||||
existing.options.transformDiagnostics ??= options.transformDiagnostics;
|
||||
return existing;
|
||||
}
|
||||
const batch: LspWritethroughBatchState = {
|
||||
entries: new Map<string, PendingWritethrough>(),
|
||||
options: { ...options },
|
||||
};
|
||||
writethroughBatches.set(id, batch);
|
||||
return batch;
|
||||
}
|
||||
|
||||
export async function flushLspWritethroughBatch(
|
||||
id: string,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FileDiagnosticsResult | undefined> {
|
||||
const state = writethroughBatches.get(id);
|
||||
if (!state) {
|
||||
return undefined;
|
||||
}
|
||||
writethroughBatches.delete(id);
|
||||
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal);
|
||||
}
|
||||
|
||||
function mergeDiagnostics(
|
||||
results: Array<FileDiagnosticsResult | undefined>,
|
||||
options: ResolvedWritethroughOptions,
|
||||
): FileDiagnosticsResult | undefined {
|
||||
const messages: string[] = [];
|
||||
const servers = new Set<string>();
|
||||
let hasResults = false;
|
||||
let hasFormatter = false;
|
||||
let formatted = false;
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) continue;
|
||||
hasResults = true;
|
||||
if (result.server) {
|
||||
for (const server of result.server.split(",")) {
|
||||
const trimmed = server.trim();
|
||||
if (trimmed) {
|
||||
servers.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.messages.length > 0) {
|
||||
messages.push(...result.messages);
|
||||
}
|
||||
if (result.formatter !== undefined) {
|
||||
hasFormatter = true;
|
||||
if (result.formatter === FileFormatResult.FORMATTED) {
|
||||
formatted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasResults && !hasFormatter) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let summary = options.enableDiagnostics ? "no issues" : "OK";
|
||||
let errored = false;
|
||||
let limitedMessages = messages;
|
||||
if (messages.length > 0) {
|
||||
const summaryInfo = summarizeDiagnosticMessages(messages);
|
||||
summary = summaryInfo.summary;
|
||||
errored = summaryInfo.errored;
|
||||
limitedMessages = limitDiagnosticMessages(messages);
|
||||
}
|
||||
const formatter = hasFormatter ? (formatted ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED) : undefined;
|
||||
|
||||
return {
|
||||
server: servers.size > 0 ? Array.from(servers).join(", ") : undefined,
|
||||
messages: limitedMessages,
|
||||
summary,
|
||||
errored,
|
||||
formatter,
|
||||
};
|
||||
}
|
||||
|
||||
async function scheduleDeferredDiagnosticsFetch(args: {
|
||||
dst: string;
|
||||
cwd: string;
|
||||
servers: Array<[string, ServerConfig]>;
|
||||
minVersions: ServerVersionMap | undefined;
|
||||
expectedDocumentVersions: ServerVersionMap | undefined;
|
||||
signal: AbortSignal;
|
||||
callback: (diagnostics: FileDiagnosticsResult) => void;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const deferredTimeout = AbortSignal.timeout(25_000);
|
||||
const combined = AbortSignal.any([args.signal, deferredTimeout]);
|
||||
const diagnostics = await getDiagnosticsForFile(args.dst, args.cwd, args.servers, {
|
||||
signal: combined,
|
||||
minVersions: args.minVersions,
|
||||
expectedDocumentVersions: args.expectedDocumentVersions,
|
||||
timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
if (args.signal.aborted || diagnostics === undefined) return;
|
||||
args.callback(diagnostics);
|
||||
} catch {
|
||||
// Cancelled or LSP gave up; silently discard.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch post-write diagnostics without making the edit/write block on a slow
|
||||
* language server.
|
||||
*
|
||||
* Blocks inline only briefly ({@link INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS}) for a
|
||||
* fresh result. Freshness is enforced by the pre-edit `minVersions` baseline:
|
||||
* exact document-version matches return immediately, and unversioned/mismatched
|
||||
* publishes must settle with no newer publish before inline acceptance. If
|
||||
* nothing fresh arrives in the inline window and a deferred
|
||||
* channel is available, the in-flight fetch is handed off to deliver late via
|
||||
* `onDeferredDiagnostics`, and this returns `undefined` so the tool result
|
||||
* lands immediately. Without a deferred channel (direct/CI callers) it blocks
|
||||
* for the standard budget so the result is still returned inline.
|
||||
*/
|
||||
async function fetchDiagnosticsWithDeferral(args: {
|
||||
dst: string;
|
||||
cwd: string;
|
||||
servers: Array<[string, ServerConfig]>;
|
||||
minVersions: ServerVersionMap | undefined;
|
||||
expectedDocumentVersions: ServerVersionMap | undefined;
|
||||
transformDiagnostics?: ResolvedWritethroughOptions["transformDiagnostics"];
|
||||
deferred?: { onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void; signal: AbortSignal };
|
||||
signal?: AbortSignal;
|
||||
}): Promise<FileDiagnosticsResult | undefined> {
|
||||
const { dst, cwd, servers, minVersions, expectedDocumentVersions, transformDiagnostics, deferred, signal } = args;
|
||||
const apply = (d: FileDiagnosticsResult | undefined) =>
|
||||
d && transformDiagnostics ? transformDiagnostics(dst, d) : d;
|
||||
|
||||
if (!deferred) {
|
||||
// No late-injection channel: block for the standard budget and return inline.
|
||||
return apply(
|
||||
await getDiagnosticsForFile(dst, cwd, servers, {
|
||||
signal,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// One background fetch with a generous inner budget; await it only briefly inline.
|
||||
const fetchPromise = getDiagnosticsForFile(dst, cwd, servers, {
|
||||
signal: deferred.signal,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
timeoutMs: DEFERRED_DIAGNOSTICS_WAIT_TIMEOUT_MS,
|
||||
});
|
||||
const INLINE_TIMEOUT = Symbol("inline-diagnostics-timeout");
|
||||
const raced = await Promise.race([
|
||||
fetchPromise,
|
||||
Bun.sleep(INLINE_DIAGNOSTICS_WAIT_TIMEOUT_MS).then(() => INLINE_TIMEOUT),
|
||||
]);
|
||||
if (raced !== INLINE_TIMEOUT) {
|
||||
return apply(raced as FileDiagnosticsResult | undefined);
|
||||
}
|
||||
// Slow server: deliver late via the deferred channel; nothing inline. The
|
||||
// deferred sink (edit tool) applies its own dedup, so pass the raw result.
|
||||
void fetchPromise
|
||||
.then(diagnostics => {
|
||||
if (diagnostics && !deferred.signal.aborted) deferred.onDeferredDiagnostics(diagnostics);
|
||||
})
|
||||
.catch(() => {});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runLspWritethrough(
|
||||
dst: string,
|
||||
content: string,
|
||||
cwd: string,
|
||||
options: ResolvedWritethroughOptions,
|
||||
changeType: FileChangeType,
|
||||
signal?: AbortSignal,
|
||||
file?: BunFile,
|
||||
deferred?: {
|
||||
onDeferredDiagnostics: (diagnostics: FileDiagnosticsResult) => void;
|
||||
signal: AbortSignal;
|
||||
},
|
||||
runOptions?: RunLspWritethroughOptions,
|
||||
): Promise<FileDiagnosticsResult | undefined> {
|
||||
const { enableFormat, enableDiagnostics } = options;
|
||||
const contentAlreadyWritten = runOptions?.contentAlreadyWritten ?? false;
|
||||
|
||||
let finalContent = content;
|
||||
const writeContent = async (value: string) => (file ? file.write(value) : Bun.write(dst, value));
|
||||
const getWritePromise = once(() =>
|
||||
contentAlreadyWritten && finalContent === content ? Promise.resolve() : writeContent(finalContent),
|
||||
);
|
||||
let writeNotified = false;
|
||||
const notifyWriteCommitted = async (notifySignal: AbortSignal | undefined = signal) => {
|
||||
if (writeNotified) return;
|
||||
writeNotified = true;
|
||||
try {
|
||||
await notifyWorkspaceWatchedFiles(cwd, [{ filePath: dst, type: changeType }], notifySignal);
|
||||
} catch (error) {
|
||||
if (notifySignal?.aborted && !signal?.aborted) {
|
||||
// The operation budget died mid-notify while the caller is still
|
||||
// live: allow the post-write retry below to re-announce with the
|
||||
// caller's signal (didChangeWatchedFiles is idempotent).
|
||||
writeNotified = false;
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
if (!enableFormat && !enableDiagnostics) {
|
||||
await getWritePromise();
|
||||
await notifyWriteCommitted();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = getConfig(cwd);
|
||||
const servers = getServersForFile(config, dst);
|
||||
|
||||
if (servers.length === 0) {
|
||||
await getWritePromise();
|
||||
await notifyWriteCommitted();
|
||||
return undefined;
|
||||
}
|
||||
const { lspServers, customLinterServers } = splitServers(servers);
|
||||
const useCustomFormatter = enableFormat && customLinterServers.length > 0;
|
||||
|
||||
// Capture diagnostic versions BEFORE syncing to detect stale diagnostics
|
||||
// Bound client creation by the writethrough budget: a hung/broken server
|
||||
// must not add its full init wait (30s default) to every edit.
|
||||
const minVersionsPromise = enableDiagnostics ? captureDiagnosticVersions(cwd, servers, 5_000, signal) : undefined;
|
||||
let minVersions = useCustomFormatter ? undefined : await minVersionsPromise;
|
||||
let expectedDocumentVersions: ServerVersionMap | undefined;
|
||||
|
||||
let formatter: FileFormatResult | undefined;
|
||||
let diagnostics: FileDiagnosticsResult | undefined;
|
||||
let timedOut = false;
|
||||
let synced = false;
|
||||
let operationSignal: AbortSignal | undefined;
|
||||
try {
|
||||
const timeoutSignal = AbortSignal.timeout(5_000);
|
||||
timeoutSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
timedOut = true;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
operationSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
await untilAborted(operationSignal, async () => {
|
||||
if (useCustomFormatter) {
|
||||
// Custom linters operate on on-disk input; the shared pre-write also
|
||||
// supports implementations that inspect the file before formatting.
|
||||
if (!contentAlreadyWritten) await writeContent(content);
|
||||
const [formattedContent, capturedVersions] = await Promise.all([
|
||||
formatContent(dst, content, cwd, customLinterServers, operationSignal),
|
||||
minVersionsPromise,
|
||||
]);
|
||||
finalContent = formattedContent;
|
||||
minVersions = capturedVersions;
|
||||
formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
|
||||
if (!contentAlreadyWritten || finalContent !== content) await writeContent(finalContent);
|
||||
await notifyWriteCommitted(operationSignal);
|
||||
await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal, enableDiagnostics);
|
||||
} else {
|
||||
// 1. Sync original content to LSP servers
|
||||
await syncFileContent(dst, content, cwd, lspServers, operationSignal);
|
||||
|
||||
// 2. Format in-memory via LSP
|
||||
if (enableFormat) {
|
||||
finalContent = await formatContent(dst, content, cwd, lspServers, operationSignal);
|
||||
formatter = finalContent !== content ? FileFormatResult.FORMATTED : FileFormatResult.UNCHANGED;
|
||||
}
|
||||
|
||||
// 3. If formatted, sync formatted content to LSP servers
|
||||
if (finalContent !== content) {
|
||||
await syncFileContent(dst, finalContent, cwd, lspServers, operationSignal);
|
||||
}
|
||||
|
||||
// 4. Write to disk
|
||||
await getWritePromise();
|
||||
await notifyWriteCommitted(operationSignal);
|
||||
}
|
||||
|
||||
if (enableDiagnostics) {
|
||||
expectedDocumentVersions = await captureOpenFileVersions(dst, cwd, lspServers, operationSignal);
|
||||
}
|
||||
|
||||
// 5. Notify saved to LSP servers
|
||||
await notifyFileSaved(dst, cwd, lspServers, operationSignal, !useCustomFormatter || enableDiagnostics);
|
||||
});
|
||||
synced = true;
|
||||
} catch {
|
||||
if (timedOut) {
|
||||
formatter = undefined;
|
||||
diagnostics = undefined;
|
||||
// Schedule background diagnostic fetch if caller wants deferred results
|
||||
if (deferred && !deferred.signal.aborted && enableDiagnostics) {
|
||||
void scheduleDeferredDiagnosticsFetch({
|
||||
dst,
|
||||
cwd,
|
||||
servers,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
signal: deferred.signal,
|
||||
callback: deferred.onDeferredDiagnostics,
|
||||
});
|
||||
}
|
||||
}
|
||||
await getWritePromise();
|
||||
// The write above committed even though the operation budget elapsed:
|
||||
// announce it on the caller's signal — the dead `operationSignal` would
|
||||
// abort the notify before it ever reaches the server.
|
||||
await notifyWriteCommitted();
|
||||
}
|
||||
|
||||
if (synced && enableDiagnostics) {
|
||||
diagnostics = await fetchDiagnosticsWithDeferral({
|
||||
dst,
|
||||
cwd,
|
||||
servers,
|
||||
minVersions,
|
||||
expectedDocumentVersions,
|
||||
transformDiagnostics: options.transformDiagnostics,
|
||||
deferred,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
if (formatter !== undefined) {
|
||||
diagnostics ??= {
|
||||
server: servers.map(([name]) => name).join(", "),
|
||||
messages: [],
|
||||
summary: "OK",
|
||||
errored: false,
|
||||
};
|
||||
diagnostics.formatter = formatter;
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function flushWritethroughBatch(
|
||||
batch: PendingWritethrough[],
|
||||
cwd: string,
|
||||
options: ResolvedWritethroughOptions,
|
||||
signal?: AbortSignal,
|
||||
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
|
||||
): Promise<FileDiagnosticsResult | undefined> {
|
||||
if (batch.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const results: Array<FileDiagnosticsResult | undefined> = [];
|
||||
for (const entry of batch) {
|
||||
const bundle = getDeferred?.(entry.dst);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.promises.readFile(entry.dst, "utf8");
|
||||
} catch (error) {
|
||||
if (!isEnoent(error)) throw error;
|
||||
bundle?.finalize(undefined);
|
||||
continue;
|
||||
}
|
||||
const deferredInner =
|
||||
bundle &&
|
||||
({
|
||||
onDeferredDiagnostics: bundle.onDeferredDiagnostics,
|
||||
signal: bundle.signal,
|
||||
} as const);
|
||||
const diag = await runLspWritethrough(
|
||||
entry.dst,
|
||||
content,
|
||||
cwd,
|
||||
options,
|
||||
entry.changeType,
|
||||
signal,
|
||||
entry.file,
|
||||
deferredInner,
|
||||
{ contentAlreadyWritten: true },
|
||||
);
|
||||
bundle?.finalize(diag);
|
||||
results.push(diag);
|
||||
}
|
||||
return mergeDiagnostics(results, options);
|
||||
}
|
||||
|
||||
/** Create a writethrough callback for LSP aware write operations */
|
||||
export function createLspWritethrough(cwd: string, options?: WritethroughOptions): WritethroughCallback {
|
||||
const resolvedOptions: ResolvedWritethroughOptions = {
|
||||
enableFormat: options?.enableFormat ?? false,
|
||||
enableDiagnostics: options?.enableDiagnostics ?? false,
|
||||
transformDiagnostics: options?.transformDiagnostics,
|
||||
};
|
||||
return async (
|
||||
dst: string,
|
||||
content: string,
|
||||
signal?: AbortSignal,
|
||||
file?: BunFile,
|
||||
batch?: LspWritethroughBatchRequest,
|
||||
getDeferred?: (dst: string) => WritethroughDeferredHandle | undefined,
|
||||
) => {
|
||||
const changeType = (await Bun.file(dst).exists()) ? FileChangeType.Changed : FileChangeType.Created;
|
||||
if (!batch) {
|
||||
const bundle = getDeferred?.(dst);
|
||||
const deferredInner =
|
||||
bundle &&
|
||||
({
|
||||
onDeferredDiagnostics: bundle.onDeferredDiagnostics,
|
||||
signal: bundle.signal,
|
||||
} as const);
|
||||
const diagnostics = await runLspWritethrough(
|
||||
dst,
|
||||
content,
|
||||
cwd,
|
||||
resolvedOptions,
|
||||
changeType,
|
||||
signal,
|
||||
file,
|
||||
deferredInner,
|
||||
);
|
||||
bundle?.finalize(diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
// File commits are never deferred: the batch owns only LSP post-processing,
|
||||
// so a later flush cannot replay an obsolete whole-file snapshot.
|
||||
try {
|
||||
await writethroughNoop(dst, content, signal, file);
|
||||
} catch (error) {
|
||||
if (batch.flush) {
|
||||
const pending = writethroughBatches.get(batch.id);
|
||||
if (pending) {
|
||||
writethroughBatches.delete(batch.id);
|
||||
try {
|
||||
await flushWritethroughBatch(
|
||||
Array.from(pending.entries.values()),
|
||||
cwd,
|
||||
pending.options,
|
||||
signal,
|
||||
getDeferred,
|
||||
);
|
||||
} catch (flushError) {
|
||||
logger.warn("Failed to flush pending LSP batch after final write failure", {
|
||||
batchId: batch.id,
|
||||
error: flushError instanceof Error ? flushError.message : String(flushError),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const state = getOrCreateWritethroughBatch(batch.id, resolvedOptions);
|
||||
state.entries.set(dst, { dst, file, changeType });
|
||||
if (!batch.flush) return undefined;
|
||||
|
||||
writethroughBatches.delete(batch.id);
|
||||
return flushWritethroughBatch(Array.from(state.entries.values()), cwd, state.options, signal, getDeferred);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user