Skip to main content

aft/lsp/
manager.rs

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