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