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