Skip to main content

aft/lsp/
manager.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
5use lsp_types::notification::{
6    DidChangeTextDocument, DidChangeWatchedFiles, DidCloseTextDocument, DidOpenTextDocument,
7};
8use lsp_types::{
9    DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidCloseTextDocumentParams,
10    DidOpenTextDocumentParams, FileChangeType, FileEvent, TextDocumentContentChangeEvent,
11    TextDocumentIdentifier, TextDocumentItem, VersionedTextDocumentIdentifier,
12};
13
14use crate::config::Config;
15use crate::lsp::child_registry::LspChildRegistry;
16use crate::lsp::client::{LspClient, LspEvent, ServerState};
17use crate::lsp::diagnostics::{
18    from_lsp_diagnostics, DiagnosticEntry, DiagnosticsStore, StoredDiagnostic,
19};
20use crate::lsp::document::DocumentStore;
21use crate::lsp::position::{uri_for_path, uri_to_path};
22use crate::lsp::pull_params::{
23    AftDocumentDiagnosticParams, AftDocumentDiagnosticRequest, AftWorkspaceDiagnosticParams,
24    AftWorkspaceDiagnosticRequest,
25};
26use crate::lsp::registry::{resolve_lsp_binary, servers_for_file, ServerDef, ServerKind};
27use crate::lsp::roots::{find_workspace_root, ServerKey};
28use crate::lsp::LspError;
29use crate::slog_error;
30
31const STDERR_REASON_BYTES: usize = 2 * 1024;
32
33/// Outcome of attempting to ensure a server is running for a single matching
34/// `ServerDef`. Returned per matching server so the caller can report exactly
35/// what happened to the user instead of collapsing all failures into "no
36/// server".
37#[derive(Debug, Clone)]
38pub enum ServerAttemptResult {
39    /// Server is running and ready to serve requests for this file.
40    Ok { server_key: ServerKey },
41    /// No workspace root was found by walking up from the file looking for
42    /// any of the server's configured root markers.
43    NoRootMarker { looked_for: Vec<String> },
44    /// The server's binary could not be found on PATH (or override was
45    /// missing/invalid).
46    BinaryNotInstalled { binary: String },
47    /// Binary was found but spawning or initializing the server failed.
48    SpawnFailed { binary: String, reason: String },
49}
50
51/// One server's attempt to handle a file.
52#[derive(Debug, Clone)]
53pub struct ServerAttempt {
54    /// Stable server identifier (kind ID, e.g. "pyright", "rust-analyzer").
55    pub server_id: String,
56    /// Server display name from the registry.
57    pub server_name: String,
58    pub result: ServerAttemptResult,
59}
60
61/// Aggregate outcome of `ensure_server_for_file_detailed`. Distinguishes:
62/// - "No server registered for this file's extension" (`attempts.is_empty()`)
63/// - "Servers registered but none could start" (`successful.is_empty()` but
64///   `!attempts.is_empty()`)
65/// - "At least one server is ready" (`!successful.is_empty()`)
66#[derive(Debug, Clone, Default)]
67pub struct EnsureServerOutcomes {
68    /// Server keys that are now running and ready to serve requests.
69    pub successful: Vec<ServerKey>,
70    /// Per-server attempt records. Empty if no server is registered for the
71    /// file's extension.
72    pub attempts: Vec<ServerAttempt>,
73}
74
75impl EnsureServerOutcomes {
76    /// True if no server in the registry matched this file's extension.
77    pub fn no_server_registered(&self) -> bool {
78        self.attempts.is_empty()
79    }
80
81    /// True when servers matched the file's extension but none actually apply
82    /// to this project — i.e. nothing started and every attempt failed the root
83    /// marker check (e.g. oxlint registered for `.ts` with no `.oxlintrc.json`).
84    /// Distinct from `no_server_registered` (extension unsupported) and from a
85    /// real outage (binary missing / spawn failed): a missing root marker is a
86    /// filesystem fact that never changes mid-scan, so such a file will never
87    /// produce diagnostics and must not be reported as "pending".
88    pub fn only_inapplicable_root_markers(&self) -> bool {
89        self.successful.is_empty()
90            && !self.attempts.is_empty()
91            && self
92                .attempts
93                .iter()
94                .all(|attempt| matches!(attempt.result, ServerAttemptResult::NoRootMarker { .. }))
95    }
96}
97
98/// Outcome of a post-edit diagnostics wait. Reports the per-server status
99/// alongside the fresh diagnostics, so the response layer can build an
100/// honest tri-state payload (`success: true` + `complete: bool` + named
101/// gap fields per `crates/aft/src/protocol.rs`).
102///
103/// `diagnostics` only contains entries from servers that proved freshness
104/// (version-match preferred, epoch-fallback for unversioned servers).
105/// Pre-edit cached entries are NEVER included — that's the whole point of
106/// this type.
107#[derive(Debug, Clone, Default)]
108pub struct PostEditWaitOutcome {
109    /// Diagnostics from servers whose response we verified is FOR the
110    /// post-edit document version (or whose epoch we saw advance after our
111    /// pre-edit snapshot, for unversioned servers).
112    pub diagnostics: Vec<StoredDiagnostic>,
113    /// Servers we expected to publish but didn't before the deadline.
114    /// Reported to the agent via `pending_lsp_servers` so they understand
115    /// the result is partial.
116    pub pending_servers: Vec<ServerKey>,
117    /// Servers whose process exited between notification and deadline.
118    /// Reported separately so the agent knows the gap is unrecoverable
119    /// without a server restart, not "wait longer."
120    pub exited_servers: Vec<ServerKey>,
121}
122
123/// Pre-edit freshness snapshot for one server/file pair.
124#[derive(Debug, Clone, Copy, Default)]
125pub struct PreEditSnapshot {
126    pub epoch: u64,
127    pub document_version_at_capture: Option<i32>,
128}
129
130pub fn post_edit_entry_is_fresh(
131    entry: &DiagnosticEntry,
132    target_version: i32,
133    pre: PreEditSnapshot,
134) -> bool {
135    if entry.epoch <= pre.epoch {
136        return false;
137    }
138
139    match entry.version {
140        Some(version) => version >= target_version,
141        // Unversioned publishDiagnostics payloads cannot prove which document
142        // state they describe. Epoch advancement only proves arrival order; an
143        // old analysis result can still arrive after our pre-snapshot. Treat as
144        // pending/partial rather than fresh.
145        None => false,
146    }
147}
148
149impl PostEditWaitOutcome {
150    /// True if every expected server reported a fresh result. False means
151    /// the agent should treat the diagnostics as a partial picture.
152    pub fn complete(&self) -> bool {
153        self.pending_servers.is_empty() && self.exited_servers.is_empty()
154    }
155}
156
157/// Per-server outcome of a `textDocument/diagnostic` (per-file pull) request.
158#[derive(Debug, Clone)]
159pub enum PullFileOutcome {
160    /// Server returned a full report; diagnostics stored.
161    Full { diagnostic_count: usize },
162    /// Server returned `kind: "unchanged"` — cached diagnostics still valid.
163    Unchanged,
164    /// Server returned a partial-result token; we don't subscribe to streamed
165    /// progress so the response is treated as a soft empty until the next pull.
166    PartialNotSupported,
167    /// Server doesn't advertise pull capability — caller should fall back to
168    /// push diagnostics for this server.
169    PullNotSupported,
170    /// The pull request failed (timeout, server error, etc.).
171    RequestFailed { reason: String },
172}
173
174/// Result of `pull_file_diagnostics` for one matching server.
175#[derive(Debug, Clone)]
176pub struct PullFileResult {
177    pub server_key: ServerKey,
178    pub outcome: PullFileOutcome,
179}
180
181/// Result of `pull_workspace_diagnostics` for a single server.
182#[derive(Debug, Clone)]
183pub struct PullWorkspaceResult {
184    pub server_key: ServerKey,
185    /// Files for which a Full report was received and cached. Files that came
186    /// back as `Unchanged` are NOT listed here because their cached entry was
187    /// already authoritative.
188    pub files_reported: Vec<PathBuf>,
189    /// True if the server returned a full response within the timeout.
190    pub complete: bool,
191    /// True if we cancelled (request timed out before the server responded).
192    pub cancelled: bool,
193    /// True if the server advertised workspace pull support. When false, the
194    /// other fields are empty and the caller should fall back to file-mode
195    /// pull or to push semantics.
196    pub supports_workspace: bool,
197}
198
199pub struct DrainedLspEvents {
200    pub events: Vec<LspEvent>,
201    pub diagnostics_changed: bool,
202}
203
204impl IntoIterator for DrainedLspEvents {
205    type Item = LspEvent;
206    type IntoIter = std::vec::IntoIter<LspEvent>;
207
208    fn into_iter(self) -> Self::IntoIter {
209        self.events.into_iter()
210    }
211}
212
213pub struct LspManager {
214    /// Active server instances, keyed by (ServerKind, workspace_root).
215    clients: HashMap<ServerKey, LspClient>,
216    /// Binary names for active server instances. Kept separate from
217    /// `LspClient` so crash handling can report the installable binary name
218    /// after a post-initialize process exit.
219    server_binaries: HashMap<ServerKey, String>,
220    /// Tracks opened documents and versions per active server.
221    documents: HashMap<ServerKey, DocumentStore>,
222    /// Stored publishDiagnostics payloads across all servers.
223    diagnostics: DiagnosticsStore,
224    /// Unified event channel — all server reader threads send here.
225    event_tx: Sender<LspEvent>,
226    event_rx: Receiver<LspEvent>,
227    /// Optional binary path overrides used by integration tests.
228    binary_overrides: HashMap<ServerKind, PathBuf>,
229    /// Extra env vars merged into every spawned LSP child. Used in tests to
230    /// drive the fake server's behavioral variants (`AFT_FAKE_LSP_PULL=1`,
231    /// `AFT_FAKE_LSP_WORKSPACE=1`, etc.). Production code does not set this.
232    extra_env: HashMap<String, String>,
233    /// Per-(kind,root) cache of spawn failures. Once a server fails to spawn
234    /// for a workspace root, we remember why and skip subsequent attempts for
235    /// the lifetime of this AFT process. Without this, every file open or
236    /// didChange retries `spawn_server` and logs a fresh ERROR — visible as
237    /// repeated `failed to spawn TypeScript Language Server: Could not find a
238    /// valid TypeScript installation` lines per edit.
239    ///
240    /// Entries are NEVER evicted automatically. The expected recovery path is
241    /// for the user to fix their environment (install the missing binary or
242    /// add a `tsconfig.json` / `package.json` with the right dependency) and
243    /// restart OpenCode/Pi, which spawns a fresh `aft` process with an empty
244    /// cache. We deliberately don't auto-retry on file events: the failure
245    /// modes we track here (binary not installed, init handshake failure)
246    /// don't fix themselves at runtime.
247    failed_spawns: HashMap<ServerKey, ServerAttemptResult>,
248    /// Server/root pairs for which we already logged that watched-file
249    /// notifications are skipped because the capability is absent.
250    watched_file_skip_logged: HashSet<ServerKey>,
251    /// Tracks PIDs of spawned LSP child processes so the signal handler can
252    /// kill them on SIGTERM/SIGINT before aft exits, preventing orphans.
253    /// Defaults to empty; production wires this from `AppContext`.
254    child_registry: LspChildRegistry,
255}
256
257impl LspManager {
258    pub fn new() -> Self {
259        let (event_tx, event_rx) = unbounded();
260        Self {
261            clients: HashMap::new(),
262            server_binaries: HashMap::new(),
263            documents: HashMap::new(),
264            diagnostics: DiagnosticsStore::new(),
265            event_tx,
266            event_rx,
267            binary_overrides: HashMap::new(),
268            extra_env: HashMap::new(),
269            failed_spawns: HashMap::new(),
270            watched_file_skip_logged: HashSet::new(),
271            child_registry: LspChildRegistry::new(),
272        }
273    }
274
275    /// Set the child-PID registry. Must be called before any servers spawn.
276    pub fn set_child_registry(&mut self, registry: LspChildRegistry) {
277        self.child_registry = registry;
278    }
279
280    /// For testing: set an extra environment variable that gets passed to
281    /// every spawned LSP child process. Useful for driving fake-server
282    /// behavioral variants in integration tests.
283    pub fn set_extra_env(&mut self, key: &str, value: &str) {
284        self.extra_env.insert(key.to_string(), value.to_string());
285    }
286
287    /// Count active LSP server instances.
288    pub fn server_count(&self) -> usize {
289        self.clients.len()
290    }
291
292    /// Apply the configured diagnostic LRU cap (the `lsp.diagnostic_cache_size`
293    /// knob). 0 disables the cap. Called at construction so the documented
294    /// config field actually takes effect instead of always using the default.
295    pub fn set_diagnostic_capacity(&mut self, capacity: usize) {
296        self.diagnostics.set_capacity(capacity);
297    }
298
299    /// For testing: override the binary for a server kind.
300    pub fn override_binary(&mut self, kind: ServerKind, binary_path: PathBuf) {
301        self.binary_overrides.insert(kind, binary_path);
302    }
303
304    /// Ensure a server is running for the given file. Spawns if needed.
305    /// Returns the active server keys for the file, or an empty vec if none match.
306    ///
307    /// This is the lightweight wrapper around [`ensure_server_for_file_detailed`]
308    /// that drops failure context. Prefer the detailed variant in command
309    /// handlers that need to surface honest error messages to the agent.
310    pub fn ensure_server_for_file(&mut self, file_path: &Path, config: &Config) -> Vec<ServerKey> {
311        self.ensure_server_for_file_detailed(file_path, config)
312            .successful
313    }
314
315    /// Detailed version of [`ensure_server_for_file`] that records every
316    /// matching server's outcome (`Ok` / `NoRootMarker` / `BinaryNotInstalled`
317    /// / `SpawnFailed`).
318    ///
319    /// Use this when the caller wants to honestly report _why_ a file has no
320    /// active server (e.g., to surface "bash-language-server not on PATH" to
321    /// the agent instead of silently returning `total: 0`).
322    pub fn ensure_server_for_file_detailed(
323        &mut self,
324        file_path: &Path,
325        config: &Config,
326    ) -> EnsureServerOutcomes {
327        let defs = servers_for_file(file_path, config);
328        let mut outcomes = EnsureServerOutcomes::default();
329
330        for def in defs {
331            let server_id = def.kind.id_str().to_string();
332            let server_name = def.name.to_string();
333
334            let Some(root) = find_workspace_root(file_path, &def.root_markers) else {
335                outcomes.attempts.push(ServerAttempt {
336                    server_id,
337                    server_name,
338                    result: ServerAttemptResult::NoRootMarker {
339                        looked_for: def.root_markers.iter().map(|s| s.to_string()).collect(),
340                    },
341                });
342                continue;
343            };
344
345            let key = ServerKey {
346                kind: def.kind.clone(),
347                root,
348            };
349
350            if !self.clients.contains_key(&key) {
351                // If we already tried and failed to spawn this server for this
352                // root, return the cached classification without retrying or
353                // re-logging. This prevents per-edit ERROR spam when the user's
354                // environment is missing a dependency the LSP needs (the
355                // typescript-language-server "Could not find a valid TypeScript
356                // installation" case is the canonical example).
357                if let Some(cached) = self.failed_spawns.get(&key) {
358                    outcomes.attempts.push(ServerAttempt {
359                        server_id,
360                        server_name,
361                        result: cached.clone(),
362                    });
363                    continue;
364                }
365
366                match self.spawn_server(&def, &key.root, config) {
367                    Ok(client) => {
368                        self.clients.insert(key.clone(), client);
369                        self.server_binaries.insert(key.clone(), def.binary.clone());
370                        self.documents.entry(key.clone()).or_default();
371                    }
372                    Err(err) => {
373                        slog_error!("failed to spawn {}: {}", def.name, err);
374                        let result = classify_spawn_error(&def.binary, &err);
375                        // Remember the failure so subsequent file events skip
376                        // this (kind, root) pair instead of producing a fresh
377                        // spawn attempt + ERROR log per request.
378                        self.failed_spawns.insert(key.clone(), result.clone());
379                        outcomes.attempts.push(ServerAttempt {
380                            server_id,
381                            server_name,
382                            result,
383                        });
384                        continue;
385                    }
386                }
387            }
388
389            outcomes.attempts.push(ServerAttempt {
390                server_id,
391                server_name,
392                result: ServerAttemptResult::Ok {
393                    server_key: key.clone(),
394                },
395            });
396            outcomes.successful.push(key);
397        }
398
399        outcomes
400    }
401
402    /// Ensure a server is running using the default LSP registry.
403    /// Kept for integration tests that exercise built-in server helpers directly.
404    pub fn ensure_server_for_file_default(&mut self, file_path: &Path) -> Vec<ServerKey> {
405        self.ensure_server_for_file(file_path, &Config::default())
406    }
407    /// Ensure that servers are running for the file and that the document is open
408    /// in each server's DocumentStore. Reads file content from disk if not already open.
409    /// Returns the server keys for the file.
410    pub fn ensure_file_open(
411        &mut self,
412        file_path: &Path,
413        config: &Config,
414    ) -> Result<Vec<ServerKey>, LspError> {
415        let canonical_path = canonicalize_for_lsp(file_path)?;
416        let server_keys = self.ensure_server_for_file(&canonical_path, config);
417        if server_keys.is_empty() {
418            return Ok(server_keys);
419        }
420
421        let uri = uri_for_path(&canonical_path)?;
422        let language_id = language_id_for_extension(
423            canonical_path
424                .extension()
425                .and_then(|ext| ext.to_str())
426                .unwrap_or_default(),
427        )
428        .to_string();
429
430        for key in &server_keys {
431            let already_open = self
432                .documents
433                .get(key)
434                .is_some_and(|store| store.is_open(&canonical_path));
435
436            if !already_open {
437                let content = std::fs::read_to_string(&canonical_path).map_err(LspError::Io)?;
438                if let Some(client) = self.clients.get_mut(key) {
439                    client.send_notification::<DidOpenTextDocument>(DidOpenTextDocumentParams {
440                        text_document: TextDocumentItem::new(
441                            uri.clone(),
442                            language_id.clone(),
443                            0,
444                            content,
445                        ),
446                    })?;
447                }
448                self.documents
449                    .entry(key.clone())
450                    .or_default()
451                    .open(canonical_path.clone());
452                continue;
453            }
454
455            // Document is already open. Check disk drift — if the file has
456            // been modified outside the AFT pipeline (other tool, manual
457            // edit, sibling session) we MUST send a didChange before any
458            // pull-diagnostic / hover query, otherwise the LSP server
459            // returns results computed from stale in-memory content.
460            //
461            // This is the regression fix Oracle flagged in finding #6:
462            // "ensure_file_open skips already-open files without checking
463            // if disk content changed."
464            let drifted = self
465                .documents
466                .get(key)
467                .is_some_and(|store| store.is_stale_on_disk(&canonical_path));
468            if drifted {
469                let content = std::fs::read_to_string(&canonical_path).map_err(LspError::Io)?;
470                let next_version = self
471                    .documents
472                    .get(key)
473                    .and_then(|store| store.version(&canonical_path))
474                    .map(|v| v + 1)
475                    .unwrap_or(1);
476                if let Some(client) = self.clients.get_mut(key) {
477                    client.send_notification::<DidChangeTextDocument>(
478                        DidChangeTextDocumentParams {
479                            text_document: VersionedTextDocumentIdentifier::new(
480                                uri.clone(),
481                                next_version,
482                            ),
483                            content_changes: vec![TextDocumentContentChangeEvent {
484                                range: None,
485                                range_length: None,
486                                text: content,
487                            }],
488                        },
489                    )?;
490                }
491                if let Some(store) = self.documents.get_mut(key) {
492                    store.bump_version(&canonical_path);
493                }
494            }
495        }
496
497        Ok(server_keys)
498    }
499
500    pub fn ensure_file_open_default(
501        &mut self,
502        file_path: &Path,
503    ) -> Result<Vec<ServerKey>, LspError> {
504        self.ensure_file_open(file_path, &Config::default())
505    }
506
507    /// Notify relevant LSP servers that a file has been written/changed.
508    /// This is the main hook called after every file write in AFT.
509    ///
510    /// If the file's server isn't running yet, starts it (lazy spawn).
511    /// If the file isn't open in LSP yet, sends didOpen. Otherwise sends didChange.
512    pub fn notify_file_changed(
513        &mut self,
514        file_path: &Path,
515        content: &str,
516        config: &Config,
517    ) -> Result<(), LspError> {
518        self.notify_file_changed_versioned(file_path, content, config)
519            .map(|_| ())
520    }
521
522    /// Like `notify_file_changed`, but returns the target document version
523    /// per server so the post-edit waiter can match `publishDiagnostics`
524    /// against the exact version that this notification carried.
525    ///
526    /// Returns: `Vec<(ServerKey, target_version)>`. `target_version` is the
527    /// `version` field on the `VersionedTextDocumentIdentifier` we just sent
528    /// (post-bump). For freshly-opened documents (`didOpen`) the version is
529    /// `0`. Servers that don't honor versioned text document sync will not
530    /// echo this back on `publishDiagnostics`; the caller is expected to
531    /// fall back to the epoch-delta path for those.
532    pub fn notify_file_changed_versioned(
533        &mut self,
534        file_path: &Path,
535        content: &str,
536        config: &Config,
537    ) -> Result<Vec<(ServerKey, i32)>, LspError> {
538        let canonical_path = canonicalize_for_lsp(file_path)?;
539        let server_keys = self.ensure_server_for_file(&canonical_path, config);
540        if server_keys.is_empty() {
541            return Ok(Vec::new());
542        }
543
544        let uri = uri_for_path(&canonical_path)?;
545        let language_id = language_id_for_extension(
546            canonical_path
547                .extension()
548                .and_then(|ext| ext.to_str())
549                .unwrap_or_default(),
550        )
551        .to_string();
552
553        let mut versions: Vec<(ServerKey, i32)> = Vec::with_capacity(server_keys.len());
554
555        for key in server_keys {
556            let current_version = self
557                .documents
558                .get(&key)
559                .and_then(|store| store.version(&canonical_path));
560
561            if let Some(version) = current_version {
562                let next_version = version + 1;
563                if let Some(client) = self.clients.get_mut(&key) {
564                    client.send_notification::<DidChangeTextDocument>(
565                        DidChangeTextDocumentParams {
566                            text_document: VersionedTextDocumentIdentifier::new(
567                                uri.clone(),
568                                next_version,
569                            ),
570                            content_changes: vec![TextDocumentContentChangeEvent {
571                                range: None,
572                                range_length: None,
573                                text: content.to_string(),
574                            }],
575                        },
576                    )?;
577                }
578                if let Some(store) = self.documents.get_mut(&key) {
579                    store.bump_version(&canonical_path);
580                }
581                versions.push((key, next_version));
582                continue;
583            }
584
585            if let Some(client) = self.clients.get_mut(&key) {
586                client.send_notification::<DidOpenTextDocument>(DidOpenTextDocumentParams {
587                    text_document: TextDocumentItem::new(
588                        uri.clone(),
589                        language_id.clone(),
590                        0,
591                        content.to_string(),
592                    ),
593                })?;
594            }
595            self.documents
596                .entry(key.clone())
597                .or_default()
598                .open(canonical_path.clone());
599            // didOpen carries version 0 — that's the version the server
600            // will echo on its first publishDiagnostics for this document.
601            versions.push((key, 0));
602        }
603
604        Ok(versions)
605    }
606
607    pub fn notify_file_changed_default(
608        &mut self,
609        file_path: &Path,
610        content: &str,
611    ) -> Result<(), LspError> {
612        self.notify_file_changed(file_path, content, &Config::default())
613    }
614
615    /// Notify every active server whose workspace contains at least one changed
616    /// path that watched files changed. This is intentionally workspace-scoped
617    /// rather than extension-scoped: configuration edits such as `package.json`
618    /// or `tsconfig.json` affect a server's project graph even though those
619    /// files may not be documents handled by the server itself.
620    pub fn notify_files_watched_changed(
621        &mut self,
622        paths: &[(PathBuf, FileChangeType)],
623        _config: &Config,
624    ) -> Result<(), LspError> {
625        if paths.is_empty() {
626            return Ok(());
627        }
628
629        let mut canonical_events = Vec::with_capacity(paths.len());
630        for (path, typ) in paths {
631            let canonical_path = resolve_for_lsp_uri(path);
632            canonical_events.push((canonical_path, *typ));
633        }
634
635        let keys: Vec<ServerKey> = self.clients.keys().cloned().collect();
636        for key in keys {
637            let mut changes = Vec::new();
638            for (path, typ) in &canonical_events {
639                if !path.starts_with(&key.root) {
640                    continue;
641                }
642                changes.push(FileEvent::new(uri_for_path(path)?, *typ));
643            }
644
645            if changes.is_empty() {
646                continue;
647            }
648
649            if let Some(client) = self.clients.get_mut(&key) {
650                // Send when the server either advertised initialize-time
651                // watched-file support or dynamically registered a watcher.
652                // The dynamic client capability we send during initialize only
653                // permits runtime registration; it is tracked separately via
654                // `has_watched_file_registration()`.
655                let supports_static_watched_files = client.supports_watched_files();
656                let has_dynamic_registration = client.has_watched_file_registration();
657                if !(supports_static_watched_files || has_dynamic_registration) {
658                    if self.watched_file_skip_logged.insert(key.clone()) {
659                        log::debug!(
660                            "skipping didChangeWatchedFiles for {:?} (not supported or registered)",
661                            key
662                        );
663                    }
664                    continue;
665                }
666                client.send_notification::<DidChangeWatchedFiles>(DidChangeWatchedFilesParams {
667                    changes,
668                })?;
669            }
670        }
671
672        Ok(())
673    }
674
675    /// Close a document in all servers that have it open.
676    pub fn notify_file_closed(&mut self, file_path: &Path) -> Result<(), LspError> {
677        let canonical_path = canonicalize_for_lsp(file_path)?;
678        let uri = uri_for_path(&canonical_path)?;
679        let keys: Vec<ServerKey> = self.documents.keys().cloned().collect();
680
681        for key in keys {
682            let was_open = self
683                .documents
684                .get(&key)
685                .map(|store| store.is_open(&canonical_path))
686                .unwrap_or(false);
687            if !was_open {
688                continue;
689            }
690
691            if let Some(client) = self.clients.get_mut(&key) {
692                client.send_notification::<DidCloseTextDocument>(DidCloseTextDocumentParams {
693                    text_document: TextDocumentIdentifier::new(uri.clone()),
694                })?;
695            }
696
697            if let Some(store) = self.documents.get_mut(&key) {
698                store.close(&canonical_path);
699            }
700            self.diagnostics
701                .clear_for_server_file(&key, &canonical_path);
702        }
703
704        Ok(())
705    }
706
707    /// Get an active client for a file path, if one exists.
708    pub fn client_for_file(&self, file_path: &Path, config: &Config) -> Option<&LspClient> {
709        let key = self.server_key_for_file(file_path, config)?;
710        self.clients.get(&key)
711    }
712
713    pub fn client_for_file_default(&self, file_path: &Path) -> Option<&LspClient> {
714        self.client_for_file(file_path, &Config::default())
715    }
716
717    /// Get a mutable active client for a file path, if one exists.
718    pub fn client_for_file_mut(
719        &mut self,
720        file_path: &Path,
721        config: &Config,
722    ) -> Option<&mut LspClient> {
723        let key = self.server_key_for_file(file_path, config)?;
724        self.clients.get_mut(&key)
725    }
726
727    pub fn client_for_file_mut_default(&mut self, file_path: &Path) -> Option<&mut LspClient> {
728        self.client_for_file_mut(file_path, &Config::default())
729    }
730
731    /// Number of tracked server clients.
732    pub fn active_client_count(&self) -> usize {
733        self.clients.len()
734    }
735
736    /// Drain all pending LSP events. Call from the main loop.
737    pub fn drain_events(&mut self) -> DrainedLspEvents {
738        let mut events = Vec::new();
739        let mut diagnostics_changed = false;
740        while let Ok(event) = self.event_rx.try_recv() {
741            if self.handle_event(&event).is_some() {
742                diagnostics_changed = true;
743            }
744            events.push(event);
745        }
746        DrainedLspEvents {
747            events,
748            diagnostics_changed,
749        }
750    }
751
752    /// Wait for diagnostics to arrive for a specific file until a timeout expires.
753    pub fn wait_for_diagnostics(
754        &mut self,
755        file_path: &Path,
756        config: &Config,
757        timeout: std::time::Duration,
758    ) -> Vec<StoredDiagnostic> {
759        let deadline = std::time::Instant::now() + timeout;
760        self.wait_for_file_diagnostics(file_path, config, deadline)
761    }
762
763    pub fn wait_for_diagnostics_default(
764        &mut self,
765        file_path: &Path,
766        timeout: std::time::Duration,
767    ) -> Vec<StoredDiagnostic> {
768        self.wait_for_diagnostics(file_path, &Config::default(), timeout)
769    }
770
771    /// Test-only accessor for the diagnostics store. Used by integration
772    /// tests that need to inspect per-server entries (e.g., to verify that
773    /// `ServerKey::root` is populated correctly, not the empty path that
774    /// the legacy `publish_with_kind` path produced).
775    #[doc(hidden)]
776    pub fn diagnostics_store_for_test(&self) -> &DiagnosticsStore {
777        &self.diagnostics
778    }
779
780    #[doc(hidden)]
781    pub fn diagnostics_store_mut_for_test(&mut self) -> &mut DiagnosticsStore {
782        &mut self.diagnostics
783    }
784
785    /// Error/warning counts across the entire warm diagnostics set (all files
786    /// any server has published for this session). Powers the agent status bar;
787    /// reads the continuously-drained store with no extra LSP round-trip.
788    pub fn warm_error_warning_counts(&self) -> (usize, usize) {
789        self.diagnostics.error_warning_counts()
790    }
791
792    /// Status-bar error/warning counts with a per-file `keep` predicate and
793    /// cross-server dedup applied (see
794    /// [`DiagnosticsStore::filtered_error_warning_counts`]). The caller supplies
795    /// the project-root + tsconfig-membership policy via `keep`.
796    pub fn filtered_error_warning_counts(
797        &self,
798        keep: impl FnMut(&std::path::Path) -> bool,
799    ) -> (usize, usize) {
800        self.diagnostics.filtered_error_warning_counts(keep)
801    }
802
803    /// Snapshot the current per-server epoch for every entry that exists
804    /// for `file_path`. Servers without an entry yet (never published)
805    /// are absent from the map; for those, `pre = 0` (any first publish
806    /// will be considered fresh under the epoch-fallback rule).
807    pub fn snapshot_diagnostic_epochs(&self, file_path: &Path) -> HashMap<ServerKey, u64> {
808        let lookup_path = normalize_lookup_path(file_path);
809        self.diagnostics
810            .entries_for_file(&lookup_path)
811            .into_iter()
812            .map(|(key, entry)| (key.clone(), entry.epoch))
813            .collect()
814    }
815
816    /// Snapshot the current diagnostic epoch and document version for every
817    /// active server relevant to `file_path` before a post-edit notification.
818    pub fn snapshot_pre_edit_state(&self, file_path: &Path) -> HashMap<ServerKey, PreEditSnapshot> {
819        let lookup_path = normalize_lookup_path(file_path);
820        let mut snapshots: HashMap<ServerKey, PreEditSnapshot> = self
821            .diagnostics
822            .entries_for_file(&lookup_path)
823            .into_iter()
824            .map(|(key, entry)| {
825                (
826                    key.clone(),
827                    PreEditSnapshot {
828                        epoch: entry.epoch,
829                        document_version_at_capture: None,
830                    },
831                )
832            })
833            .collect();
834
835        for (key, store) in &self.documents {
836            if let Some(version) = store.version(&lookup_path) {
837                snapshots
838                    .entry(key.clone())
839                    .or_default()
840                    .document_version_at_capture = Some(version);
841            }
842        }
843
844        snapshots
845    }
846
847    /// True when the current diagnostic entry for `server_key` can be tied to
848    /// that server's current in-memory document version for `file_path`.
849    ///
850    /// File-mode `lsp_diagnostics` uses this for push-only fallback after it
851    /// has synced/opened the document. Versioned publishes are accepted when
852    /// they match the current document version; unversioned publishes are not
853    /// accepted as fresh because epoch/wall-clock ordering alone is racy.
854    pub fn diagnostic_entry_is_fresh_for_document(
855        &self,
856        file_path: &Path,
857        server_key: &ServerKey,
858        pre: PreEditSnapshot,
859    ) -> bool {
860        let lookup_path = normalize_lookup_path(file_path);
861        let Some(entry) = self
862            .diagnostics
863            .entries_for_file(&lookup_path)
864            .into_iter()
865            .find_map(|(key, entry)| if key == server_key { Some(entry) } else { None })
866        else {
867            return false;
868        };
869
870        let target_version = self
871            .documents
872            .get(server_key)
873            .and_then(|store| store.version(&lookup_path))
874            .or(pre.document_version_at_capture)
875            .unwrap_or(0);
876
877        matches!(entry.version, Some(version) if version >= target_version)
878    }
879
880    /// Wait for FRESH per-server diagnostics that match the just-sent
881    /// document version. This is the v0.17.3 post-edit path that fixes the
882    /// stale-diagnostics bug: instead of returning whatever is in the cache
883    /// when the deadline hits, we only return entries whose `version`
884    /// matches the post-edit target version (or, for servers that don't
885    /// participate in versioned sync, whose `epoch` was bumped after the
886    /// pre-edit snapshot).
887    ///
888    /// `expected_versions` should come from `notify_file_changed_versioned`
889    /// — one `(ServerKey, target_version)` per server we sent didChange/
890    /// didOpen to.
891    ///
892    /// `pre_snapshot` is the per-server epoch BEFORE the notification was
893    /// sent; it gates the epoch-fallback path so an old-version publish
894    /// arriving after `drain_events` and before `didChange` cannot be
895    /// mistaken for a fresh response.
896    ///
897    /// Returns a per-server tri-state: `Fresh` (publish matched target
898    /// version OR epoch advanced past snapshot for an unversioned server),
899    /// `Pending` (deadline hit before this server published anything we
900    /// could verify), or `Exited` (server died between notification and
901    /// deadline).
902    pub fn wait_for_post_edit_diagnostics(
903        &mut self,
904        file_path: &Path,
905        // `config` is intentionally accepted (matches sibling wait APIs and
906        // future-proofs us if freshness rules need it). Currently unused
907        // because expected_versions/pre_snapshot fully determine behavior.
908        _config: &Config,
909        expected_versions: &[(ServerKey, i32)],
910        pre_snapshot: &HashMap<ServerKey, PreEditSnapshot>,
911        timeout: std::time::Duration,
912    ) -> PostEditWaitOutcome {
913        let lookup_path = normalize_lookup_path(file_path);
914        let deadline = std::time::Instant::now() + timeout;
915
916        // Drain any events that arrived while we were sending didChange.
917        // The publishDiagnostics handler stores the version, so even
918        // pre-snapshot publishes that landed late won't be mistaken for
919        // fresh — the version-match check will reject them.
920        let _ = self.drain_events_for_file(&lookup_path);
921
922        let mut fresh: HashMap<ServerKey, Vec<StoredDiagnostic>> = HashMap::new();
923        let mut exited: Vec<ServerKey> = Vec::new();
924
925        loop {
926            // Check freshness for every expected server. A server is fresh
927            // if its current entry for this file satisfies either:
928            //   1. version-match: entry.version == Some(target_version), OR
929            //   2. push-only freshness: entry.version is None AND entry.epoch
930            //      advanced strictly after the pre-edit snapshot. Versioned
931            //      publishes must be >= the post-edit target version.
932            // Servers whose process has exited are reported separately.
933            for (key, target_version) in expected_versions {
934                if fresh.contains_key(key) || exited.contains(key) {
935                    continue;
936                }
937                if !self.clients.contains_key(key) {
938                    exited.push(key.clone());
939                    continue;
940                }
941                if let Some(entry) = self
942                    .diagnostics
943                    .entries_for_file(&lookup_path)
944                    .into_iter()
945                    .find_map(|(k, e)| if k == key { Some(e) } else { None })
946                {
947                    let pre = pre_snapshot.get(key).copied().unwrap_or_default();
948                    let is_fresh = post_edit_entry_is_fresh(entry, *target_version, pre);
949                    if is_fresh {
950                        fresh.insert(key.clone(), entry.diagnostics.clone());
951                    }
952                }
953            }
954
955            // All accounted for? Done.
956            if fresh.len() + exited.len() == expected_versions.len() {
957                break;
958            }
959
960            let now = std::time::Instant::now();
961            if now >= deadline {
962                break;
963            }
964
965            let timeout = deadline.saturating_duration_since(now);
966            match self.event_rx.recv_timeout(timeout) {
967                Ok(event) => {
968                    self.handle_event(&event);
969                }
970                Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
971            }
972        }
973
974        // Pending = expected but neither fresh nor exited.
975        let pending: Vec<ServerKey> = expected_versions
976            .iter()
977            .filter(|(k, _)| !fresh.contains_key(k) && !exited.contains(k))
978            .map(|(k, _)| k.clone())
979            .collect();
980
981        // Build deduplicated, sorted diagnostics from the fresh servers only.
982        // Stale or pending servers contribute zero diagnostics.
983        let mut diagnostics: Vec<StoredDiagnostic> = fresh
984            .into_iter()
985            .flat_map(|(_, diags)| diags.into_iter())
986            .collect();
987        diagnostics.sort_by(|a, b| {
988            a.file
989                .cmp(&b.file)
990                .then(a.line.cmp(&b.line))
991                .then(a.column.cmp(&b.column))
992                .then(a.message.cmp(&b.message))
993        });
994
995        PostEditWaitOutcome {
996            diagnostics,
997            pending_servers: pending,
998            exited_servers: exited,
999        }
1000    }
1001
1002    /// Wait for diagnostics to arrive for a specific file until a deadline.
1003    ///
1004    /// Drains already-queued events first, then blocks on the shared event
1005    /// channel only until either `publishDiagnostics` arrives for this file or
1006    /// the deadline is reached.
1007    pub fn wait_for_file_diagnostics(
1008        &mut self,
1009        file_path: &Path,
1010        config: &Config,
1011        deadline: std::time::Instant,
1012    ) -> Vec<StoredDiagnostic> {
1013        let lookup_path = normalize_lookup_path(file_path);
1014
1015        if self.server_key_for_file(&lookup_path, config).is_none() {
1016            return Vec::new();
1017        }
1018
1019        loop {
1020            if self.drain_events_for_file(&lookup_path) {
1021                break;
1022            }
1023
1024            let now = std::time::Instant::now();
1025            if now >= deadline {
1026                break;
1027            }
1028
1029            let timeout = deadline.saturating_duration_since(now);
1030            match self.event_rx.recv_timeout(timeout) {
1031                Ok(event) => {
1032                    if matches!(
1033                        self.handle_event(&event),
1034                        Some(ref published_file) if published_file.as_path() == lookup_path.as_path()
1035                    ) {
1036                        break;
1037                    }
1038                }
1039                Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
1040            }
1041        }
1042
1043        self.get_diagnostics_for_file(&lookup_path)
1044            .into_iter()
1045            .cloned()
1046            .collect()
1047    }
1048
1049    /// Default timeout for `textDocument/diagnostic` (per-file pull). Servers
1050    /// usually respond in under 1s for files they've already analyzed; we
1051    /// allow up to 10s before falling back to push semantics. Currently
1052    /// surfaced via [`Self::pull_file_timeout`] for callers that want to
1053    /// override the wait via the `wait_ms` knob.
1054    pub const PULL_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1055
1056    /// Public accessor so command handlers can reuse the documented default.
1057    pub fn pull_file_timeout() -> std::time::Duration {
1058        Self::PULL_FILE_TIMEOUT
1059    }
1060
1061    /// Default timeout for `workspace/diagnostic`. The LSP spec allows the
1062    /// server to hold this open indefinitely; we cap at 10s and report
1063    /// `complete: false` to the agent rather than hanging the bridge.
1064    const PULL_WORKSPACE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
1065
1066    /// Issue a `textDocument/diagnostic` (LSP 3.17 per-file pull) request to
1067    /// every server that supports pull diagnostics for the given file.
1068    ///
1069    /// Returns the per-server outcome. If a server reports `kind: "unchanged"`,
1070    /// the cached entry's diagnostics are surfaced (deterministic re-use of
1071    /// the previous response). If a server doesn't advertise pull capability,
1072    /// it's skipped here — the caller should fall back to push for those.
1073    ///
1074    /// Side effects: results are stored in `DiagnosticsStore` so directory-mode
1075    /// queries can aggregate them later.
1076    pub fn pull_file_diagnostics(
1077        &mut self,
1078        file_path: &Path,
1079        config: &Config,
1080    ) -> Result<Vec<PullFileResult>, LspError> {
1081        let canonical_path = canonicalize_for_lsp(file_path)?;
1082        // Make sure servers are running and the document is open with fresh
1083        // content (handles disk-drift via DocumentStore::is_stale_on_disk).
1084        self.ensure_file_open(&canonical_path, config)?;
1085
1086        let server_keys = self.ensure_server_for_file(&canonical_path, config);
1087        if server_keys.is_empty() {
1088            return Ok(Vec::new());
1089        }
1090
1091        let uri = uri_for_path(&canonical_path)?;
1092        let mut results = Vec::with_capacity(server_keys.len());
1093
1094        for key in server_keys {
1095            let supports_pull = self
1096                .clients
1097                .get(&key)
1098                .and_then(|c| c.diagnostic_capabilities())
1099                .is_some_and(|caps| caps.pull_diagnostics);
1100
1101            if !supports_pull {
1102                results.push(PullFileResult {
1103                    server_key: key.clone(),
1104                    outcome: PullFileOutcome::PullNotSupported,
1105                });
1106                continue;
1107            }
1108
1109            // Look up previous resultId for incremental requests.
1110            let previous_result_id = self
1111                .diagnostics
1112                .entries_for_file(&canonical_path)
1113                .into_iter()
1114                .find(|(k, _)| **k == key)
1115                .and_then(|(_, entry)| entry.result_id.clone());
1116
1117            let identifier = self
1118                .clients
1119                .get(&key)
1120                .and_then(|c| c.diagnostic_capabilities())
1121                .and_then(|caps| caps.identifier.clone());
1122
1123            let params = AftDocumentDiagnosticParams {
1124                text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
1125                identifier,
1126                previous_result_id,
1127                work_done_progress_params: Default::default(),
1128                partial_result_params: Default::default(),
1129            };
1130
1131            let outcome = match self.send_pull_request(&key, params) {
1132                Ok(report) => self.ingest_document_report(&key, &canonical_path, report),
1133                Err(err) => {
1134                    if let Some(result) = self.cache_post_initialize_exit(&key, &err) {
1135                        PullFileOutcome::RequestFailed {
1136                            reason: server_attempt_result_reason(&result),
1137                        }
1138                    } else if recoverable_pull_rejection(&err)
1139                        && self.clients.get(&key).is_some_and(|client| {
1140                            matches!(
1141                                client.state(),
1142                                ServerState::Ready | ServerState::Initializing
1143                            )
1144                        })
1145                    {
1146                        PullFileOutcome::RequestFailed {
1147                            reason: format!("pull_rejected_push_fallback: {err}"),
1148                        }
1149                    } else {
1150                        PullFileOutcome::RequestFailed {
1151                            reason: err.to_string(),
1152                        }
1153                    }
1154                }
1155            };
1156
1157            results.push(PullFileResult {
1158                server_key: key,
1159                outcome,
1160            });
1161        }
1162
1163        Ok(results)
1164    }
1165
1166    /// Issue a `workspace/diagnostic` request to a specific server. Cancels
1167    /// internally if `timeout` elapses before the server responds. Cached
1168    /// entries from the response are stored so directory-mode queries pick
1169    /// them up.
1170    pub fn pull_workspace_diagnostics(
1171        &mut self,
1172        server_key: &ServerKey,
1173        timeout: Option<std::time::Duration>,
1174    ) -> Result<PullWorkspaceResult, LspError> {
1175        let timeout = timeout.unwrap_or(Self::PULL_WORKSPACE_TIMEOUT);
1176
1177        let supports_workspace = self
1178            .clients
1179            .get(server_key)
1180            .and_then(|c| c.diagnostic_capabilities())
1181            .is_some_and(|caps| caps.workspace_diagnostics);
1182
1183        if !supports_workspace {
1184            return Ok(PullWorkspaceResult {
1185                server_key: server_key.clone(),
1186                files_reported: Vec::new(),
1187                complete: false,
1188                cancelled: false,
1189                supports_workspace: false,
1190            });
1191        }
1192
1193        let identifier = self
1194            .clients
1195            .get(server_key)
1196            .and_then(|c| c.diagnostic_capabilities())
1197            .and_then(|caps| caps.identifier.clone());
1198
1199        let params = AftWorkspaceDiagnosticParams {
1200            identifier,
1201            previous_result_ids: Vec::new(),
1202            work_done_progress_params: Default::default(),
1203            partial_result_params: Default::default(),
1204        };
1205
1206        let result = match self
1207            .clients
1208            .get_mut(server_key)
1209            .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?
1210            .send_request_with_timeout::<AftWorkspaceDiagnosticRequest>(params, timeout)
1211        {
1212            Ok(result) => result,
1213            Err(LspError::Timeout(_)) => {
1214                return Ok(PullWorkspaceResult {
1215                    server_key: server_key.clone(),
1216                    files_reported: Vec::new(),
1217                    complete: false,
1218                    cancelled: true,
1219                    supports_workspace: true,
1220                });
1221            }
1222            Err(err) => {
1223                if let Some(result) = self.cache_post_initialize_exit(server_key, &err) {
1224                    return Err(LspError::ServerNotReady(server_attempt_result_reason(
1225                        &result,
1226                    )));
1227                }
1228                return Err(err);
1229            }
1230        };
1231
1232        // Extract the items list. Partial responses are not a complete
1233        // workspace view, but the partial payload can still contain useful
1234        // document reports; ingest those while surfacing complete=false.
1235        let (items, complete) = match result {
1236            lsp_types::WorkspaceDiagnosticReportResult::Report(report) => (report.items, true),
1237            lsp_types::WorkspaceDiagnosticReportResult::Partial(partial) => (partial.items, false),
1238        };
1239
1240        // Ingest each file report into the diagnostics store.
1241        let mut files_reported = Vec::with_capacity(items.len());
1242        for item in items {
1243            match item {
1244                lsp_types::WorkspaceDocumentDiagnosticReport::Full(full) => {
1245                    if let Some(file) = uri_to_path(&full.uri) {
1246                        let stored = from_lsp_diagnostics(
1247                            file.clone(),
1248                            full.full_document_diagnostic_report.items.clone(),
1249                        );
1250                        self.diagnostics.publish_with_result_id(
1251                            server_key.clone(),
1252                            file.clone(),
1253                            stored,
1254                            full.full_document_diagnostic_report.result_id.clone(),
1255                        );
1256                        files_reported.push(file);
1257                    }
1258                }
1259                lsp_types::WorkspaceDocumentDiagnosticReport::Unchanged(_unchanged) => {
1260                    // "Unchanged" means the previously cached report is still
1261                    // valid. We left it in place; nothing to do.
1262                }
1263            }
1264        }
1265
1266        Ok(PullWorkspaceResult {
1267            server_key: server_key.clone(),
1268            files_reported,
1269            complete,
1270            cancelled: false,
1271            supports_workspace: true,
1272        })
1273    }
1274
1275    fn cache_post_initialize_exit(
1276        &mut self,
1277        key: &ServerKey,
1278        err: &LspError,
1279    ) -> Option<ServerAttemptResult> {
1280        let binary = self
1281            .server_binaries
1282            .get(key)
1283            .cloned()
1284            .unwrap_or_else(|| key.kind.id_str().to_string());
1285        let (status, stderr_tail) = {
1286            let client = self.clients.get_mut(key)?;
1287            let mut status = client.child_exit_status();
1288            for _ in 0..10 {
1289                if status.is_some() {
1290                    break;
1291                }
1292                std::thread::sleep(std::time::Duration::from_millis(10));
1293                status = client.child_exit_status();
1294            }
1295            let status = status?;
1296            wait_for_stderr_tail(client);
1297            (status, client.stderr_tail())
1298        };
1299        let reason = format_post_initialize_exit_reason(&binary, status, &stderr_tail, err);
1300        let result = ServerAttemptResult::SpawnFailed { binary, reason };
1301        self.clients.remove(key);
1302        self.server_binaries.remove(key);
1303        self.documents.remove(key);
1304        self.diagnostics.clear_for_server(key);
1305        self.failed_spawns.insert(key.clone(), result.clone());
1306        Some(result)
1307    }
1308
1309    /// Issue the per-file diagnostic request and return the report.
1310    fn send_pull_request(
1311        &mut self,
1312        key: &ServerKey,
1313        params: AftDocumentDiagnosticParams,
1314    ) -> Result<lsp_types::DocumentDiagnosticReportResult, LspError> {
1315        let client = self
1316            .clients
1317            .get_mut(key)
1318            .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?;
1319        // Use the documented 10s pull cap, not the global 30s request timeout —
1320        // a stalled pull server must not blow the scoped aft_inspect 8s budget
1321        // (or the lsp_diagnostics wait caps) all the way out to 30s.
1322        client.send_request_with_timeout::<AftDocumentDiagnosticRequest>(
1323            params,
1324            Self::PULL_FILE_TIMEOUT,
1325        )
1326    }
1327
1328    /// Store the result of a per-file pull request and return a structured
1329    /// outcome the caller can inspect.
1330    fn ingest_document_report(
1331        &mut self,
1332        key: &ServerKey,
1333        canonical_path: &Path,
1334        result: lsp_types::DocumentDiagnosticReportResult,
1335    ) -> PullFileOutcome {
1336        let report = match result {
1337            lsp_types::DocumentDiagnosticReportResult::Report(report) => report,
1338            lsp_types::DocumentDiagnosticReportResult::Partial(_) => {
1339                // Partial results stream in via $/progress notifications which
1340                // we don't currently subscribe to. Treat as a soft-empty
1341                // success — the next pull will get the full version.
1342                return PullFileOutcome::PartialNotSupported;
1343            }
1344        };
1345
1346        match report {
1347            lsp_types::DocumentDiagnosticReport::Full(full) => {
1348                let result_id = full.full_document_diagnostic_report.result_id.clone();
1349                let stored = from_lsp_diagnostics(
1350                    canonical_path.to_path_buf(),
1351                    full.full_document_diagnostic_report.items.clone(),
1352                );
1353                let count = stored.len();
1354                self.diagnostics.publish_with_result_id(
1355                    key.clone(),
1356                    canonical_path.to_path_buf(),
1357                    stored,
1358                    result_id,
1359                );
1360                PullFileOutcome::Full {
1361                    diagnostic_count: count,
1362                }
1363            }
1364            lsp_types::DocumentDiagnosticReport::Unchanged(_unchanged) => {
1365                // The server says cache is still valid. That is only usable if
1366                // we already have a report for this exact server/file; an
1367                // initial `unchanged` response cannot prove freshness.
1368                if self
1369                    .diagnostics
1370                    .has_report_for_server_file(key, canonical_path)
1371                {
1372                    PullFileOutcome::Unchanged
1373                } else {
1374                    PullFileOutcome::RequestFailed {
1375                        reason: "no_cache_for_unchanged".to_string(),
1376                    }
1377                }
1378            }
1379        }
1380    }
1381
1382    /// Shutdown all servers gracefully.
1383    pub fn shutdown_all(&mut self) {
1384        for (key, mut client) in self.clients.drain() {
1385            if let Err(err) = client.shutdown() {
1386                slog_error!("error shutting down {:?}: {}", key, err);
1387            }
1388        }
1389        self.server_binaries.clear();
1390        self.documents.clear();
1391        self.diagnostics = DiagnosticsStore::new();
1392    }
1393
1394    /// Check if any server is active.
1395    pub fn has_active_servers(&self) -> bool {
1396        self.clients
1397            .values()
1398            .any(|client| client.state() == ServerState::Ready)
1399    }
1400
1401    /// Active server keys (running clients). Used by `lsp_diagnostics`
1402    /// directory mode to know which servers to ask for workspace pull.
1403    pub fn active_server_keys(&self) -> Vec<ServerKey> {
1404        self.clients.keys().cloned().collect()
1405    }
1406
1407    pub fn get_diagnostics_for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
1408        let normalized = normalize_lookup_path(file);
1409        self.diagnostics.for_file(&normalized)
1410    }
1411
1412    /// Drop all cached diagnostics for a file across every server. Called when a
1413    /// file is deleted/renamed away so its diagnostics don't linger in the warm
1414    /// set (no server republishes for a vanished path), inflating the
1415    /// error/warning counts in the status bar and `aft_inspect`.
1416    ///
1417    /// The store key is the canonical path from publish time, but a deleted file
1418    /// can no longer be canonicalized directly (`canonicalize` needs the file to
1419    /// exist). We therefore try several equivalent forms: the raw path, the
1420    /// canonicalize-or-fallback form, and — crucially — a reconstruction that
1421    /// canonicalizes the still-present parent directory and rejoins the file
1422    /// name, which reproduces the publish-time key even across `/var`↔
1423    /// `/private/var`-style symlink aliasing. Returns true if anything was
1424    /// removed.
1425    /// Forget all cached spawn FAILURES so the next file event retries them.
1426    /// Called on `configure`: a configure means something changed (the user may
1427    /// have just installed the missing language server, or fixed PATH / a
1428    /// version pin), so a previously-failed (kind, root) pair deserves a fresh
1429    /// attempt instead of being skipped until a full restart. Bounded: configure
1430    /// is not a per-request hot path, so this cannot cause a spawn storm.
1431    /// Returns the number of cleared entries.
1432    pub fn clear_failed_spawns(&mut self) -> usize {
1433        let n = self.failed_spawns.len();
1434        self.failed_spawns.clear();
1435        n
1436    }
1437
1438    #[cfg(test)]
1439    pub(crate) fn insert_failed_spawn_for_test(&mut self) {
1440        let key = ServerKey {
1441            kind: crate::lsp::registry::ServerKind::Rust,
1442            root: std::path::PathBuf::from("/tmp/test-root"),
1443        };
1444        self.failed_spawns.insert(
1445            key,
1446            ServerAttemptResult::SpawnFailed {
1447                binary: "rust-analyzer".to_string(),
1448                reason: "test".to_string(),
1449            },
1450        );
1451    }
1452
1453    pub fn clear_diagnostics_for_file(&mut self, file: &Path) -> bool {
1454        let mut removed = self.diagnostics.clear_for_file(file);
1455
1456        let normalized = normalize_lookup_path(file);
1457        if normalized != file {
1458            removed |= self.diagnostics.clear_for_file(&normalized);
1459        }
1460
1461        // Reconstruct the canonical key via the parent dir (which still exists
1462        // for a just-deleted file) so symlink-aliased roots still match.
1463        if let (Some(parent), Some(name)) = (file.parent(), file.file_name()) {
1464            if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
1465                let reconstructed = canonical_parent.join(name);
1466                if reconstructed != file && reconstructed != normalized {
1467                    removed |= self.diagnostics.clear_for_file(&reconstructed);
1468                }
1469            }
1470        }
1471
1472        removed
1473    }
1474
1475    pub fn get_diagnostics_for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
1476        let normalized = normalize_lookup_path(dir);
1477        self.diagnostics.for_directory(&normalized)
1478    }
1479
1480    pub fn get_all_diagnostics(&self) -> Vec<&StoredDiagnostic> {
1481        self.diagnostics.all()
1482    }
1483
1484    /// True if any LSP server has reported diagnostics at least once, including
1485    /// an empty report that proves a checked-clean file. This lets callers avoid
1486    /// treating an empty flattened diagnostic list as trustworthy when no server
1487    /// has actually run.
1488    pub fn has_any_diagnostic_reports(&self) -> bool {
1489        !self.diagnostics.is_empty()
1490    }
1491
1492    /// True if any server has reported for this file, including an empty
1493    /// checked-clean report.
1494    pub fn has_diagnostic_report_for_file(&self, file: &Path) -> bool {
1495        let normalized = normalize_lookup_path(file);
1496        self.diagnostics.has_any_report_for_file(&normalized)
1497    }
1498
1499    /// True if this exact server/file pair has a diagnostic report, including
1500    /// an empty checked-clean report.
1501    pub fn has_diagnostic_report_for_server_file(&self, server: &ServerKey, file: &Path) -> bool {
1502        let normalized = normalize_lookup_path(file);
1503        self.diagnostics
1504            .has_report_for_server_file(server, &normalized)
1505    }
1506
1507    fn drain_events_for_file(&mut self, file_path: &Path) -> bool {
1508        let mut saw_file_diagnostics = false;
1509        while let Ok(event) = self.event_rx.try_recv() {
1510            if matches!(
1511                self.handle_event(&event),
1512                Some(ref published_file) if published_file.as_path() == file_path
1513            ) {
1514                saw_file_diagnostics = true;
1515            }
1516        }
1517        saw_file_diagnostics
1518    }
1519
1520    fn handle_event(&mut self, event: &LspEvent) -> Option<PathBuf> {
1521        match event {
1522            LspEvent::Notification {
1523                server_kind,
1524                root,
1525                method,
1526                params: Some(params),
1527            } if method == "textDocument/publishDiagnostics" => {
1528                self.handle_publish_diagnostics(server_kind.clone(), root.clone(), params)
1529            }
1530            LspEvent::ServerExited { server_kind, root } => {
1531                let key = ServerKey {
1532                    kind: server_kind.clone(),
1533                    root: root.clone(),
1534                };
1535                self.clients.remove(&key);
1536                self.server_binaries.remove(&key);
1537                self.documents.remove(&key);
1538                self.diagnostics.clear_for_server(&key);
1539                None
1540            }
1541            _ => None,
1542        }
1543    }
1544
1545    fn handle_publish_diagnostics(
1546        &mut self,
1547        server: ServerKind,
1548        root: PathBuf,
1549        params: &serde_json::Value,
1550    ) -> Option<PathBuf> {
1551        if let Ok(publish_params) =
1552            serde_json::from_value::<lsp_types::PublishDiagnosticsParams>(params.clone())
1553        {
1554            let file = uri_to_path(&publish_params.uri)?;
1555            let stored = from_lsp_diagnostics(file.clone(), publish_params.diagnostics);
1556            // v0.17.3: store with real ServerKey { kind, root } and capture
1557            // the document `version` (when the server provided one) so the
1558            // post-edit waiter can reject stale publishes deterministically
1559            // via version-match (preferred) or epoch-delta (fallback). The
1560            // earlier `publish_with_kind` path silently dropped both.
1561            let key = ServerKey { kind: server, root };
1562            self.diagnostics
1563                .publish_full(key, file.clone(), stored, None, publish_params.version);
1564            return Some(file);
1565        }
1566        None
1567    }
1568
1569    fn spawn_server(
1570        &self,
1571        def: &ServerDef,
1572        root: &Path,
1573        config: &Config,
1574    ) -> Result<LspClient, LspError> {
1575        let binary = self.resolve_binary(def, config)?;
1576
1577        // Merge the server-defined env with our test-injected env.
1578        // `extra_env` is empty in production; tests use it to drive fake
1579        // server variants (AFT_FAKE_LSP_PULL=1, etc.).
1580        let mut merged_env = def.env.clone();
1581        for (key, value) in &self.extra_env {
1582            merged_env.insert(key.clone(), value.clone());
1583        }
1584
1585        let mut client = LspClient::spawn(
1586            def.kind.clone(),
1587            root.to_path_buf(),
1588            &binary,
1589            &def.args,
1590            &merged_env,
1591            self.event_tx.clone(),
1592            self.child_registry.clone(),
1593        )?;
1594        if let Err(err) = client.initialize(root, def.initialization_options.clone()) {
1595            wait_for_stderr_tail(&mut client);
1596            let stderr_tail = client.stderr_tail();
1597            let reason = if client.child_exited() || !stderr_tail.is_empty() {
1598                format_initialize_failure_reason(&def.binary, &stderr_tail, &err)
1599            } else {
1600                format!("server failed during initialize: {err}")
1601            };
1602            return Err(LspError::ServerNotReady(reason));
1603        }
1604        Ok(client)
1605    }
1606
1607    fn resolve_binary(&self, def: &ServerDef, config: &Config) -> Result<PathBuf, LspError> {
1608        if let Some(path) = self.binary_overrides.get(&def.kind) {
1609            if path.exists() {
1610                return Ok(path.clone());
1611            }
1612            return Err(LspError::NotFound(format!(
1613                "override binary for {:?} not found: {}",
1614                def.kind,
1615                path.display()
1616            )));
1617        }
1618
1619        if let Some(path) = env_binary_override(&def.kind) {
1620            if path.exists() {
1621                return Ok(path);
1622            }
1623            return Err(LspError::NotFound(format!(
1624                "environment override binary for {:?} not found: {}",
1625                def.kind,
1626                path.display()
1627            )));
1628        }
1629
1630        // Layered resolution:
1631        //   1. <project_root>/node_modules/.bin/<binary>
1632        //   2. config.lsp_paths_extra (plugin auto-install cache, etc.)
1633        //   3. PATH via `which`
1634        resolve_lsp_binary(
1635            &def.binary,
1636            config.project_root.as_deref(),
1637            &config.lsp_paths_extra,
1638        )
1639        .ok_or_else(|| {
1640            LspError::NotFound(format!(
1641                "language server binary '{}' not found in node_modules/.bin, lsp_paths_extra, or PATH",
1642                def.binary
1643            ))
1644        })
1645    }
1646
1647    fn server_key_for_file(&self, file_path: &Path, config: &Config) -> Option<ServerKey> {
1648        for def in servers_for_file(file_path, config) {
1649            let root = find_workspace_root(file_path, &def.root_markers)?;
1650            let key = ServerKey {
1651                kind: def.kind.clone(),
1652                root,
1653            };
1654            if self.clients.contains_key(&key) {
1655                return Some(key);
1656            }
1657        }
1658        None
1659    }
1660}
1661
1662impl Default for LspManager {
1663    fn default() -> Self {
1664        Self::new()
1665    }
1666}
1667
1668fn wait_for_stderr_tail(client: &mut LspClient) {
1669    for _ in 0..10 {
1670        if !client.stderr_tail().is_empty() {
1671            break;
1672        }
1673        std::thread::sleep(std::time::Duration::from_millis(10));
1674    }
1675}
1676
1677fn recoverable_pull_rejection(err: &LspError) -> bool {
1678    matches!(
1679        err,
1680        LspError::ServerError {
1681            code: -32601 | -32602,
1682            ..
1683        }
1684    )
1685}
1686
1687fn server_attempt_result_reason(result: &ServerAttemptResult) -> String {
1688    match result {
1689        ServerAttemptResult::SpawnFailed { binary, reason } => {
1690            format!("spawn_failed: {binary} ({reason})")
1691        }
1692        ServerAttemptResult::BinaryNotInstalled { binary } => {
1693            format!("binary_not_installed: {binary}")
1694        }
1695        ServerAttemptResult::NoRootMarker { looked_for } => {
1696            format!("no_root_marker (looked for: {})", looked_for.join(", "))
1697        }
1698        ServerAttemptResult::Ok { .. } => "ok".to_string(),
1699    }
1700}
1701
1702fn format_stderr_tail_for_reason(stderr_tail: &str) -> String {
1703    truncate_stderr_tail_for_reason(stderr_tail)
1704        .lines()
1705        .map(|line| format!("  {line}"))
1706        .collect::<Vec<_>>()
1707        .join("\n")
1708}
1709
1710fn truncate_stderr_tail_for_reason(stderr_tail: &str) -> String {
1711    if stderr_tail.len() <= STDERR_REASON_BYTES {
1712        return stderr_tail.to_string();
1713    }
1714
1715    let ellipsis = "...";
1716    let target_len = STDERR_REASON_BYTES.saturating_sub(ellipsis.len());
1717    let mut start = stderr_tail.len() - target_len;
1718    while start < stderr_tail.len() && !stderr_tail.is_char_boundary(start) {
1719        start += 1;
1720    }
1721    format!("{ellipsis}{}", &stderr_tail[start..])
1722}
1723
1724fn format_initialize_failure_reason(binary: &str, stderr_tail: &str, err: &LspError) -> String {
1725    let mut reason = format!("server crashed during initialize: {err}");
1726    if !stderr_tail.is_empty() {
1727        reason.push_str("; stderr (last 64 lines):\n");
1728        reason.push_str(&format_stderr_tail_for_reason(stderr_tail));
1729        reason.push_str("\n\n");
1730        reason.push_str(&failure_hint(binary, stderr_tail));
1731    }
1732    reason
1733}
1734
1735fn format_post_initialize_exit_reason(
1736    binary: &str,
1737    status: std::process::ExitStatus,
1738    stderr_tail: &str,
1739    err: &LspError,
1740) -> String {
1741    let code = status
1742        .code()
1743        .map(|c| c.to_string())
1744        .unwrap_or_else(|| "signal/unknown".to_string());
1745    let mut reason = format!("server exited after initialize (code {code}): {err}");
1746    if !stderr_tail.is_empty() {
1747        reason.push_str("; stderr (last 64 lines):\n");
1748        reason.push_str(&format_stderr_tail_for_reason(stderr_tail));
1749        reason.push_str("\n\n");
1750        reason.push_str(&failure_hint(binary, stderr_tail));
1751    }
1752    reason
1753}
1754
1755fn failure_hint(binary: &str, stderr_tail: &str) -> String {
1756    if stderr_tail.contains("MODULE_NOT_FOUND") || stderr_tail.contains("Cannot find module") {
1757        let package_manager = infer_package_manager(stderr_tail);
1758        format!(
1759            "Your package-manager shim resolves to a missing file. Try reinstalling: {package_manager} install -g {binary} --force. Common cause: hard-link breakage from fs migration or store prune."
1760        )
1761    } else if let Some(component) = rustup_missing_component(stderr_tail) {
1762        // The binary on PATH is rustup's proxy shim, but the toolchain
1763        // component isn't installed, so rustup rejects the dispatch with
1764        // "Unknown binary '<name>' in ... toolchain". The actionable fix is to
1765        // add the component, not anything about the binary itself.
1766        format!("'{component}' is a rustup proxy but the component is not installed. Install it: rustup component add {component}")
1767    } else {
1768        format!("Hint: see stderr above for '{binary}' failure details.")
1769    }
1770}
1771
1772/// Detect the rustup "proxy shim without installed component" failure and
1773/// return the component name to add. rustup prints
1774/// `error: Unknown binary '<name>' in official toolchain '<triple>'` when a
1775/// `~/.cargo/bin/<name>` proxy is on PATH but the component was never installed
1776/// (the canonical case is `rust-analyzer`, which ships as an opt-in component).
1777fn rustup_missing_component(stderr_tail: &str) -> Option<String> {
1778    let marker = "Unknown binary '";
1779    let start = stderr_tail.find(marker)? + marker.len();
1780    let rest = &stderr_tail[start..];
1781    let end = rest.find('\'')?;
1782    let name = &rest[..end];
1783    // Only treat it as a rustup-component issue when the toolchain phrasing is
1784    // present, so an unrelated "Unknown binary" message doesn't mislead.
1785    if name.is_empty() || !stderr_tail.contains("toolchain") {
1786        return None;
1787    }
1788    Some(name.to_string())
1789}
1790
1791fn infer_package_manager(stderr_tail: &str) -> &'static str {
1792    let lower = stderr_tail.to_ascii_lowercase();
1793    if lower.contains(".pnpm/") || lower.contains(".pnpm\\") || lower.contains("/pnpm/") {
1794        "pnpm"
1795    } else if lower.contains(".yarn/")
1796        || lower.contains(".yarn\\")
1797        || lower.contains("/yarn/")
1798        || lower.contains("yarn")
1799    {
1800        "yarn"
1801    } else {
1802        "npm"
1803    }
1804}
1805
1806fn canonicalize_for_lsp(file_path: &Path) -> Result<PathBuf, LspError> {
1807    std::fs::canonicalize(file_path).map_err(LspError::from)
1808}
1809
1810fn resolve_for_lsp_uri(file_path: &Path) -> PathBuf {
1811    if let Ok(path) = std::fs::canonicalize(file_path) {
1812        return path;
1813    }
1814
1815    let mut existing = file_path.to_path_buf();
1816    let mut missing = Vec::new();
1817    while !existing.exists() {
1818        let Some(name) = existing.file_name() else {
1819            break;
1820        };
1821        missing.push(name.to_owned());
1822        let Some(parent) = existing.parent() else {
1823            break;
1824        };
1825        existing = parent.to_path_buf();
1826    }
1827
1828    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
1829    for segment in missing.into_iter().rev() {
1830        resolved.push(segment);
1831    }
1832    resolved
1833}
1834
1835fn language_id_for_extension(ext: &str) -> &'static str {
1836    match ext {
1837        "ts" => "typescript",
1838        "tsx" => "typescriptreact",
1839        "js" | "mjs" | "cjs" => "javascript",
1840        "jsx" => "javascriptreact",
1841        "py" | "pyi" => "python",
1842        "rs" => "rust",
1843        "go" => "go",
1844        "html" | "htm" => "html",
1845        _ => "plaintext",
1846    }
1847}
1848
1849fn normalize_lookup_path(path: &Path) -> PathBuf {
1850    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1851}
1852
1853/// Classify an error returned by `spawn_server` into a structured
1854/// `ServerAttemptResult`. The two interesting cases for callers are:
1855/// - `BinaryNotInstalled` — the server's binary couldn't be resolved on PATH
1856///   or via override. The agent can be told "install bash-language-server".
1857/// - `SpawnFailed` — binary was found but spawning/initializing failed
1858///   (permissions, missing runtime, server crashed during initialize, etc.).
1859fn classify_spawn_error(binary: &str, err: &LspError) -> ServerAttemptResult {
1860    match err {
1861        // resolve_binary returns NotFound for both missing override paths and
1862        // missing PATH binaries. The "override missing" case is rare in
1863        // practice (only set in tests / env vars); we report all NotFound as
1864        // BinaryNotInstalled so the user sees an actionable install hint.
1865        LspError::NotFound(_) => ServerAttemptResult::BinaryNotInstalled {
1866            binary: binary.to_string(),
1867        },
1868        other => ServerAttemptResult::SpawnFailed {
1869            binary: binary.to_string(),
1870            reason: other.to_string(),
1871        },
1872    }
1873}
1874
1875fn env_binary_override(kind: &ServerKind) -> Option<PathBuf> {
1876    let id = kind.id_str();
1877    let suffix: String = id
1878        .chars()
1879        .map(|ch| {
1880            if ch.is_ascii_alphanumeric() {
1881                ch.to_ascii_uppercase()
1882            } else {
1883                '_'
1884            }
1885        })
1886        .collect();
1887    let key = format!("AFT_LSP_{suffix}_BINARY");
1888    std::env::var_os(key).map(PathBuf::from)
1889}
1890
1891#[cfg(test)]
1892mod failure_hint_tests {
1893    use super::{failure_hint, rustup_missing_component};
1894
1895    #[test]
1896    fn detects_rustup_proxy_without_component() {
1897        // The exact rustup stderr for a proxy shim whose component is missing.
1898        let stderr = "error: Unknown binary 'rust-analyzer' in official toolchain 'stable-aarch64-apple-darwin'.";
1899        assert_eq!(
1900            rustup_missing_component(stderr).as_deref(),
1901            Some("rust-analyzer")
1902        );
1903        let hint = failure_hint("rust-analyzer", stderr);
1904        assert!(
1905            hint.contains("rustup component add rust-analyzer"),
1906            "expected actionable rustup hint, got: {hint}"
1907        );
1908    }
1909
1910    #[test]
1911    fn ignores_unknown_binary_without_toolchain_phrasing() {
1912        // "Unknown binary" without the rustup toolchain phrasing must not be
1913        // misattributed to a rustup component issue.
1914        let stderr = "fatal: Unknown binary 'foo' was requested by the linker.";
1915        assert_eq!(rustup_missing_component(stderr), None);
1916        assert!(failure_hint("foo", stderr).starts_with("Hint: see stderr"));
1917    }
1918
1919    #[test]
1920    fn npm_module_not_found_still_wins() {
1921        // The existing package-manager-shim case is unaffected.
1922        let stderr = "Error: Cannot find module '/x/typescript-language-server/lib/cli.mjs'";
1923        let hint = failure_hint("typescript-language-server", stderr);
1924        assert!(hint.contains("install -g"), "got: {hint}");
1925    }
1926}
1927
1928#[cfg(test)]
1929mod diagnostic_capacity_tests {
1930    use super::LspManager;
1931
1932    // The lsp.diagnostic_cache_size config knob must actually take effect:
1933    // set_diagnostic_capacity (called at AppContext construction with the config
1934    // value) propagates the cap to the underlying DiagnosticsStore. Before this
1935    // wiring the field was parsed but never applied (always the hardcoded 5000).
1936    #[test]
1937    fn set_diagnostic_capacity_propagates_to_store() {
1938        let mut manager = LspManager::new();
1939        manager.set_diagnostic_capacity(7);
1940        assert_eq!(manager.diagnostics_store_for_test().capacity_for_test(), 7);
1941        manager.set_diagnostic_capacity(0); // 0 = unbounded
1942        assert_eq!(manager.diagnostics_store_for_test().capacity_for_test(), 0);
1943    }
1944
1945    // configure clears cached spawn failures so a just-installed server retries
1946    // without a full restart.
1947    #[test]
1948    fn clear_failed_spawns_empties_the_cache() {
1949        let mut manager = LspManager::new();
1950        assert_eq!(manager.clear_failed_spawns(), 0);
1951        manager.insert_failed_spawn_for_test();
1952        assert_eq!(manager.clear_failed_spawns(), 1);
1953        assert_eq!(manager.clear_failed_spawns(), 0);
1954    }
1955}
1956
1957#[cfg(test)]
1958mod clear_diagnostics_tests {
1959    use std::path::PathBuf;
1960
1961    use super::LspManager;
1962    use crate::lsp::client::LspEvent;
1963    use crate::lsp::diagnostics::{DiagnosticSeverity, StoredDiagnostic};
1964    use crate::lsp::position::uri_for_path;
1965    use crate::lsp::registry::ServerKind;
1966    use crate::lsp::roots::ServerKey;
1967
1968    fn err_diag(file: &PathBuf) -> StoredDiagnostic {
1969        StoredDiagnostic {
1970            file: file.clone(),
1971            line: 1,
1972            column: 1,
1973            end_line: 1,
1974            end_column: 2,
1975            severity: DiagnosticSeverity::Error,
1976            message: "boom".into(),
1977            code: None,
1978            source: None,
1979        }
1980    }
1981
1982    // A just-deleted file can no longer be canonicalized directly, but its
1983    // store key was the canonical path from publish time. The manager must
1984    // reconstruct that key via the still-present parent dir so symlink-aliased
1985    // roots (macOS /var -> /private/var) still match and the diagnostic clears.
1986    #[test]
1987    fn clear_diagnostics_for_deleted_file_matches_canonical_key() {
1988        let dir = tempfile::tempdir().unwrap();
1989        // Canonicalize the parent the way publish time would have.
1990        let canonical_dir = std::fs::canonicalize(dir.path()).unwrap();
1991        let canonical_file = canonical_dir.join("gone.ts");
1992        // Write then remove the file so its parent exists but the file does not,
1993        // mirroring the post-delete state the watcher observes.
1994        std::fs::write(&canonical_file, "x").unwrap();
1995
1996        let mut manager = LspManager::new();
1997        let key = ServerKey {
1998            kind: ServerKind::TypeScript,
1999            root: canonical_dir.clone(),
2000        };
2001        manager.diagnostics_store_mut_for_test().publish(
2002            key,
2003            canonical_file.clone(),
2004            vec![err_diag(&canonical_file)],
2005        );
2006        assert_eq!(manager.warm_error_warning_counts(), (1, 0));
2007
2008        std::fs::remove_file(&canonical_file).unwrap();
2009
2010        // Clear by the NON-canonical path the watcher might hand us (the raw
2011        // tempdir path, which on macOS differs from the canonical /private form).
2012        let watcher_path = dir.path().join("gone.ts");
2013        let removed = manager.clear_diagnostics_for_file(&watcher_path);
2014
2015        assert!(removed, "expected the deleted file's diagnostic to clear");
2016        assert_eq!(manager.warm_error_warning_counts(), (0, 0));
2017    }
2018
2019    #[test]
2020    fn clear_diagnostics_for_unknown_file_is_noop() {
2021        let mut manager = LspManager::new();
2022        assert!(!manager.clear_diagnostics_for_file(&PathBuf::from("/nope/missing.ts")));
2023        assert_eq!(manager.warm_error_warning_counts(), (0, 0));
2024    }
2025
2026    #[test]
2027    fn drain_events_reports_publish_diagnostics_updates() {
2028        let dir = tempfile::tempdir().unwrap();
2029        let root = std::fs::canonicalize(dir.path()).unwrap();
2030        let file = root.join("main.ts");
2031        std::fs::write(&file, "const x: number = 'nope';").unwrap();
2032
2033        let mut manager = LspManager::new();
2034        let diagnostic = lsp_types::Diagnostic {
2035            range: lsp_types::Range {
2036                start: lsp_types::Position {
2037                    line: 0,
2038                    character: 0,
2039                },
2040                end: lsp_types::Position {
2041                    line: 0,
2042                    character: 1,
2043                },
2044            },
2045            severity: Some(lsp_types::DiagnosticSeverity::ERROR),
2046            code: None,
2047            code_description: None,
2048            source: Some("test".into()),
2049            message: "boom".into(),
2050            related_information: None,
2051            tags: None,
2052            data: None,
2053        };
2054        let params = serde_json::to_value(lsp_types::PublishDiagnosticsParams {
2055            uri: uri_for_path(&file).unwrap(),
2056            diagnostics: vec![diagnostic],
2057            version: Some(1),
2058        })
2059        .unwrap();
2060        manager
2061            .event_tx
2062            .send(LspEvent::Notification {
2063                server_kind: ServerKind::TypeScript,
2064                root,
2065                method: "textDocument/publishDiagnostics".into(),
2066                params: Some(params),
2067            })
2068            .unwrap();
2069
2070        let drained = manager.drain_events();
2071
2072        assert!(drained.diagnostics_changed);
2073        assert_eq!(drained.events.len(), 1);
2074        assert_eq!(manager.warm_error_warning_counts(), (1, 0));
2075    }
2076}