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