Skip to main content

aft/lsp/
client.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::io::{self, BufRead, BufReader, BufWriter};
3use std::path::{Path, PathBuf};
4#[cfg(windows)]
5use std::process::Command;
6use std::process::{Child, Stdio};
7use std::str::FromStr;
8use std::sync::atomic::{AtomicI64, Ordering};
9use std::sync::{Arc, Mutex};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use crossbeam_channel::{bounded, RecvTimeoutError, Sender};
14use serde::de::DeserializeOwned;
15use serde_json::{json, Value};
16
17use crate::lsp::child_registry::LspChildRegistry;
18use crate::lsp::jsonrpc::{
19    Notification, Request, RequestId, Response as JsonRpcResponse, ServerMessage,
20};
21use crate::lsp::position::path_to_uri;
22use crate::lsp::registry::ServerKind;
23use crate::lsp::{transport, LspError};
24
25/// Default timeout for interactive LSP requests (hover, goto-def, references, rename).
26const INTERACTIVE_REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
27/// Longer budget for one-shot handshake requests (initialize, shutdown).
28const HANDSHAKE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
29const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
30const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(25);
31const STDERR_TAIL_LINES: usize = 64;
32const STDERR_LINE_BYTES: usize = 4 * 1024;
33
34type PendingMap = HashMap<RequestId, Sender<JsonRpcResponse>>;
35type WatchedFileRegistrations = Arc<Mutex<HashSet<String>>>;
36
37#[cfg(windows)]
38fn is_windows_batch_file(path: &Path) -> bool {
39    path.extension()
40        .and_then(|ext| ext.to_str())
41        .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"))
42}
43
44/// Lifecycle state of a language server.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ServerState {
47    Starting,
48    Initializing,
49    Ready,
50    ShuttingDown,
51    Exited,
52}
53
54/// Events sent from background reader threads into the main loop.
55#[derive(Debug)]
56pub enum LspEvent {
57    /// Server sent a notification (e.g. publishDiagnostics).
58    Notification {
59        server_kind: ServerKind,
60        root: PathBuf,
61        method: String,
62        params: Option<Value>,
63    },
64    /// Server sent a request (e.g. workspace/configuration).
65    ServerRequest {
66        server_kind: ServerKind,
67        root: PathBuf,
68        id: RequestId,
69        method: String,
70        params: Option<Value>,
71    },
72    /// Server process exited or the transport stream closed.
73    ServerExited {
74        server_kind: ServerKind,
75        root: PathBuf,
76    },
77}
78
79/// What this server told us it can do during the LSP `initialize` handshake.
80///
81/// We capture this once and use it to route diagnostic requests:
82/// - `pull_diagnostics` → use `textDocument/diagnostic` instead of waiting for push
83/// - `workspace_diagnostics` → use `workspace/diagnostic` for directory mode
84///
85/// Defaults are conservative: `false` means "fall back to push semantics".
86#[derive(Debug, Clone, Default)]
87pub struct ServerDiagnosticCapabilities {
88    /// Server supports `textDocument/diagnostic` (LSP 3.17 per-file pull).
89    pub pull_diagnostics: bool,
90    /// Server supports `workspace/diagnostic` (LSP 3.17 workspace-wide pull).
91    pub workspace_diagnostics: bool,
92    /// `identifier` field from server's diagnosticProvider, if any.
93    /// Used to scope previousResultId tracking when multiple servers share a file.
94    pub identifier: Option<String>,
95    /// Whether the server requested workspace diagnostic refresh notifications.
96    /// We declare `refreshSupport: false` in our client capabilities so this
97    /// should always be false in practice — kept for completeness.
98    pub refresh_support: bool,
99}
100
101/// A client connected to one language server process.
102pub struct LspClient {
103    kind: ServerKind,
104    root: PathBuf,
105    state: ServerState,
106    child: Child,
107    /// Child PID captured at spawn time. Used by Drop to untrack the
108    /// PID from the shared registry; we capture once rather than reading
109    /// `child.id()` later because Drop ordering with the Child can race.
110    child_pid: u32,
111    writer: Arc<Mutex<BufWriter<std::process::ChildStdin>>>,
112
113    /// Pending request responses, keyed by request ID.
114    pending: Arc<Mutex<PendingMap>>,
115    /// Next request ID counter.
116    next_id: AtomicI64,
117    /// Diagnostic capabilities reported by the server in its initialize response.
118    /// `None` until `initialize()` succeeds; conservative defaults thereafter
119    /// when the server doesn't advertise diagnosticProvider.
120    diagnostic_caps: Option<ServerDiagnosticCapabilities>,
121    /// Rust-analyzer's workspace analysis has reached quiescence. Other server
122    /// kinds do not use the experimental server-status signal and start
123    /// authoritative by default.
124    rust_analyzer_quiescent: bool,
125    /// Whether the server advertised static `workspace.didChangeWatchedFiles`
126    /// support during `initialize`. Dynamic registration is tracked separately
127    /// in `watched_file_registrations`; either path permits notifications.
128    /// Intentional default: `false` (conservative — requires server opt-in).
129    supports_watched_files: bool,
130    /// Dynamic `workspace/didChangeWatchedFiles` registrations requested by
131    /// the server via `client/registerCapability`. Per LSP, the client must
132    /// not send watched-file notifications merely because a server mentions
133    /// dynamic registration during initialize; a real registration is required.
134    watched_file_registrations: WatchedFileRegistrations,
135    /// Shared registry that tracks live LSP child PIDs across the process
136    /// so the signal handler can SIGKILL them on SIGTERM/SIGINT before
137    /// aft exits. Cloned via `Arc` — multiple clients share the same set.
138    child_registry: LspChildRegistry,
139    stderr_tail: Arc<Mutex<VecDeque<String>>>,
140}
141
142impl LspClient {
143    /// Spawn a new language server process and start the background reader thread.
144    ///
145    /// `child_registry` is a shared handle that records this child's PID so
146    /// the signal handler can SIGKILL it on SIGTERM/SIGINT. Tests that don't
147    /// care about signal cleanup can pass `LspChildRegistry::new()`.
148    pub fn spawn(
149        kind: ServerKind,
150        root: PathBuf,
151        binary: &Path,
152        args: &[String],
153        env: &HashMap<String, String>,
154        event_tx: Sender<LspEvent>,
155        child_registry: LspChildRegistry,
156    ) -> io::Result<Self> {
157        #[cfg(windows)]
158        let mut command = if is_windows_batch_file(binary) {
159            let mut command = Command::new("cmd.exe");
160            command.arg("/C").arg(binary.as_os_str());
161            command
162        } else {
163            Command::new(binary)
164        };
165        #[cfg(not(windows))]
166        let mut command = crate::effective_path::new_command(binary);
167        command
168            .args(args)
169            .current_dir(&root)
170            .stdin(Stdio::piped())
171            .stdout(Stdio::piped())
172            // Drain stderr on a background thread so failed shims/crashes have
173            // actionable diagnostics without risking pipe-buffer deadlock.
174            .stderr(Stdio::piped());
175        for (key, value) in env {
176            command.env(key, value);
177        }
178
179        // Put each LSP child in its own process group so we can SIGKILL the
180        // whole group on shutdown. Critical for npm-wrapped servers like
181        // biome (`node biome lsp-proxy` spawns `cli-darwin-arm64 biome
182        // lsp-proxy` as a child); killing just the wrapper PID leaves the
183        // real server orphaned to PID 1.
184        #[cfg(unix)]
185        unsafe {
186            use std::os::unix::process::CommandExt;
187            command.pre_exec(|| {
188                #[cfg(target_os = "linux")]
189                {
190                    // If aft is killed with SIGKILL, Rust cleanup and our
191                    // signal-handler thread never run. Ask the kernel to kill
192                    // the LSP process group as soon as the parent dies. This is
193                    // best-effort Linux coverage for the otherwise unhandleable
194                    // parent-death path.
195                    if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 {
196                        return Err(io::Error::last_os_error());
197                    }
198                    if libc::getppid() == 1 {
199                        return Err(io::Error::other("parent died before LSP spawn completed"));
200                    }
201                }
202                if libc::setsid() == -1 {
203                    return Err(io::Error::last_os_error());
204                }
205                Ok(())
206            });
207        }
208
209        let mut child = child_registry.spawn_tracked(&mut command)?;
210        let child_pid = child.id();
211
212        let stdout = child
213            .stdout
214            .take()
215            .ok_or_else(|| io::Error::other("language server missing stdout pipe"))?;
216        let stdin = child
217            .stdin
218            .take()
219            .ok_or_else(|| io::Error::other("language server missing stdin pipe"))?;
220        let stderr = child
221            .stderr
222            .take()
223            .ok_or_else(|| io::Error::other("language server missing stderr pipe"))?;
224        let stderr_tail = Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_LINES)));
225        spawn_stderr_drain_thread(stderr, Arc::clone(&stderr_tail));
226
227        let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
228        let pending = Arc::new(Mutex::new(PendingMap::new()));
229        let watched_file_registrations = Arc::new(Mutex::new(HashSet::new()));
230        let reader_pending = Arc::clone(&pending);
231        let reader_writer = Arc::clone(&writer);
232        let reader_watched_file_registrations = Arc::clone(&watched_file_registrations);
233        let reader_kind = kind.clone();
234        let reader_root = root.clone();
235
236        thread::spawn(move || {
237            let mut reader = BufReader::new(stdout);
238            loop {
239                match transport::read_message(&mut reader) {
240                    Ok(Some(ServerMessage::Response(response))) => {
241                        if let Ok(mut guard) = reader_pending.lock() {
242                            if let Some(tx) = guard.remove(&response.id) {
243                                if tx.send(response).is_err() {
244                                    log::debug!("response channel closed");
245                                }
246                            }
247                        } else {
248                            let _ = event_tx.send(LspEvent::ServerExited {
249                                server_kind: reader_kind.clone(),
250                                root: reader_root.clone(),
251                            });
252                            break;
253                        }
254                    }
255                    Ok(Some(ServerMessage::Notification { method, params })) => {
256                        let _ = event_tx.send(LspEvent::Notification {
257                            server_kind: reader_kind.clone(),
258                            root: reader_root.clone(),
259                            method,
260                            params,
261                        });
262                    }
263                    Ok(Some(ServerMessage::Request { id, method, params })) => {
264                        record_watched_file_registration(
265                            &reader_watched_file_registrations,
266                            &method,
267                            params.as_ref(),
268                        );
269                        // Auto-respond to server requests to prevent deadlocks.
270                        // Server requests (like client/registerCapability,
271                        // window/workDoneProgress/create) block the server until
272                        // we respond. If we don't respond, the server won't send
273                        // responses to OUR pending requests → deadlock.
274                        //
275                        // Dispatch by method to return correct types:
276                        // - workspace/configuration expects Vec<Value> (one per item)
277                        // - Everything else gets null (safe default for registration/progress)
278                        let response_value = if method == "workspace/configuration" {
279                            // Return an array of null configs — one per requested item.
280                            // Servers fall back to filesystem config (tsconfig, pyrightconfig, etc.)
281                            let item_count = params
282                                .as_ref()
283                                .and_then(|p| p.get("items"))
284                                .and_then(|items| items.as_array())
285                                .map_or(1, |arr| arr.len());
286                            serde_json::Value::Array(vec![serde_json::Value::Null; item_count])
287                        } else {
288                            serde_json::Value::Null
289                        };
290                        if let Ok(mut w) = reader_writer.lock() {
291                            let response = super::jsonrpc::OutgoingResponse::success(
292                                id.clone(),
293                                response_value,
294                            );
295                            let _ = transport::write_response(&mut *w, &response);
296                        }
297                        // Also forward as event for any interested handlers
298                        let _ = event_tx.send(LspEvent::ServerRequest {
299                            server_kind: reader_kind.clone(),
300                            root: reader_root.clone(),
301                            id,
302                            method,
303                            params,
304                        });
305                    }
306                    Ok(None) | Err(_) => {
307                        if let Ok(mut guard) = reader_pending.lock() {
308                            guard.clear();
309                        }
310                        let _ = event_tx.send(LspEvent::ServerExited {
311                            server_kind: reader_kind.clone(),
312                            root: reader_root.clone(),
313                        });
314                        break;
315                    }
316                }
317            }
318        });
319
320        let rust_analyzer_quiescent = !matches!(&kind, ServerKind::Rust);
321        Ok(Self {
322            kind,
323            root,
324            state: ServerState::Starting,
325            child,
326            child_pid,
327            writer,
328            pending,
329            next_id: AtomicI64::new(1),
330            diagnostic_caps: None,
331            rust_analyzer_quiescent,
332            supports_watched_files: false,
333            watched_file_registrations,
334            child_registry,
335            stderr_tail,
336        })
337    }
338
339    /// Send the initialize request and wait for response. Transition to Ready.
340    pub fn initialize(
341        &mut self,
342        workspace_root: &Path,
343        initialization_options: Option<serde_json::Value>,
344    ) -> Result<lsp_types::InitializeResult, LspError> {
345        self.ensure_can_send()?;
346        self.state = ServerState::Initializing;
347
348        let root_url = path_to_uri(workspace_root)?;
349        let root_uri = lsp_types::Uri::from_str(root_url.as_str()).map_err(|_| {
350            LspError::NotFound(format!(
351                "failed to convert workspace root '{}' to file URI",
352                workspace_root.display()
353            ))
354        })?;
355
356        let mut params_value = json!({
357            "processId": std::process::id(),
358            "rootUri": root_uri,
359            "capabilities": {
360                "experimental": {
361                    "serverStatusNotification": true
362                },
363                "workspace": {
364                    "workspaceFolders": true,
365                    "configuration": true,
366                    "didChangeWatchedFiles": {
367                        "dynamicRegistration": true
368                    },
369                    // LSP 3.17 workspace diagnostic pull. We declare refreshSupport=false
370                    // because we drive diagnostics on-demand via pull/push and re-query
371                    // when the agent calls lsp_diagnostics again — we don't need the
372                    // server to proactively push refresh notifications.
373                    "diagnostic": {
374                        "refreshSupport": false
375                    }
376                },
377                "textDocument": {
378                    "synchronization": {
379                        "dynamicRegistration": false,
380                        "didSave": true,
381                        "willSave": false,
382                        "willSaveWaitUntil": false
383                    },
384                    "publishDiagnostics": {
385                        "relatedInformation": true,
386                        "versionSupport": true,
387                        "codeDescriptionSupport": true,
388                        "dataSupport": true
389                    },
390                    // LSP 3.17 textDocument diagnostic pull. dynamicRegistration=false
391                    // because we use static capability discovery from the InitializeResult.
392                    // relatedDocumentSupport=true to receive cascading diagnostics for
393                    // files that became known while analyzing the requested one.
394                    "diagnostic": {
395                        "dynamicRegistration": false,
396                        "relatedDocumentSupport": true
397                    }
398                }
399            },
400            "clientInfo": {
401                "name": "aft",
402                "version": env!("CARGO_PKG_VERSION")
403            },
404            "workspaceFolders": [
405                {
406                    "uri": root_uri,
407                    "name": workspace_root
408                        .file_name()
409                        .and_then(|name| name.to_str())
410                        .unwrap_or("workspace")
411                }
412            ]
413        });
414        if let Some(initialization_options) = initialization_options {
415            params_value["initializationOptions"] = initialization_options;
416        }
417
418        let params = serde_json::from_value::<lsp_types::InitializeParams>(params_value)?;
419
420        let result_value = self.send_request_value_with_timeout(
421            <lsp_types::request::Initialize as lsp_types::request::Request>::METHOD,
422            params,
423            HANDSHAKE_REQUEST_TIMEOUT,
424        )?;
425        let result: lsp_types::InitializeResult = serde_json::from_value(result_value.clone())?;
426
427        // Capture diagnostic capabilities from the initialize response. We parse
428        // from a re-serialized JSON Value because the lsp-types crate's
429        // diagnostic_provider strict variants reject some shapes real servers
430        // emit (e.g. bare `true`), and we want defensive Default fallback.
431        let caps_value = result_value
432            .get("capabilities")
433            .cloned()
434            .unwrap_or_else(|| serde_json::to_value(&result.capabilities).unwrap_or(Value::Null));
435        self.diagnostic_caps = Some(parse_diagnostic_capabilities(&caps_value));
436
437        // Capture initialize-time (static) workspace/didChangeWatchedFiles
438        // support. Runtime client/registerCapability subscriptions are recorded
439        // separately by the reader thread. Missing capability is unsupported by
440        // default; callers must not send notifications unless one of those two
441        // server opt-in paths is present.
442        self.supports_watched_files = caps_value
443            .pointer("/workspace/didChangeWatchedFiles/dynamicRegistration")
444            .and_then(|v| v.as_bool())
445            .unwrap_or(false)
446            || caps_value
447                .pointer("/workspace/didChangeWatchedFiles")
448                .map(|v| v.is_object() || v.as_bool() == Some(true))
449                .unwrap_or(false);
450
451        self.send_notification::<lsp_types::notification::Initialized>(serde_json::from_value(
452            json!({}),
453        )?)?;
454        self.state = ServerState::Ready;
455        Ok(result)
456    }
457
458    /// Diagnostic capabilities advertised by the server. Returns `None` until
459    /// `initialize()` has succeeded; returns `Some` with conservative defaults
460    /// (all `false`) when the server didn't advertise diagnosticProvider.
461    pub fn diagnostic_capabilities(&self) -> Option<&ServerDiagnosticCapabilities> {
462        self.diagnostic_caps.as_ref()
463    }
464
465    /// Whether diagnostics from this server instance should be treated as
466    /// provisional because rust-analyzer has not reached quiescence.
467    pub fn diagnostics_are_provisional(&self) -> bool {
468        matches!(&self.kind, ServerKind::Rust) && !self.rust_analyzer_quiescent
469    }
470
471    /// Record a rust-analyzer server-status transition. Returns true only for
472    /// the first transition to quiescent, which is when cached warming reports
473    /// must be invalidated before later publishes become authoritative.
474    pub fn set_rust_analyzer_quiescent(&mut self, quiescent: bool) -> bool {
475        if !matches!(&self.kind, ServerKind::Rust) || !quiescent || self.rust_analyzer_quiescent {
476            return false;
477        }
478        self.rust_analyzer_quiescent = true;
479        true
480    }
481
482    /// Whether the server advertised initialize-time
483    /// `workspace/didChangeWatchedFiles` support. Dynamic registrations are
484    /// reported by `has_watched_file_registration()`.
485    pub fn supports_watched_files(&self) -> bool {
486        self.supports_watched_files
487    }
488
489    /// Whether this server currently has an active dynamic watched-file
490    /// registration. This, not the initialize-time capability shape, controls
491    /// whether `workspace/didChangeWatchedFiles` may be sent.
492    pub fn has_watched_file_registration(&self) -> bool {
493        self.watched_file_registrations
494            .lock()
495            .map(|registrations| !registrations.is_empty())
496            .unwrap_or(false)
497    }
498
499    /// Send a request and wait for the response.
500    pub fn send_request<R>(&mut self, params: R::Params) -> Result<R::Result, LspError>
501    where
502        R: lsp_types::request::Request,
503        R::Params: serde::Serialize,
504        R::Result: DeserializeOwned,
505    {
506        self.ensure_can_send()?;
507
508        let value = self.send_request_value(R::METHOD, params)?;
509        serde_json::from_value(value).map_err(Into::into)
510    }
511
512    /// Send a request and wait up to `timeout` for the response. If the local
513    /// deadline expires, remove the pending response handler and notify the
514    /// server with `$/cancelRequest` so it can stop work.
515    pub fn send_request_with_timeout<R>(
516        &mut self,
517        params: R::Params,
518        timeout: Duration,
519    ) -> Result<R::Result, LspError>
520    where
521        R: lsp_types::request::Request,
522        R::Params: serde::Serialize,
523        R::Result: DeserializeOwned,
524    {
525        self.ensure_can_send()?;
526
527        let value = self.send_request_value_with_timeout(R::METHOD, params, timeout)?;
528        serde_json::from_value(value).map_err(Into::into)
529    }
530
531    fn send_request_value<P>(&mut self, method: &'static str, params: P) -> Result<Value, LspError>
532    where
533        P: serde::Serialize,
534    {
535        self.send_request_value_with_timeout(method, params, INTERACTIVE_REQUEST_TIMEOUT)
536    }
537
538    fn send_request_value_with_timeout<P>(
539        &mut self,
540        method: &'static str,
541        params: P,
542        timeout: Duration,
543    ) -> Result<Value, LspError>
544    where
545        P: serde::Serialize,
546    {
547        self.ensure_can_send()?;
548
549        let id = RequestId::Int(self.next_id.fetch_add(1, Ordering::Relaxed));
550        let (tx, rx) = bounded(1);
551        {
552            let mut pending = self.lock_pending()?;
553            pending.insert(id.clone(), tx);
554        }
555
556        let request = Request::new(id.clone(), method, Some(serde_json::to_value(params)?));
557        {
558            let mut writer = self
559                .writer
560                .lock()
561                .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
562            if let Err(err) = transport::write_request(&mut *writer, &request) {
563                self.remove_pending(&id);
564                return Err(err.into());
565            }
566        }
567
568        let response = match rx.recv_timeout(timeout) {
569            Ok(response) => response,
570            Err(RecvTimeoutError::Timeout) => {
571                self.remove_pending(&id);
572                self.send_cancel_request(&id)?;
573                return Err(LspError::Timeout(format!(
574                    "timed out waiting for '{}' response from {:?}",
575                    method, self.kind
576                )));
577            }
578            Err(RecvTimeoutError::Disconnected) => {
579                self.remove_pending(&id);
580                return Err(LspError::ServerNotReady(format!(
581                    "language server {:?} disconnected while waiting for '{}'",
582                    self.kind, method
583                )));
584            }
585        };
586
587        if let Some(error) = response.error {
588            return Err(LspError::ServerError {
589                code: error.code,
590                message: error.message,
591            });
592        }
593
594        Ok(response.result.unwrap_or(Value::Null))
595    }
596
597    /// Send a notification (fire-and-forget).
598    pub fn send_notification<N>(&mut self, params: N::Params) -> Result<(), LspError>
599    where
600        N: lsp_types::notification::Notification,
601        N::Params: serde::Serialize,
602    {
603        self.ensure_can_send()?;
604        let notification = Notification::new(N::METHOD, Some(serde_json::to_value(params)?));
605        let mut writer = self
606            .writer
607            .lock()
608            .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
609        transport::write_notification(&mut *writer, &notification)?;
610        Ok(())
611    }
612
613    /// Graceful shutdown: send shutdown request, then exit notification.
614    pub fn shutdown(&mut self) -> Result<(), LspError> {
615        if self.state == ServerState::Exited {
616            self.child_registry.untrack(self.child_pid);
617            return Ok(());
618        }
619
620        if self.child.try_wait()?.is_some() {
621            self.state = ServerState::Exited;
622            self.child_registry.untrack(self.child_pid);
623            return Ok(());
624        }
625
626        if let Err(err) = self.send_request_with_timeout::<lsp_types::request::Shutdown>(
627            (),
628            HANDSHAKE_REQUEST_TIMEOUT,
629        ) {
630            self.state = ServerState::ShuttingDown;
631            if self.child.try_wait()?.is_some() {
632                self.state = ServerState::Exited;
633                return Ok(());
634            }
635            return Err(err);
636        }
637
638        self.state = ServerState::ShuttingDown;
639
640        if let Err(err) = self.send_notification::<lsp_types::notification::Exit>(()) {
641            if self.child.try_wait()?.is_some() {
642                self.state = ServerState::Exited;
643                return Ok(());
644            }
645            return Err(err);
646        }
647
648        let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
649        loop {
650            if self.child.try_wait()?.is_some() {
651                self.state = ServerState::Exited;
652                return Ok(());
653            }
654            if Instant::now() >= deadline {
655                // Kill the entire process group, not just the wrapper PID, so
656                // npm-wrapped servers (biome's `node biome lsp-proxy` spawns
657                // a separate cli-darwin-arm64 child) don't leak orphans.
658                kill_lsp_child_group(&mut self.child);
659                self.state = ServerState::Exited;
660                return Err(LspError::Timeout(format!(
661                    "timed out waiting for {:?} to exit",
662                    self.kind
663                )));
664            }
665            thread::sleep(EXIT_POLL_INTERVAL);
666        }
667    }
668
669    pub fn stderr_tail(&self) -> String {
670        self.stderr_tail
671            .lock()
672            .map(|tail| stderr_tail_to_string(&tail))
673            .unwrap_or_default()
674    }
675
676    pub fn child_exited(&mut self) -> bool {
677        self.child.try_wait().ok().flatten().is_some()
678    }
679
680    pub fn child_exit_status(&mut self) -> Option<std::process::ExitStatus> {
681        self.child.try_wait().ok().flatten()
682    }
683
684    pub fn state(&self) -> ServerState {
685        self.state
686    }
687
688    pub fn kind(&self) -> ServerKind {
689        self.kind.clone()
690    }
691
692    pub fn root(&self) -> &Path {
693        &self.root
694    }
695
696    fn ensure_can_send(&self) -> Result<(), LspError> {
697        if matches!(self.state, ServerState::ShuttingDown | ServerState::Exited) {
698            return Err(LspError::ServerNotReady(format!(
699                "language server {:?} is not ready (state: {:?})",
700                self.kind, self.state
701            )));
702        }
703        Ok(())
704    }
705
706    fn lock_pending(&self) -> Result<std::sync::MutexGuard<'_, PendingMap>, LspError> {
707        self.pending
708            .lock()
709            .map_err(|_| io::Error::other("pending response map poisoned").into())
710    }
711
712    fn remove_pending(&self, id: &RequestId) {
713        if let Ok(mut pending) = self.pending.lock() {
714            pending.remove(id);
715        }
716    }
717
718    fn send_cancel_request(&mut self, id: &RequestId) -> Result<(), LspError> {
719        let notification = Notification::new("$/cancelRequest", Some(json!({ "id": id })));
720        let mut writer = self
721            .writer
722            .lock()
723            .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
724        transport::write_notification(&mut *writer, &notification)?;
725        Ok(())
726    }
727}
728
729impl Drop for LspClient {
730    fn drop(&mut self) {
731        // Untrack first so the signal handler can't race with this kill and
732        // try to SIGKILL a PID that's already been reaped.
733        self.child_registry.untrack(self.child_pid);
734        kill_lsp_child_group(&mut self.child);
735    }
736}
737
738fn spawn_stderr_drain_thread(
739    stderr: std::process::ChildStderr,
740    stderr_tail: Arc<Mutex<VecDeque<String>>>,
741) {
742    thread::spawn(move || {
743        let mut reader = BufReader::new(stderr);
744        let mut line = String::new();
745
746        loop {
747            line.clear();
748            match reader.read_line(&mut line) {
749                Ok(0) => break,
750                Ok(_) => {
751                    if let Ok(mut tail) = stderr_tail.lock() {
752                        append_stderr_tail(&mut tail, &line);
753                    } else {
754                        break;
755                    }
756                }
757                Err(_) => break,
758            }
759        }
760    });
761}
762
763fn append_stderr_tail(tail: &mut VecDeque<String>, line: &str) {
764    if tail.len() == STDERR_TAIL_LINES {
765        tail.pop_front();
766    }
767    tail.push_back(trim_stderr_line(line));
768}
769
770fn trim_stderr_line(line: &str) -> String {
771    let line = line.trim_end_matches(|ch| ch == '\r' || ch == '\n');
772    if line.len() <= STDERR_LINE_BYTES {
773        return line.to_string();
774    }
775
776    let mut start = line.len() - STDERR_LINE_BYTES;
777    while start < line.len() && !line.is_char_boundary(start) {
778        start += 1;
779    }
780    format!("...{}", &line[start..])
781}
782
783fn stderr_tail_to_string(tail: &VecDeque<String>) -> String {
784    tail.iter()
785        .map(String::as_str)
786        .collect::<Vec<_>>()
787        .join("\n")
788}
789
790/// Force-terminate an LSP child and its entire process group on Unix.
791/// On Windows, `taskkill /F /T` kills the process tree.
792///
793/// Necessary because some LSP servers ship as npm-installed Node shims that
794/// spawn the real binary as a child. Killing only the wrapper PID leaves the
795/// real server orphaned to PID 1 and accumulates over time.
796fn kill_lsp_child_group(child: &mut std::process::Child) {
797    #[cfg(unix)]
798    {
799        let pgid = child.id() as i32;
800        crate::bash_background::process::terminate_pgid(pgid, Some(child));
801        let _ = child.wait();
802    }
803    #[cfg(not(unix))]
804    {
805        crate::bash_background::process::terminate_process(child);
806        let _ = child.wait();
807    }
808}
809
810fn record_watched_file_registration(
811    registrations: &WatchedFileRegistrations,
812    method: &str,
813    params: Option<&Value>,
814) {
815    match method {
816        "client/registerCapability" => {
817            let Some(items) = params
818                .and_then(|params| params.get("registrations"))
819                .and_then(|registrations| registrations.as_array())
820            else {
821                return;
822            };
823            if let Ok(mut guard) = registrations.lock() {
824                for item in items {
825                    if item.get("method").and_then(Value::as_str)
826                        == Some("workspace/didChangeWatchedFiles")
827                    {
828                        if let Some(id) = item.get("id").and_then(Value::as_str) {
829                            guard.insert(id.to_string());
830                        }
831                    }
832                }
833            }
834        }
835        "client/unregisterCapability" => {
836            let Some(items) = params
837                .and_then(|params| params.get("unregisterations"))
838                .and_then(|registrations| registrations.as_array())
839            else {
840                return;
841            };
842            if let Ok(mut guard) = registrations.lock() {
843                for item in items {
844                    if item.get("method").and_then(Value::as_str)
845                        == Some("workspace/didChangeWatchedFiles")
846                    {
847                        if let Some(id) = item.get("id").and_then(Value::as_str) {
848                            guard.remove(id);
849                        }
850                    }
851                }
852            }
853        }
854        _ => {}
855    }
856}
857
858/// Parse `ServerDiagnosticCapabilities` from a re-serialized
859/// `ServerCapabilities` JSON value.
860///
861/// LSP 3.17 spec for `diagnosticProvider`:
862/// - `capabilities.diagnosticProvider` may be absent (no pull support),
863///   `DiagnosticOptions`, or `DiagnosticRegistrationOptions`.
864/// - If present:
865///   - `interFileDependencies: bool` (we don't currently use this)
866///   - `workspaceDiagnostics: bool` → workspace pull support
867///   - `identifier?: string` → optional identifier scoping result IDs
868///
869/// We parse the raw JSON Value defensively: presence of any
870/// `diagnosticProvider` value (object or `true`) means the server supports
871/// at least `textDocument/diagnostic` pull.
872fn parse_diagnostic_capabilities(value: &Value) -> ServerDiagnosticCapabilities {
873    let mut caps = ServerDiagnosticCapabilities::default();
874
875    if let Some(provider) = value.get("diagnosticProvider") {
876        // diagnosticProvider can be `true` (rare) or an object. Treat both as
877        // pull_diagnostics support.
878        if provider.is_object() || provider.as_bool() == Some(true) {
879            caps.pull_diagnostics = true;
880        }
881
882        if let Some(obj) = provider.as_object() {
883            if obj
884                .get("workspaceDiagnostics")
885                .and_then(|v| v.as_bool())
886                .unwrap_or(false)
887            {
888                caps.workspace_diagnostics = true;
889            }
890            if let Some(identifier) = obj.get("identifier").and_then(|v| v.as_str()) {
891                caps.identifier = Some(identifier.to_string());
892            }
893        }
894    }
895
896    // Workspace diagnostic refresh (rare — most servers don't request this,
897    // and we declared refreshSupport=false in our client capabilities anyway).
898    if let Some(refresh) = value
899        .get("workspace")
900        .and_then(|w| w.get("diagnostic"))
901        .and_then(|d| d.get("refreshSupport"))
902        .and_then(|r| r.as_bool())
903    {
904        caps.refresh_support = refresh;
905    }
906
907    caps
908}
909
910#[cfg(test)]
911mod tests {
912    use super::*;
913
914    #[test]
915    fn parse_caps_no_diagnostic_provider() {
916        let value = json!({});
917        let caps = parse_diagnostic_capabilities(&value);
918        assert!(!caps.pull_diagnostics);
919        assert!(!caps.workspace_diagnostics);
920        assert!(caps.identifier.is_none());
921    }
922
923    #[test]
924    fn parse_caps_basic_pull_only() {
925        let value = json!({
926            "diagnosticProvider": {
927                "interFileDependencies": false,
928                "workspaceDiagnostics": false
929            }
930        });
931        let caps = parse_diagnostic_capabilities(&value);
932        assert!(caps.pull_diagnostics);
933        assert!(!caps.workspace_diagnostics);
934    }
935
936    #[test]
937    fn parse_caps_full_pull_with_workspace() {
938        let value = json!({
939            "diagnosticProvider": {
940                "interFileDependencies": true,
941                "workspaceDiagnostics": true,
942                "identifier": "rust-analyzer"
943            }
944        });
945        let caps = parse_diagnostic_capabilities(&value);
946        assert!(caps.pull_diagnostics);
947        assert!(caps.workspace_diagnostics);
948        assert_eq!(caps.identifier.as_deref(), Some("rust-analyzer"));
949    }
950
951    #[test]
952    fn parse_caps_provider_as_bare_true() {
953        // LSP 3.17 allows DiagnosticOptions OR boolean — treat true as pull_diagnostics
954        let value = json!({
955            "diagnosticProvider": true
956        });
957        let caps = parse_diagnostic_capabilities(&value);
958        assert!(caps.pull_diagnostics);
959        assert!(!caps.workspace_diagnostics);
960    }
961
962    #[test]
963    fn interactive_request_timeout_is_eight_seconds() {
964        assert_eq!(INTERACTIVE_REQUEST_TIMEOUT, Duration::from_secs(8));
965    }
966
967    #[test]
968    fn handshake_request_timeout_remains_thirty_seconds() {
969        assert_eq!(HANDSHAKE_REQUEST_TIMEOUT, Duration::from_secs(30));
970    }
971
972    #[test]
973    fn parse_caps_workspace_refresh_support() {
974        let value = json!({
975            "workspace": {
976                "diagnostic": {
977                    "refreshSupport": true
978                }
979            }
980        });
981        let caps = parse_diagnostic_capabilities(&value);
982        assert!(caps.refresh_support);
983        // No diagnosticProvider → pull still false
984        assert!(!caps.pull_diagnostics);
985    }
986}