Files
oh-my-pi/packages/coding-agent/src/lsp/diagnostics.ts
T
can1357 cd4e04e8ee 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.
2026-08-08 06:32:01 +02:00

517 lines
16 KiB
TypeScript

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;
}