Skip to main content

aft/lsp/
client.rs

1use std::collections::HashMap;
2use std::io::{self, BufReader, BufWriter};
3use std::path::{Path, PathBuf};
4use std::process::{Child, Command, Stdio};
5use std::str::FromStr;
6use std::sync::atomic::{AtomicI64, Ordering};
7use std::sync::{Arc, Mutex};
8use std::thread;
9use std::time::{Duration, Instant};
10
11use crossbeam_channel::{bounded, RecvTimeoutError, Sender};
12use serde::de::DeserializeOwned;
13use serde_json::{json, Value};
14
15use crate::lsp::child_registry::LspChildRegistry;
16use crate::lsp::jsonrpc::{
17    Notification, Request, RequestId, Response as JsonRpcResponse, ServerMessage,
18};
19use crate::lsp::registry::ServerKind;
20use crate::lsp::{transport, LspError};
21
22const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
23const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
24const EXIT_POLL_INTERVAL: Duration = Duration::from_millis(25);
25
26type PendingMap = HashMap<RequestId, Sender<JsonRpcResponse>>;
27
28/// Lifecycle state of a language server.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ServerState {
31    Starting,
32    Initializing,
33    Ready,
34    ShuttingDown,
35    Exited,
36}
37
38/// Events sent from background reader threads into the main loop.
39#[derive(Debug)]
40pub enum LspEvent {
41    /// Server sent a notification (e.g. publishDiagnostics).
42    Notification {
43        server_kind: ServerKind,
44        root: PathBuf,
45        method: String,
46        params: Option<Value>,
47    },
48    /// Server sent a request (e.g. workspace/configuration).
49    ServerRequest {
50        server_kind: ServerKind,
51        root: PathBuf,
52        id: RequestId,
53        method: String,
54        params: Option<Value>,
55    },
56    /// Server process exited or the transport stream closed.
57    ServerExited {
58        server_kind: ServerKind,
59        root: PathBuf,
60    },
61}
62
63/// What this server told us it can do during the LSP `initialize` handshake.
64///
65/// We capture this once and use it to route diagnostic requests:
66/// - `pull_diagnostics` → use `textDocument/diagnostic` instead of waiting for push
67/// - `workspace_diagnostics` → use `workspace/diagnostic` for directory mode
68///
69/// Defaults are conservative: `false` means "fall back to push semantics".
70#[derive(Debug, Clone, Default)]
71pub struct ServerDiagnosticCapabilities {
72    /// Server supports `textDocument/diagnostic` (LSP 3.17 per-file pull).
73    pub pull_diagnostics: bool,
74    /// Server supports `workspace/diagnostic` (LSP 3.17 workspace-wide pull).
75    pub workspace_diagnostics: bool,
76    /// `identifier` field from server's diagnosticProvider, if any.
77    /// Used to scope previousResultId tracking when multiple servers share a file.
78    pub identifier: Option<String>,
79    /// Whether the server requested workspace diagnostic refresh notifications.
80    /// We declare `refreshSupport: false` in our client capabilities so this
81    /// should always be false in practice — kept for completeness.
82    pub refresh_support: bool,
83}
84
85/// A client connected to one language server process.
86pub struct LspClient {
87    kind: ServerKind,
88    root: PathBuf,
89    state: ServerState,
90    child: Child,
91    /// Child PID captured at spawn time. Used by Drop to untrack the
92    /// PID from the shared registry; we capture once rather than reading
93    /// `child.id()` later because Drop ordering with the Child can race.
94    child_pid: u32,
95    writer: Arc<Mutex<BufWriter<std::process::ChildStdin>>>,
96
97    /// Pending request responses, keyed by request ID.
98    pending: Arc<Mutex<PendingMap>>,
99    /// Next request ID counter.
100    next_id: AtomicI64,
101    /// Diagnostic capabilities reported by the server in its initialize response.
102    /// `None` until `initialize()` succeeds; conservative defaults thereafter
103    /// when the server doesn't advertise diagnosticProvider.
104    diagnostic_caps: Option<ServerDiagnosticCapabilities>,
105    /// Whether the server advertised `workspace.didChangeWatchedFiles` support
106    /// during `initialize`. When `false` (or `None` pre-init), we skip sending
107    /// `workspace/didChangeWatchedFiles` notifications to avoid spec violations.
108    /// Intentional default: `false` (conservative — requires server opt-in).
109    supports_watched_files: bool,
110    /// Shared registry that tracks live LSP child PIDs across the process
111    /// so the signal handler can SIGKILL them on SIGTERM/SIGINT before
112    /// aft exits. Cloned via `Arc` — multiple clients share the same set.
113    child_registry: LspChildRegistry,
114}
115
116impl LspClient {
117    /// Spawn a new language server process and start the background reader thread.
118    ///
119    /// `child_registry` is a shared handle that records this child's PID so
120    /// the signal handler can SIGKILL it on SIGTERM/SIGINT. Tests that don't
121    /// care about signal cleanup can pass `LspChildRegistry::new()`.
122    pub fn spawn(
123        kind: ServerKind,
124        root: PathBuf,
125        binary: &Path,
126        args: &[String],
127        env: &HashMap<String, String>,
128        event_tx: Sender<LspEvent>,
129        child_registry: LspChildRegistry,
130    ) -> io::Result<Self> {
131        let mut command = Command::new(binary);
132        command
133            .args(args)
134            .current_dir(&root)
135            .stdin(Stdio::piped())
136            .stdout(Stdio::piped())
137            // Use null() instead of piped() to prevent deadlock when the server
138            // writes more than ~64KB to stderr (piped buffer fills, server blocks)
139            .stderr(Stdio::null());
140        for (key, value) in env {
141            command.env(key, value);
142        }
143
144        let mut child = command.spawn()?;
145        let child_pid = child.id();
146        child_registry.track(child_pid);
147
148        let stdout = child
149            .stdout
150            .take()
151            .ok_or_else(|| io::Error::other("language server missing stdout pipe"))?;
152        let stdin = child
153            .stdin
154            .take()
155            .ok_or_else(|| io::Error::other("language server missing stdin pipe"))?;
156
157        let writer = Arc::new(Mutex::new(BufWriter::new(stdin)));
158        let pending = Arc::new(Mutex::new(PendingMap::new()));
159        let reader_pending = Arc::clone(&pending);
160        let reader_writer = Arc::clone(&writer);
161        let reader_kind = kind.clone();
162        let reader_root = root.clone();
163
164        thread::spawn(move || {
165            let mut reader = BufReader::new(stdout);
166            loop {
167                match transport::read_message(&mut reader) {
168                    Ok(Some(ServerMessage::Response(response))) => {
169                        if let Ok(mut guard) = reader_pending.lock() {
170                            if let Some(tx) = guard.remove(&response.id) {
171                                if tx.send(response).is_err() {
172                                    log::debug!("response channel closed");
173                                }
174                            }
175                        } else {
176                            let _ = event_tx.send(LspEvent::ServerExited {
177                                server_kind: reader_kind.clone(),
178                                root: reader_root.clone(),
179                            });
180                            break;
181                        }
182                    }
183                    Ok(Some(ServerMessage::Notification { method, params })) => {
184                        let _ = event_tx.send(LspEvent::Notification {
185                            server_kind: reader_kind.clone(),
186                            root: reader_root.clone(),
187                            method,
188                            params,
189                        });
190                    }
191                    Ok(Some(ServerMessage::Request { id, method, params })) => {
192                        // Auto-respond to server requests to prevent deadlocks.
193                        // Server requests (like client/registerCapability,
194                        // window/workDoneProgress/create) block the server until
195                        // we respond. If we don't respond, the server won't send
196                        // responses to OUR pending requests → deadlock.
197                        //
198                        // Dispatch by method to return correct types:
199                        // - workspace/configuration expects Vec<Value> (one per item)
200                        // - Everything else gets null (safe default for registration/progress)
201                        let response_value = if method == "workspace/configuration" {
202                            // Return an array of null configs — one per requested item.
203                            // Servers fall back to filesystem config (tsconfig, pyrightconfig, etc.)
204                            let item_count = params
205                                .as_ref()
206                                .and_then(|p| p.get("items"))
207                                .and_then(|items| items.as_array())
208                                .map_or(1, |arr| arr.len());
209                            serde_json::Value::Array(vec![serde_json::Value::Null; item_count])
210                        } else {
211                            serde_json::Value::Null
212                        };
213                        if let Ok(mut w) = reader_writer.lock() {
214                            let response = super::jsonrpc::OutgoingResponse::success(
215                                id.clone(),
216                                response_value,
217                            );
218                            let _ = transport::write_response(&mut *w, &response);
219                        }
220                        // Also forward as event for any interested handlers
221                        let _ = event_tx.send(LspEvent::ServerRequest {
222                            server_kind: reader_kind.clone(),
223                            root: reader_root.clone(),
224                            id,
225                            method,
226                            params,
227                        });
228                    }
229                    Ok(None) | Err(_) => {
230                        if let Ok(mut guard) = reader_pending.lock() {
231                            guard.clear();
232                        }
233                        let _ = event_tx.send(LspEvent::ServerExited {
234                            server_kind: reader_kind.clone(),
235                            root: reader_root.clone(),
236                        });
237                        break;
238                    }
239                }
240            }
241        });
242
243        Ok(Self {
244            kind,
245            root,
246            state: ServerState::Starting,
247            child,
248            child_pid,
249            writer,
250            pending,
251            next_id: AtomicI64::new(1),
252            diagnostic_caps: None,
253            supports_watched_files: false,
254            child_registry,
255        })
256    }
257
258    /// Send the initialize request and wait for response. Transition to Ready.
259    pub fn initialize(
260        &mut self,
261        workspace_root: &Path,
262        initialization_options: Option<serde_json::Value>,
263    ) -> Result<lsp_types::InitializeResult, LspError> {
264        self.ensure_can_send()?;
265        self.state = ServerState::Initializing;
266
267        let normalized = normalize_windows_path(workspace_root);
268        let root_url = url::Url::from_file_path(&normalized).map_err(|_| {
269            LspError::NotFound(format!(
270                "failed to convert workspace root '{}' to file URI",
271                workspace_root.display()
272            ))
273        })?;
274        let root_uri = lsp_types::Uri::from_str(root_url.as_str()).map_err(|_| {
275            LspError::NotFound(format!(
276                "failed to convert workspace root '{}' to file URI",
277                workspace_root.display()
278            ))
279        })?;
280
281        let mut params_value = json!({
282            "processId": std::process::id(),
283            "rootUri": root_uri,
284            "capabilities": {
285                "workspace": {
286                    "workspaceFolders": true,
287                    "configuration": true,
288                    // LSP 3.17 workspace diagnostic pull. We declare refreshSupport=false
289                    // because we drive diagnostics on-demand via pull/push and re-query
290                    // when the agent calls lsp_diagnostics again — we don't need the
291                    // server to proactively push refresh notifications.
292                    "diagnostic": {
293                        "refreshSupport": false
294                    }
295                },
296                "textDocument": {
297                    "synchronization": {
298                        "dynamicRegistration": false,
299                        "didSave": true,
300                        "willSave": false,
301                        "willSaveWaitUntil": false
302                    },
303                    "publishDiagnostics": {
304                        "relatedInformation": true,
305                        "versionSupport": true,
306                        "codeDescriptionSupport": true,
307                        "dataSupport": true
308                    },
309                    // LSP 3.17 textDocument diagnostic pull. dynamicRegistration=false
310                    // because we use static capability discovery from the InitializeResult.
311                    // relatedDocumentSupport=true to receive cascading diagnostics for
312                    // files that became known while analyzing the requested one.
313                    "diagnostic": {
314                        "dynamicRegistration": false,
315                        "relatedDocumentSupport": true
316                    }
317                }
318            },
319            "clientInfo": {
320                "name": "aft",
321                "version": env!("CARGO_PKG_VERSION")
322            },
323            "workspaceFolders": [
324                {
325                    "uri": root_uri,
326                    "name": workspace_root
327                        .file_name()
328                        .and_then(|name| name.to_str())
329                        .unwrap_or("workspace")
330                }
331            ]
332        });
333        if let Some(initialization_options) = initialization_options {
334            params_value["initializationOptions"] = initialization_options;
335        }
336
337        let params = serde_json::from_value::<lsp_types::InitializeParams>(params_value)?;
338
339        let result = self.send_request::<lsp_types::request::Initialize>(params)?;
340
341        // Capture diagnostic capabilities from the initialize response. We parse
342        // from a re-serialized JSON Value because the lsp-types crate's
343        // diagnostic_provider strict variants reject some shapes real servers
344        // emit (e.g. bare `true`), and we want defensive Default fallback.
345        let caps_value = serde_json::to_value(&result.capabilities).unwrap_or(Value::Null);
346        self.diagnostic_caps = Some(parse_diagnostic_capabilities(&caps_value));
347
348        // Capture whether the server supports workspace/didChangeWatchedFiles (#32).
349        //
350        // IMPORTANT: lsp-types 0.97's WorkspaceServerCapabilities struct does NOT
351        // include a `didChangeWatchedFiles` field, so `caps_value` will never have
352        // it after re-serialization. We therefore default to `true` (permissive).
353        //
354        // Per the LSP specification, servers MUST ignore notifications for methods
355        // they don't support, so sending didChangeWatchedFiles unconditionally is
356        // spec-safe. The default-true matches the pre-#32 unconditional behavior
357        // and avoids a regression for servers that do support it (tsserver, rust-
358        // analyzer, pyright all accept it even without explicit advertising).
359        //
360        // If a future lsp-types version exposes the field, the pointer lookup
361        // below will start returning real values and the default won't matter.
362        self.supports_watched_files = caps_value
363            .pointer("/workspace/didChangeWatchedFiles/dynamicRegistration")
364            .and_then(|v| v.as_bool())
365            .unwrap_or(true) // permissive default: spec-safe to send if server doesn't say false
366            || caps_value
367                .pointer("/workspace/didChangeWatchedFiles")
368                .map(|v| v.is_object() || v.as_bool() == Some(true))
369                .unwrap_or(true);
370
371        self.send_notification::<lsp_types::notification::Initialized>(serde_json::from_value(
372            json!({}),
373        )?)?;
374        self.state = ServerState::Ready;
375        Ok(result)
376    }
377
378    /// Diagnostic capabilities advertised by the server. Returns `None` until
379    /// `initialize()` has succeeded; returns `Some` with conservative defaults
380    /// (all `false`) when the server didn't advertise diagnosticProvider.
381    pub fn diagnostic_capabilities(&self) -> Option<&ServerDiagnosticCapabilities> {
382        self.diagnostic_caps.as_ref()
383    }
384
385    /// Whether the server supports `workspace/didChangeWatchedFiles`.
386    /// Captured from the `initialize` response. Default `false` (conservative).
387    pub fn supports_watched_files(&self) -> bool {
388        self.supports_watched_files
389    }
390
391    /// Send a request and wait for the response.
392    pub fn send_request<R>(&mut self, params: R::Params) -> Result<R::Result, LspError>
393    where
394        R: lsp_types::request::Request,
395        R::Params: serde::Serialize,
396        R::Result: DeserializeOwned,
397    {
398        self.ensure_can_send()?;
399
400        let id = RequestId::Int(self.next_id.fetch_add(1, Ordering::Relaxed));
401        let (tx, rx) = bounded(1);
402        {
403            let mut pending = self.lock_pending()?;
404            pending.insert(id.clone(), tx);
405        }
406
407        let request = Request::new(id.clone(), R::METHOD, Some(serde_json::to_value(params)?));
408        {
409            let mut writer = self
410                .writer
411                .lock()
412                .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
413            if let Err(err) = transport::write_request(&mut *writer, &request) {
414                self.remove_pending(&id);
415                return Err(err.into());
416            }
417        }
418
419        let response = match rx.recv_timeout(REQUEST_TIMEOUT) {
420            Ok(response) => response,
421            Err(RecvTimeoutError::Timeout) => {
422                self.remove_pending(&id);
423                return Err(LspError::Timeout(format!(
424                    "timed out waiting for '{}' response from {:?}",
425                    R::METHOD,
426                    self.kind
427                )));
428            }
429            Err(RecvTimeoutError::Disconnected) => {
430                self.remove_pending(&id);
431                return Err(LspError::ServerNotReady(format!(
432                    "language server {:?} disconnected while waiting for '{}'",
433                    self.kind,
434                    R::METHOD
435                )));
436            }
437        };
438
439        if let Some(error) = response.error {
440            return Err(LspError::ServerError {
441                code: error.code,
442                message: error.message,
443            });
444        }
445
446        serde_json::from_value(response.result.unwrap_or(Value::Null)).map_err(Into::into)
447    }
448
449    /// Send a notification (fire-and-forget).
450    pub fn send_notification<N>(&mut self, params: N::Params) -> Result<(), LspError>
451    where
452        N: lsp_types::notification::Notification,
453        N::Params: serde::Serialize,
454    {
455        self.ensure_can_send()?;
456        let notification = Notification::new(N::METHOD, Some(serde_json::to_value(params)?));
457        let mut writer = self
458            .writer
459            .lock()
460            .map_err(|_| LspError::ServerNotReady("writer lock poisoned".to_string()))?;
461        transport::write_notification(&mut *writer, &notification)?;
462        Ok(())
463    }
464
465    /// Graceful shutdown: send shutdown request, then exit notification.
466    pub fn shutdown(&mut self) -> Result<(), LspError> {
467        if self.state == ServerState::Exited {
468            self.child_registry.untrack(self.child_pid);
469            return Ok(());
470        }
471
472        if self.child.try_wait()?.is_some() {
473            self.state = ServerState::Exited;
474            self.child_registry.untrack(self.child_pid);
475            return Ok(());
476        }
477
478        if let Err(err) = self.send_request::<lsp_types::request::Shutdown>(()) {
479            self.state = ServerState::ShuttingDown;
480            if self.child.try_wait()?.is_some() {
481                self.state = ServerState::Exited;
482                return Ok(());
483            }
484            return Err(err);
485        }
486
487        self.state = ServerState::ShuttingDown;
488
489        if let Err(err) = self.send_notification::<lsp_types::notification::Exit>(()) {
490            if self.child.try_wait()?.is_some() {
491                self.state = ServerState::Exited;
492                return Ok(());
493            }
494            return Err(err);
495        }
496
497        let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
498        loop {
499            if self.child.try_wait()?.is_some() {
500                self.state = ServerState::Exited;
501                return Ok(());
502            }
503            if Instant::now() >= deadline {
504                let _ = self.child.kill();
505                let _ = self.child.wait();
506                self.state = ServerState::Exited;
507                return Err(LspError::Timeout(format!(
508                    "timed out waiting for {:?} to exit",
509                    self.kind
510                )));
511            }
512            thread::sleep(EXIT_POLL_INTERVAL);
513        }
514    }
515
516    pub fn state(&self) -> ServerState {
517        self.state
518    }
519
520    pub fn kind(&self) -> ServerKind {
521        self.kind.clone()
522    }
523
524    pub fn root(&self) -> &Path {
525        &self.root
526    }
527
528    fn ensure_can_send(&self) -> Result<(), LspError> {
529        if matches!(self.state, ServerState::ShuttingDown | ServerState::Exited) {
530            return Err(LspError::ServerNotReady(format!(
531                "language server {:?} is not ready (state: {:?})",
532                self.kind, self.state
533            )));
534        }
535        Ok(())
536    }
537
538    fn lock_pending(&self) -> Result<std::sync::MutexGuard<'_, PendingMap>, LspError> {
539        self.pending
540            .lock()
541            .map_err(|_| io::Error::other("pending response map poisoned").into())
542    }
543
544    fn remove_pending(&self, id: &RequestId) {
545        if let Ok(mut pending) = self.pending.lock() {
546            pending.remove(id);
547        }
548    }
549}
550
551impl Drop for LspClient {
552    fn drop(&mut self) {
553        // Untrack first so the signal handler can't race with this kill and
554        // try to SIGKILL a PID that's already been reaped.
555        self.child_registry.untrack(self.child_pid);
556        let _ = self.child.kill();
557        let _ = self.child.wait();
558    }
559}
560
561/// Normalize a path for file URI conversion.
562/// On Windows, strips the extended-length `\\?\` prefix that `Url::from_file_path` cannot handle.
563/// On other platforms, returns the path unchanged.
564fn normalize_windows_path(path: &Path) -> PathBuf {
565    let s = path.to_string_lossy();
566    if let Some(stripped) = s.strip_prefix(r"\\?\") {
567        PathBuf::from(stripped)
568    } else {
569        path.to_path_buf()
570    }
571}
572
573/// Parse `ServerDiagnosticCapabilities` from a re-serialized
574/// `ServerCapabilities` JSON value.
575///
576/// LSP 3.17 spec for `diagnosticProvider`:
577/// - `capabilities.diagnosticProvider` may be absent (no pull support),
578///   `DiagnosticOptions`, or `DiagnosticRegistrationOptions`.
579/// - If present:
580///   - `interFileDependencies: bool` (we don't currently use this)
581///   - `workspaceDiagnostics: bool` → workspace pull support
582///   - `identifier?: string` → optional identifier scoping result IDs
583///
584/// We parse the raw JSON Value defensively: presence of any
585/// `diagnosticProvider` value (object or `true`) means the server supports
586/// at least `textDocument/diagnostic` pull.
587fn parse_diagnostic_capabilities(value: &Value) -> ServerDiagnosticCapabilities {
588    let mut caps = ServerDiagnosticCapabilities::default();
589
590    if let Some(provider) = value.get("diagnosticProvider") {
591        // diagnosticProvider can be `true` (rare) or an object. Treat both as
592        // pull_diagnostics support.
593        if provider.is_object() || provider.as_bool() == Some(true) {
594            caps.pull_diagnostics = true;
595        }
596
597        if let Some(obj) = provider.as_object() {
598            if obj
599                .get("workspaceDiagnostics")
600                .and_then(|v| v.as_bool())
601                .unwrap_or(false)
602            {
603                caps.workspace_diagnostics = true;
604            }
605            if let Some(identifier) = obj.get("identifier").and_then(|v| v.as_str()) {
606                caps.identifier = Some(identifier.to_string());
607            }
608        }
609    }
610
611    // Workspace diagnostic refresh (rare — most servers don't request this,
612    // and we declared refreshSupport=false in our client capabilities anyway).
613    if let Some(refresh) = value
614        .get("workspace")
615        .and_then(|w| w.get("diagnostic"))
616        .and_then(|d| d.get("refreshSupport"))
617        .and_then(|r| r.as_bool())
618    {
619        caps.refresh_support = refresh;
620    }
621
622    caps
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn parse_caps_no_diagnostic_provider() {
631        let value = json!({});
632        let caps = parse_diagnostic_capabilities(&value);
633        assert!(!caps.pull_diagnostics);
634        assert!(!caps.workspace_diagnostics);
635        assert!(caps.identifier.is_none());
636    }
637
638    #[test]
639    fn parse_caps_basic_pull_only() {
640        let value = json!({
641            "diagnosticProvider": {
642                "interFileDependencies": false,
643                "workspaceDiagnostics": false
644            }
645        });
646        let caps = parse_diagnostic_capabilities(&value);
647        assert!(caps.pull_diagnostics);
648        assert!(!caps.workspace_diagnostics);
649    }
650
651    #[test]
652    fn parse_caps_full_pull_with_workspace() {
653        let value = json!({
654            "diagnosticProvider": {
655                "interFileDependencies": true,
656                "workspaceDiagnostics": true,
657                "identifier": "rust-analyzer"
658            }
659        });
660        let caps = parse_diagnostic_capabilities(&value);
661        assert!(caps.pull_diagnostics);
662        assert!(caps.workspace_diagnostics);
663        assert_eq!(caps.identifier.as_deref(), Some("rust-analyzer"));
664    }
665
666    #[test]
667    fn parse_caps_provider_as_bare_true() {
668        // LSP 3.17 allows DiagnosticOptions OR boolean — treat true as pull_diagnostics
669        let value = json!({
670            "diagnosticProvider": true
671        });
672        let caps = parse_diagnostic_capabilities(&value);
673        assert!(caps.pull_diagnostics);
674        assert!(!caps.workspace_diagnostics);
675    }
676
677    #[test]
678    fn parse_caps_workspace_refresh_support() {
679        let value = json!({
680            "workspace": {
681                "diagnostic": {
682                    "refreshSupport": true
683                }
684            }
685        });
686        let caps = parse_diagnostic_capabilities(&value);
687        assert!(caps.refresh_support);
688        // No diagnosticProvider → pull still false
689        assert!(!caps.pull_diagnostics);
690    }
691}