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