summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/diagnostics.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/lsp/src/diagnostics.ts')
-rw-r--r--packages/lsp/src/diagnostics.ts217
1 files changed, 132 insertions, 85 deletions
diff --git a/packages/lsp/src/diagnostics.ts b/packages/lsp/src/diagnostics.ts
index 50beca9..cce261f 100644
--- a/packages/lsp/src/diagnostics.ts
+++ b/packages/lsp/src/diagnostics.ts
@@ -4,107 +4,154 @@
*/
export interface Diagnostic {
- readonly range: {
- readonly start: { readonly line: number; readonly character: number };
- readonly end: { readonly line: number; readonly character: number };
- };
- readonly severity?: number;
- readonly source?: string;
- readonly message: string;
- readonly code?: string | number;
+ readonly range: {
+ readonly start: { readonly line: number; readonly character: number };
+ readonly end: { readonly line: number; readonly character: number };
+ };
+ readonly severity?: number;
+ readonly source?: string;
+ readonly message: string;
+ readonly code?: string | number;
}
export interface PublishDiagnosticsParams {
- readonly uri: string;
- readonly diagnostics: readonly Diagnostic[];
+ readonly uri: string;
+ readonly diagnostics: readonly Diagnostic[];
}
export interface DocumentDiagnosticReport {
- readonly kind: "full" | "unchanged";
- readonly items?: readonly Diagnostic[];
+ readonly kind: "full" | "unchanged";
+ readonly items?: readonly Diagnostic[];
}
const severityNames: Record<number, string> = {
- 1: "ERROR",
- 2: "WARNING",
- 3: "INFO",
- 4: "HINT",
+ 1: "ERROR",
+ 2: "WARNING",
+ 3: "INFO",
+ 4: "HINT",
};
export class DiagnosticsStore {
- private pushDiagnostics = new Map<string, readonly Diagnostic[]>();
- private pullDiagnostics = new Map<string, readonly Diagnostic[]>();
- private pushReceived = new Set<string>();
-
- setPushDiagnostics(params: PublishDiagnosticsParams): void {
- this.pushDiagnostics.set(params.uri, params.diagnostics);
- this.pushReceived.add(params.uri);
- }
-
- setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void {
- if (report.kind === "full" && report.items) {
- this.pullDiagnostics.set(uri, report.items);
- }
- }
-
- /** True if the server has pushed at least one publishDiagnostics for this URI. */
- hasReceivedPush(uri: string): boolean {
- return this.pushReceived.has(uri);
- }
-
- /** Clear the "received" flag so the next waitForDiagnostics poll detects fresh pushes. */
- clearReceived(uri: string): void {
- this.pushReceived.delete(uri);
- }
-
- getMerged(uri: string): readonly Diagnostic[] {
- const push = this.pushDiagnostics.get(uri) ?? [];
- const pull = this.pullDiagnostics.get(uri) ?? [];
- return dedupeDiagnostics([...push, ...pull]);
- }
-
- /**
- * Format diagnostics for a URI, optionally filtering by minimum severity.
- * `minSeverity` includes only diagnostics with severity ≤ the given value
- * (1=Error, 2=Warning, 3=Info, 4=Hint). Omit to include all.
- */
- formatFiltered(uri: string, minSeverity?: number): string {
- let diags = this.getMerged(uri);
- if (minSeverity !== undefined) {
- diags = diags.filter((d) => (d.severity ?? 0) <= minSeverity);
- }
- if (diags.length === 0) return "";
- const lines: string[] = [];
- for (const d of diags) {
- const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN";
- const line = d.range.start.line + 1;
- const col = d.range.start.character + 1;
- const src = d.source ? ` [${d.source}]` : "";
- const code = d.code ? ` (${d.code})` : "";
- lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`);
- }
- return lines.join("\n");
- }
-
- format(uri: string): string {
- return this.formatFiltered(uri);
- }
+ private pushDiagnostics = new Map<string, readonly Diagnostic[]>();
+ private pullDiagnostics = new Map<string, readonly Diagnostic[]>();
+ private pushReceived = new Set<string>();
+ /**
+ * Bounded push-diagnostics set: once more than this many URIs have cached
+ * push diagnostics, the least-recently-pushed is evicted (just deleted —
+ * no didClose, since these are often background-scanned files the agent
+ * never opened). Language servers (tsserver, rust-analyzer, …) scan the
+ * workspace and emit textDocument/publishDiagnostics for files the client
+ * never touched; those never enter the client's openDocuments LRU, so
+ * without this cap the map grew without bound over a long session.
+ */
+ private static readonly MAX_PUSH_DIAGNOSTICS = 100;
+
+ setPushDiagnostics(params: PublishDiagnosticsParams): void {
+ // Delete-then-set to refresh LRU recency: a URI re-pushed (e.g. an
+ // actively edited file) moves to the tail (most-recently-used), so
+ // background-scanned files pushed once drift to the head and are evicted
+ // first. Mirrors the openDocuments LRU in client.ts. (A plain `set` on
+ // an existing key updates the value but leaves its insertion position —
+ // and thus its eviction priority — unchanged.)
+ this.pushDiagnostics.delete(params.uri);
+ this.pushDiagnostics.set(params.uri, params.diagnostics);
+ this.pushReceived.add(params.uri);
+ this.evictPushIfOverCap();
+ }
+
+ /**
+ * If the push-diagnostics set exceeds MAX_PUSH_DIAGNOSTICS, drop the
+ * least-recently-pushed entry (the first key in insertion order) from
+ * both `pushDiagnostics` and `pushReceived`. No didClose is sent: these
+ * entries are frequently for files the agent never opened, and even for
+ * an opened file the next push re-caches it.
+ */
+ private evictPushIfOverCap(): void {
+ while (this.pushDiagnostics.size > DiagnosticsStore.MAX_PUSH_DIAGNOSTICS) {
+ const oldest = this.pushDiagnostics.keys().next().value;
+ if (oldest === undefined) break;
+ this.pushDiagnostics.delete(oldest);
+ this.pushReceived.delete(oldest);
+ }
+ }
+
+ setPullDiagnostics(uri: string, report: DocumentDiagnosticReport): void {
+ if (report.kind === "full" && report.items) {
+ this.pullDiagnostics.set(uri, report.items);
+ }
+ }
+
+ /** True if the server has pushed at least one publishDiagnostics for this URI. */
+ hasReceivedPush(uri: string): boolean {
+ return this.pushReceived.has(uri);
+ }
+
+ /** Clear the "received" flag so the next waitForDiagnostics poll detects fresh pushes. */
+ clearReceived(uri: string): void {
+ this.pushReceived.delete(uri);
+ }
+
+ /**
+ * Drop ALL state for a URI — push diagnostics, pull diagnostics, and the
+ * received flag. Called when a document is closed (textDocument/didClose)
+ * so the store stops retaining the file's diagnostics forever. Without
+ * this, the maps grow unboundedly as an agent touches thousands of files
+ * (the 9.5 GB leak).
+ */
+ purge(uri: string): void {
+ this.pushDiagnostics.delete(uri);
+ this.pullDiagnostics.delete(uri);
+ this.pushReceived.delete(uri);
+ }
+
+ getMerged(uri: string): readonly Diagnostic[] {
+ const push = this.pushDiagnostics.get(uri) ?? [];
+ const pull = this.pullDiagnostics.get(uri) ?? [];
+ return dedupeDiagnostics([...push, ...pull]);
+ }
+
+ /**
+ * Format diagnostics for a URI, optionally filtering by minimum severity.
+ * `minSeverity` includes only diagnostics with severity ≤ the given value
+ * (1=Error, 2=Warning, 3=Info, 4=Hint). Omit to include all.
+ */
+ formatFiltered(uri: string, minSeverity?: number): string {
+ let diags = this.getMerged(uri);
+ if (minSeverity !== undefined) {
+ diags = diags.filter((d) => (d.severity ?? 0) <= minSeverity);
+ }
+ if (diags.length === 0) return "";
+ const lines: string[] = [];
+ for (const d of diags) {
+ const sev = d.severity ? (severityNames[d.severity] ?? "UNKNOWN") : "UNKNOWN";
+ const line = d.range.start.line + 1;
+ const col = d.range.start.character + 1;
+ const src = d.source ? ` [${d.source}]` : "";
+ const code = d.code ? ` (${d.code})` : "";
+ lines.push(`${sev}${code}${src} L${line}:${col}: ${d.message}`);
+ }
+ return lines.join("\n");
+ }
+
+ format(uri: string): string {
+ return this.formatFiltered(uri);
+ }
}
export function diagnosticKey(d: Diagnostic): string {
- const r = d.range;
- return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`;
+ const r = d.range;
+ return `${r.start.line}:${r.start.character}-${r.end.line}:${r.end.character}:${d.severity ?? 0}:${d.message}`;
}
function dedupeDiagnostics(diags: readonly Diagnostic[]): readonly Diagnostic[] {
- const seen = new Set<string>();
- const result: Diagnostic[] = [];
- for (const d of diags) {
- const key = diagnosticKey(d);
- if (!seen.has(key)) {
- seen.add(key);
- result.push(d);
- }
- }
- return result;
+ const seen = new Set<string>();
+ const result: Diagnostic[] = [];
+ for (const d of diags) {
+ const key = diagnosticKey(d);
+ if (!seen.has(key)) {
+ seen.add(key);
+ result.push(d);
+ }
+ }
+ return result;
}