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