Skip to main content

aft/lsp/
manager.rs

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