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