Skip to main content

aft/lsp/
manager.rs

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