Skip to main content

aft/lsp/
manager.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4
5use crossbeam_channel::{unbounded, Receiver, RecvTimeoutError, Sender};
6use lsp_types::notification::{
7    DidChangeTextDocument, DidChangeWatchedFiles, DidCloseTextDocument, DidOpenTextDocument,
8};
9use lsp_types::{
10    DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidCloseTextDocumentParams,
11    DidOpenTextDocumentParams, FileChangeType, FileEvent, TextDocumentContentChangeEvent,
12    TextDocumentIdentifier, TextDocumentItem, VersionedTextDocumentIdentifier,
13};
14
15use crate::config::Config;
16use crate::lsp::client::{LspClient, LspEvent, ServerState};
17use crate::lsp::diagnostics::{from_lsp_diagnostics, DiagnosticsStore, StoredDiagnostic};
18use crate::lsp::document::DocumentStore;
19use crate::lsp::registry::{resolve_lsp_binary, servers_for_file, ServerDef, ServerKind};
20use crate::lsp::roots::{find_workspace_root, ServerKey};
21use crate::lsp::LspError;
22
23/// Outcome of attempting to ensure a server is running for a single matching
24/// `ServerDef`. Returned per matching server so the caller can report exactly
25/// what happened to the user instead of collapsing all failures into "no
26/// server".
27#[derive(Debug, Clone)]
28pub enum ServerAttemptResult {
29    /// Server is running and ready to serve requests for this file.
30    Ok { server_key: ServerKey },
31    /// No workspace root was found by walking up from the file looking for
32    /// any of the server's configured root markers.
33    NoRootMarker { looked_for: Vec<String> },
34    /// The server's binary could not be found on PATH (or override was
35    /// missing/invalid).
36    BinaryNotInstalled { binary: String },
37    /// Binary was found but spawning or initializing the server failed.
38    SpawnFailed { binary: String, reason: String },
39}
40
41/// One server's attempt to handle a file.
42#[derive(Debug, Clone)]
43pub struct ServerAttempt {
44    /// Stable server identifier (kind ID, e.g. "pyright", "rust-analyzer").
45    pub server_id: String,
46    /// Server display name from the registry.
47    pub server_name: String,
48    pub result: ServerAttemptResult,
49}
50
51/// Aggregate outcome of `ensure_server_for_file_detailed`. Distinguishes:
52/// - "No server registered for this file's extension" (`attempts.is_empty()`)
53/// - "Servers registered but none could start" (`successful.is_empty()` but
54///   `!attempts.is_empty()`)
55/// - "At least one server is ready" (`!successful.is_empty()`)
56#[derive(Debug, Clone, Default)]
57pub struct EnsureServerOutcomes {
58    /// Server keys that are now running and ready to serve requests.
59    pub successful: Vec<ServerKey>,
60    /// Per-server attempt records. Empty if no server is registered for the
61    /// file's extension.
62    pub attempts: Vec<ServerAttempt>,
63}
64
65impl EnsureServerOutcomes {
66    /// True if no server in the registry matched this file's extension.
67    pub fn no_server_registered(&self) -> bool {
68        self.attempts.is_empty()
69    }
70}
71
72/// Outcome of a post-edit diagnostics wait. Reports the per-server status
73/// alongside the fresh diagnostics, so the response layer can build an
74/// honest tri-state payload (`success: true` + `complete: bool` + named
75/// gap fields per `crates/aft/src/protocol.rs`).
76///
77/// `diagnostics` only contains entries from servers that proved freshness
78/// (version-match preferred, epoch-fallback for unversioned servers).
79/// Pre-edit cached entries are NEVER included — that's the whole point of
80/// this type.
81#[derive(Debug, Clone, Default)]
82pub struct PostEditWaitOutcome {
83    /// Diagnostics from servers whose response we verified is FOR the
84    /// post-edit document version (or whose epoch we saw advance after our
85    /// pre-edit snapshot, for unversioned servers).
86    pub diagnostics: Vec<StoredDiagnostic>,
87    /// Servers we expected to publish but didn't before the deadline.
88    /// Reported to the agent via `pending_lsp_servers` so they understand
89    /// the result is partial.
90    pub pending_servers: Vec<ServerKey>,
91    /// Servers whose process exited between notification and deadline.
92    /// Reported separately so the agent knows the gap is unrecoverable
93    /// without a server restart, not "wait longer."
94    pub exited_servers: Vec<ServerKey>,
95}
96
97impl PostEditWaitOutcome {
98    /// True if every expected server reported a fresh result. False means
99    /// the agent should treat the diagnostics as a partial picture.
100    pub fn complete(&self) -> bool {
101        self.pending_servers.is_empty() && self.exited_servers.is_empty()
102    }
103}
104
105/// Per-server outcome of a `textDocument/diagnostic` (per-file pull) request.
106#[derive(Debug, Clone)]
107pub enum PullFileOutcome {
108    /// Server returned a full report; diagnostics stored.
109    Full { diagnostic_count: usize },
110    /// Server returned `kind: "unchanged"` — cached diagnostics still valid.
111    Unchanged,
112    /// Server returned a partial-result token; we don't subscribe to streamed
113    /// progress so the response is treated as a soft empty until the next pull.
114    PartialNotSupported,
115    /// Server doesn't advertise pull capability — caller should fall back to
116    /// push diagnostics for this server.
117    PullNotSupported,
118    /// The pull request failed (timeout, server error, etc.).
119    RequestFailed { reason: String },
120}
121
122/// Result of `pull_file_diagnostics` for one matching server.
123#[derive(Debug, Clone)]
124pub struct PullFileResult {
125    pub server_key: ServerKey,
126    pub outcome: PullFileOutcome,
127}
128
129/// Result of `pull_workspace_diagnostics` for a single server.
130#[derive(Debug, Clone)]
131pub struct PullWorkspaceResult {
132    pub server_key: ServerKey,
133    /// Files for which a Full report was received and cached. Files that came
134    /// back as `Unchanged` are NOT listed here because their cached entry was
135    /// already authoritative.
136    pub files_reported: Vec<PathBuf>,
137    /// True if the server returned a full response within the timeout.
138    pub complete: bool,
139    /// True if we cancelled (request timed out before the server responded).
140    pub cancelled: bool,
141    /// True if the server advertised workspace pull support. When false, the
142    /// other fields are empty and the caller should fall back to file-mode
143    /// pull or to push semantics.
144    pub supports_workspace: bool,
145}
146
147pub struct LspManager {
148    /// Active server instances, keyed by (ServerKind, workspace_root).
149    clients: HashMap<ServerKey, LspClient>,
150    /// Tracks opened documents and versions per active server.
151    documents: HashMap<ServerKey, DocumentStore>,
152    /// Stored publishDiagnostics payloads across all servers.
153    diagnostics: DiagnosticsStore,
154    /// Unified event channel — all server reader threads send here.
155    event_tx: Sender<LspEvent>,
156    event_rx: Receiver<LspEvent>,
157    /// Optional binary path overrides used by integration tests.
158    binary_overrides: HashMap<ServerKind, PathBuf>,
159    /// Extra env vars merged into every spawned LSP child. Used in tests to
160    /// drive the fake server's behavioral variants (`AFT_FAKE_LSP_PULL=1`,
161    /// `AFT_FAKE_LSP_WORKSPACE=1`, etc.). Production code does not set this.
162    extra_env: HashMap<String, String>,
163}
164
165impl LspManager {
166    pub fn new() -> Self {
167        let (event_tx, event_rx) = unbounded();
168        Self {
169            clients: HashMap::new(),
170            documents: HashMap::new(),
171            diagnostics: DiagnosticsStore::new(),
172            event_tx,
173            event_rx,
174            binary_overrides: HashMap::new(),
175            extra_env: HashMap::new(),
176        }
177    }
178
179    /// For testing: set an extra environment variable that gets passed to
180    /// every spawned LSP child process. Useful for driving fake-server
181    /// behavioral variants in integration tests.
182    pub fn set_extra_env(&mut self, key: &str, value: &str) {
183        self.extra_env.insert(key.to_string(), value.to_string());
184    }
185
186    /// Count active LSP server instances.
187    pub fn server_count(&self) -> usize {
188        self.clients.len()
189    }
190
191    /// For testing: override the binary for a server kind.
192    pub fn override_binary(&mut self, kind: ServerKind, binary_path: PathBuf) {
193        self.binary_overrides.insert(kind, binary_path);
194    }
195
196    /// Ensure a server is running for the given file. Spawns if needed.
197    /// Returns the active server keys for the file, or an empty vec if none match.
198    ///
199    /// This is the lightweight wrapper around [`ensure_server_for_file_detailed`]
200    /// that drops failure context. Prefer the detailed variant in command
201    /// handlers that need to surface honest error messages to the agent.
202    pub fn ensure_server_for_file(&mut self, file_path: &Path, config: &Config) -> Vec<ServerKey> {
203        self.ensure_server_for_file_detailed(file_path, config)
204            .successful
205    }
206
207    /// Detailed version of [`ensure_server_for_file`] that records every
208    /// matching server's outcome (`Ok` / `NoRootMarker` / `BinaryNotInstalled`
209    /// / `SpawnFailed`).
210    ///
211    /// Use this when the caller wants to honestly report _why_ a file has no
212    /// active server (e.g., to surface "bash-language-server not on PATH" to
213    /// the agent instead of silently returning `total: 0`).
214    pub fn ensure_server_for_file_detailed(
215        &mut self,
216        file_path: &Path,
217        config: &Config,
218    ) -> EnsureServerOutcomes {
219        let defs = servers_for_file(file_path, config);
220        let mut outcomes = EnsureServerOutcomes::default();
221
222        for def in defs {
223            let server_id = def.kind.id_str().to_string();
224            let server_name = def.name.to_string();
225
226            let Some(root) = find_workspace_root(file_path, &def.root_markers) else {
227                outcomes.attempts.push(ServerAttempt {
228                    server_id,
229                    server_name,
230                    result: ServerAttemptResult::NoRootMarker {
231                        looked_for: def.root_markers.iter().map(|s| s.to_string()).collect(),
232                    },
233                });
234                continue;
235            };
236
237            let key = ServerKey {
238                kind: def.kind.clone(),
239                root,
240            };
241
242            if !self.clients.contains_key(&key) {
243                match self.spawn_server(&def, &key.root, config) {
244                    Ok(client) => {
245                        self.clients.insert(key.clone(), client);
246                        self.documents.entry(key.clone()).or_default();
247                    }
248                    Err(err) => {
249                        log::error!("failed to spawn {}: {}", def.name, err);
250                        let result = classify_spawn_error(&def.binary, &err);
251                        outcomes.attempts.push(ServerAttempt {
252                            server_id,
253                            server_name,
254                            result,
255                        });
256                        continue;
257                    }
258                }
259            }
260
261            outcomes.attempts.push(ServerAttempt {
262                server_id,
263                server_name,
264                result: ServerAttemptResult::Ok {
265                    server_key: key.clone(),
266                },
267            });
268            outcomes.successful.push(key);
269        }
270
271        outcomes
272    }
273
274    /// Ensure a server is running using the default LSP registry.
275    /// Kept for integration tests that exercise built-in server helpers directly.
276    pub fn ensure_server_for_file_default(&mut self, file_path: &Path) -> Vec<ServerKey> {
277        self.ensure_server_for_file(file_path, &Config::default())
278    }
279    /// Ensure that servers are running for the file and that the document is open
280    /// in each server's DocumentStore. Reads file content from disk if not already open.
281    /// Returns the server keys for the file.
282    pub fn ensure_file_open(
283        &mut self,
284        file_path: &Path,
285        config: &Config,
286    ) -> Result<Vec<ServerKey>, LspError> {
287        let canonical_path = canonicalize_for_lsp(file_path)?;
288        let server_keys = self.ensure_server_for_file(&canonical_path, config);
289        if server_keys.is_empty() {
290            return Ok(server_keys);
291        }
292
293        let uri = uri_for_path(&canonical_path)?;
294        let language_id = language_id_for_extension(
295            canonical_path
296                .extension()
297                .and_then(|ext| ext.to_str())
298                .unwrap_or_default(),
299        )
300        .to_string();
301
302        for key in &server_keys {
303            let already_open = self
304                .documents
305                .get(key)
306                .is_some_and(|store| store.is_open(&canonical_path));
307
308            if !already_open {
309                let content = std::fs::read_to_string(&canonical_path).map_err(LspError::Io)?;
310                if let Some(client) = self.clients.get_mut(key) {
311                    client.send_notification::<DidOpenTextDocument>(DidOpenTextDocumentParams {
312                        text_document: TextDocumentItem::new(
313                            uri.clone(),
314                            language_id.clone(),
315                            0,
316                            content,
317                        ),
318                    })?;
319                }
320                self.documents
321                    .entry(key.clone())
322                    .or_default()
323                    .open(canonical_path.clone());
324                continue;
325            }
326
327            // Document is already open. Check disk drift — if the file has
328            // been modified outside the AFT pipeline (other tool, manual
329            // edit, sibling session) we MUST send a didChange before any
330            // pull-diagnostic / hover query, otherwise the LSP server
331            // returns results computed from stale in-memory content.
332            //
333            // This is the regression fix Oracle flagged in finding #6:
334            // "ensure_file_open skips already-open files without checking
335            // if disk content changed."
336            let drifted = self
337                .documents
338                .get(key)
339                .is_some_and(|store| store.is_stale_on_disk(&canonical_path));
340            if drifted {
341                let content = std::fs::read_to_string(&canonical_path).map_err(LspError::Io)?;
342                let next_version = self
343                    .documents
344                    .get(key)
345                    .and_then(|store| store.version(&canonical_path))
346                    .map(|v| v + 1)
347                    .unwrap_or(1);
348                if let Some(client) = self.clients.get_mut(key) {
349                    client.send_notification::<DidChangeTextDocument>(
350                        DidChangeTextDocumentParams {
351                            text_document: VersionedTextDocumentIdentifier::new(
352                                uri.clone(),
353                                next_version,
354                            ),
355                            content_changes: vec![TextDocumentContentChangeEvent {
356                                range: None,
357                                range_length: None,
358                                text: content,
359                            }],
360                        },
361                    )?;
362                }
363                if let Some(store) = self.documents.get_mut(key) {
364                    store.bump_version(&canonical_path);
365                }
366            }
367        }
368
369        Ok(server_keys)
370    }
371
372    pub fn ensure_file_open_default(
373        &mut self,
374        file_path: &Path,
375    ) -> Result<Vec<ServerKey>, LspError> {
376        self.ensure_file_open(file_path, &Config::default())
377    }
378
379    /// Notify relevant LSP servers that a file has been written/changed.
380    /// This is the main hook called after every file write in AFT.
381    ///
382    /// If the file's server isn't running yet, starts it (lazy spawn).
383    /// If the file isn't open in LSP yet, sends didOpen. Otherwise sends didChange.
384    pub fn notify_file_changed(
385        &mut self,
386        file_path: &Path,
387        content: &str,
388        config: &Config,
389    ) -> Result<(), LspError> {
390        self.notify_file_changed_versioned(file_path, content, config)
391            .map(|_| ())
392    }
393
394    /// Like `notify_file_changed`, but returns the target document version
395    /// per server so the post-edit waiter can match `publishDiagnostics`
396    /// against the exact version that this notification carried.
397    ///
398    /// Returns: `Vec<(ServerKey, target_version)>`. `target_version` is the
399    /// `version` field on the `VersionedTextDocumentIdentifier` we just sent
400    /// (post-bump). For freshly-opened documents (`didOpen`) the version is
401    /// `0`. Servers that don't honor versioned text document sync will not
402    /// echo this back on `publishDiagnostics`; the caller is expected to
403    /// fall back to the epoch-delta path for those.
404    pub fn notify_file_changed_versioned(
405        &mut self,
406        file_path: &Path,
407        content: &str,
408        config: &Config,
409    ) -> Result<Vec<(ServerKey, i32)>, LspError> {
410        let canonical_path = canonicalize_for_lsp(file_path)?;
411        let server_keys = self.ensure_server_for_file(&canonical_path, config);
412        if server_keys.is_empty() {
413            return Ok(Vec::new());
414        }
415
416        let uri = uri_for_path(&canonical_path)?;
417        let language_id = language_id_for_extension(
418            canonical_path
419                .extension()
420                .and_then(|ext| ext.to_str())
421                .unwrap_or_default(),
422        )
423        .to_string();
424
425        let mut versions: Vec<(ServerKey, i32)> = Vec::with_capacity(server_keys.len());
426
427        for key in server_keys {
428            let current_version = self
429                .documents
430                .get(&key)
431                .and_then(|store| store.version(&canonical_path));
432
433            if let Some(version) = current_version {
434                let next_version = version + 1;
435                if let Some(client) = self.clients.get_mut(&key) {
436                    client.send_notification::<DidChangeTextDocument>(
437                        DidChangeTextDocumentParams {
438                            text_document: VersionedTextDocumentIdentifier::new(
439                                uri.clone(),
440                                next_version,
441                            ),
442                            content_changes: vec![TextDocumentContentChangeEvent {
443                                range: None,
444                                range_length: None,
445                                text: content.to_string(),
446                            }],
447                        },
448                    )?;
449                }
450                if let Some(store) = self.documents.get_mut(&key) {
451                    store.bump_version(&canonical_path);
452                }
453                versions.push((key, next_version));
454                continue;
455            }
456
457            if let Some(client) = self.clients.get_mut(&key) {
458                client.send_notification::<DidOpenTextDocument>(DidOpenTextDocumentParams {
459                    text_document: TextDocumentItem::new(
460                        uri.clone(),
461                        language_id.clone(),
462                        0,
463                        content.to_string(),
464                    ),
465                })?;
466            }
467            self.documents
468                .entry(key.clone())
469                .or_default()
470                .open(canonical_path.clone());
471            // didOpen carries version 0 — that's the version the server
472            // will echo on its first publishDiagnostics for this document.
473            versions.push((key, 0));
474        }
475
476        Ok(versions)
477    }
478
479    pub fn notify_file_changed_default(
480        &mut self,
481        file_path: &Path,
482        content: &str,
483    ) -> Result<(), LspError> {
484        self.notify_file_changed(file_path, content, &Config::default())
485    }
486
487    /// Notify every active server whose workspace contains at least one changed
488    /// path that watched files changed. This is intentionally workspace-scoped
489    /// rather than extension-scoped: configuration edits such as `package.json`
490    /// or `tsconfig.json` affect a server's project graph even though those
491    /// files may not be documents handled by the server itself.
492    pub fn notify_files_watched_changed(
493        &mut self,
494        paths: &[(PathBuf, FileChangeType)],
495        _config: &Config,
496    ) -> Result<(), LspError> {
497        if paths.is_empty() {
498            return Ok(());
499        }
500
501        let mut canonical_events = Vec::with_capacity(paths.len());
502        for (path, typ) in paths {
503            let canonical_path = resolve_for_lsp_uri(path);
504            canonical_events.push((canonical_path, *typ));
505        }
506
507        let keys: Vec<ServerKey> = self.clients.keys().cloned().collect();
508        for key in keys {
509            let mut changes = Vec::new();
510            for (path, typ) in &canonical_events {
511                if !path.starts_with(&key.root) {
512                    continue;
513                }
514                changes.push(FileEvent::new(uri_for_path(path)?, *typ));
515            }
516
517            if changes.is_empty() {
518                continue;
519            }
520
521            if let Some(client) = self.clients.get_mut(&key) {
522                // Only send if the server advertised this capability (#32).
523                // Sending didChangeWatchedFiles to a server that didn't declare
524                // workspace.didChangeWatchedFiles causes spurious errors on some
525                // servers (e.g. older tsserver builds) and is a spec violation.
526                if !client.supports_watched_files() {
527                    log::debug!(
528                        "[aft-lsp] skipping didChangeWatchedFiles for {:?} (capability not declared)",
529                        key
530                    );
531                    continue;
532                }
533                client.send_notification::<DidChangeWatchedFiles>(DidChangeWatchedFilesParams {
534                    changes,
535                })?;
536            }
537        }
538
539        Ok(())
540    }
541
542    /// Close a document in all servers that have it open.
543    pub fn notify_file_closed(&mut self, file_path: &Path) -> Result<(), LspError> {
544        let canonical_path = canonicalize_for_lsp(file_path)?;
545        let uri = uri_for_path(&canonical_path)?;
546        let keys: Vec<ServerKey> = self.documents.keys().cloned().collect();
547
548        for key in keys {
549            let was_open = self
550                .documents
551                .get(&key)
552                .map(|store| store.is_open(&canonical_path))
553                .unwrap_or(false);
554            if !was_open {
555                continue;
556            }
557
558            if let Some(client) = self.clients.get_mut(&key) {
559                client.send_notification::<DidCloseTextDocument>(DidCloseTextDocumentParams {
560                    text_document: TextDocumentIdentifier::new(uri.clone()),
561                })?;
562            }
563
564            if let Some(store) = self.documents.get_mut(&key) {
565                store.close(&canonical_path);
566            }
567        }
568
569        Ok(())
570    }
571
572    /// Get an active client for a file path, if one exists.
573    pub fn client_for_file(&self, file_path: &Path, config: &Config) -> Option<&LspClient> {
574        let key = self.server_key_for_file(file_path, config)?;
575        self.clients.get(&key)
576    }
577
578    pub fn client_for_file_default(&self, file_path: &Path) -> Option<&LspClient> {
579        self.client_for_file(file_path, &Config::default())
580    }
581
582    /// Get a mutable active client for a file path, if one exists.
583    pub fn client_for_file_mut(
584        &mut self,
585        file_path: &Path,
586        config: &Config,
587    ) -> Option<&mut LspClient> {
588        let key = self.server_key_for_file(file_path, config)?;
589        self.clients.get_mut(&key)
590    }
591
592    pub fn client_for_file_mut_default(&mut self, file_path: &Path) -> Option<&mut LspClient> {
593        self.client_for_file_mut(file_path, &Config::default())
594    }
595
596    /// Number of tracked server clients.
597    pub fn active_client_count(&self) -> usize {
598        self.clients.len()
599    }
600
601    /// Drain all pending LSP events. Call from the main loop.
602    pub fn drain_events(&mut self) -> Vec<LspEvent> {
603        let mut events = Vec::new();
604        while let Ok(event) = self.event_rx.try_recv() {
605            self.handle_event(&event);
606            events.push(event);
607        }
608        events
609    }
610
611    /// Wait for diagnostics to arrive for a specific file until a timeout expires.
612    pub fn wait_for_diagnostics(
613        &mut self,
614        file_path: &Path,
615        config: &Config,
616        timeout: std::time::Duration,
617    ) -> Vec<StoredDiagnostic> {
618        let deadline = std::time::Instant::now() + timeout;
619        self.wait_for_file_diagnostics(file_path, config, deadline)
620    }
621
622    pub fn wait_for_diagnostics_default(
623        &mut self,
624        file_path: &Path,
625        timeout: std::time::Duration,
626    ) -> Vec<StoredDiagnostic> {
627        self.wait_for_diagnostics(file_path, &Config::default(), timeout)
628    }
629
630    /// Test-only accessor for the diagnostics store. Used by integration
631    /// tests that need to inspect per-server entries (e.g., to verify that
632    /// `ServerKey::root` is populated correctly, not the empty path that
633    /// the legacy `publish_with_kind` path produced).
634    #[doc(hidden)]
635    pub fn diagnostics_store_for_test(&self) -> &DiagnosticsStore {
636        &self.diagnostics
637    }
638
639    /// Snapshot the current per-server epoch for every entry that exists
640    /// for `file_path`. Servers without an entry yet (never published)
641    /// are absent from the map; for those, `pre = 0` (any first publish
642    /// will be considered fresh under the epoch-fallback rule).
643    pub fn snapshot_diagnostic_epochs(&self, file_path: &Path) -> HashMap<ServerKey, u64> {
644        let lookup_path = normalize_lookup_path(file_path);
645        self.diagnostics
646            .entries_for_file(&lookup_path)
647            .into_iter()
648            .map(|(key, entry)| (key.clone(), entry.epoch))
649            .collect()
650    }
651
652    /// Wait for FRESH per-server diagnostics that match the just-sent
653    /// document version. This is the v0.17.3 post-edit path that fixes the
654    /// stale-diagnostics bug: instead of returning whatever is in the cache
655    /// when the deadline hits, we only return entries whose `version`
656    /// matches the post-edit target version (or, for servers that don't
657    /// participate in versioned sync, whose `epoch` was bumped after the
658    /// pre-edit snapshot).
659    ///
660    /// `expected_versions` should come from `notify_file_changed_versioned`
661    /// — one `(ServerKey, target_version)` per server we sent didChange/
662    /// didOpen to.
663    ///
664    /// `pre_snapshot` is the per-server epoch BEFORE the notification was
665    /// sent; it gates the epoch-fallback path so an old-version publish
666    /// arriving after `drain_events` and before `didChange` cannot be
667    /// mistaken for a fresh response.
668    ///
669    /// Returns a per-server tri-state: `Fresh` (publish matched target
670    /// version OR epoch advanced past snapshot for an unversioned server),
671    /// `Pending` (deadline hit before this server published anything we
672    /// could verify), or `Exited` (server died between notification and
673    /// deadline).
674    pub fn wait_for_post_edit_diagnostics(
675        &mut self,
676        file_path: &Path,
677        // `config` is intentionally accepted (matches sibling wait APIs and
678        // future-proofs us if freshness rules need it). Currently unused
679        // because expected_versions/pre_snapshot fully determine behavior.
680        _config: &Config,
681        expected_versions: &[(ServerKey, i32)],
682        pre_snapshot: &HashMap<ServerKey, u64>,
683        timeout: std::time::Duration,
684    ) -> PostEditWaitOutcome {
685        let lookup_path = normalize_lookup_path(file_path);
686        let deadline = std::time::Instant::now() + timeout;
687
688        // Drain any events that arrived while we were sending didChange.
689        // The publishDiagnostics handler stores the version, so even
690        // pre-snapshot publishes that landed late won't be mistaken for
691        // fresh — the version-match check will reject them.
692        let _ = self.drain_events_for_file(&lookup_path);
693
694        let mut fresh: HashMap<ServerKey, Vec<StoredDiagnostic>> = HashMap::new();
695        let mut exited: Vec<ServerKey> = Vec::new();
696
697        loop {
698            // Check freshness for every expected server. A server is fresh
699            // if its current entry for this file satisfies either:
700            //   1. version-match: entry.version == Some(target_version), OR
701            //   2. epoch-fallback: entry.version is None AND
702            //      entry.epoch > pre_snapshot.get(&key).copied().unwrap_or(0)
703            // Servers whose process has exited are reported separately.
704            for (key, target_version) in expected_versions {
705                if fresh.contains_key(key) || exited.contains(key) {
706                    continue;
707                }
708                if !self.clients.contains_key(key) {
709                    exited.push(key.clone());
710                    continue;
711                }
712                if let Some(entry) = self
713                    .diagnostics
714                    .entries_for_file(&lookup_path)
715                    .into_iter()
716                    .find_map(|(k, e)| if k == key { Some(e) } else { None })
717                {
718                    let is_fresh = match entry.version {
719                        Some(v) => v == *target_version,
720                        None => {
721                            let pre = pre_snapshot.get(key).copied().unwrap_or(0);
722                            entry.epoch > pre
723                        }
724                    };
725                    if is_fresh {
726                        fresh.insert(key.clone(), entry.diagnostics.clone());
727                    }
728                }
729            }
730
731            // All accounted for? Done.
732            if fresh.len() + exited.len() == expected_versions.len() {
733                break;
734            }
735
736            let now = std::time::Instant::now();
737            if now >= deadline {
738                break;
739            }
740
741            let timeout = deadline.saturating_duration_since(now);
742            match self.event_rx.recv_timeout(timeout) {
743                Ok(event) => {
744                    self.handle_event(&event);
745                }
746                Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
747            }
748        }
749
750        // Pending = expected but neither fresh nor exited.
751        let pending: Vec<ServerKey> = expected_versions
752            .iter()
753            .filter(|(k, _)| !fresh.contains_key(k) && !exited.contains(k))
754            .map(|(k, _)| k.clone())
755            .collect();
756
757        // Build deduplicated, sorted diagnostics from the fresh servers only.
758        // Stale or pending servers contribute zero diagnostics.
759        let mut diagnostics: Vec<StoredDiagnostic> = fresh
760            .into_iter()
761            .flat_map(|(_, diags)| diags.into_iter())
762            .collect();
763        diagnostics.sort_by(|a, b| {
764            a.file
765                .cmp(&b.file)
766                .then(a.line.cmp(&b.line))
767                .then(a.column.cmp(&b.column))
768                .then(a.message.cmp(&b.message))
769        });
770
771        PostEditWaitOutcome {
772            diagnostics,
773            pending_servers: pending,
774            exited_servers: exited,
775        }
776    }
777
778    /// Wait for diagnostics to arrive for a specific file until a deadline.
779    ///
780    /// Drains already-queued events first, then blocks on the shared event
781    /// channel only until either `publishDiagnostics` arrives for this file or
782    /// the deadline is reached.
783    pub fn wait_for_file_diagnostics(
784        &mut self,
785        file_path: &Path,
786        config: &Config,
787        deadline: std::time::Instant,
788    ) -> Vec<StoredDiagnostic> {
789        let lookup_path = normalize_lookup_path(file_path);
790
791        if self.server_key_for_file(&lookup_path, config).is_none() {
792            return Vec::new();
793        }
794
795        loop {
796            if self.drain_events_for_file(&lookup_path) {
797                break;
798            }
799
800            let now = std::time::Instant::now();
801            if now >= deadline {
802                break;
803            }
804
805            let timeout = deadline.saturating_duration_since(now);
806            match self.event_rx.recv_timeout(timeout) {
807                Ok(event) => {
808                    if matches!(
809                        self.handle_event(&event),
810                        Some(ref published_file) if published_file.as_path() == lookup_path.as_path()
811                    ) {
812                        break;
813                    }
814                }
815                Err(RecvTimeoutError::Timeout) | Err(RecvTimeoutError::Disconnected) => break,
816            }
817        }
818
819        self.get_diagnostics_for_file(&lookup_path)
820            .into_iter()
821            .cloned()
822            .collect()
823    }
824
825    /// Default timeout for `textDocument/diagnostic` (per-file pull). Servers
826    /// usually respond in under 1s for files they've already analyzed; we
827    /// allow up to 10s before falling back to push semantics. Currently
828    /// surfaced via [`Self::pull_file_timeout`] for callers that want to
829    /// override the wait via the `wait_ms` knob.
830    pub const PULL_FILE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
831
832    /// Public accessor so command handlers can reuse the documented default.
833    pub fn pull_file_timeout() -> std::time::Duration {
834        Self::PULL_FILE_TIMEOUT
835    }
836
837    /// Default timeout for `workspace/diagnostic`. The LSP spec allows the
838    /// server to hold this open indefinitely; we cap at 10s and report
839    /// `complete: false` to the agent rather than hanging the bridge.
840    const PULL_WORKSPACE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
841
842    /// Issue a `textDocument/diagnostic` (LSP 3.17 per-file pull) request to
843    /// every server that supports pull diagnostics for the given file.
844    ///
845    /// Returns the per-server outcome. If a server reports `kind: "unchanged"`,
846    /// the cached entry's diagnostics are surfaced (deterministic re-use of
847    /// the previous response). If a server doesn't advertise pull capability,
848    /// it's skipped here — the caller should fall back to push for those.
849    ///
850    /// Side effects: results are stored in `DiagnosticsStore` so directory-mode
851    /// queries can aggregate them later.
852    pub fn pull_file_diagnostics(
853        &mut self,
854        file_path: &Path,
855        config: &Config,
856    ) -> Result<Vec<PullFileResult>, LspError> {
857        let canonical_path = canonicalize_for_lsp(file_path)?;
858        // Make sure servers are running and the document is open with fresh
859        // content (handles disk-drift via DocumentStore::is_stale_on_disk).
860        self.ensure_file_open(&canonical_path, config)?;
861
862        let server_keys = self.ensure_server_for_file(&canonical_path, config);
863        if server_keys.is_empty() {
864            return Ok(Vec::new());
865        }
866
867        let uri = uri_for_path(&canonical_path)?;
868        let mut results = Vec::with_capacity(server_keys.len());
869
870        for key in server_keys {
871            let supports_pull = self
872                .clients
873                .get(&key)
874                .and_then(|c| c.diagnostic_capabilities())
875                .is_some_and(|caps| caps.pull_diagnostics);
876
877            if !supports_pull {
878                results.push(PullFileResult {
879                    server_key: key.clone(),
880                    outcome: PullFileOutcome::PullNotSupported,
881                });
882                continue;
883            }
884
885            // Look up previous resultId for incremental requests.
886            let previous_result_id = self
887                .diagnostics
888                .entries_for_file(&canonical_path)
889                .into_iter()
890                .find(|(k, _)| **k == key)
891                .and_then(|(_, entry)| entry.result_id.clone());
892
893            let identifier = self
894                .clients
895                .get(&key)
896                .and_then(|c| c.diagnostic_capabilities())
897                .and_then(|caps| caps.identifier.clone());
898
899            let params = lsp_types::DocumentDiagnosticParams {
900                text_document: lsp_types::TextDocumentIdentifier { uri: uri.clone() },
901                identifier,
902                previous_result_id,
903                work_done_progress_params: Default::default(),
904                partial_result_params: Default::default(),
905            };
906
907            let outcome = match self.send_pull_request(&key, params) {
908                Ok(report) => self.ingest_document_report(&key, &canonical_path, report),
909                Err(err) => PullFileOutcome::RequestFailed {
910                    reason: err.to_string(),
911                },
912            };
913
914            results.push(PullFileResult {
915                server_key: key,
916                outcome,
917            });
918        }
919
920        Ok(results)
921    }
922
923    /// Issue a `workspace/diagnostic` request to a specific server. Cancels
924    /// internally if `timeout` elapses before the server responds. Cached
925    /// entries from the response are stored so directory-mode queries pick
926    /// them up.
927    pub fn pull_workspace_diagnostics(
928        &mut self,
929        server_key: &ServerKey,
930        timeout: Option<std::time::Duration>,
931    ) -> Result<PullWorkspaceResult, LspError> {
932        let _timeout = timeout.unwrap_or(Self::PULL_WORKSPACE_TIMEOUT);
933
934        let supports_workspace = self
935            .clients
936            .get(server_key)
937            .and_then(|c| c.diagnostic_capabilities())
938            .is_some_and(|caps| caps.workspace_diagnostics);
939
940        if !supports_workspace {
941            return Ok(PullWorkspaceResult {
942                server_key: server_key.clone(),
943                files_reported: Vec::new(),
944                complete: false,
945                cancelled: false,
946                supports_workspace: false,
947            });
948        }
949
950        let identifier = self
951            .clients
952            .get(server_key)
953            .and_then(|c| c.diagnostic_capabilities())
954            .and_then(|caps| caps.identifier.clone());
955
956        let params = lsp_types::WorkspaceDiagnosticParams {
957            identifier,
958            previous_result_ids: Vec::new(),
959            work_done_progress_params: Default::default(),
960            partial_result_params: Default::default(),
961        };
962
963        // Note: LspClient::send_request currently uses a fixed REQUEST_TIMEOUT
964        // (30s, see client.rs). For workspace pull this is intentionally not
965        // overridden because servers like rust-analyzer may legitimately take
966        // many seconds on first request. The plugin bridge timeout (also 30s)
967        // is what we ultimately defer to. In a future revision we should plumb
968        // a custom timeout through send_request — for v0.16 we accept that
969        // workspace pull obeys the standard request timeout.
970        let result = match self
971            .clients
972            .get_mut(server_key)
973            .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?
974            .send_request::<lsp_types::request::WorkspaceDiagnosticRequest>(params)
975        {
976            Ok(result) => result,
977            Err(LspError::Timeout(_)) => {
978                return Ok(PullWorkspaceResult {
979                    server_key: server_key.clone(),
980                    files_reported: Vec::new(),
981                    complete: false,
982                    cancelled: true,
983                    supports_workspace: true,
984                });
985            }
986            Err(err) => return Err(err),
987        };
988
989        // Extract the items list. Partial responses stream via $/progress
990        // notifications which we don't subscribe to — treat them as soft
991        // empty (caller will see complete: true with files_reported empty,
992        // matching "got a partial response, no full report").
993        let items = match result {
994            lsp_types::WorkspaceDiagnosticReportResult::Report(report) => report.items,
995            lsp_types::WorkspaceDiagnosticReportResult::Partial(_) => Vec::new(),
996        };
997
998        // Ingest each file report into the diagnostics store.
999        let mut files_reported = Vec::with_capacity(items.len());
1000        for item in items {
1001            match item {
1002                lsp_types::WorkspaceDocumentDiagnosticReport::Full(full) => {
1003                    if let Some(file) = uri_to_path(&full.uri) {
1004                        let stored = from_lsp_diagnostics(
1005                            file.clone(),
1006                            full.full_document_diagnostic_report.items.clone(),
1007                        );
1008                        self.diagnostics.publish_with_result_id(
1009                            server_key.clone(),
1010                            file.clone(),
1011                            stored,
1012                            full.full_document_diagnostic_report.result_id.clone(),
1013                        );
1014                        files_reported.push(file);
1015                    }
1016                }
1017                lsp_types::WorkspaceDocumentDiagnosticReport::Unchanged(_unchanged) => {
1018                    // "Unchanged" means the previously cached report is still
1019                    // valid. We left it in place; nothing to do.
1020                }
1021            }
1022        }
1023
1024        Ok(PullWorkspaceResult {
1025            server_key: server_key.clone(),
1026            files_reported,
1027            complete: true,
1028            cancelled: false,
1029            supports_workspace: true,
1030        })
1031    }
1032
1033    /// Issue the per-file diagnostic request and return the report.
1034    fn send_pull_request(
1035        &mut self,
1036        key: &ServerKey,
1037        params: lsp_types::DocumentDiagnosticParams,
1038    ) -> Result<lsp_types::DocumentDiagnosticReportResult, LspError> {
1039        let client = self
1040            .clients
1041            .get_mut(key)
1042            .ok_or_else(|| LspError::ServerNotReady("server not found".into()))?;
1043        client.send_request::<lsp_types::request::DocumentDiagnosticRequest>(params)
1044    }
1045
1046    /// Store the result of a per-file pull request and return a structured
1047    /// outcome the caller can inspect.
1048    fn ingest_document_report(
1049        &mut self,
1050        key: &ServerKey,
1051        canonical_path: &Path,
1052        result: lsp_types::DocumentDiagnosticReportResult,
1053    ) -> PullFileOutcome {
1054        let report = match result {
1055            lsp_types::DocumentDiagnosticReportResult::Report(report) => report,
1056            lsp_types::DocumentDiagnosticReportResult::Partial(_) => {
1057                // Partial results stream in via $/progress notifications which
1058                // we don't currently subscribe to. Treat as a soft-empty
1059                // success — the next pull will get the full version.
1060                return PullFileOutcome::PartialNotSupported;
1061            }
1062        };
1063
1064        match report {
1065            lsp_types::DocumentDiagnosticReport::Full(full) => {
1066                let result_id = full.full_document_diagnostic_report.result_id.clone();
1067                let stored = from_lsp_diagnostics(
1068                    canonical_path.to_path_buf(),
1069                    full.full_document_diagnostic_report.items.clone(),
1070                );
1071                let count = stored.len();
1072                self.diagnostics.publish_with_result_id(
1073                    key.clone(),
1074                    canonical_path.to_path_buf(),
1075                    stored,
1076                    result_id,
1077                );
1078                PullFileOutcome::Full {
1079                    diagnostic_count: count,
1080                }
1081            }
1082            lsp_types::DocumentDiagnosticReport::Unchanged(_unchanged) => {
1083                // The server says cache is still valid. We don't refresh
1084                // anything; the existing entry's diagnostics remain authoritative.
1085                PullFileOutcome::Unchanged
1086            }
1087        }
1088    }
1089
1090    /// Shutdown all servers gracefully.
1091    pub fn shutdown_all(&mut self) {
1092        for (key, mut client) in self.clients.drain() {
1093            if let Err(err) = client.shutdown() {
1094                log::error!("error shutting down {:?}: {}", key, err);
1095            }
1096        }
1097        self.documents.clear();
1098        self.diagnostics = DiagnosticsStore::new();
1099    }
1100
1101    /// Check if any server is active.
1102    pub fn has_active_servers(&self) -> bool {
1103        self.clients
1104            .values()
1105            .any(|client| client.state() == ServerState::Ready)
1106    }
1107
1108    /// Active server keys (running clients). Used by `lsp_diagnostics`
1109    /// directory mode to know which servers to ask for workspace pull.
1110    pub fn active_server_keys(&self) -> Vec<ServerKey> {
1111        self.clients.keys().cloned().collect()
1112    }
1113
1114    pub fn get_diagnostics_for_file(&self, file: &Path) -> Vec<&StoredDiagnostic> {
1115        let normalized = normalize_lookup_path(file);
1116        self.diagnostics.for_file(&normalized)
1117    }
1118
1119    pub fn get_diagnostics_for_directory(&self, dir: &Path) -> Vec<&StoredDiagnostic> {
1120        let normalized = normalize_lookup_path(dir);
1121        self.diagnostics.for_directory(&normalized)
1122    }
1123
1124    pub fn get_all_diagnostics(&self) -> Vec<&StoredDiagnostic> {
1125        self.diagnostics.all()
1126    }
1127
1128    fn drain_events_for_file(&mut self, file_path: &Path) -> bool {
1129        let mut saw_file_diagnostics = false;
1130        while let Ok(event) = self.event_rx.try_recv() {
1131            if matches!(
1132                self.handle_event(&event),
1133                Some(ref published_file) if published_file.as_path() == file_path
1134            ) {
1135                saw_file_diagnostics = true;
1136            }
1137        }
1138        saw_file_diagnostics
1139    }
1140
1141    fn handle_event(&mut self, event: &LspEvent) -> Option<PathBuf> {
1142        match event {
1143            LspEvent::Notification {
1144                server_kind,
1145                root,
1146                method,
1147                params: Some(params),
1148            } if method == "textDocument/publishDiagnostics" => {
1149                self.handle_publish_diagnostics(server_kind.clone(), root.clone(), params)
1150            }
1151            LspEvent::ServerExited { server_kind, root } => {
1152                let key = ServerKey {
1153                    kind: server_kind.clone(),
1154                    root: root.clone(),
1155                };
1156                self.clients.remove(&key);
1157                self.documents.remove(&key);
1158                self.diagnostics.clear_server(server_kind.clone());
1159                None
1160            }
1161            _ => None,
1162        }
1163    }
1164
1165    fn handle_publish_diagnostics(
1166        &mut self,
1167        server: ServerKind,
1168        root: PathBuf,
1169        params: &serde_json::Value,
1170    ) -> Option<PathBuf> {
1171        if let Ok(publish_params) =
1172            serde_json::from_value::<lsp_types::PublishDiagnosticsParams>(params.clone())
1173        {
1174            let file = uri_to_path(&publish_params.uri)?;
1175            let stored = from_lsp_diagnostics(file.clone(), publish_params.diagnostics);
1176            // v0.17.3: store with real ServerKey { kind, root } and capture
1177            // the document `version` (when the server provided one) so the
1178            // post-edit waiter can reject stale publishes deterministically
1179            // via version-match (preferred) or epoch-delta (fallback). The
1180            // earlier `publish_with_kind` path silently dropped both.
1181            let key = ServerKey { kind: server, root };
1182            self.diagnostics
1183                .publish_full(key, file.clone(), stored, None, publish_params.version);
1184            return Some(file);
1185        }
1186        None
1187    }
1188
1189    fn spawn_server(
1190        &self,
1191        def: &ServerDef,
1192        root: &Path,
1193        config: &Config,
1194    ) -> Result<LspClient, LspError> {
1195        let binary = self.resolve_binary(def, config)?;
1196
1197        // Merge the server-defined env with our test-injected env.
1198        // `extra_env` is empty in production; tests use it to drive fake
1199        // server variants (AFT_FAKE_LSP_PULL=1, etc.).
1200        let mut merged_env = def.env.clone();
1201        for (key, value) in &self.extra_env {
1202            merged_env.insert(key.clone(), value.clone());
1203        }
1204
1205        let mut client = LspClient::spawn(
1206            def.kind.clone(),
1207            root.to_path_buf(),
1208            &binary,
1209            &def.args,
1210            &merged_env,
1211            self.event_tx.clone(),
1212        )?;
1213        client.initialize(root, def.initialization_options.clone())?;
1214        Ok(client)
1215    }
1216
1217    fn resolve_binary(&self, def: &ServerDef, config: &Config) -> Result<PathBuf, LspError> {
1218        if let Some(path) = self.binary_overrides.get(&def.kind) {
1219            if path.exists() {
1220                return Ok(path.clone());
1221            }
1222            return Err(LspError::NotFound(format!(
1223                "override binary for {:?} not found: {}",
1224                def.kind,
1225                path.display()
1226            )));
1227        }
1228
1229        if let Some(path) = env_binary_override(&def.kind) {
1230            if path.exists() {
1231                return Ok(path);
1232            }
1233            return Err(LspError::NotFound(format!(
1234                "environment override binary for {:?} not found: {}",
1235                def.kind,
1236                path.display()
1237            )));
1238        }
1239
1240        // Layered resolution:
1241        //   1. <project_root>/node_modules/.bin/<binary>
1242        //   2. config.lsp_paths_extra (plugin auto-install cache, etc.)
1243        //   3. PATH via `which`
1244        resolve_lsp_binary(
1245            &def.binary,
1246            config.project_root.as_deref(),
1247            &config.lsp_paths_extra,
1248        )
1249        .ok_or_else(|| {
1250            LspError::NotFound(format!(
1251                "language server binary '{}' not found in node_modules/.bin, lsp_paths_extra, or PATH",
1252                def.binary
1253            ))
1254        })
1255    }
1256
1257    fn server_key_for_file(&self, file_path: &Path, config: &Config) -> Option<ServerKey> {
1258        for def in servers_for_file(file_path, config) {
1259            let root = find_workspace_root(file_path, &def.root_markers)?;
1260            let key = ServerKey {
1261                kind: def.kind.clone(),
1262                root,
1263            };
1264            if self.clients.contains_key(&key) {
1265                return Some(key);
1266            }
1267        }
1268        None
1269    }
1270}
1271
1272impl Default for LspManager {
1273    fn default() -> Self {
1274        Self::new()
1275    }
1276}
1277
1278fn canonicalize_for_lsp(file_path: &Path) -> Result<PathBuf, LspError> {
1279    std::fs::canonicalize(file_path).map_err(LspError::from)
1280}
1281
1282fn resolve_for_lsp_uri(file_path: &Path) -> PathBuf {
1283    if let Ok(path) = std::fs::canonicalize(file_path) {
1284        return path;
1285    }
1286
1287    let mut existing = file_path.to_path_buf();
1288    let mut missing = Vec::new();
1289    while !existing.exists() {
1290        let Some(name) = existing.file_name() else {
1291            break;
1292        };
1293        missing.push(name.to_owned());
1294        let Some(parent) = existing.parent() else {
1295            break;
1296        };
1297        existing = parent.to_path_buf();
1298    }
1299
1300    let mut resolved = std::fs::canonicalize(&existing).unwrap_or(existing);
1301    for segment in missing.into_iter().rev() {
1302        resolved.push(segment);
1303    }
1304    resolved
1305}
1306
1307fn uri_for_path(path: &Path) -> Result<lsp_types::Uri, LspError> {
1308    let url = url::Url::from_file_path(path).map_err(|_| {
1309        LspError::NotFound(format!(
1310            "failed to convert '{}' to file URI",
1311            path.display()
1312        ))
1313    })?;
1314    lsp_types::Uri::from_str(url.as_str()).map_err(|_| {
1315        LspError::NotFound(format!("failed to parse file URI for '{}'", path.display()))
1316    })
1317}
1318
1319fn language_id_for_extension(ext: &str) -> &'static str {
1320    match ext {
1321        "ts" => "typescript",
1322        "tsx" => "typescriptreact",
1323        "js" | "mjs" | "cjs" => "javascript",
1324        "jsx" => "javascriptreact",
1325        "py" | "pyi" => "python",
1326        "rs" => "rust",
1327        "go" => "go",
1328        "html" | "htm" => "html",
1329        _ => "plaintext",
1330    }
1331}
1332
1333fn normalize_lookup_path(path: &Path) -> PathBuf {
1334    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
1335}
1336
1337fn uri_to_path(uri: &lsp_types::Uri) -> Option<PathBuf> {
1338    let url = url::Url::parse(uri.as_str()).ok()?;
1339    url.to_file_path()
1340        .ok()
1341        .map(|path| normalize_lookup_path(&path))
1342}
1343
1344/// Classify an error returned by `spawn_server` into a structured
1345/// `ServerAttemptResult`. The two interesting cases for callers are:
1346/// - `BinaryNotInstalled` — the server's binary couldn't be resolved on PATH
1347///   or via override. The agent can be told "install bash-language-server".
1348/// - `SpawnFailed` — binary was found but spawning/initializing failed
1349///   (permissions, missing runtime, server crashed during initialize, etc.).
1350fn classify_spawn_error(binary: &str, err: &LspError) -> ServerAttemptResult {
1351    match err {
1352        // resolve_binary returns NotFound for both missing override paths and
1353        // missing PATH binaries. The "override missing" case is rare in
1354        // practice (only set in tests / env vars); we report all NotFound as
1355        // BinaryNotInstalled so the user sees an actionable install hint.
1356        LspError::NotFound(_) => ServerAttemptResult::BinaryNotInstalled {
1357            binary: binary.to_string(),
1358        },
1359        other => ServerAttemptResult::SpawnFailed {
1360            binary: binary.to_string(),
1361            reason: other.to_string(),
1362        },
1363    }
1364}
1365
1366fn env_binary_override(kind: &ServerKind) -> Option<PathBuf> {
1367    let id = kind.id_str();
1368    let suffix: String = id
1369        .chars()
1370        .map(|ch| {
1371            if ch.is_ascii_alphanumeric() {
1372                ch.to_ascii_uppercase()
1373            } else {
1374                '_'
1375            }
1376        })
1377        .collect();
1378    let key = format!("AFT_LSP_{suffix}_BINARY");
1379    std::env::var_os(key).map(PathBuf::from)
1380}