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