From 3c816bf8068f2808f5b09ec28d77427de7e242b0 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 25 Jun 2026 20:09:42 +0000 Subject: [PATCH 1/4] feat: add dashboard lsp diagnostics --- Cargo.toml | 1 + build.rs | 3 + dashboard/build.mjs | 2 + dashboard/build.shared.mjs | 3 + dashboard/code-diagnostics/manifest.json | 9 + .../code-diagnostics/src/CodeDiagnostics.tsx | 323 ++++++++++ dashboard/code-diagnostics/src/api.ts | 24 + dashboard/code-diagnostics/src/entry.tsx | 20 + dashboard/code-diagnostics/src/styles.css | 302 +++++++++ dashboard/code-diagnostics/src/types.ts | 76 +++ dashboard/dev/main.tsx | 3 + src/agents/mod.rs | 49 +- src/automation/config.rs | 1 + src/cli.rs | 15 + src/cli/parse_tests.rs | 15 +- src/dashboard/assets.rs | 96 ++- src/dashboard/automation_run_api.rs | 30 +- src/dashboard/code_diagnostics_api.rs | 280 ++++++++ src/dashboard/memory_curate.rs | 8 +- src/dashboard/mod.rs | 101 +-- src/diagnostics/lsp/adapters.rs | 225 +++++++ src/diagnostics/lsp/broker.rs | 520 +++++++++++++++ src/diagnostics/lsp/client.rs | 371 +++++++++++ src/diagnostics/lsp/mod.rs | 6 + src/diagnostics/lsp/settings.rs | 139 ++++ src/diagnostics/mod.rs | 1 + src/lsp_cmd.rs | 60 ++ src/main.rs | 6 + tests/dashboard_code_diagnostics_api_test.rs | 87 +++ tests/lsp_code_diagnostics_test.rs | 602 ++++++++++++++++++ 30 files changed, 3304 insertions(+), 74 deletions(-) create mode 100644 dashboard/code-diagnostics/manifest.json create mode 100644 dashboard/code-diagnostics/src/CodeDiagnostics.tsx create mode 100644 dashboard/code-diagnostics/src/api.ts create mode 100644 dashboard/code-diagnostics/src/entry.tsx create mode 100644 dashboard/code-diagnostics/src/styles.css create mode 100644 dashboard/code-diagnostics/src/types.ts create mode 100644 src/dashboard/code_diagnostics_api.rs create mode 100644 src/diagnostics/lsp/adapters.rs create mode 100644 src/diagnostics/lsp/broker.rs create mode 100644 src/diagnostics/lsp/client.rs create mode 100644 src/diagnostics/lsp/mod.rs create mode 100644 src/diagnostics/lsp/settings.rs create mode 100644 src/lsp_cmd.rs create mode 100644 tests/dashboard_code_diagnostics_api_test.rs create mode 100644 tests/lsp_code_diagnostics_test.rs diff --git a/Cargo.toml b/Cargo.toml index 5e6a67dc..d4aa1be8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ include = [ "/dashboard/holographic/dist/**", "/dashboard/lcm/dist/**", "/dashboard/graph/dist/**", + "/dashboard/code-diagnostics/dist/**", "/dashboard/savings/dist/**", "/dashboard/hermes-wrapper/manifest.json", "/dashboard/hermes-wrapper/plugin_api.py", diff --git a/build.rs b/build.rs index 19e86971..c4e02428 100644 --- a/build.rs +++ b/build.rs @@ -16,6 +16,8 @@ const DASHBOARD_ASSET_FILES: &[&str] = &[ "dashboard/lcm/dist/style.css", "dashboard/graph/dist/index.js", "dashboard/graph/dist/style.css", + "dashboard/code-diagnostics/dist/index.js", + "dashboard/code-diagnostics/dist/style.css", "dashboard/savings/dist/index.js", "dashboard/savings/dist/style.css", ]; @@ -31,6 +33,7 @@ const DASHBOARD_SOURCE_DIRS: &[&str] = &[ "dashboard/graph/src", "dashboard/holographic/src", "dashboard/lcm/src", + "dashboard/code-diagnostics/src", "dashboard/lib", "dashboard/savings/src", "dashboard/shell/src", diff --git a/dashboard/build.mjs b/dashboard/build.mjs index e71d6c55..6abd1a38 100644 --- a/dashboard/build.mjs +++ b/dashboard/build.mjs @@ -7,6 +7,7 @@ * shell/dist/shell.js + shell.css Standalone host shell. * holographic/dist/index.js Holographic-memory plugin bundle. * graph/dist/index.js Code graph explorer plugin bundle. + * code-diagnostics/dist/index.js LSP code diagnostics plugin bundle. * savings/dist/index.js Savings plugin bundle. * lcm/dist/index.js + style.css Copied from lcm/src. * hermes-wrapper/dist/* Combined Hermes dashboard plugin. @@ -32,6 +33,7 @@ async function main() { buildShell(), buildHolographicPlugin(), buildPlugin("graph", "code graph", { primitives: true }), + buildPlugin("code-diagnostics", "code diagnostics", { primitives: true }), buildPlugin("savings", "savings & cost", { primitives: true }), buildPlugin("lcm", "LCM", { primitives: true }), ]); diff --git a/dashboard/build.shared.mjs b/dashboard/build.shared.mjs index 7112975f..e30b4b70 100644 --- a/dashboard/build.shared.mjs +++ b/dashboard/build.shared.mjs @@ -23,6 +23,8 @@ export const EMBEDDED_DIST_FILES = [ "lcm/dist/style.css", "graph/dist/index.js", "graph/dist/style.css", + "code-diagnostics/dist/index.js", + "code-diagnostics/dist/style.css", "savings/dist/index.js", "savings/dist/style.css", ]; @@ -47,6 +49,7 @@ export const DASHBOARD_SOURCE_DIRS = [ "graph/src", "holographic/src", "lcm/src", + "code-diagnostics/src", "lib", "savings/src", "shell/src", diff --git a/dashboard/code-diagnostics/manifest.json b/dashboard/code-diagnostics/manifest.json new file mode 100644 index 00000000..0fa128f8 --- /dev/null +++ b/dashboard/code-diagnostics/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "code-diagnostics", + "label": "Code Diagnostics", + "description": "LSP-backed compiler and type diagnostics.", + "icon": "Bug", + "version": "0.1.0", + "entry": "dist/index.js", + "css": "dist/style.css" +} diff --git a/dashboard/code-diagnostics/src/CodeDiagnostics.tsx b/dashboard/code-diagnostics/src/CodeDiagnostics.tsx new file mode 100644 index 00000000..31528d81 --- /dev/null +++ b/dashboard/code-diagnostics/src/CodeDiagnostics.tsx @@ -0,0 +1,323 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, cn, timeAgo } from "../../lib/sdk"; +import { EmptyState, ErrorPanel, Stat } from "../../lib/primitives"; +import { fmt } from "../../lib/format"; +import { api } from "./api"; +import type { + CodeDiagnostic, + DiagnosticsSnapshot, + EngineState, + EngineStatus, + IdleBackfillMode, +} from "./types"; + +const STATE_LABELS: Record = { + unavailable: "Unavailable", + disabled: "Disabled", + starting: "Starting", + indexing: "Indexing", + ready: "Ready", + refreshing: "Refreshing", + crashed: "Crashed", +}; + +function copyCommand(command: string) { + navigator.clipboard?.writeText(command).catch(() => undefined); +} + +export default function CodeDiagnostics() { + const [snapshot, setSnapshot] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(""); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + setSnapshot(await api.overview()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const patch = useCallback(async (body: Parameters[0]) => { + setBusy("settings"); + setError(""); + try { + setSnapshot(await api.patchSettings(body)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(""); + } + }, []); + + const refresh = useCallback(async (language?: string) => { + setBusy(language || "all"); + setError(""); + try { + setSnapshot(language ? await api.refreshLanguage(language) : await api.refreshAll()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(""); + } + }, []); + + const engines = snapshot?.engines ?? []; + const diagnosticsByFile = useMemo( + () => groupDiagnostics(snapshot?.diagnostics ?? []), + [snapshot?.diagnostics], + ); + + if (loading && !snapshot) { + return Loading code diagnostics...; + } + + return ( +
+ {error && } + +
+
+ + + + +
+
+ + +
+
+ +
+ + + Engines + + +
+ {engines.map((engine) => ( + + + patch({ languages: { [engine.language]: { enabled } } }) + } + onConfigure={(commandOverride) => + patch({ + languages: { + [engine.language]: { + enabled: engine.enabled, + command_override: commandOverride, + }, + }, + }) + } + onRefresh={() => refresh(engine.language)} + /> + + ))} +
+
+
+ + + + Diagnostics + + + {diagnosticsByFile.length === 0 ? ( + No cached diagnostics. + ) : ( +
+ {diagnosticsByFile.map(([file, rows]) => ( +
+
+ {file} + {rows.length} +
+ {rows.map((diagnostic, index) => ( + + + + ))} +
+ ))} +
+ )} +
+
+
+
+ ); +} + +function EngineRow({ + engine, + busy, + onToggle, + onConfigure, + onRefresh, +}: { + engine: EngineStatus; + busy: boolean; + onToggle: (enabled: boolean) => void; + onConfigure: (commandOverride: string | null) => void; + onRefresh: () => void; +}) { + const [expanded, setExpanded] = useState(false); + const [commandDraft, setCommandDraft] = useState(engine.command); + const hasOverride = engine.command !== engine.default_command; + + useEffect(() => { + setCommandDraft(engine.command); + }, [engine.command]); + + return ( +
+ + {engine.command} + + {STATE_LABELS[engine.state]} + + + {engine.last_diagnostic_update ? timeAgo(engine.last_diagnostic_update) : "Never"} + + + + {engine.last_error &&

{engine.last_error}

} + {expanded && ( +
+
+ Default + {engine.default_command} + {engine.args.length > 0 && {engine.args.join(" ")}} +
+ {engine.install_options.length > 0 && ( +
+ {engine.install_options.map((option) => ( +
+ {option.label} +
+ {option.command} + +
+ {option.notes && {option.notes}} +
+ ))} +
+ )} + +
+ + {hasOverride && ( + + )} +
+
+ )} +
+ ); +} + +function DiagnosticRow({ diagnostic }: { diagnostic: CodeDiagnostic }) { + return ( +
+
+ {diagnostic.severity} + + {diagnostic.line_start} + {diagnostic.line_end !== diagnostic.line_start ? `-${diagnostic.line_end}` : ""} + + {diagnostic.code && {diagnostic.code}} + {diagnostic.source} +
+

{diagnostic.message}

+ {diagnostic.enclosing_node && {diagnostic.enclosing_node}} +
+ ); +} + +function groupDiagnostics(rows: CodeDiagnostic[]): Array<[string, CodeDiagnostic[]]> { + const groups = new Map(); + for (const row of rows) { + const group = groups.get(row.file) ?? []; + group.push(row); + groups.set(row.file, group); + } + return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); +} + +function diagnosticKey(diagnostic: CodeDiagnostic): string { + return [ + diagnostic.file, + diagnostic.line_start, + diagnostic.line_end, + diagnostic.character_start ?? "", + diagnostic.character_end ?? "", + diagnostic.source, + diagnostic.code ?? "", + diagnostic.message, + ].join(":"); +} diff --git a/dashboard/code-diagnostics/src/api.ts b/dashboard/code-diagnostics/src/api.ts new file mode 100644 index 00000000..c1a219f6 --- /dev/null +++ b/dashboard/code-diagnostics/src/api.ts @@ -0,0 +1,24 @@ +import { fetchJSON } from "../../lib/sdk"; +import type { DiagnosticsSnapshot, IdleBackfillMode, LanguageSettings } from "./types"; + +const BASE = "/api/plugins/code-diagnostics"; + +export const api = { + overview: () => fetchJSON(BASE), + patchSettings: (patch: { + idle_backfill?: IdleBackfillMode; + languages?: Record; + }) => + fetchJSON(BASE, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }), + refreshAll: () => + fetchJSON(`${BASE}/refresh`, { method: "POST" }), + refreshLanguage: (language: string) => + fetchJSON( + `${BASE}/refresh/${encodeURIComponent(language)}`, + { method: "POST" }, + ), +}; diff --git a/dashboard/code-diagnostics/src/entry.tsx b/dashboard/code-diagnostics/src/entry.tsx new file mode 100644 index 00000000..d436c55a --- /dev/null +++ b/dashboard/code-diagnostics/src/entry.tsx @@ -0,0 +1,20 @@ +import CodeDiagnostics from "./CodeDiagnostics"; + +interface PluginRegistry { + register: (name: string, component: unknown) => void; +} + +const registry: PluginRegistry | null = + (typeof window !== "undefined" && + (window as unknown as { __HERMES_PLUGINS__?: PluginRegistry }) + .__HERMES_PLUGINS__) || + null; + +const sdk = + typeof window !== "undefined" && + (window as unknown as { __HERMES_PLUGIN_SDK__?: unknown }) + .__HERMES_PLUGIN_SDK__; + +if (sdk && registry && typeof registry.register === "function") { + registry.register("code-diagnostics", CodeDiagnostics); +} diff --git a/dashboard/code-diagnostics/src/styles.css b/dashboard/code-diagnostics/src/styles.css new file mode 100644 index 00000000..766de1b9 --- /dev/null +++ b/dashboard/code-diagnostics/src/styles.css @@ -0,0 +1,302 @@ +.tdcd-root { + display: grid; + gap: 1rem; + color: var(--color-foreground); +} + +.tdcd-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: color-mix(in srgb, var(--color-card) 72%, transparent); +} + +.tdcd-summary { + display: grid; + grid-template-columns: repeat(4, minmax(7rem, 1fr)); + gap: 0.65rem; +} + +.tdcd-controls { + display: flex; + align-items: center; + gap: 0.7rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.tdcd-select-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + color: var(--color-muted-foreground); + font-size: 0.78rem; +} + +.tdcd-select-label select { + min-width: 6rem; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + color: var(--color-foreground); + padding: 0.35rem 0.55rem; +} + +.tdcd-layout { + display: grid; + grid-template-columns: minmax(24rem, 0.8fr) minmax(0, 1.2fr); + gap: 1rem; + align-items: start; +} + +.tdcd-engines-card, +.tdcd-diagnostics-card { + border-radius: 8px; +} + +.tdcd-engine-table { + display: grid; + gap: 0.45rem; +} + +.tdcd-engine-row { + display: grid; + grid-template-columns: minmax(7rem, 0.8fr) minmax(0, 1.2fr) auto auto auto auto; + gap: 0.6rem; + align-items: center; + min-width: 0; + padding: 0.55rem 0; + border-bottom: 1px solid var(--color-border); +} + +.tdcd-engine-row:last-child { + border-bottom: 0; +} + +.tdcd-engine-row > code, +.tdcd-file-group code, +.tdcd-diagnostic code { + min-width: 0; + overflow-wrap: anywhere; + color: var(--color-foreground); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.76rem; +} + +.tdcd-toggle { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + font-weight: 650; +} + +.tdcd-toggle input { + width: 1rem; + height: 1rem; + accent-color: var(--ts-cyan, #55c2ba); +} + +.tdcd-toggle span { + overflow: hidden; + text-overflow: ellipsis; +} + +.tdcd-state-ready { + border-color: color-mix(in srgb, var(--ts-green, #67e8a9) 45%, transparent); + color: var(--ts-green, #67e8a9); +} + +.tdcd-state-unavailable, +.tdcd-state-crashed { + border-color: color-mix(in srgb, var(--color-destructive) 45%, transparent); + color: var(--color-destructive); +} + +.tdcd-state-disabled { + color: var(--color-muted-foreground); +} + +.tdcd-engine-time { + color: var(--color-muted-foreground); + font-size: 0.76rem; + white-space: nowrap; +} + +.tdcd-engine-error { + grid-column: 1 / -1; + margin: 0; + color: var(--color-destructive); + font-size: 0.78rem; + overflow-wrap: anywhere; +} + +.tdcd-engine-setup { + grid-column: 1 / -1; + display: grid; + gap: 0.6rem; + padding: 0.65rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background) 48%, transparent); +} + +.tdcd-engine-setup-copy, +.tdcd-install-option { + display: grid; + gap: 0.35rem; +} + +.tdcd-engine-setup-copy span, +.tdcd-install-option span, +.tdcd-command-override { + color: var(--color-muted-foreground); + font-size: 0.76rem; + font-weight: 650; +} + +.tdcd-install-options { + display: grid; + gap: 0.5rem; +} + +.tdcd-install-option { + padding: 0.45rem 0.55rem; + border-left: 3px solid var(--ts-cyan, #55c2ba); + background: color-mix(in srgb, var(--color-card) 56%, transparent); +} + +.tdcd-install-command { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.5rem; + align-items: center; +} + +.tdcd-install-option small { + color: var(--color-muted-foreground); + overflow-wrap: anywhere; +} + +.tdcd-command-override { + display: grid; + gap: 0.35rem; +} + +.tdcd-command-override input { + width: 100%; + min-width: 0; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + color: var(--color-foreground); + padding: 0.45rem 0.55rem; + font: inherit; +} + +.tdcd-engine-setup-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.tdcd-file-list { + display: grid; + gap: 0.75rem; + max-height: min(64vh, 46rem); + overflow: auto; + padding-right: 0.2rem; +} + +.tdcd-file-group { + display: grid; + gap: 0.45rem; + padding: 0.65rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background) 38%, transparent); +} + +.tdcd-file-group header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + min-width: 0; +} + +.tdcd-file-group header code { + flex: 1 1 auto; +} + +.tdcd-diagnostic { + display: grid; + gap: 0.35rem; + border-left: 3px solid var(--color-muted-foreground); + padding: 0.45rem 0.6rem; + background: color-mix(in srgb, var(--color-card) 52%, transparent); +} + +.tdcd-diagnostic-error { + border-left-color: var(--color-destructive); +} + +.tdcd-diagnostic-warning { + border-left-color: var(--ts-amber, #d6a84f); +} + +.tdcd-diagnostic-information, +.tdcd-diagnostic-hint { + border-left-color: var(--ts-blue, #77a7ff); +} + +.tdcd-diagnostic-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + color: var(--color-muted-foreground); + font-size: 0.74rem; +} + +.tdcd-diagnostic p { + margin: 0; + color: var(--color-foreground); + line-height: 1.4; + overflow-wrap: anywhere; +} + +.tdcd-diagnostic small { + color: var(--color-muted-foreground); + overflow-wrap: anywhere; +} + +@media (max-width: 980px) { + .tdcd-toolbar, + .tdcd-layout { + grid-template-columns: 1fr; + } + + .tdcd-controls { + justify-content: flex-start; + } +} + +@media (max-width: 720px) { + .tdcd-summary, + .tdcd-engine-row { + grid-template-columns: 1fr; + } + + .tdcd-engine-time { + white-space: normal; + } + + .tdcd-install-command { + grid-template-columns: 1fr; + } +} diff --git a/dashboard/code-diagnostics/src/types.ts b/dashboard/code-diagnostics/src/types.ts new file mode 100644 index 00000000..6863795a --- /dev/null +++ b/dashboard/code-diagnostics/src/types.ts @@ -0,0 +1,76 @@ +export type IdleBackfillMode = "off" | "idle"; +export type EngineState = + | "unavailable" + | "disabled" + | "starting" + | "indexing" + | "ready" + | "refreshing" + | "crashed"; +export type DiagnosticSeverity = "error" | "warning" | "information" | "hint"; + +export interface LanguageSettings { + enabled: boolean; + command_override?: string | null; +} + +export interface CodeDiagnosticsSettings { + idle_backfill: IdleBackfillMode; + languages: Record; +} + +export interface LspInstallOption { + label: string; + command: string; + notes: string | null; +} + +export interface DiagnosticsSummary { + total_errors: number; + total_warnings: number; + pending_refreshes: number; + last_refresh_age_seconds: number | null; +} + +export interface EngineStatus { + language: string; + language_id: string; + command: string; + default_command: string; + args: string[]; + enabled: boolean; + state: EngineState; + install_options: LspInstallOption[]; + last_error: string | null; + last_diagnostic_update: number | null; +} + +export interface CodeDiagnostic { + language: string; + source: string; + file: string; + line_start: number; + line_end: number; + character_start: number | null; + character_end: number | null; + severity: DiagnosticSeverity; + code: string | null; + message: string; + enclosing_node: string | null; + updated_at: number; +} + +export interface BackfillProgress { + queued_files: number; + opened_files: number; + files_with_diagnostics: number; + last_completed_sweep: number | null; +} + +export interface DiagnosticsSnapshot { + summary: DiagnosticsSummary; + engines: EngineStatus[]; + diagnostics: CodeDiagnostic[]; + backfill: Record; + settings: CodeDiagnosticsSettings; +} diff --git a/dashboard/dev/main.tsx b/dashboard/dev/main.tsx index b41f040d..d786498c 100644 --- a/dashboard/dev/main.tsx +++ b/dashboard/dev/main.tsx @@ -16,6 +16,7 @@ import { buildSDK } from "../shell/src/sdk.jsx"; import "../shell/src/styles.css"; import "../lib/primitives.css"; import "../graph/src/styles.css"; +import "../code-diagnostics/src/styles.css"; import "../savings/src/styles.css"; import "../lcm/src/styles.css"; // Holographic styles are generated by dev/run.mjs with the same Tailwind v4 @@ -86,6 +87,7 @@ try { const PLUGIN_ENTRIES = [ { name: "holographic", load: () => import("../holographic/src/entry") }, { name: "graph", load: () => import("../graph/src/entry") }, + { name: "code-diagnostics", load: () => import("../code-diagnostics/src/entry") }, { name: "savings", load: () => import("../savings/src/entry") }, { name: "hermes-lcm", load: () => import("../lcm/src/entry") }, ]; @@ -117,6 +119,7 @@ loadPlugins(); const PLUGIN_LABELS = { holographic: "Holographic Memory", graph: "Code Graph", + "code-diagnostics": "Code Diagnostics", savings: "Savings & Cost", "hermes-lcm": "LCM", }; diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 8443be02..2aed9e97 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -601,15 +601,21 @@ pub fn backup_and_write_json(path: &Path, value: &serde_json::Value) -> bool { pub fn which_tracedecay() -> Option { let current_exe = std::env::current_exe().ok(); let path_var = std::env::var_os("PATH"); - which_tracedecay_from(current_exe.as_deref(), path_var.as_deref()) + let cargo_target_dir = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from); + which_tracedecay_from( + current_exe.as_deref(), + path_var.as_deref(), + cargo_target_dir.as_deref(), + ) } fn which_tracedecay_from( current_exe: Option<&Path>, path_var: Option<&std::ffi::OsStr>, + cargo_target_dir: Option<&Path>, ) -> Option { - if let Some(exe) = - current_exe.filter(|exe| is_tracedecay_exe(exe) && !is_cargo_target_binary(exe)) + if let Some(exe) = current_exe + .filter(|exe| is_tracedecay_exe(exe) && !is_cargo_target_binary(exe, cargo_target_dir)) { return Some(normalize_path_separators(&exe.to_string_lossy())); } @@ -617,7 +623,7 @@ fn which_tracedecay_from( let path_match = path_var.and_then(|path_var| { std::env::split_paths(path_var).find_map(|dir| { let candidate = dir.join(tracedecay_bin_name()); - (candidate.exists() && !is_cargo_target_binary(&candidate)) + (candidate.exists() && !is_cargo_target_binary(&candidate, cargo_target_dir)) .then(|| normalize_path_separators(&candidate.to_string_lossy())) }) }); @@ -638,7 +644,11 @@ fn is_tracedecay_exe(path: &Path) -> bool { .is_some_and(|name| name == "tracedecay") } -fn is_cargo_target_binary(path: &Path) -> bool { +fn is_cargo_target_binary(path: &Path, cargo_target_dir: Option<&Path>) -> bool { + if cargo_target_dir.is_some_and(|target_dir| path.starts_with(target_dir)) { + return true; + } + let mut saw_target = false; for component in path.components() { let value = component.as_os_str(); @@ -2184,7 +2194,7 @@ mod path_normalize_tests { .join(tracedecay_bin_name()); let path_var = std::env::join_paths([dir.path().join("bin")]).unwrap(); - let found = which_tracedecay_from(Some(¤t_exe), Some(path_var.as_os_str())) + let found = which_tracedecay_from(Some(¤t_exe), Some(path_var.as_os_str()), None) .expect("PATH binary should be preferred over cargo target binary"); assert_eq!( @@ -2193,6 +2203,29 @@ mod path_normalize_tests { ); } + #[test] + fn which_tracedecay_prefers_path_when_current_exe_is_custom_cargo_target_binary() { + let dir = tempfile::tempdir().unwrap(); + let path_bin = dir.path().join("bin").join(tracedecay_bin_name()); + std::fs::create_dir_all(path_bin.parent().unwrap()).unwrap(); + std::fs::write(&path_bin, "").unwrap(); + let cargo_target_dir = dir.path().join("custom-target"); + let current_exe = cargo_target_dir.join("debug").join(tracedecay_bin_name()); + let path_var = std::env::join_paths([dir.path().join("bin")]).unwrap(); + + let found = which_tracedecay_from( + Some(¤t_exe), + Some(path_var.as_os_str()), + Some(&cargo_target_dir), + ) + .expect("PATH binary should be preferred over a custom cargo target binary"); + + assert_eq!( + found, + normalize_path_separators(&path_bin.to_string_lossy()) + ); + } + #[test] fn which_tracedecay_skips_cargo_target_binary_on_path() { let dir = tempfile::tempdir().unwrap(); @@ -2209,7 +2242,7 @@ mod path_normalize_tests { std::env::join_paths([target_bin.parent().unwrap(), stable_bin.parent().unwrap()]) .unwrap(); - let found = which_tracedecay_from(None, Some(path_var.as_os_str())) + let found = which_tracedecay_from(None, Some(path_var.as_os_str()), None) .expect("stable PATH binary should be found after skipping cargo target binary"); assert_eq!( @@ -2227,7 +2260,7 @@ mod path_normalize_tests { let current_exe = dir.path().join(".cargo/bin").join(tracedecay_bin_name()); let path_var = std::env::join_paths([dir.path().join("bin")]).unwrap(); - let found = which_tracedecay_from(Some(¤t_exe), Some(path_var.as_os_str())) + let found = which_tracedecay_from(Some(¤t_exe), Some(path_var.as_os_str()), None) .expect("non-target current exe should be accepted"); assert_eq!( diff --git a/src/automation/config.rs b/src/automation/config.rs index 9553a30d..16c3bf04 100644 --- a/src/automation/config.rs +++ b/src/automation/config.rs @@ -217,6 +217,7 @@ fn default_scheduler_tick_secs() -> u64 { DEFAULT_SCHEDULER_TICK_SECS } +#[allow(clippy::option_option)] fn deserialize_clearable_field<'de, D, T>( deserializer: D, ) -> std::result::Result>, D::Error> diff --git a/src/cli.rs b/src/cli.rs index f06da26e..bdd34824 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -103,6 +103,11 @@ pub enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Inspect language-server support for dashboard code diagnostics + Lsp { + #[command(subcommand)] + action: LspAction, + }, /// Configure agent integration (MCP server, permissions, hooks, prompt rules) #[command(name = "install", visible_alias = "claude-install")] Install { @@ -390,6 +395,16 @@ pub enum Commands { }, } +#[derive(Subcommand)] +pub enum LspAction { + /// List supported language servers, availability, and install hints + Servers { + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] pub enum DaemonAction { /// Run the foreground daemon process diff --git a/src/cli/parse_tests.rs b/src/cli/parse_tests.rs index 218573eb..4ca56047 100644 --- a/src/cli/parse_tests.rs +++ b/src/cli/parse_tests.rs @@ -1,7 +1,7 @@ use super::{ AutomationAction, AutomationConfigAction, AutomationConfigScope, AutomationRunAction, AutomationRunsAction, AutomationSkillsAction, AutomationSkillsInstallTarget, BranchAction, Cli, - Commands, DaemonAction, MemoryAction, MigrateAction, SessionsAction, + Commands, DaemonAction, LspAction, MemoryAction, MigrateAction, SessionsAction, }; use clap::{error::ErrorKind, CommandFactory, Parser}; @@ -106,6 +106,19 @@ fn update_help_describes_refresh_scope() { assert!(help.contains("Refresh the tracedecay binary, generated plugins, and daemon")); } +#[test] +fn lsp_servers_command_parses_json_flag() { + let cli = Cli::try_parse_from(["tracedecay", "lsp", "servers", "--json"]) + .expect("lsp servers should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Lsp { + action: LspAction::Servers { json: true } + }) + )); +} + #[test] fn codex_install_automation_flag_parses_without_extra_knobs() { let cli = Cli::try_parse_from(["tracedecay", "install", "--agent", "codex", "--automation"]) diff --git a/src/dashboard/assets.rs b/src/dashboard/assets.rs index 416c5c2c..fd1a8a9f 100644 --- a/src/dashboard/assets.rs +++ b/src/dashboard/assets.rs @@ -34,10 +34,88 @@ pub(crate) const LCM_JS: &str = include_str!("../../dashboard/lcm/dist/index.js" pub(crate) const LCM_CSS: &str = include_str!("../../dashboard/lcm/dist/style.css"); pub(crate) const GRAPH_JS: &str = include_str!("../../dashboard/graph/dist/index.js"); pub(crate) const GRAPH_CSS: &str = include_str!("../../dashboard/graph/dist/style.css"); +pub(crate) const CODE_DIAGNOSTICS_JS: &str = + include_str!("../../dashboard/code-diagnostics/dist/index.js"); +pub(crate) const CODE_DIAGNOSTICS_CSS: &str = + include_str!("../../dashboard/code-diagnostics/dist/style.css"); pub(crate) const SAVINGS_JS: &str = include_str!("../../dashboard/savings/dist/index.js"); pub(crate) const SAVINGS_CSS: &str = include_str!("../../dashboard/savings/dist/style.css"); const ASSET_STAMP: &str = env!("TRACEDECAY_DASHBOARD_ASSET_STAMP"); +pub(crate) struct DashboardPlugin { + pub(crate) name: &'static str, + pub(crate) label: &'static str, + pub(crate) description: &'static str, + pub(crate) icon: &'static str, +} + +pub(crate) const DASHBOARD_PLUGINS: &[DashboardPlugin] = &[ + DashboardPlugin { + name: "holographic", + label: "Holographic Memory", + description: "Holographic memory explorer + curation", + icon: "BrainCircuit", + }, + DashboardPlugin { + name: "hermes-lcm", + label: "LCM", + description: "Lossless Context Management dashboard tab.", + icon: "Database", + }, + DashboardPlugin { + name: "graph", + label: "Code Graph", + description: "Search and explore the indexed code graph.", + icon: "Network", + }, + DashboardPlugin { + name: "savings", + label: "Savings & Cost", + description: "Token savings ledger and session cost accounting.", + icon: "PiggyBank", + }, + DashboardPlugin { + name: "code-diagnostics", + label: "Code Diagnostics", + description: "LSP-backed compiler and type diagnostics.", + icon: "Bug", + }, +]; + +struct PluginAsset { + plugin: &'static str, + js: &'static str, + css: &'static str, +} + +const PLUGIN_ASSETS: &[PluginAsset] = &[ + PluginAsset { + plugin: "holographic", + js: HOLOGRAPHIC_JS, + css: HOLOGRAPHIC_CSS, + }, + PluginAsset { + plugin: "hermes-lcm", + js: LCM_JS, + css: LCM_CSS, + }, + PluginAsset { + plugin: "graph", + js: GRAPH_JS, + css: GRAPH_CSS, + }, + PluginAsset { + plugin: "code-diagnostics", + js: CODE_DIAGNOSTICS_JS, + css: CODE_DIAGNOSTICS_CSS, + }, + PluginAsset { + plugin: "savings", + js: SAVINGS_JS, + css: SAVINGS_CSS, + }, +]; + /// `ETag` value for every embedded asset: the compile-time bundle stamp, /// quoted per RFC 9110. fn asset_etag() -> String { @@ -113,15 +191,15 @@ pub(crate) async fn plugin_asset( ) -> Response { let serve = |body: &'static str, content_type| static_response(&headers, body.as_bytes(), content_type); - match (plugin.as_str(), file.as_str()) { - ("holographic", "index.js") => serve(HOLOGRAPHIC_JS, "application/javascript"), - ("holographic", "style.css") => serve(HOLOGRAPHIC_CSS, "text/css"), - ("hermes-lcm", "index.js") => serve(LCM_JS, "application/javascript"), - ("hermes-lcm", "style.css") => serve(LCM_CSS, "text/css"), - ("graph", "index.js") => serve(GRAPH_JS, "application/javascript"), - ("graph", "style.css") => serve(GRAPH_CSS, "text/css"), - ("savings", "index.js") => serve(SAVINGS_JS, "application/javascript"), - ("savings", "style.css") => serve(SAVINGS_CSS, "text/css"), + let Some(asset) = PLUGIN_ASSETS + .iter() + .find(|asset| asset.plugin == plugin.as_str()) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + match file.as_str() { + "index.js" => serve(asset.js, "application/javascript"), + "style.css" => serve(asset.css, "text/css"), _ => StatusCode::NOT_FOUND.into_response(), } } diff --git a/src/dashboard/automation_run_api.rs b/src/dashboard/automation_run_api.rs index 24f91823..e7737e3e 100644 --- a/src/dashboard/automation_run_api.rs +++ b/src/dashboard/automation_run_api.rs @@ -131,10 +131,12 @@ pub(crate) async fn memory_curator( "memory-curator", AgentTaskKind::MemoryCurator, move |state, run_id| async move { - automation_run_service::curation_agent_plan_payload_with_run_id( - &state, - request, - Some(run_id), + Box::pin( + automation_run_service::curation_agent_plan_payload_with_run_id( + &state, + request, + Some(run_id), + ), ) .await }, @@ -155,10 +157,12 @@ pub(crate) async fn session_reflection( "session-reflection", AgentTaskKind::SessionReflector, move |state, run_id| async move { - automation_run_service::session_reflection_run_payload_with_run_id( - &state, - request, - Some(run_id), + Box::pin( + automation_run_service::session_reflection_run_payload_with_run_id( + &state, + request, + Some(run_id), + ), ) .await }, @@ -179,10 +183,12 @@ pub(crate) async fn skill_writing( "skill-writing", AgentTaskKind::SkillWriter, move |state, run_id| async move { - automation_run_service::skill_writing_run_payload_with_run_id( - &state, - request, - Some(run_id), + Box::pin( + automation_run_service::skill_writing_run_payload_with_run_id( + &state, + request, + Some(run_id), + ), ) .await }, diff --git a/src/dashboard/code_diagnostics_api.rs b/src/dashboard/code_diagnostics_api.rs new file mode 100644 index 00000000..63af6743 --- /dev/null +++ b/src/dashboard/code_diagnostics_api.rs @@ -0,0 +1,280 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use axum::extract::{Path as AxumPath, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::util::{http_detail, JsonError}; +use super::DashboardState; +use crate::diagnostics::lsp::adapters::LspAdapterDefinition; +use crate::diagnostics::lsp::client::LspDocument; +use crate::diagnostics::lsp::settings::{ + save_settings, IdleBackfillMode, LanguageDiagnosticsSettings, +}; + +type ApiResult = std::result::Result, JsonError>; + +#[derive(Debug, Clone, Deserialize, Default)] +struct SettingsPatch { + #[serde(default)] + idle_backfill: Option, + #[serde(default)] + languages: BTreeMap, + #[serde(default)] + custom_adapters: Option>, +} + +pub(crate) async fn overview(State(state): State) -> ApiResult { + maybe_spawn_idle_backfill(&state).await; + let snapshot = state.code_diagnostics.read().await.snapshot(); + Ok(Json(json!(snapshot))) +} + +pub(crate) async fn patch_settings( + State(state): State, + Json(patch): Json, +) -> ApiResult { + let patch = serde_json::from_value::(patch) + .map_err(|err| bad_request(&format!("invalid code diagnostics settings patch: {err}")))?; + let mut settings = state.code_diagnostics.read().await.snapshot().settings; + if let Some(mode) = patch.idle_backfill { + settings.idle_backfill = mode; + } + for (language, language_settings) in patch.languages { + settings.languages.insert(language, language_settings); + } + if let Some(custom_adapters) = patch.custom_adapters { + settings.custom_adapters = custom_adapters; + } + save_settings(&state.dashboard_root, &settings) + .await + .map_err(|err| internal_error(&err))?; + let mut adapters = crate::diagnostics::lsp::adapters::builtin_adapters(); + adapters.extend(settings.custom_adapters.clone()); + let mut broker = state.code_diagnostics.write().await; + broker.update_adapters(adapters); + broker.update_settings(settings); + Ok(Json(json!(broker.snapshot()))) +} + +pub(crate) async fn refresh_all(State(state): State) -> ApiResult { + let languages: Vec = state + .code_diagnostics + .read() + .await + .snapshot() + .engines + .into_iter() + .map(|engine| engine.language) + .collect(); + for language in languages { + refresh_one(&state, &language).await?; + } + let snapshot = state.code_diagnostics.read().await.snapshot(); + Ok(Json(json!(snapshot))) +} + +pub(crate) async fn refresh_language( + State(state): State, + AxumPath(language): AxumPath, +) -> ApiResult { + refresh_one(&state, &language).await?; + let snapshot = state.code_diagnostics.read().await.snapshot(); + Ok(Json(json!(snapshot))) +} + +async fn refresh_one(state: &DashboardState, language: &str) -> std::result::Result<(), JsonError> { + let snapshot = state.code_diagnostics.read().await.snapshot(); + if !snapshot.settings.language_enabled(language) { + state + .code_diagnostics + .write() + .await + .set_language_enabled(language, false); + return Ok(()); + } + let Some(adapter) = state.code_diagnostics.read().await.adapter_for(language) else { + return Err(bad_request(&format!( + "no code diagnostics adapter registered for language '{language}'" + ))); + }; + let files = indexed_files(&state.graph_conn) + .await + .map_err(|err| internal_error(&err))?; + let documents = documents_for_adapter(&state.project_root, &adapter, files) + .await + .map_err(|err| internal_error(&err))?; + let document_count = documents.len(); + state + .code_diagnostics + .write() + .await + .record_backfill_progress(language, document_count, document_count, 0, None); + if documents.is_empty() { + state + .code_diagnostics + .write() + .await + .record_backfill_progress( + language, + 0, + 0, + 0, + Some(crate::tracedecay::current_timestamp()), + ); + return Ok(()); + } + let prepared = state + .code_diagnostics + .write() + .await + .prepare_refresh(language, documents); + let refresh_ok = match prepared { + Ok(Some(prepared)) => { + let completed = prepared.collect_diagnostics(Duration::from_secs(5)).await; + let refresh_ok = completed.is_ok(); + state + .code_diagnostics + .write() + .await + .finish_refresh(completed) + .ok(); + refresh_ok + } + Ok(None) => true, + Err(_) => false, + }; + let snapshot = state.code_diagnostics.read().await.snapshot(); + let files_with_diagnostics = snapshot + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.language == language) + .map(|diagnostic| diagnostic.file.as_str()) + .collect::>() + .len(); + state + .code_diagnostics + .write() + .await + .record_backfill_progress( + language, + document_count, + document_count, + files_with_diagnostics, + refresh_ok.then(crate::tracedecay::current_timestamp), + ); + Ok(()) +} + +async fn maybe_spawn_idle_backfill(state: &DashboardState) { + let snapshot = state.code_diagnostics.read().await.snapshot(); + if snapshot.settings.idle_backfill != IdleBackfillMode::Idle { + return; + } + if state + .code_diagnostics_backfill_started + .swap(true, Ordering::AcqRel) + { + return; + } + let state = state.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(750)).await; + let languages: Vec = state + .code_diagnostics + .read() + .await + .snapshot() + .engines + .into_iter() + .filter(|engine| engine.enabled) + .map(|engine| engine.language) + .collect(); + for language in languages { + let _ = refresh_one(&state, &language).await; + tokio::task::yield_now().await; + } + }); +} + +async fn indexed_files(conn: &libsql::Connection) -> crate::errors::Result> { + let mut rows = conn + .query("SELECT path FROM files ORDER BY path ASC", ()) + .await?; + let mut files = Vec::new(); + while let Some(row) = rows.next().await? { + if let Ok(path) = row.get::(0) { + files.push(path); + } + } + Ok(files) +} + +async fn documents_for_adapter( + project_root: &Path, + adapter: &LspAdapterDefinition, + files: Vec, +) -> crate::errors::Result> { + let mut documents = Vec::new(); + for file in files { + if !matches_adapter_extension(adapter, &file) { + continue; + } + let path = project_root.join(&file); + let Ok(text) = tokio::fs::read_to_string(&path).await else { + continue; + }; + documents.push(LspDocument { + language: adapter.language.clone(), + language_id: language_id_for_file(adapter, &file), + relative_path: file, + text, + }); + } + Ok(documents) +} + +fn language_id_for_file(adapter: &LspAdapterDefinition, file: &str) -> String { + let extension = Path::new(file) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default(); + match (adapter.language.as_str(), extension) { + ("typescript", "tsx") => "typescriptreact".to_string(), + ("javascript", "jsx") => "javascriptreact".to_string(), + _ => adapter.language_id.clone(), + } +} + +fn matches_adapter_extension(adapter: &LspAdapterDefinition, file: &str) -> bool { + Path::new(file) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + adapter + .extensions + .iter() + .any(|candidate| candidate == extension) + }) +} + +fn bad_request(err: &impl ToString) -> JsonError { + ( + StatusCode::BAD_REQUEST, + Json(json!({ + "detail": err.to_string(), + })), + ) +} + +fn internal_error(err: &impl ToString) -> JsonError { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(http_detail(&err.to_string())), + ) +} diff --git a/src/dashboard/memory_curate.rs b/src/dashboard/memory_curate.rs index 0ba32eca..76d261d7 100644 --- a/src/dashboard/memory_curate.rs +++ b/src/dashboard/memory_curate.rs @@ -14,6 +14,7 @@ //! (the evidence guard) and applies them through the canonical store paths. use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::sync::atomic::AtomicBool; use std::sync::Arc; use serde_json::{json, Map, Value}; @@ -24,7 +25,7 @@ use super::memory_service::{ apply_delete_op, apply_merge_op, build_delete_plan, delete_fact, similarity_computation, }; use super::util::{qmarks, query_rows}; -use super::{storage_mode_label, token_count, DashboardState}; +use super::{code_diagnostics_broker, storage_mode_label, token_count, DashboardState}; use crate::errors::{Result, TraceDecayError}; use crate::tracedecay::TraceDecay; @@ -135,6 +136,11 @@ async fn cli_state(cg: &TraceDecay) -> DashboardState { curate_preview: Arc::new(RwLock::new(None)), curation_activity: Arc::new(RwLock::new(Vec::new())), token_counts: Arc::new(token_count::TokenCountCache::new()), + code_diagnostics: Arc::new(RwLock::new(code_diagnostics_broker( + cg.project_root().to_path_buf(), + crate::diagnostics::lsp::settings::CodeDiagnosticsSettings::default(), + ))), + code_diagnostics_backfill_started: Arc::new(AtomicBool::new(false)), } } diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs index 507d08fb..0485dcc7 100644 --- a/src/dashboard/mod.rs +++ b/src/dashboard/mod.rs @@ -29,6 +29,7 @@ mod automation_run_api; mod automation_run_service; mod automation_scheduler_api; mod automation_skills_api; +mod code_diagnostics_api; mod curate_preview_store; mod graph_api; mod graph_queries; @@ -47,6 +48,7 @@ mod token_count; mod util; use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; use std::sync::Arc; use axum::extract::State; @@ -59,6 +61,7 @@ use tokio::sync::RwLock; use crate::automation::backend; use crate::automation::config::{self, AutomationBackend, AutomationHostMode}; use crate::db::Database; +use crate::diagnostics::lsp; use crate::errors::{Result, TraceDecayError}; use crate::global_db::GlobalDb; use crate::storage::StorageMode; @@ -118,6 +121,12 @@ pub(crate) struct DashboardState { /// In-process BPE token-count cache for the Savings & Cost tab (backed /// by the `dashboard_token_counts` sidecar in the global accounting DB). pub(crate) token_counts: Arc, + /// Dashboard-owned LSP diagnostics broker. This is deliberately not + /// exposed to hooks or model-context paths in Phase 1. + pub(crate) code_diagnostics: Arc>, + /// Ensures the dashboard-opened idle backfill pass is scheduled once per + /// dashboard server lifetime. + pub(crate) code_diagnostics_backfill_started: Arc, } /// The LCM session store the dashboard will serve. @@ -166,6 +175,15 @@ pub(crate) fn storage_mode_label(mode: &StorageMode) -> &'static str { } } +pub(crate) fn code_diagnostics_broker( + project_root: PathBuf, + settings: lsp::settings::CodeDiagnosticsSettings, +) -> lsp::broker::DiagnosticBroker { + let mut adapters = lsp::adapters::builtin_adapters(); + adapters.extend(settings.custom_adapters.clone()); + lsp::broker::DiagnosticBroker::new(project_root, adapters, settings) +} + async fn open_dashboard_connection(path: &Path) -> Option { let (db, _) = Database::open(path).await.ok()?; Some(db.conn().clone()) @@ -224,6 +242,11 @@ pub(crate) async fn build_state(cg: &TraceDecay) -> DashboardState { let store_root = cg.store_layout().data_root.clone(); let storage_mode = storage_mode_label(&cg.store_layout().storage_mode).to_string(); let persisted_preview = curate_preview_store::load(&dashboard_root).await; + let code_diagnostics_settings = lsp::settings::load_settings(&dashboard_root) + .await + .unwrap_or_default(); + let code_diagnostics = + code_diagnostics_broker(cg.project_root().to_path_buf(), code_diagnostics_settings); let savings_db = GlobalDb::open().await.map(Arc::new); let savings_db_path = crate::global_db::global_db_path() .map(|p| p.display().to_string()) @@ -245,6 +268,8 @@ pub(crate) async fn build_state(cg: &TraceDecay) -> DashboardState { curate_preview: Arc::new(RwLock::new(persisted_preview)), curation_activity: Arc::new(RwLock::new(Vec::new())), token_counts: Arc::new(token_count::TokenCountCache::new()), + code_diagnostics: Arc::new(RwLock::new(code_diagnostics)), + code_diagnostics_backfill_started: Arc::new(AtomicBool::new(false)), }; if let Err(err) = memory_api::repair_derived_memory(&state).await { eprintln!("Dashboard memory repair skipped: {err}"); @@ -546,6 +571,19 @@ pub(crate) fn router(state: DashboardState) -> Router { "/api/plugins/analytics/underused", get(analytics_api::underused), ) + // Code Diagnostics API (dashboard-only LSP diagnostics broker) + .route( + "/api/plugins/code-diagnostics", + get(code_diagnostics_api::overview).patch(code_diagnostics_api::patch_settings), + ) + .route( + "/api/plugins/code-diagnostics/refresh", + post(code_diagnostics_api::refresh_all), + ) + .route( + "/api/plugins/code-diagnostics/refresh/{language}", + post(code_diagnostics_api::refresh_language), + ) // Savings & Cost API (savings ledger + session cost accounting) .route("/api/plugins/savings/overview", get(savings_api::overview)) .route("/api/plugins/savings/ledger", get(savings_api::ledger)) @@ -599,6 +637,7 @@ async fn capabilities(State(state): State) -> Json { "lcm_payload_health": has_lcm, "graph": true, "analytics": true, + "code_diagnostics": true, // Similarity-based dedup curation (delete/merge ops via /curate // and /curate/apply). LLM-proposed curation is served by the // configured standalone automation backend when enabled. @@ -617,53 +656,29 @@ async fn capabilities(State(state): State) -> Json { "host_mode": automation_host_mode, "availability": backend_availability, }, - "dashboards": ["holographic", "hermes-lcm", "graph", "savings"], + "dashboards": assets::DASHBOARD_PLUGINS + .iter() + .map(|plugin| plugin.name) + .collect::>(), })) } /// Plugin manifest list, mirroring the Hermes `/api/dashboard/plugins` /// endpoint shape closely enough for the standalone shell. async fn plugins_list() -> Json { - Json(json!([ - { - "name": "holographic", - "label": "Holographic Memory", - "description": "Holographic memory explorer + curation", - "icon": "BrainCircuit", - "entry": "dist/index.js", - "css": "dist/style.css", - "has_api": true, - "source": "tracedecay", - }, - { - "name": "hermes-lcm", - "label": "LCM", - "description": "Lossless Context Management dashboard tab.", - "icon": "Database", - "entry": "dist/index.js", - "css": "dist/style.css", - "has_api": true, - "source": "tracedecay", - }, - { - "name": "graph", - "label": "Code Graph", - "description": "Search and explore the indexed code graph.", - "icon": "Network", - "entry": "dist/index.js", - "css": "dist/style.css", - "has_api": true, - "source": "tracedecay", - }, - { - "name": "savings", - "label": "Savings & Cost", - "description": "Token savings ledger and session cost accounting.", - "icon": "PiggyBank", - "entry": "dist/index.js", - "css": "dist/style.css", - "has_api": true, - "source": "tracedecay", - } - ])) + Json(json!(assets::DASHBOARD_PLUGINS + .iter() + .map(|plugin| { + json!({ + "name": plugin.name, + "label": plugin.label, + "description": plugin.description, + "icon": plugin.icon, + "entry": "dist/index.js", + "css": "dist/style.css", + "has_api": true, + "source": "tracedecay", + }) + }) + .collect::>())) } diff --git a/src/diagnostics/lsp/adapters.rs b/src/diagnostics/lsp/adapters.rs new file mode 100644 index 00000000..9e30c208 --- /dev/null +++ b/src/diagnostics/lsp/adapters.rs @@ -0,0 +1,225 @@ +use serde::{Deserialize, Serialize}; + +/// How an LSP server reports diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticMode { + Push, + Pull, + PushAndPull, +} + +/// Static description of an LSP adapter the dashboard broker can manage. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LspAdapterDefinition { + pub language: String, + pub language_id: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub extensions: Vec, + #[serde(default)] + pub root_markers: Vec, + #[serde(default)] + pub install_options: Vec, + pub diagnostics: DiagnosticMode, +} + +/// Operator-facing install hint for an LSP server. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LspInstallOption { + pub label: String, + pub command: String, + #[serde(default)] + pub notes: Option, +} + +/// Built-in language-server adapters for the Phase 1 dashboard surface. +pub fn builtin_adapters() -> Vec { + vec![ + adapter(AdapterSpec { + language: "rust", + language_id: "rust", + command: "rust-analyzer", + args: &[], + extensions: &["rs"], + root_markers: &["Cargo.toml"], + install_options: &[install("rustup", "rustup component add rust-analyzer", None)], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "typescript", + language_id: "typescript", + command: "typescript-language-server", + args: &["--stdio"], + extensions: &["ts", "tsx"], + root_markers: &["tsconfig.json", "jsconfig.json"], + install_options: &[install( + "npm", + "npm install -g typescript typescript-language-server", + None, + )], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "javascript", + language_id: "javascript", + command: "typescript-language-server", + args: &["--stdio"], + extensions: &["js", "jsx"], + root_markers: &["jsconfig.json", "tsconfig.json"], + install_options: &[install( + "npm", + "npm install -g typescript typescript-language-server", + None, + )], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "python", + language_id: "python", + command: "pyright-langserver", + args: &["--stdio"], + extensions: &["py"], + root_markers: &["pyrightconfig.json", "pyproject.toml"], + install_options: &[install("npm", "npm install -g pyright", None)], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "go", + language_id: "go", + command: "gopls", + args: &[], + extensions: &["go"], + root_markers: &["go.mod"], + install_options: &[install( + "go", + "go install golang.org/x/tools/gopls@latest", + None, + )], + diagnostics: DiagnosticMode::PushAndPull, + }), + adapter(AdapterSpec { + language: "c", + language_id: "c", + command: "clangd", + args: &[], + extensions: &["c", "h"], + root_markers: &["compile_commands.json"], + install_options: &[install( + "system package", + "sudo apt install clangd", + Some("Use your platform package manager on non-Debian systems."), + )], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "cpp", + language_id: "cpp", + command: "clangd", + args: &[], + extensions: &["cc", "cpp", "cxx", "hh", "hpp", "hxx"], + root_markers: &["compile_commands.json"], + install_options: &[install( + "system package", + "sudo apt install clangd", + Some("Use your platform package manager on non-Debian systems."), + )], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "objc", + language_id: "objective-c", + command: "clangd", + args: &[], + extensions: &["m", "mm"], + root_markers: &["compile_commands.json"], + install_options: &[install( + "system package", + "sudo apt install clangd", + Some("Use your platform package manager on non-Debian systems."), + )], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "zig", + language_id: "zig", + command: "zls", + args: &[], + extensions: &["zig"], + root_markers: &["build.zig"], + install_options: &[install( + "package manager", + "brew install zls", + Some("Use your platform package manager or the zigtools/zls release for non-macOS systems."), + )], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "lua", + language_id: "lua", + command: "lua-language-server", + args: &[], + extensions: &["lua"], + root_markers: &[".luarc.json"], + install_options: &[install( + "system package", + "brew install lua-language-server", + Some("Use your platform package manager on non-macOS systems."), + )], + diagnostics: DiagnosticMode::Push, + }), + adapter(AdapterSpec { + language: "php", + language_id: "php", + command: "intelephense", + args: &["--stdio"], + extensions: &["php"], + root_markers: &["composer.json"], + install_options: &[install("npm", "npm install -g intelephense", None)], + diagnostics: DiagnosticMode::Push, + }), + ] +} + +#[derive(Clone, Copy)] +struct AdapterSpec<'a> { + language: &'a str, + language_id: &'a str, + command: &'a str, + args: &'a [&'a str], + extensions: &'a [&'a str], + root_markers: &'a [&'a str], + install_options: &'a [LspInstallOption], + diagnostics: DiagnosticMode, +} + +fn adapter(spec: AdapterSpec<'_>) -> LspAdapterDefinition { + LspAdapterDefinition { + language: spec.language.to_string(), + language_id: spec.language_id.to_string(), + command: spec.command.to_string(), + args: spec.args.iter().map(|arg| (*arg).to_string()).collect(), + extensions: spec + .extensions + .iter() + .map(|extension| (*extension).to_string()) + .collect(), + root_markers: spec + .root_markers + .iter() + .map(|marker| (*marker).to_string()) + .collect(), + install_options: spec.install_options.to_vec(), + diagnostics: spec.diagnostics, + } +} + +fn install(label: &str, command: &str, notes: Option<&str>) -> LspInstallOption { + LspInstallOption { + label: label.to_string(), + command: command.to_string(), + notes: notes.map(str::to_string), + } +} diff --git a/src/diagnostics/lsp/broker.rs b/src/diagnostics/lsp/broker.rs new file mode 100644 index 00000000..656660e3 --- /dev/null +++ b/src/diagnostics/lsp/broker.rs @@ -0,0 +1,520 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use crate::diagnostics::lsp::adapters::{LspAdapterDefinition, LspInstallOption}; +use crate::diagnostics::lsp::client::{LspDocument, StdioLspClient}; +use crate::diagnostics::lsp::settings::CodeDiagnosticsSettings; +use crate::errors::{Result, TraceDecayError}; + +/// Normalized code diagnostic shared by the LSP broker and dashboard API. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodeDiagnostic { + pub language: String, + pub source: String, + pub file: String, + pub line_start: u32, + pub line_end: u32, + pub character_start: Option, + pub character_end: Option, + pub severity: DiagnosticSeverity, + pub code: Option, + pub message: String, + pub enclosing_node: Option, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticSeverity { + Error, + Warning, + Information, + Hint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EngineState { + Unavailable, + Disabled, + Starting, + Indexing, + Ready, + Refreshing, + Crashed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EngineStatus { + pub language: String, + pub language_id: String, + pub command: String, + pub default_command: String, + pub args: Vec, + pub enabled: bool, + pub state: EngineState, + pub install_options: Vec, + pub last_error: Option, + pub last_diagnostic_update: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct BackfillProgress { + pub queued_files: usize, + pub opened_files: usize, + pub files_with_diagnostics: usize, + pub last_completed_sweep: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct DiagnosticsSummary { + pub total_errors: usize, + pub total_warnings: usize, + pub pending_refreshes: usize, + pub last_refresh_age_seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiagnosticsSnapshot { + pub summary: DiagnosticsSummary, + pub engines: Vec, + pub diagnostics: Vec, + pub backfill: BTreeMap, + pub settings: CodeDiagnosticsSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct LspSessionKey { + language: String, + command: String, + workspace_root: PathBuf, +} + +struct RefreshBatch { + workspace_root: PathBuf, + documents: Vec, + client: Arc>>, +} + +pub struct PreparedRefresh { + language: String, + project_root: PathBuf, + command: String, + args: Vec, + batches: Vec, +} + +pub struct CompletedRefresh { + language: String, + command: String, + result: std::result::Result, String>, +} + +impl CompletedRefresh { + pub fn is_ok(&self) -> bool { + self.result.is_ok() + } +} + +impl PreparedRefresh { + pub async fn collect_diagnostics(self, diagnostics_timeout: Duration) -> CompletedRefresh { + let language = self.language.clone(); + let command = self.command.clone(); + let result = self.collect(diagnostics_timeout).await; + CompletedRefresh { + language, + command, + result, + } + } + + async fn collect( + self, + diagnostics_timeout: Duration, + ) -> std::result::Result, String> { + let mut diagnostics = Vec::new(); + for batch in self.batches { + let mut client_slot = batch.client.lock().await; + if client_slot.is_none() { + *client_slot = Some( + StdioLspClient::start(&self.command, &self.args, &batch.workspace_root) + .await + .map_err(|err| err.to_string())?, + ); + } + let Some(client) = client_slot.as_mut() else { + return Err("LSP client should be initialized".to_string()); + }; + let result = client + .collect_document_diagnostics( + &self.project_root, + batch.documents, + diagnostics_timeout, + ) + .await; + match result { + Ok(mut batch_diagnostics) => diagnostics.append(&mut batch_diagnostics), + Err(err) => { + *client_slot = None; + return Err(err.to_string()); + } + } + } + Ok(diagnostics) + } +} + +/// Dashboard-owned diagnostics broker state. +pub struct DiagnosticBroker { + project_root: PathBuf, + adapters: Vec, + settings: CodeDiagnosticsSettings, + diagnostics: Vec, + clients: BTreeMap>>>, + engine_overrides: BTreeMap, + engine_errors: BTreeMap, + backfill: BTreeMap, +} + +impl DiagnosticBroker { + pub fn new( + project_root: impl Into, + adapters: Vec, + settings: CodeDiagnosticsSettings, + ) -> Self { + Self { + project_root: project_root.into(), + adapters, + settings, + diagnostics: Vec::new(), + clients: BTreeMap::new(), + engine_overrides: BTreeMap::new(), + engine_errors: BTreeMap::new(), + backfill: BTreeMap::new(), + } + } + + pub fn new_for_test( + project_root: impl Into, + adapters: Vec, + ) -> Self { + Self::new(project_root, adapters, CodeDiagnosticsSettings::default()) + } + + pub fn snapshot(&self) -> DiagnosticsSnapshot { + DiagnosticsSnapshot { + summary: self.summary(), + engines: self.engine_statuses(), + diagnostics: self.diagnostics.clone(), + backfill: self.backfill.clone(), + settings: self.settings.clone(), + } + } + + pub fn adapter_for(&self, language: &str) -> Option { + self.adapters + .iter() + .find(|adapter| adapter.language == language) + .cloned() + } + + pub fn update_adapters(&mut self, adapters: Vec) { + self.adapters = adapters; + self.clients.clear(); + } + + pub fn set_language_enabled(&mut self, language: &str, enabled: bool) { + self.settings.set_language_enabled(language, enabled); + if enabled { + self.engine_overrides.remove(language); + } else { + self.engine_overrides + .insert(language.to_string(), EngineState::Disabled); + self.remove_language_clients(language); + self.clear_language(language); + } + } + + pub fn prepare_refresh( + &mut self, + language: &str, + documents: Vec, + ) -> Result> { + if !self.settings.language_enabled(language) { + self.engine_overrides + .insert(language.to_string(), EngineState::Disabled); + self.remove_language_clients(language); + self.clear_language(language); + return Ok(None); + } + let adapter = self + .adapters + .iter() + .find(|adapter| adapter.language == language) + .cloned() + .ok_or_else(|| TraceDecayError::Config { + message: format!("no LSP adapter registered for language '{language}'"), + })?; + + let command = self.settings.command_for(language, &adapter.command); + if !command_available(&command) { + let message = format!("LSP command '{command}' is not available on PATH"); + self.engine_errors + .insert(language.to_string(), message.clone()); + self.engine_overrides + .insert(language.to_string(), EngineState::Unavailable); + self.remove_language_clients(language); + return Err(TraceDecayError::Config { message }); + } + + self.engine_overrides + .insert(language.to_string(), EngineState::Refreshing); + let project_root = self.project_root.clone(); + let mut documents_by_root: BTreeMap> = BTreeMap::new(); + for document in documents { + let workspace_root = + workspace_root_for_document(&self.project_root, &adapter, &document); + documents_by_root + .entry(workspace_root) + .or_default() + .push(document); + } + let batches = documents_by_root + .into_iter() + .map(|(workspace_root, documents)| { + let session_key = LspSessionKey { + language: language.to_string(), + command: command.clone(), + workspace_root: workspace_root.clone(), + }; + let client = self + .clients + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(None))) + .clone(); + RefreshBatch { + workspace_root, + documents, + client, + } + }) + .collect(); + Ok(Some(PreparedRefresh { + language: language.to_string(), + project_root, + command, + args: adapter.args, + batches, + })) + } + + pub async fn refresh_documents( + &mut self, + language: &str, + documents: Vec, + diagnostics_timeout: Duration, + ) -> Result<()> { + let Some(prepared) = self.prepare_refresh(language, documents)? else { + return Ok(()); + }; + let result = prepared.collect_diagnostics(diagnostics_timeout).await; + self.finish_refresh(result) + } + + pub fn finish_refresh(&mut self, completed: CompletedRefresh) -> Result<()> { + let language = completed.language; + if !self.settings.language_enabled(&language) { + self.engine_overrides + .insert(language.clone(), EngineState::Disabled); + self.remove_language_clients(&language); + self.clear_language(&language); + return Ok(()); + } + if !self.command_matches_current_settings(&language, &completed.command) { + return Ok(()); + } + match completed.result { + Ok(mut diagnostics) => { + self.diagnostics + .retain(|diagnostic| diagnostic.language != language); + self.diagnostics.append(&mut diagnostics); + self.engine_errors.remove(&language); + self.engine_overrides.insert(language, EngineState::Ready); + Ok(()) + } + Err(message) => { + self.engine_errors.insert(language.clone(), message.clone()); + self.engine_overrides + .insert(language.clone(), EngineState::Crashed); + self.remove_language_clients(&language); + Err(TraceDecayError::Config { message }) + } + } + } + + pub fn update_settings(&mut self, settings: CodeDiagnosticsSettings) { + self.settings = settings; + self.clients.clear(); + self.engine_overrides.clear(); + let disabled_languages: Vec = self + .settings + .languages + .iter() + .filter(|(_, settings)| !settings.enabled) + .map(|(language, _)| language.clone()) + .collect(); + for language in disabled_languages { + self.engine_overrides + .insert(language.clone(), EngineState::Disabled); + self.clear_language(&language); + } + } + + pub fn cache_diagnostic(&mut self, diagnostic: CodeDiagnostic) { + self.diagnostics.push(diagnostic); + } + + pub fn record_backfill_progress( + &mut self, + language: &str, + queued_files: usize, + opened_files: usize, + files_with_diagnostics: usize, + last_completed_sweep: Option, + ) { + self.backfill.insert( + language.to_string(), + BackfillProgress { + queued_files, + opened_files, + files_with_diagnostics, + last_completed_sweep, + }, + ); + } + + pub fn project_root(&self) -> &Path { + &self.project_root + } + + fn clear_language(&mut self, language: &str) { + self.diagnostics + .retain(|diagnostic| diagnostic.language != language); + self.backfill.remove(language); + } + + fn remove_language_clients(&mut self, language: &str) { + self.clients + .retain(|key, _| key.language.as_str() != language); + } + + fn command_matches_current_settings(&self, language: &str, command: &str) -> bool { + self.adapters + .iter() + .find(|adapter| adapter.language == language) + .is_some_and(|adapter| self.settings.command_for(language, &adapter.command) == command) + } + + fn summary(&self) -> DiagnosticsSummary { + let total_errors = self + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + .count(); + let total_warnings = self + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.severity == DiagnosticSeverity::Warning) + .count(); + DiagnosticsSummary { + total_errors, + total_warnings, + pending_refreshes: 0, + last_refresh_age_seconds: None, + } + } + + fn engine_statuses(&self) -> Vec { + self.adapters + .iter() + .map(|adapter| { + let enabled = self.settings.language_enabled(&adapter.language); + let command = self + .settings + .command_for(&adapter.language, &adapter.command); + let state = self + .engine_overrides + .get(&adapter.language) + .copied() + .unwrap_or_else(|| default_state(enabled, &command)); + let last_diagnostic_update = self + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.language == adapter.language) + .map(|diagnostic| diagnostic.updated_at) + .max(); + EngineStatus { + language: adapter.language.clone(), + language_id: adapter.language_id.clone(), + command, + default_command: adapter.command.clone(), + args: adapter.args.clone(), + enabled, + state, + install_options: adapter.install_options.clone(), + last_error: self.engine_errors.get(&adapter.language).cloned(), + last_diagnostic_update, + } + }) + .collect() + } +} + +fn default_state(enabled: bool, command: &str) -> EngineState { + if !enabled { + return EngineState::Disabled; + } + if command_available(command) { + EngineState::Ready + } else { + EngineState::Unavailable + } +} + +fn workspace_root_for_document( + project_root: &Path, + adapter: &LspAdapterDefinition, + document: &LspDocument, +) -> PathBuf { + let file = project_root.join(&document.relative_path); + let mut current = file.parent(); + while let Some(dir) = current { + if adapter + .root_markers + .iter() + .any(|marker| dir.join(marker).exists()) + { + return dir.to_path_buf(); + } + if dir == project_root { + break; + } + current = dir.parent(); + } + project_root.to_path_buf() +} + +pub fn command_available(command: &str) -> bool { + if command.contains(std::path::MAIN_SEPARATOR) { + return Path::new(command).is_file(); + } + let Some(paths) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&paths).any(|path| path.join(command).is_file()) +} diff --git a/src/diagnostics/lsp/client.rs b/src/diagnostics/lsp/client.rs new file mode 100644 index 00000000..3479b038 --- /dev/null +++ b/src/diagnostics/lsp/client.rs @@ -0,0 +1,371 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + +use crate::diagnostics::lsp::broker::{CodeDiagnostic, DiagnosticSeverity}; +use crate::errors::{Result, TraceDecayError}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LspDocument { + pub language: String, + pub language_id: String, + pub relative_path: String, + pub text: String, +} + +pub async fn collect_document_diagnostics( + command: &str, + args: &[String], + project_root: &Path, + documents: Vec, + diagnostics_timeout: Duration, +) -> Result> { + let mut client = StdioLspClient::start(command, args, project_root).await?; + client + .collect_document_diagnostics(project_root, documents, diagnostics_timeout) + .await +} + +pub struct StdioLspClient { + command: String, + document_versions: BTreeMap, + stdin: tokio::process::ChildStdin, + reader: BufReader, + child: tokio::process::Child, +} + +impl StdioLspClient { + pub async fn start(command: &str, args: &[String], project_root: &Path) -> Result { + let mut child = tokio::process::Command::new(command) + .args(args) + .current_dir(project_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|e| TraceDecayError::Config { + message: format!("failed to spawn LSP server '{command}': {e}"), + })?; + + let mut stdin = child.stdin.take().ok_or_else(|| TraceDecayError::Config { + message: format!("failed to open stdin for LSP server '{command}'"), + })?; + let stdout = child.stdout.take().ok_or_else(|| TraceDecayError::Config { + message: format!("failed to open stdout for LSP server '{command}'"), + })?; + let mut reader = BufReader::new(stdout); + + write_message( + &mut stdin, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "processId": null, + "rootUri": file_uri(project_root), + "capabilities": { + "textDocument": { + "publishDiagnostics": {} + } + }, + "workspaceFolders": [{ + "uri": file_uri(project_root), + "name": project_root + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + }] + } + }), + ) + .await?; + wait_for_initialize(&mut reader).await?; + write_message( + &mut stdin, + json!({ + "jsonrpc": "2.0", + "method": "initialized", + "params": {} + }), + ) + .await?; + + Ok(Self { + command: command.to_string(), + document_versions: BTreeMap::new(), + stdin, + reader, + child, + }) + } + + pub async fn collect_document_diagnostics( + &mut self, + project_root: &Path, + documents: Vec, + diagnostics_timeout: Duration, + ) -> Result> { + let mut uri_to_document = BTreeMap::new(); + for document in &documents { + let uri = file_uri(&project_root.join(&document.relative_path)); + uri_to_document.insert(uri.clone(), document.clone()); + let next_version = self.document_versions.get(&uri).copied().unwrap_or(0) + 1; + if next_version == 1 { + write_message( + &mut self.stdin, + json!({ + "jsonrpc": "2.0", + "method": "textDocument/didOpen", + "params": { + "textDocument": { + "uri": uri, + "languageId": document.language_id, + "version": next_version, + "text": document.text, + } + } + }), + ) + .await?; + } + let change_version = next_version + 1; + write_message( + &mut self.stdin, + json!({ + "jsonrpc": "2.0", + "method": "textDocument/didChange", + "params": { + "textDocument": { + "uri": uri, + "version": change_version + }, + "contentChanges": [{ + "text": document.text, + }] + } + }), + ) + .await?; + self.document_versions.insert(uri, change_version); + } + + let mut diagnostics_by_uri: BTreeMap> = BTreeMap::new(); + let deadline = tokio::time::Instant::now() + diagnostics_timeout; + loop { + let now = tokio::time::Instant::now(); + if now >= deadline { + break; + } + let remaining = deadline.saturating_duration_since(now); + let message = + match tokio::time::timeout(remaining, read_message(&mut self.reader)).await { + Ok(Ok(Some(message))) => message, + Ok(Ok(None)) | Err(_) => break, + Ok(Err(err)) => return Err(err), + }; + if message.method.as_deref() != Some("textDocument/publishDiagnostics") { + continue; + } + let Some(params) = message.params else { + continue; + }; + let Ok(published) = serde_json::from_value::(params) else { + continue; + }; + let Some(document) = uri_to_document.get(&published.uri) else { + continue; + }; + diagnostics_by_uri.insert( + published.uri, + published + .diagnostics + .into_iter() + .map(|diagnostic| diagnostic.into_code_diagnostic(document, &self.command)) + .collect(), + ); + } + Ok(diagnostics_by_uri.into_values().flatten().collect()) + } +} + +impl Drop for StdioLspClient { + fn drop(&mut self) { + let _ = self.child.start_kill(); + } +} + +async fn wait_for_initialize(reader: &mut BufReader) -> Result<()> { + loop { + let Some(message) = read_message(reader).await? else { + return Err(TraceDecayError::Config { + message: "LSP server closed before initialize response".to_string(), + }); + }; + if message.id == Some(json!(1)) { + return Ok(()); + } + } +} + +async fn write_message(stdin: &mut tokio::process::ChildStdin, value: Value) -> Result<()> { + let body = serde_json::to_vec(&value).map_err(|e| TraceDecayError::Config { + message: format!("failed to encode LSP message: {e}"), + })?; + let header = format!("Content-Length: {}\r\n\r\n", body.len()); + stdin + .write_all(header.as_bytes()) + .await + .map_err(|e| TraceDecayError::Config { + message: format!("failed to write LSP message: {e}"), + })?; + stdin + .write_all(&body) + .await + .map_err(|e| TraceDecayError::Config { + message: format!("failed to write LSP message: {e}"), + })?; + stdin.flush().await.map_err(|e| TraceDecayError::Config { + message: format!("failed to flush LSP message: {e}"), + }) +} + +async fn read_message( + reader: &mut BufReader, +) -> Result> { + let mut content_length = None; + loop { + let mut line = String::new(); + let bytes = reader + .read_line(&mut line) + .await + .map_err(|e| TraceDecayError::Config { + message: format!("failed to read LSP header: {e}"), + })?; + if bytes == 0 { + return Ok(None); + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + let Some((name, value)) = trimmed.split_once(':') else { + continue; + }; + if name.eq_ignore_ascii_case("content-length") { + content_length = value.trim().parse::().ok(); + } + } + let Some(length) = content_length else { + return Err(TraceDecayError::Config { + message: "LSP message missing Content-Length header".to_string(), + }); + }; + let mut body = vec![0_u8; length]; + reader + .read_exact(&mut body) + .await + .map_err(|e| TraceDecayError::Config { + message: format!("failed to read LSP body: {e}"), + })?; + serde_json::from_slice(&body) + .map(Some) + .map_err(|e| TraceDecayError::Config { + message: format!("failed to parse LSP message: {e}"), + }) +} + +fn file_uri(path: &Path) -> String { + let absolute = if path.is_absolute() { + PathBuf::from(path) + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + }; + format!("file://{}", absolute.to_string_lossy()) +} + +#[derive(Debug, Deserialize)] +struct JsonRpcMessage { + #[serde(default)] + id: Option, + #[serde(default)] + method: Option, + #[serde(default)] + params: Option, +} + +#[derive(Debug, Deserialize)] +struct PublishDiagnosticsParams { + uri: String, + diagnostics: Vec, +} + +#[derive(Debug, Deserialize)] +struct LspDiagnostic { + range: LspRange, + #[serde(default)] + severity: Option, + #[serde(default)] + code: Option, + #[serde(default)] + source: Option, + message: String, +} + +impl LspDiagnostic { + fn into_code_diagnostic(self, document: &LspDocument, command: &str) -> CodeDiagnostic { + CodeDiagnostic { + language: document.language.clone(), + source: self.source.unwrap_or_else(|| command.to_string()), + file: document.relative_path.clone(), + line_start: self.range.start.line + 1, + line_end: self.range.end.line + 1, + character_start: Some(self.range.start.character), + character_end: Some(self.range.end.character), + severity: match self.severity { + Some(1) => DiagnosticSeverity::Error, + Some(2) => DiagnosticSeverity::Warning, + Some(4) => DiagnosticSeverity::Hint, + _ => DiagnosticSeverity::Information, + }, + code: self.code.and_then(code_to_string), + message: self.message, + enclosing_node: None, + updated_at: now_unix(), + } + } +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs() as i64) +} + +fn code_to_string(value: Value) -> Option { + match value { + Value::String(value) => Some(value), + Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +#[derive(Debug, Deserialize)] +struct LspRange { + start: LspPosition, + end: LspPosition, +} + +#[derive(Debug, Deserialize)] +struct LspPosition { + line: u32, + character: u32, +} diff --git a/src/diagnostics/lsp/mod.rs b/src/diagnostics/lsp/mod.rs new file mode 100644 index 00000000..094c99dd --- /dev/null +++ b/src/diagnostics/lsp/mod.rs @@ -0,0 +1,6 @@ +//! Dashboard-owned LSP diagnostics support. + +pub mod adapters; +pub mod broker; +pub mod client; +pub mod settings; diff --git a/src/diagnostics/lsp/settings.rs b/src/diagnostics/lsp/settings.rs new file mode 100644 index 00000000..b053a494 --- /dev/null +++ b/src/diagnostics/lsp/settings.rs @@ -0,0 +1,139 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::diagnostics::lsp::adapters::LspAdapterDefinition; +use crate::errors::{Result, TraceDecayError}; + +const SETTINGS_FILENAME: &str = "code_diagnostics_settings.json"; + +/// Dashboard-owned idle whole-project diagnostics mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IdleBackfillMode { + Off, + #[default] + Idle, +} + +/// Per-language Code Diagnostics settings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LanguageDiagnosticsSettings { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default)] + pub command_override: Option, +} + +impl Default for LanguageDiagnosticsSettings { + fn default() -> Self { + Self { + enabled: default_enabled(), + command_override: None, + } + } +} + +/// Project-scoped Code Diagnostics settings persisted for the dashboard. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CodeDiagnosticsSettings { + #[serde(default)] + pub idle_backfill: IdleBackfillMode, + #[serde(default)] + pub languages: BTreeMap, + #[serde(default)] + pub custom_adapters: Vec, +} + +impl Default for CodeDiagnosticsSettings { + fn default() -> Self { + Self { + idle_backfill: IdleBackfillMode::Idle, + languages: BTreeMap::new(), + custom_adapters: Vec::new(), + } + } +} + +impl CodeDiagnosticsSettings { + pub fn language_enabled(&self, language: &str) -> bool { + self.languages + .get(language) + .map_or_else(default_enabled, |settings| settings.enabled) + } + + pub fn set_language_enabled(&mut self, language: &str, enabled: bool) { + self.languages + .entry(language.to_string()) + .or_default() + .enabled = enabled; + } + + pub fn command_for(&self, language: &str, default_command: &str) -> String { + self.languages + .get(language) + .and_then(|settings| settings.command_override.as_deref()) + .map(str::trim) + .filter(|command| !command.is_empty()) + .unwrap_or(default_command) + .to_string() + } +} + +pub fn settings_path(dashboard_root: &Path) -> PathBuf { + dashboard_root.join(SETTINGS_FILENAME) +} + +pub async fn load_settings(dashboard_root: &Path) -> Result { + let path = settings_path(dashboard_root); + match tokio::fs::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to parse code diagnostics settings '{}': {e}", + path.display() + ), + }), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Ok(CodeDiagnosticsSettings::default()) + } + Err(e) => Err(TraceDecayError::Config { + message: format!( + "failed to read code diagnostics settings '{}': {e}", + path.display() + ), + }), + } +} + +pub async fn save_settings( + dashboard_root: &Path, + settings: &CodeDiagnosticsSettings, +) -> Result<()> { + let path = settings_path(dashboard_root); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| TraceDecayError::Config { + message: format!( + "failed to create code diagnostics settings directory '{}': {e}", + parent.display() + ), + })?; + } + let bytes = serde_json::to_vec_pretty(settings).map_err(|e| TraceDecayError::Config { + message: format!("failed to serialize code diagnostics settings: {e}"), + })?; + tokio::fs::write(&path, bytes) + .await + .map_err(|e| TraceDecayError::Config { + message: format!( + "failed to write code diagnostics settings '{}': {e}", + path.display() + ), + }) +} + +fn default_enabled() -> bool { + true +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 26aa1b17..eb9dd1aa 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -11,6 +11,7 @@ //! enclosing graph node, so callers get structured errors mapped to the //! same node IDs the rest of tracedecay's tools speak. +pub mod lsp; pub mod python; pub mod rust; pub mod typescript; diff --git a/src/lsp_cmd.rs b/src/lsp_cmd.rs new file mode 100644 index 00000000..3aaf0d00 --- /dev/null +++ b/src/lsp_cmd.rs @@ -0,0 +1,60 @@ +use serde_json::Value; +use tracedecay::diagnostics::lsp::{adapters as lsp_adapters, broker as lsp_broker}; + +use crate::cli::LspAction; + +pub(crate) fn handle_lsp_action(action: LspAction) -> tracedecay::errors::Result<()> { + match action { + LspAction::Servers { json } => print_lsp_servers(json)?, + } + Ok(()) +} + +fn print_lsp_servers(json: bool) -> tracedecay::errors::Result<()> { + let adapters = lsp_adapters::builtin_adapters(); + if json { + let rows: Vec<_> = adapters.iter().map(lsp_server_row).collect(); + println!("{}", serde_json::to_string_pretty(&rows)?); + } else { + print_lsp_servers_table(&adapters); + } + Ok(()) +} + +fn lsp_server_row(adapter: &lsp_adapters::LspAdapterDefinition) -> Value { + serde_json::json!({ + "language": adapter.language, + "language_id": adapter.language_id, + "command": adapter.command, + "args": adapter.args, + "available": lsp_broker::command_available(&adapter.command), + "extensions": adapter.extensions, + "root_markers": adapter.root_markers, + "install_options": adapter.install_options, + }) +} + +fn print_lsp_servers_table(adapters: &[lsp_adapters::LspAdapterDefinition]) { + println!( + "{:<14} {:<12} {:<28} install", + "language", "available", "command" + ); + for adapter in adapters { + let install = adapter + .install_options + .first() + .map(|option| option.command.as_str()) + .unwrap_or(""); + println!( + "{:<14} {:<12} {:<28} {}", + adapter.language, + if lsp_broker::command_available(&adapter.command) { + "yes" + } else { + "no" + }, + adapter.command, + install + ); + } +} diff --git a/src/main.rs b/src/main.rs index 44647859..58ab9121 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod commands; mod cost_cmd; mod global; mod hook_cmd; +mod lsp_cmd; mod sessions_cmd; mod status_cmd; mod tool_command; @@ -562,6 +563,9 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { } => { tool_command::run(project, name, args).await?; } + Commands::Lsp { action } => { + lsp_cmd::handle_lsp_action(action)?; + } Commands::Install { agent, local, @@ -875,6 +879,7 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { | Commands::Update | Commands::PostUpdate | Commands::Uninstall { .. } + | Commands::Lsp { .. } | Commands::Doctor { .. } | Commands::Migrate { .. } | Commands::HookPreToolUse @@ -938,6 +943,7 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool { | Commands::Update | Commands::PostUpdate | Commands::Uninstall { .. } + | Commands::Lsp { .. } | Commands::Doctor { .. } | Commands::Migrate { .. } | Commands::Tool { .. } diff --git a/tests/dashboard_code_diagnostics_api_test.rs b/tests/dashboard_code_diagnostics_api_test.rs new file mode 100644 index 00000000..d797ed9b --- /dev/null +++ b/tests/dashboard_code_diagnostics_api_test.rs @@ -0,0 +1,87 @@ +mod common; +mod dashboard_api_support; + +use dashboard_api_support::*; +use serde_json::Value; + +#[test] +fn code_diagnostics_dashboard_api_exposes_engines_and_applies_settings() { + let _env_lock = GLOBAL_DB_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let runtime = create_runtime(); + runtime.block_on(async { + let fixture = start_dashboard_fixture(false).await; + let agent = http_agent(); + let url = format!("{}/api/plugins/code-diagnostics", fixture.base_url); + + let (status, initial) = get_json(&agent, &url); + assert_eq!(status, 200); + assert_eq!(initial["settings"]["idle_backfill"], "idle"); + assert!( + engines(&initial) + .iter() + .any(|engine| engine["language"] == "rust"), + "rust engine should be advertised" + ); + + let (status, patched) = patch_json_body( + &agent, + &url, + &serde_json::json!({ + "idle_backfill": "off", + "languages": { + "rust": { + "enabled": false, + "command_override": "/opt/tracedecay-test/rust-analyzer" + } + } + }), + ); + assert_eq!(status, 200, "patch failed: {patched}"); + assert_eq!(patched["settings"]["idle_backfill"], "off"); + let rust_status = engine(&patched, "rust"); + assert_eq!(rust_status["enabled"], false); + assert_eq!(rust_status["state"], "disabled"); + assert_eq!(rust_status["command"], "/opt/tracedecay-test/rust-analyzer"); + assert_eq!(rust_status["default_command"], "rust-analyzer"); + assert!(rust_status["install_options"] + .as_array() + .unwrap_or_else(|| panic!("expected install options")) + .iter() + .any(|option| option["command"] + .as_str() + .unwrap_or_default() + .contains("rust-analyzer"))); + + let (status, refreshed) = post_json(&agent, &format!("{url}/refresh/rust")); + assert_eq!( + status, 200, + "disabled refresh should be fail-open: {refreshed}" + ); + let refreshed_rust = engine(&refreshed, "rust"); + assert_eq!(refreshed_rust["state"], "disabled"); + + let (status, reloaded) = get_json(&agent, &url); + assert_eq!(status, 200); + assert_eq!(reloaded["settings"]["idle_backfill"], "off"); + assert_eq!(reloaded["settings"]["languages"]["rust"]["enabled"], false); + assert_eq!( + reloaded["settings"]["languages"]["rust"]["command_override"], + "/opt/tracedecay-test/rust-analyzer" + ); + }); +} + +fn engines(payload: &Value) -> &[Value] { + payload["engines"] + .as_array() + .unwrap_or_else(|| panic!("expected engines array: {payload}")) +} + +fn engine<'a>(payload: &'a Value, language: &str) -> &'a Value { + engines(payload) + .iter() + .find(|engine| engine["language"] == language) + .unwrap_or_else(|| panic!("expected {language} engine status: {payload}")) +} diff --git a/tests/lsp_code_diagnostics_test.rs b/tests/lsp_code_diagnostics_test.rs new file mode 100644 index 00000000..42458da6 --- /dev/null +++ b/tests/lsp_code_diagnostics_test.rs @@ -0,0 +1,602 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use tracedecay::diagnostics::lsp; + +const FAKE_LANGUAGE: &str = "fake"; +const FAKE_PATH: &str = "src/lib.fake"; + +#[test] +fn builtin_registry_advertises_phase_one_setup_contract() { + let adapters = lsp::adapters::builtin_adapters(); + for language in [ + "rust", + "typescript", + "javascript", + "python", + "go", + "c", + "cpp", + "objc", + "zig", + "lua", + "php", + ] { + let adapter = adapter(&adapters, language); + assert!( + !adapter.extensions.is_empty(), + "{language} should advertise file extensions" + ); + assert!( + !adapter.install_options.is_empty(), + "{language} should expose setup help" + ); + } + + assert_eq!(adapter(&adapters, "typescript").args, ["--stdio"]); + assert_eq!(adapter(&adapters, "javascript").args, ["--stdio"]); + assert_eq!(adapter(&adapters, "python").args, ["--stdio"]); + assert!(adapter(&adapters, "typescript").install_options[0] + .command + .contains("typescript-language-server")); + assert!(adapter(&adapters, "rust").install_options[0] + .command + .contains("rust-analyzer")); +} + +#[test] +fn settings_disable_language_and_backfill_mode_round_trip() { + let mut settings = lsp::settings::CodeDiagnosticsSettings::default(); + settings.set_language_enabled("rust", false); + settings + .languages + .entry("rust".to_string()) + .or_default() + .command_override = Some("/opt/bin/rust-analyzer".to_string()); + settings.idle_backfill = lsp::settings::IdleBackfillMode::Off; + settings + .custom_adapters + .push(lsp::adapters::LspAdapterDefinition { + language: "ruby".to_string(), + language_id: "ruby".to_string(), + command: "ruby-lsp".to_string(), + args: Vec::new(), + extensions: vec!["rb".to_string()], + root_markers: vec!["Gemfile".to_string()], + install_options: Vec::new(), + diagnostics: lsp::adapters::DiagnosticMode::Push, + }); + + let encoded = serde_json::to_string(&settings).unwrap(); + let decoded: lsp::settings::CodeDiagnosticsSettings = serde_json::from_str(&encoded).unwrap(); + + assert!(!decoded.language_enabled("rust")); + assert_eq!(decoded.idle_backfill, lsp::settings::IdleBackfillMode::Off); + assert_eq!( + decoded.command_for("rust", "rust-analyzer"), + "/opt/bin/rust-analyzer" + ); + assert_eq!(decoded.custom_adapters[0].language, "ruby"); +} + +#[tokio::test] +async fn settings_persist_under_dashboard_root() { + let temp = tempfile::tempdir().unwrap(); + let mut settings = lsp::settings::CodeDiagnosticsSettings::default(); + settings.set_language_enabled("python", false); + settings.idle_backfill = lsp::settings::IdleBackfillMode::Off; + + lsp::settings::save_settings(temp.path(), &settings) + .await + .unwrap(); + let loaded = lsp::settings::load_settings(temp.path()).await.unwrap(); + + assert!(!loaded.language_enabled("python")); + assert_eq!(loaded.idle_backfill, lsp::settings::IdleBackfillMode::Off); +} + +#[tokio::test] +async fn stdio_client_collects_publish_diagnostics() { + let temp = tempfile::tempdir().unwrap(); + let script_path = temp.path().join("fake_lsp.py"); + std::fs::write(&script_path, fake_lsp_script()).unwrap(); + + let diagnostics = lsp::client::collect_document_diagnostics( + python_command(), + &[script_path.display().to_string()], + temp.path(), + vec![fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope")], + std::time::Duration::from_secs(3), + ) + .await + .unwrap(); + + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].file, "src/lib.fake"); + assert_eq!(diagnostics[0].line_start, 1); + assert_eq!( + diagnostics[0].severity, + lsp::broker::DiagnosticSeverity::Error + ); + assert_eq!(diagnostics[0].code.as_deref(), Some("E_FAKE")); +} + +#[tokio::test] +async fn stdio_client_keeps_listening_after_initial_empty_publish() { + let temp = tempfile::tempdir().unwrap(); + let script_path = temp.path().join("late_fake_lsp.py"); + std::fs::write(&script_path, fake_lsp_script_with_initial_empty_publish()).unwrap(); + + let diagnostics = lsp::client::collect_document_diagnostics( + python_command(), + &[script_path.display().to_string()], + temp.path(), + vec![fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope")], + std::time::Duration::from_millis(500), + ) + .await + .unwrap(); + + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].message, "late semantic error"); +} + +#[tokio::test] +async fn broker_refresh_documents_populates_cached_diagnostics() { + let temp = tempfile::tempdir().unwrap(); + let script_path = temp.path().join("fake_lsp.py"); + std::fs::write(&script_path, fake_lsp_script()).unwrap(); + let mut broker = lsp::broker::DiagnosticBroker::new_for_test( + temp.path(), + vec![fake_python_adapter(FAKE_LANGUAGE, "fake", &script_path)], + ); + + broker + .refresh_documents( + FAKE_LANGUAGE, + vec![fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope")], + std::time::Duration::from_secs(3), + ) + .await + .unwrap(); + + let snapshot = broker.snapshot(); + assert_eq!(snapshot.summary.total_errors, 1); + assert_eq!(snapshot.diagnostics[0].source, "fake-ls"); + assert_eq!( + snapshot + .engines + .iter() + .find(|engine| engine.language == "fake") + .unwrap() + .state, + lsp::broker::EngineState::Ready + ); +} + +#[tokio::test] +async fn broker_keeps_diagnostics_for_multiple_languages_in_one_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let script_path = temp.path().join("fake_lsp.py"); + std::fs::write(&script_path, fake_lsp_script()).unwrap(); + let mut broker = lsp::broker::DiagnosticBroker::new_for_test( + temp.path(), + vec![ + fake_python_adapter("alpha", "alpha", &script_path), + fake_python_adapter("beta", "beta", &script_path), + ], + ); + + broker + .refresh_documents( + "alpha", + vec![fake_document("alpha", "src/lib.alpha", "alpha nope")], + std::time::Duration::from_secs(3), + ) + .await + .unwrap(); + broker + .refresh_documents( + "beta", + vec![fake_document("beta", "src/lib.beta", "beta nope")], + std::time::Duration::from_secs(3), + ) + .await + .unwrap(); + + let snapshot = broker.snapshot(); + assert_eq!(snapshot.summary.total_errors, 2); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.language == "alpha" && diagnostic.file == "src/lib.alpha")); + assert!(snapshot + .diagnostics + .iter() + .any(|diagnostic| diagnostic.language == "beta" && diagnostic.file == "src/lib.beta")); + for language in ["alpha", "beta"] { + let status = snapshot + .engines + .iter() + .find(|engine| engine.language == language) + .unwrap_or_else(|| panic!("missing {language} status")); + assert_eq!(status.state, lsp::broker::EngineState::Ready); + } +} + +#[tokio::test] +async fn broker_marks_missing_lsp_command_unavailable_after_refresh_failure() { + let temp = tempfile::tempdir().unwrap(); + let mut broker = lsp::broker::DiagnosticBroker::new_for_test( + temp.path(), + vec![fake_adapter( + FAKE_LANGUAGE, + "fake", + "__tracedecay_missing_lsp_for_test__", + Vec::new(), + )], + ); + + let err = broker + .refresh_documents( + FAKE_LANGUAGE, + vec![fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope")], + std::time::Duration::from_millis(50), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("not available on PATH")); + let snapshot = broker.snapshot(); + let status = snapshot + .engines + .iter() + .find(|engine| engine.language == "fake") + .expect("fake engine status should be listed"); + assert_eq!(status.state, lsp::broker::EngineState::Unavailable); + assert!(status + .last_error + .as_deref() + .unwrap_or_default() + .contains("not available on PATH")); +} + +#[tokio::test] +async fn broker_reuses_warm_lsp_client_between_refreshes() { + let temp = tempfile::tempdir().unwrap(); + let script_path = temp.path().join("warm_fake_lsp.py"); + let counter_path = temp.path().join("starts.txt"); + std::fs::write( + &script_path, + fake_lsp_script_that_records_start(&counter_path), + ) + .unwrap(); + let mut broker = lsp::broker::DiagnosticBroker::new_for_test( + temp.path(), + vec![fake_python_adapter(FAKE_LANGUAGE, "fake", &script_path)], + ); + let document = fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope"); + + broker + .refresh_documents( + "fake", + vec![document.clone()], + std::time::Duration::from_secs(3), + ) + .await + .unwrap(); + broker + .refresh_documents("fake", vec![document], std::time::Duration::from_secs(3)) + .await + .unwrap(); + + let starts = std::fs::read_to_string(counter_path).unwrap(); + assert_eq!(starts.lines().count(), 1); +} + +#[tokio::test] +async fn broker_keys_warm_lsp_clients_by_workspace_root() { + let temp = tempfile::tempdir().unwrap(); + let script_path = temp.path().join("workspace_fake_lsp.py"); + let counter_path = temp.path().join("starts.txt"); + std::fs::write( + &script_path, + fake_lsp_script_that_records_start(&counter_path), + ) + .unwrap(); + std::fs::create_dir_all(temp.path().join("workspace-a/src")).unwrap(); + std::fs::create_dir_all(temp.path().join("workspace-b/src")).unwrap(); + std::fs::write(temp.path().join("workspace-a/fake-root"), "").unwrap(); + std::fs::write(temp.path().join("workspace-b/fake-root"), "").unwrap(); + let mut broker = lsp::broker::DiagnosticBroker::new_for_test( + temp.path(), + vec![fake_adapter_with_root_marker( + FAKE_LANGUAGE, + "fake", + python_command(), + vec![script_path.display().to_string()], + "fake-root", + )], + ); + let documents = vec![ + fake_document(FAKE_LANGUAGE, "workspace-a/src/lib.fake", "let nope"), + fake_document(FAKE_LANGUAGE, "workspace-b/src/lib.fake", "let nope"), + ]; + + broker + .refresh_documents("fake", documents.clone(), std::time::Duration::from_secs(3)) + .await + .unwrap(); + broker + .refresh_documents("fake", documents, std::time::Duration::from_secs(3)) + .await + .unwrap(); + + let starts = std::fs::read_to_string(counter_path).unwrap(); + assert_eq!(starts.lines().count(), 2); +} + +#[tokio::test] +async fn broker_ignores_refresh_completion_after_language_is_disabled() { + let temp = tempfile::tempdir().unwrap(); + let script_path = temp.path().join("fake_lsp.py"); + std::fs::write(&script_path, fake_lsp_script()).unwrap(); + let mut broker = lsp::broker::DiagnosticBroker::new_for_test( + temp.path(), + vec![fake_python_adapter(FAKE_LANGUAGE, "fake", &script_path)], + ); + let prepared = broker + .prepare_refresh( + FAKE_LANGUAGE, + vec![fake_document(FAKE_LANGUAGE, FAKE_PATH, "let nope")], + ) + .unwrap() + .expect("enabled language should prepare a refresh"); + + broker.set_language_enabled(FAKE_LANGUAGE, false); + let completed = prepared + .collect_diagnostics(std::time::Duration::from_secs(3)) + .await; + broker.finish_refresh(completed).unwrap(); + + let snapshot = broker.snapshot(); + let status = snapshot + .engines + .iter() + .find(|engine| engine.language == FAKE_LANGUAGE) + .expect("fake engine status should be listed"); + assert_eq!(status.state, lsp::broker::EngineState::Disabled); + assert!(snapshot.diagnostics.is_empty()); +} + +#[test] +fn broker_clears_language_diagnostics_when_disabled() { + let mut broker = lsp::broker::DiagnosticBroker::new_for_test( + "/tmp/tracedecay-lsp-test", + vec![fake_adapter( + "typescript", + "ts", + "typescript-language-server", + Vec::new(), + )], + ); + broker.cache_diagnostic(lsp::broker::CodeDiagnostic { + language: "typescript".to_string(), + source: "typescript-language-server".to_string(), + file: "src/app.ts".to_string(), + line_start: 3, + line_end: 3, + character_start: Some(10), + character_end: Some(12), + severity: lsp::broker::DiagnosticSeverity::Error, + code: Some("TS2322".to_string()), + message: "Type 'string' is not assignable to type 'number'.".to_string(), + enclosing_node: None, + updated_at: 42, + }); + broker.record_backfill_progress("typescript", 8, 3, 1, Some(99)); + + broker.set_language_enabled("typescript", false); + + let snapshot = broker.snapshot(); + assert!(snapshot.diagnostics.is_empty()); + assert!(!snapshot.backfill.contains_key("typescript")); + assert_eq!(snapshot.summary.total_errors, 0); +} + +fn adapter<'a>( + adapters: &'a [lsp::adapters::LspAdapterDefinition], + language: &str, +) -> &'a lsp::adapters::LspAdapterDefinition { + adapters + .iter() + .find(|adapter| adapter.language == language) + .unwrap_or_else(|| panic!("missing adapter for {language}")) +} + +fn fake_document(language: &str, relative_path: &str, text: &str) -> lsp::client::LspDocument { + lsp::client::LspDocument { + language: language.to_string(), + language_id: language.to_string(), + relative_path: relative_path.to_string(), + text: text.to_string(), + } +} + +fn fake_python_adapter( + language: &str, + extension: &str, + script_path: &std::path::Path, +) -> lsp::adapters::LspAdapterDefinition { + fake_adapter( + language, + extension, + python_command(), + vec![script_path.display().to_string()], + ) +} + +fn python_command() -> &'static str { + if cfg!(windows) { + "python" + } else { + "python3" + } +} + +fn fake_adapter( + language: &str, + extension: &str, + command: &str, + args: Vec, +) -> lsp::adapters::LspAdapterDefinition { + fake_adapter_with_root_markers(language, extension, command, args, Vec::new()) +} + +fn fake_adapter_with_root_marker( + language: &str, + extension: &str, + command: &str, + args: Vec, + root_marker: &str, +) -> lsp::adapters::LspAdapterDefinition { + fake_adapter_with_root_markers( + language, + extension, + command, + args, + vec![root_marker.to_string()], + ) +} + +fn fake_adapter_with_root_markers( + language: &str, + extension: &str, + command: &str, + args: Vec, + root_markers: Vec, +) -> lsp::adapters::LspAdapterDefinition { + lsp::adapters::LspAdapterDefinition { + language: language.to_string(), + language_id: language.to_string(), + command: command.to_string(), + args, + extensions: vec![extension.to_string()], + root_markers, + install_options: Vec::new(), + diagnostics: lsp::adapters::DiagnosticMode::Push, + } +} + +fn fake_lsp_script() -> String { + fake_lsp_script_with_preamble("", FAKE_DIAGNOSTIC_PUBLISH) +} + +fn fake_lsp_script_with_initial_empty_publish() -> String { + fake_lsp_script_with_preamble("import time\n", INITIAL_EMPTY_THEN_DIAGNOSTIC_PUBLISH) +} + +fn fake_lsp_script_that_records_start(counter_path: &std::path::Path) -> String { + let preamble = format!( + r#" +with open({:?}, "a", encoding="utf-8") as f: + f.write("start\n") +"#, + counter_path.display().to_string() + ); + fake_lsp_script_with_preamble(&preamble, EMPTY_DIAGNOSTIC_PUBLISH) +} + +fn fake_lsp_script_with_preamble(preamble: &str, did_open_body: &str) -> String { + let mut script = String::from( + r#" +import json +import sys + +"#, + ); + script.push_str(preamble); + script.push_str( + r#" +def read_message(): + headers = {} + while True: + line = sys.stdin.buffer.readline() + if not line: + return None + if line in (b"\r\n", b"\n"): + break + name, value = line.decode("ascii").split(":", 1) + headers[name.lower()] = value.strip() + length = int(headers["content-length"]) + return json.loads(sys.stdin.buffer.read(length).decode("utf-8")) + +def send(payload): + body = json.dumps(payload).encode("utf-8") + sys.stdout.buffer.write(b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body) + sys.stdout.buffer.flush() + +while True: + message = read_message() + if message is None: + break + if message.get("method") == "initialize": + send({"jsonrpc": "2.0", "id": message["id"], "result": {"capabilities": {"textDocumentSync": 1}}}) + elif message.get("method") == "textDocument/didOpen": + uri = message["params"]["textDocument"]["uri"] +"#, + ); + script.push_str(did_open_body); + script.push_str( + r#" elif message.get("method") == "textDocument/didChange": + uri = message["params"]["textDocument"]["uri"] +"#, + ); + script.push_str(did_open_body); + script +} + +const FAKE_DIAGNOSTIC_PUBLISH: &str = r#" send({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "diagnostics": [{ + "range": { + "start": {"line": 0, "character": 4}, + "end": {"line": 0, "character": 9} + }, + "severity": 1, + "code": "E_FAKE", + "source": "fake-ls", + "message": "fake type error" + }] + } + }) +"#; + +const INITIAL_EMPTY_THEN_DIAGNOSTIC_PUBLISH: &str = r#" send({"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics", "params": {"uri": uri, "diagnostics": []}}) + time.sleep(0.05) + send({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "diagnostics": [{ + "range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 1}}, + "severity": 1, + "source": "fake-ls", + "message": "late semantic error" + }] + } + }) +"#; + +const EMPTY_DIAGNOSTIC_PUBLISH: &str = r#" send({ + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": { + "uri": uri, + "diagnostics": [] + } + }) +"#; From 0e406cd2d4595650297594035829b5e5fae4d5e1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 25 Jun 2026 21:24:25 +0000 Subject: [PATCH 2/4] fix: resolve lsp commands with Windows PATHEXT --- src/diagnostics/lsp/broker.rs | 38 +++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/diagnostics/lsp/broker.rs b/src/diagnostics/lsp/broker.rs index 656660e3..4a2a5671 100644 --- a/src/diagnostics/lsp/broker.rs +++ b/src/diagnostics/lsp/broker.rs @@ -510,11 +510,45 @@ fn workspace_root_for_document( } pub fn command_available(command: &str) -> bool { - if command.contains(std::path::MAIN_SEPARATOR) { + if Path::new(command).components().count() > 1 { return Path::new(command).is_file(); } let Some(paths) = std::env::var_os("PATH") else { return false; }; - std::env::split_paths(&paths).any(|path| path.join(command).is_file()) + let candidates = command_candidates(command); + std::env::split_paths(&paths).any(|path| { + candidates + .iter() + .any(|candidate| path.join(candidate).is_file()) + }) +} + +#[cfg(windows)] +fn command_candidates(command: &str) -> Vec { + if Path::new(command).extension().is_some() { + return vec![command.to_string()]; + } + + let pathext = std::env::var_os("PATHEXT") + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".to_string()); + + let mut candidates = vec![command.to_string()]; + candidates.extend(pathext.split(';').filter_map(|extension| { + let extension = extension.trim(); + if extension.is_empty() { + None + } else if extension.starts_with('.') { + Some(format!("{command}{extension}")) + } else { + Some(format!("{command}.{extension}")) + } + })); + candidates +} + +#[cfg(not(windows))] +fn command_candidates(command: &str) -> Vec { + vec![command.to_string()] } From cad89dc4d6e32b74dad7e2e9147756ca43e0b562 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 26 Jun 2026 04:06:11 +0000 Subject: [PATCH 3/4] fix: preserve code diagnostics settings patches --- src/dashboard/code_diagnostics_api.rs | 52 +++++++++++++++--- src/diagnostics/lsp/client.rs | 56 ++++++++++++++++++- tests/dashboard_code_diagnostics_api_test.rs | 57 ++++++++++++++++++++ 3 files changed, 157 insertions(+), 8 deletions(-) diff --git a/src/dashboard/code_diagnostics_api.rs b/src/dashboard/code_diagnostics_api.rs index 63af6743..b6fb67f0 100644 --- a/src/dashboard/code_diagnostics_api.rs +++ b/src/dashboard/code_diagnostics_api.rs @@ -6,16 +6,14 @@ use std::time::Duration; use axum::extract::{Path as AxumPath, State}; use axum::http::StatusCode; use axum::Json; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use serde_json::{json, Value}; use super::util::{http_detail, JsonError}; use super::DashboardState; use crate::diagnostics::lsp::adapters::LspAdapterDefinition; use crate::diagnostics::lsp::client::LspDocument; -use crate::diagnostics::lsp::settings::{ - save_settings, IdleBackfillMode, LanguageDiagnosticsSettings, -}; +use crate::diagnostics::lsp::settings::{save_settings, IdleBackfillMode}; type ApiResult = std::result::Result, JsonError>; @@ -24,11 +22,27 @@ struct SettingsPatch { #[serde(default)] idle_backfill: Option, #[serde(default)] - languages: BTreeMap, + languages: BTreeMap, #[serde(default)] custom_adapters: Option>, } +#[derive(Debug, Clone, Deserialize, Default)] +struct LanguageSettingsPatch { + #[serde(default)] + enabled: Option, + #[serde(default, deserialize_with = "deserialize_command_override_patch")] + command_override: CommandOverridePatch, +} + +#[derive(Debug, Clone, Default)] +enum CommandOverridePatch { + #[default] + Missing, + Null, + Value(String), +} + pub(crate) async fn overview(State(state): State) -> ApiResult { maybe_spawn_idle_backfill(&state).await; let snapshot = state.code_diagnostics.read().await.snapshot(); @@ -45,8 +59,20 @@ pub(crate) async fn patch_settings( if let Some(mode) = patch.idle_backfill { settings.idle_backfill = mode; } - for (language, language_settings) in patch.languages { - settings.languages.insert(language, language_settings); + for (language, language_patch) in patch.languages { + let language_settings = settings.languages.entry(language).or_default(); + if let Some(enabled) = language_patch.enabled { + language_settings.enabled = enabled; + } + match language_patch.command_override { + CommandOverridePatch::Missing => {} + CommandOverridePatch::Null => { + language_settings.command_override = None; + } + CommandOverridePatch::Value(command_override) => { + language_settings.command_override = Some(command_override); + } + } } if let Some(custom_adapters) = patch.custom_adapters { settings.custom_adapters = custom_adapters; @@ -272,6 +298,18 @@ fn bad_request(err: &impl ToString) -> JsonError { ) } +fn deserialize_command_override_patch<'de, D>( + deserializer: D, +) -> std::result::Result +where + D: Deserializer<'de>, +{ + Ok(match Option::::deserialize(deserializer)? { + Some(value) => CommandOverridePatch::Value(value), + None => CommandOverridePatch::Null, + }) +} + fn internal_error(err: &impl ToString) -> JsonError { ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/diagnostics/lsp/client.rs b/src/diagnostics/lsp/client.rs index 3479b038..8ed0ef02 100644 --- a/src/diagnostics/lsp/client.rs +++ b/src/diagnostics/lsp/client.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; @@ -289,7 +290,39 @@ fn file_uri(path: &Path) -> String { .unwrap_or_else(|_| PathBuf::from(".")) .join(path) }; - format!("file://{}", absolute.to_string_lossy()) + file_uri_from_path_text(&absolute.to_string_lossy()) +} + +fn file_uri_from_path_text(path: &str) -> String { + let normalized = path.replace('\\', "/"); + let encoded = percent_encode_file_uri_path(&normalized); + if normalized.starts_with("//") { + format!("file:{encoded}") + } else if looks_like_windows_drive_path(&normalized) { + format!("file:///{encoded}") + } else { + format!("file://{encoded}") + } +} + +fn looks_like_windows_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' +} + +fn percent_encode_file_uri_path(path: &str) -> String { + let mut encoded = String::with_capacity(path.len()); + for byte in path.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => { + encoded.push(*byte as char); + } + _ => { + let _ = write!(encoded, "%{byte:02X}"); + } + } + } + encoded } #[derive(Debug, Deserialize)] @@ -369,3 +402,24 @@ struct LspPosition { line: u32, character: u32, } + +#[cfg(test)] +mod tests { + use super::file_uri_from_path_text; + + #[test] + fn file_uri_encodes_lsp_paths() { + assert_eq!( + file_uri_from_path_text("/tmp/trace decay/main#one.rs"), + "file:///tmp/trace%20decay/main%23one.rs" + ); + assert_eq!( + file_uri_from_path_text(r"C:\repo with spaces\src\main.rs"), + "file:///C:/repo%20with%20spaces/src/main.rs" + ); + assert_eq!( + file_uri_from_path_text("/tmp/100% real.rs"), + "file:///tmp/100%25%20real.rs" + ); + } +} diff --git a/tests/dashboard_code_diagnostics_api_test.rs b/tests/dashboard_code_diagnostics_api_test.rs index d797ed9b..92e69ce2 100644 --- a/tests/dashboard_code_diagnostics_api_test.rs +++ b/tests/dashboard_code_diagnostics_api_test.rs @@ -70,6 +70,63 @@ fn code_diagnostics_dashboard_api_exposes_engines_and_applies_settings() { reloaded["settings"]["languages"]["rust"]["command_override"], "/opt/tracedecay-test/rust-analyzer" ); + + let (status, toggled) = patch_json_body( + &agent, + &url, + &serde_json::json!({ + "languages": { + "rust": { + "enabled": true + } + } + }), + ); + assert_eq!(status, 200, "toggle patch failed: {toggled}"); + assert_eq!(toggled["settings"]["languages"]["rust"]["enabled"], true); + assert_eq!( + toggled["settings"]["languages"]["rust"]["command_override"], + "/opt/tracedecay-test/rust-analyzer" + ); + + let (status, command_only) = patch_json_body( + &agent, + &url, + &serde_json::json!({ + "languages": { + "rust": { + "command_override": "/opt/tracedecay-test/rust-analyzer-2" + } + } + }), + ); + assert_eq!(status, 200, "command patch failed: {command_only}"); + assert_eq!( + command_only["settings"]["languages"]["rust"]["enabled"], + true + ); + assert_eq!( + command_only["settings"]["languages"]["rust"]["command_override"], + "/opt/tracedecay-test/rust-analyzer-2" + ); + + let (status, cleared) = patch_json_body( + &agent, + &url, + &serde_json::json!({ + "languages": { + "rust": { + "command_override": null + } + } + }), + ); + assert_eq!(status, 200, "clear patch failed: {cleared}"); + assert_eq!(cleared["settings"]["languages"]["rust"]["enabled"], true); + assert_eq!( + cleared["settings"]["languages"]["rust"]["command_override"], + Value::Null + ); }); } From f29ddbb45b1baec0ceb4d89bc4531abf9ad2e42a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 26 Jun 2026 04:52:15 +0000 Subject: [PATCH 4/4] refactor: simplify lsp diagnostics cleanup --- dashboard/code-diagnostics/src/CodeDiagnostics.tsx | 1 - src/diagnostics/lsp/adapters.rs | 2 +- src/diagnostics/lsp/broker.rs | 10 ++++------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/dashboard/code-diagnostics/src/CodeDiagnostics.tsx b/dashboard/code-diagnostics/src/CodeDiagnostics.tsx index 31528d81..31887832 100644 --- a/dashboard/code-diagnostics/src/CodeDiagnostics.tsx +++ b/dashboard/code-diagnostics/src/CodeDiagnostics.tsx @@ -131,7 +131,6 @@ export default function CodeDiagnostics() { patch({ languages: { [engine.language]: { - enabled: engine.enabled, command_override: commandOverride, }, }, diff --git a/src/diagnostics/lsp/adapters.rs b/src/diagnostics/lsp/adapters.rs index 9e30c208..492c3454 100644 --- a/src/diagnostics/lsp/adapters.rs +++ b/src/diagnostics/lsp/adapters.rs @@ -35,7 +35,7 @@ pub struct LspInstallOption { pub notes: Option, } -/// Built-in language-server adapters for the Phase 1 dashboard surface. +/// Built-in language-server adapters for the dashboard surface. pub fn builtin_adapters() -> Vec { vec![ adapter(AdapterSpec { diff --git a/src/diagnostics/lsp/broker.rs b/src/diagnostics/lsp/broker.rs index 4a2a5671..dae08946 100644 --- a/src/diagnostics/lsp/broker.rs +++ b/src/diagnostics/lsp/broker.rs @@ -140,15 +140,13 @@ impl PreparedRefresh { let mut diagnostics = Vec::new(); for batch in self.batches { let mut client_slot = batch.client.lock().await; - if client_slot.is_none() { - *client_slot = Some( + let client = match client_slot.as_mut() { + Some(client) => client, + None => client_slot.insert( StdioLspClient::start(&self.command, &self.args, &batch.workspace_root) .await .map_err(|err| err.to_string())?, - ); - } - let Some(client) = client_slot.as_mut() else { - return Err("LSP client should be initialized".to_string()); + ), }; let result = client .collect_document_diagnostics(