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