Skip to main content

oxicode_lsp/
lib.rs

1//! `oxicode-lsp` โ€” Thin LSP protocol adapter.
2//!
3//! This crate wraps [`async_lsp`] + [`lsp_types`] + [`async_process`]
4//! to provide a single [`LspClient`] that owns a language server
5//! process and a JSON-RPC correlation loop. **It does not** do:
6//!
7//! - config discovery / layering (user > project > plugin)
8//! - folder-trust gating (project `lsp.json` skipped in untrusted folders)
9//! - crash recovery / lifetime restart budget
10//! - multi-server lifecycle / extension conflict resolution
11//!
12//! Those concerns belong in `oxicode-cli's` LSP adapter (see
13//! `oxicode-cli/src/lsp/manager.rs`). Keeping `oxicode-lsp` thin lets the
14//! adapter own policy without re-implementing the JSON-RPC loop.
15//!
16//! # Pattern
17//!
18//! Ported from grok's `LspManager` (see
19//! `docs/designs/2026-07-18-stub-completion.md` ยง2): a single
20//! `LspClient` per server, with `diagnostics_ready: Arc<Notify>`
21//! gating `drain_diagnostics`, and `lifecycle_id: AtomicU64` marking
22//! restart epochs so cached diagnostics from a dead epoch can be
23//! evicted.
24
25use std::io;
26use std::ops::ControlFlow;
27use std::path::{Path, PathBuf};
28use std::process::Stdio;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::time::Duration;
32
33use async_lsp::router::Router;
34use async_lsp::{LanguageServer, MainLoop, ServerSocket};
35use async_process::Command;
36use dashmap::DashMap;
37use futures::AsyncReadExt;
38use lsp_types::notification::{Notification, PublishDiagnostics};
39use lsp_types::{
40    self as types, ClientCapabilities, InitializeParams, ServerCapabilities, Url, WorkspaceFolder,
41};
42use parking_lot::Mutex;
43use thiserror::Error;
44use tokio::sync::Notify;
45use tokio::task::JoinHandle;
46
47/// Default timeout for an individual `request โ†’ response` RPC.
48pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
49
50/// Crate-wide error type.
51#[derive(Debug, Error)]
52pub enum LspError {
53    #[error("failed to spawn LSP server `{server}`: {source}")]
54    SpawnFailed {
55        server: String,
56        #[source]
57        source: io::Error,
58    },
59    #[error("LSP server `{server}` failed to initialize: {message}")]
60    InitFailed { server: String, message: String },
61    #[error("LSP `{server}` request `{method}` timed out after {timeout:?}")]
62    Timeout {
63        server: String,
64        method: String,
65        timeout: Duration,
66    },
67    #[error("LSP `{server}` request `{method}` failed: {message}")]
68    RequestFailed {
69        server: String,
70        method: String,
71        message: String,
72    },
73    #[error("LSP `{server}` transport error in `{method}`: {source}")]
74    Transport {
75        server: String,
76        method: String,
77        #[source]
78        source: async_lsp::Error,
79    },
80    #[error("LSP `{server}` is shut down")]
81    ShutDown { server: String },
82}
83
84impl LspError {
85    fn shut_down(server: &str) -> Self {
86        LspError::ShutDown {
87            server: server.to_string(),
88        }
89    }
90}
91
92/// Per-file `textDocument/publishDiagnostics` snapshot.
93#[derive(Debug, Clone)]
94pub struct PublishedDiagnostics {
95    pub uri: String,
96    pub diagnostics: serde_json::Value,
97}
98
99/// Process-handle and runtime knobs for one LSP server.
100#[derive(Debug, Clone)]
101pub struct LspClientConfig {
102    pub server_name: String,
103    pub command: String,
104    pub args: Vec<String>,
105    pub startup_timeout: Duration,
106    pub request_timeout: Duration,
107    pub shutdown_timeout: Duration,
108}
109
110impl LspClientConfig {
111    pub fn new(
112        server_name: impl Into<String>,
113        command: impl Into<String>,
114        args: Vec<String>,
115    ) -> Self {
116        Self {
117            server_name: server_name.into(),
118            command: command.into(),
119            args,
120            startup_timeout: Duration::from_secs(10),
121            request_timeout: REQUEST_TIMEOUT,
122            shutdown_timeout: Duration::from_secs(5),
123        }
124    }
125}
126
127/// One process-spawned language server.
128pub struct LspClient {
129    server_name: String,
130    workspace_root: PathBuf,
131    lifecycle_id: Arc<AtomicU64>,
132    diagnostics: Arc<DashMap<String, PublishedDiagnostics>>,
133    diagnostics_ready: Arc<Notify>,
134    server: Option<ServerSocket>,
135    capabilities: parking_lot::RwLock<Option<types::ServerCapabilities>>,
136    main_loop: Option<JoinHandle<async_lsp::Result<()>>>,
137    child: Option<async_process::Child>,
138    #[allow(dead_code)]
139    request_timeout: Duration,
140    shutdown_timeout: Duration,
141}
142
143impl std::fmt::Debug for LspClient {
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.debug_struct("LspClient")
146            .field("server_name", &self.server_name)
147            .field("workspace_root", &self.workspace_root)
148            .field("lifecycle_id", &self.lifecycle_id.load(Ordering::Acquire))
149            .field("child_running", &self.child.is_some())
150            .finish()
151    }
152}
153
154impl LspClient {
155    pub async fn start(
156        config: LspClientConfig,
157        workspace_root: PathBuf,
158    ) -> Result<Arc<Self>, LspError> {
159        let server_name = config.server_name.clone();
160
161        let mut command = Command::new(&config.command);
162        command
163            .args(&config.args)
164            .stdin(Stdio::piped())
165            .stdout(Stdio::piped())
166            .stderr(Stdio::piped())
167            .kill_on_drop(true);
168
169        let mut child = command.spawn().map_err(|source| LspError::SpawnFailed {
170            server: config.server_name.clone(),
171            source,
172        })?;
173
174        // async-process Child::stdout/stdin implement futures AsyncRead/AsyncWrite.
175        let stdout = child.stdout.take().ok_or_else(|| LspError::SpawnFailed {
176            server: config.server_name.clone(),
177            source: io::Error::other("child stdout unavailable"),
178        })?;
179        let stdin = child.stdin.take().ok_or_else(|| LspError::SpawnFailed {
180            server: config.server_name.clone(),
181            source: io::Error::other("child stdin unavailable"),
182        })?;
183        let stderr = child.stderr.take();
184
185        let diagnostics: Arc<DashMap<String, PublishedDiagnostics>> = Arc::new(DashMap::new());
186        let diagnostics_for_handler = diagnostics.clone();
187        let server_name_for_handler = config.server_name.clone();
188
189        let (mainloop, server) = MainLoop::new_client(move |_server| {
190            let mut router = Router::new(());
191            router.notification::<PublishDiagnostics>(move |_st, params| {
192                let uri = params.uri.to_string();
193                let payload = serde_json::to_value(&params.diagnostics)
194                    .unwrap_or(serde_json::Value::Array(vec![]));
195                diagnostics_for_handler.insert(
196                    uri.clone(),
197                    PublishedDiagnostics {
198                        uri,
199                        diagnostics: payload,
200                    },
201                );
202                tracing::debug!(
203                    server = %server_name_for_handler,
204                    "publishDiagnostics stored",
205                );
206                ControlFlow::Continue(())
207            });
208            router
209        });
210
211        let main_loop_handle =
212            tokio::spawn(async move { mainloop.run_buffered(stdout, stdin).await });
213
214        // Forward child stderr to oxicode's tracing.
215        if let Some(stderr) = stderr {
216            let srv = config.server_name.clone();
217            tokio::spawn(async move {
218                let mut stderr = stderr;
219                let mut buf = Vec::new();
220                let mut tmp = [0u8; 1024];
221                loop {
222                    match stderr.read(&mut tmp).await {
223                        Ok(0) => break,
224                        Ok(n) => {
225                            buf.extend_from_slice(&tmp[..n]);
226                            while let Some(idx) = buf.iter().position(|b| *b == b'\n') {
227                                let line: Vec<u8> = buf.drain(..=idx).collect();
228                                tracing::debug!(server = %srv, stderr = ?line);
229                            }
230                        }
231                        Err(_) => break,
232                    }
233                }
234            });
235        }
236
237        let client = Arc::new(Self {
238            server_name: server_name.clone(),
239            workspace_root,
240            lifecycle_id: Arc::new(AtomicU64::new(1)),
241            diagnostics,
242            diagnostics_ready: Arc::new(Notify::new()),
243            server: Some(server),
244            capabilities: parking_lot::RwLock::new(None),
245            main_loop: Some(main_loop_handle),
246            child: Some(child),
247            request_timeout: config.request_timeout,
248            shutdown_timeout: config.shutdown_timeout,
249        });
250
251        client.spawn_diagnostics_watcher();
252
253        client
254            .initialize_with_timeout(config.startup_timeout)
255            .await?;
256
257        Ok(client)
258    }
259
260    pub fn server_name(&self) -> &str {
261        &self.server_name
262    }
263
264    pub fn workspace_root(&self) -> &Path {
265        &self.workspace_root
266    }
267
268    pub fn lifecycle_id(&self) -> u64 {
269        self.lifecycle_id.load(Ordering::Acquire)
270    }
271
272    pub fn bump_lifecycle_id(&self) -> u64 {
273        self.lifecycle_id.fetch_add(1, Ordering::AcqRel) + 1
274    }
275
276    pub async fn initialize_with_timeout(
277        &self,
278        timeout: Duration,
279    ) -> Result<ServerCapabilities, LspError> {
280        let mut server = self
281            .server
282            .as_ref()
283            .ok_or_else(|| LspError::shut_down(&self.server_name))?
284            .clone();
285
286        let workspace_uri =
287            Url::from_file_path(&self.workspace_root).map_err(|_| LspError::InitFailed {
288                server: self.server_name.clone(),
289                message: "workspace root is not absolute or has no URI form".into(),
290            })?;
291
292        let params = InitializeParams {
293            workspace_folders: Some(vec![WorkspaceFolder {
294                uri: workspace_uri,
295                name: self.workspace_root.display().to_string(),
296            }]),
297            capabilities: ClientCapabilities::default(),
298            ..InitializeParams::default()
299        };
300
301        let init_fut = server.initialize(params);
302        let resp = match tokio::time::timeout(timeout, init_fut).await {
303            Err(_) => {
304                return Err(LspError::Timeout {
305                    server: self.server_name.clone(),
306                    method: "initialize".into(),
307                    timeout,
308                });
309            }
310            Ok(Err(e)) => {
311                return Err(LspError::Transport {
312                    server: self.server_name.clone(),
313                    method: "initialize".into(),
314                    source: e,
315                });
316            }
317            Ok(Ok(v)) => v,
318        };
319
320        server
321            .initialized(types::InitializedParams {})
322            .map_err(|e| LspError::Transport {
323                server: self.server_name.clone(),
324                method: "initialized".into(),
325                source: e,
326            })?;
327        *self.capabilities.write() = Some(resp.capabilities.clone());
328
329        Ok(resp.capabilities)
330    }
331
332    /// Returns the server capabilities captured during `initialize`, if available.
333    pub fn cached_capabilities(&self) -> Option<ServerCapabilities> {
334        self.capabilities.read().clone()
335    }
336
337    pub async fn request<R>(
338        &self,
339        params: R::Params,
340        timeout: Duration,
341    ) -> Result<R::Result, LspError>
342    where
343        R: types::request::Request,
344        R::Params: serde::Serialize,
345    {
346        let server = self
347            .server
348            .as_ref()
349            .ok_or_else(|| LspError::shut_down(&self.server_name))?
350            .clone();
351
352        let method = R::METHOD.to_string();
353        let fut = server.request::<R>(params);
354        let value = match tokio::time::timeout(timeout, fut).await {
355            Err(_) => {
356                return Err(LspError::Timeout {
357                    server: self.server_name.clone(),
358                    method: method.clone(),
359                    timeout,
360                });
361            }
362            Ok(Err(e)) => {
363                return Err(LspError::Transport {
364                    server: self.server_name.clone(),
365                    method: method.clone(),
366                    source: e,
367                });
368            }
369            Ok(Ok(v)) => v,
370        };
371        Ok(value)
372    }
373
374    pub fn notify<N>(&self, params: N::Params) -> Result<(), LspError>
375    where
376        N: Notification,
377        N::Params: serde::Serialize,
378    {
379        let server = self
380            .server
381            .as_ref()
382            .ok_or_else(|| LspError::shut_down(&self.server_name))?;
383        server.notify::<N>(params).map_err(|e| LspError::Transport {
384            server: self.server_name.clone(),
385            method: N::METHOD.into(),
386            source: e,
387        })
388    }
389
390    pub async fn drain_diagnostics(&self, timeout: Duration) -> Option<Vec<PublishedDiagnostics>> {
391        let notified = tokio::time::timeout(timeout, self.diagnostics_ready.notified()).await;
392        if notified.is_err() {
393            return None;
394        }
395        let entries: Vec<PublishedDiagnostics> = self
396            .diagnostics
397            .iter()
398            .map(|kv| kv.value().clone())
399            .collect();
400        if entries.is_empty() {
401            None
402        } else {
403            Some(entries)
404        }
405    }
406
407    pub fn read_diagnostics(&self, uris: &[String]) -> Vec<PublishedDiagnostics> {
408        uris.iter()
409            .filter_map(|u| self.diagnostics.get(u).map(|kv| kv.value().clone()))
410            .collect()
411    }
412
413    pub fn diagnostics_file_count(&self) -> usize {
414        self.diagnostics.len()
415    }
416
417    pub fn clear_diagnostics(&self) {
418        self.diagnostics.clear();
419    }
420
421    pub async fn shutdown(&mut self) -> Result<(), LspError> {
422        use types::notification::Exit;
423
424        if let Some(server) = self.server.as_ref() {
425            let mut server = server.clone();
426            let _ = tokio::time::timeout(self.shutdown_timeout, async {
427                let _ = server.shutdown(()).await;
428                let _ = server.notify::<Exit>(());
429            })
430            .await;
431        }
432        self.server = None;
433
434        if let Some(handle) = self.main_loop.as_ref() {
435            handle.abort();
436            self.main_loop = None;
437        }
438
439        let _ = self.child.take();
440
441        Ok(())
442    }
443
444    fn spawn_diagnostics_watcher(&self) {
445        let diag_map = self.diagnostics.clone();
446        let notify = self.diagnostics_ready.clone();
447        tokio::spawn(async move {
448            let mut last: std::collections::HashSet<String> =
449                diag_map.iter().map(|kv| kv.key().clone()).collect();
450            loop {
451                tokio::time::sleep(Duration::from_millis(50)).await;
452                let current: std::collections::HashSet<String> =
453                    diag_map.iter().map(|kv| kv.key().clone()).collect();
454                if current != last {
455                    last = current;
456                    notify.notify_waiters();
457                }
458            }
459        });
460    }
461}
462
463/// Helper: build a `file://` URI from a path.
464pub fn uri_for(path: &Path) -> Option<Url> {
465    Url::from_file_path(path).ok()
466}
467
468/// Tracked-document replay helper used by `oxicode-cli's` restart monitor.
469#[derive(Debug, Default)]
470pub struct ReplayState {
471    inner: Mutex<std::collections::HashMap<PathBuf, String>>,
472}
473
474impl ReplayState {
475    pub fn new() -> Self {
476        Self::default()
477    }
478
479    pub fn record(&self, path: PathBuf, content: String) {
480        self.inner.lock().insert(path, content);
481    }
482
483    pub fn forget(&self, path: &Path) {
484        self.inner.lock().remove(path);
485    }
486
487    pub fn snapshot(&self) -> Vec<(PathBuf, String)> {
488        self.inner
489            .lock()
490            .iter()
491            .map(|(k, v)| (k.clone(), v.clone()))
492            .collect()
493    }
494
495    pub fn len(&self) -> usize {
496        self.inner.lock().len()
497    }
498
499    pub fn is_empty(&self) -> bool {
500        self.inner.lock().is_empty()
501    }
502}
503
504pub use async_lsp::Error as AsyncLspError;
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn request_timeout_constant_is_sane() {
512        assert_eq!(REQUEST_TIMEOUT, Duration::from_secs(15));
513    }
514
515    #[test]
516    fn uri_for_absolute_path() {
517        let uri = uri_for(Path::new("/tmp/foo.rs")).unwrap();
518        assert!(uri.to_string().starts_with("file:///"));
519    }
520
521    #[test]
522    fn uri_for_relative_path_is_none() {
523        assert!(uri_for(Path::new("foo.rs")).is_none());
524    }
525
526    #[test]
527    fn config_defaults_match_spec() {
528        let cfg = LspClientConfig::new("rust-analyzer", "rust-analyzer", vec![]);
529        assert_eq!(cfg.server_name, "rust-analyzer");
530        assert_eq!(cfg.startup_timeout, Duration::from_secs(10));
531        assert_eq!(cfg.request_timeout, REQUEST_TIMEOUT);
532        assert_eq!(cfg.shutdown_timeout, Duration::from_secs(5));
533    }
534
535    #[test]
536    fn replay_state_records_and_forgets() {
537        let st = ReplayState::new();
538        st.record(PathBuf::from("/tmp/foo.rs"), "fn main() {}".into());
539        let snap = st.snapshot();
540        assert_eq!(snap.len(), 1);
541        assert_eq!(snap[0].0, PathBuf::from("/tmp/foo.rs"));
542        st.forget(Path::new("/tmp/foo.rs"));
543        assert!(st.is_empty());
544        assert_eq!(st.len(), 0);
545    }
546
547    #[test]
548    fn error_shut_down_carries_server_name() {
549        let e = LspError::shut_down("rust-analyzer");
550        assert!(matches!(e, LspError::ShutDown { ref server } if server == "rust-analyzer"));
551    }
552}