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