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