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