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