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