Skip to main content

aft/lsp/
manager.rs

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