返回 DeepSeek-TUI-2026
client.rs
根目录 / crates / tui / src / lsp / client.rs
1 //! Thin JSON-RPC over stdio client for LSP servers.
2 //!
3 //! We deliberately do **not** depend on `tower-lsp` — it is a server-side
4 //! framework and dragging it in here would add hundreds of unnecessary
5 //! transitive dependencies and slow down `cargo build` for every contributor.
6 //! The LSP wire protocol is small enough that handling it ourselves is a
7 //! self-contained ~400 LOC and lets us keep total control of the spawn
8 //! lifecycle, timeouts, and the async surface.
9 //!
10 //! Architecture:
11 //!
12 //! - [`LspTransport`] is the trait the [`super::LspManager`] talks to. The
13 //! real implementation is [`StdioLspTransport`] (forks an LSP server with
14 //! `tokio::process::Command`); tests use `super::tests::FakeTransport`.
15 //! - [`StdioLspTransport`] runs three tokio tasks: a reader, a writer, and
16 //! the public API. Communication uses tokio mpsc channels.
17 //! - We parse `Content-Length`-framed JSON-RPC and route inbound messages
18 //! either to a per-request response slot (for replies) or to the
19 //! diagnostics queue (for `textDocument/publishDiagnostics` notifications).
20 //!
21 //! The transport is one-shot per file in MVP form: the manager spawns a
22 //! transport on demand for a language and reuses it. We do not implement
23 //! workspace sync beyond didOpen/didChange because the goal is "post-edit
24 //! diagnostics," not full IDE smartness.
25
26 use std::collections::HashMap;
27 use std::path::{Path, PathBuf};
28 use std::process::Stdio;
29 use std::sync::Arc;
30 use std::time::Duration;
31
32 use anyhow::{Context, Result, anyhow};
33 use async_trait::async_trait;
34 use serde_json::{Value, json};
35 use tokio::io::{AsyncReadExt, AsyncWriteExt};
36 use tokio::process::{Child, Command};
37 use tokio::sync::Mutex as AsyncMutex;
38 use tokio::sync::{mpsc, oneshot};
39 use tokio::time::timeout;
40
41 use super::diagnostics::{Diagnostic, Severity};
42 use super::registry::Language;
43 use crate::utils::spawn_supervised;
44
45 /// Trait the LSP manager talks to. A real LSP server speaks this via stdio;
46 /// tests use an in-process fake.
47 #[async_trait]
48 pub trait LspTransport: Send + Sync {
49 /// Notify the server that a file was opened or its contents updated, then
50 /// wait up to `wait` for a `publishDiagnostics` notification for that
51 /// file. Returns the diagnostics list (possibly empty). Implementations
52 /// must NOT block past `wait`.
53 async fn diagnostics_for(
54 &self,
55 path: &Path,
56 text: &str,
57 wait: Duration,
58 ) -> Result<Vec<Diagnostic>>;
59
60 /// Best-effort shutdown. Called via `LspManager::shutdown_all`.
61 #[allow(dead_code)]
62 async fn shutdown(&self);
63 }
64
65 /// Stdio-backed transport. Spawns the LSP server as a child process and
66 /// pipes JSON-RPC over stdin/stdout. Stderr is captured into a buffer so
67 /// callers can include it in error messages without polluting our own stderr.
68 pub struct StdioLspTransport {
69 /// JoinHandle for the running server. Held so the child stays alive for
70 /// the transport's lifetime; consumed during `shutdown`.
71 #[allow(dead_code)]
72 child: AsyncMutex<Option<Child>>,
73 /// Outgoing message sender to the writer task.
74 tx_outbound: mpsc::Sender<Vec<u8>>,
75 /// Inbound diagnostics queue. We push every `publishDiagnostics`
76 /// notification into here and the public API drains the relevant entries.
77 diagnostics_rx: AsyncMutex<mpsc::Receiver<(PathBuf, Vec<Diagnostic>)>>,
78 /// Map of in-flight request id -> reply slot. We do not currently call
79 /// methods that need replies after `initialize`, but this is the hook
80 /// for it.
81 #[allow(dead_code)]
82 pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
83 /// Monotonic request id counter. Reserved for future LSP request/reply
84 /// methods (workspace symbol queries, etc.).
85 #[allow(dead_code)]
86 next_id: AsyncMutex<i64>,
87 /// Language id passed in `textDocument/didOpen` (e.g. "rust").
88 language_id: &'static str,
89 /// Track which files we have opened so the second touch sends
90 /// `didChange` instead of `didOpen`.
91 opened: AsyncMutex<HashMap<PathBuf, i64>>,
92 }
93
94 impl StdioLspTransport {
95 /// Spawn `command args…` and run the LSP `initialize` handshake. Returns
96 /// `Err` immediately if the binary is not on PATH or `initialize` fails.
97 pub async fn spawn(
98 command: &str,
99 args: &[String],
100 language: Language,
101 workspace: PathBuf,
102 ) -> Result<Self> {
103 let mut cmd = Command::new(command);
104 cmd.args(args);
105 cmd.stdin(Stdio::piped());
106 cmd.stdout(Stdio::piped());
107 cmd.stderr(Stdio::piped());
108 cmd.kill_on_drop(true);
109
110 let mut child = cmd
111 .spawn()
112 .with_context(|| format!("failed to spawn LSP server `{command}`"))?;
113
114 let stdin = child
115 .stdin
116 .take()
117 .context("LSP child has no stdin handle")?;
118 let stdout = child
119 .stdout
120 .take()
121 .context("LSP child has no stdout handle")?;
122
123 let (tx_outbound, rx_outbound) = mpsc::channel::<Vec<u8>>(64);
124 let (tx_inbound, rx_inbound) = mpsc::channel::<Value>(64);
125 let (tx_diag, rx_diag) = mpsc::channel::<(PathBuf, Vec<Diagnostic>)>(64);
126
127 // Writer task: drain outbound channel, frame with Content-Length, write to stdin.
128 spawn_supervised(
129 "lsp-writer",
130 std::panic::Location::caller(),
131 writer_task(stdin, rx_outbound),
132 );
133 // Reader task: parse Content-Length frames from stdout, push to inbound queue.
134 spawn_supervised(
135 "lsp-reader",
136 std::panic::Location::caller(),
137 reader_task(stdout, tx_inbound),
138 );
139 // Inbound dispatcher: routes notifications to `tx_diag`, replies to a
140 // pending map. We keep the pending map for completeness even though
141 // diagnostics polling itself does not reuse it.
142 let pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>> =
143 Arc::new(AsyncMutex::new(HashMap::new()));
144 spawn_supervised(
145 "lsp-dispatcher",
146 std::panic::Location::caller(),
147 dispatcher_task(rx_inbound, tx_diag, pending.clone()),
148 );
149
150 // Send `initialize` and wait for `initialized`. We synthesize id=1.
151 let init_payload = json!({
152 "jsonrpc": "2.0",
153 "id": 1,
154 "method": "initialize",
155 "params": {
156 "processId": std::process::id(),
157 "rootUri": uri_from_path(&workspace),
158 "capabilities": {
159 "textDocument": {
160 "publishDiagnostics": { "relatedInformation": false }
161 }
162 },
163 "workspaceFolders": [{
164 "uri": uri_from_path(&workspace),
165 "name": "workspace"
166 }]
167 }
168 });
169 send_message(&tx_outbound, &init_payload).await?;
170
171 // We do not actually wait for the initialize response here in MVP —
172 // most servers buffer notifications until they are ready, and waiting
173 // for `initialize` reply doubles the latency of the first edit. Send
174 // `initialized` immediately and let publishDiagnostics arrive on its
175 // own clock.
176 let initialized = json!({
177 "jsonrpc": "2.0",
178 "method": "initialized",
179 "params": {}
180 });
181 send_message(&tx_outbound, &initialized).await?;
182
183 Ok(Self {
184 child: AsyncMutex::new(Some(child)),
185 tx_outbound,
186 diagnostics_rx: AsyncMutex::new(rx_diag),
187 pending,
188 next_id: AsyncMutex::new(2),
189 language_id: language.language_id(),
190 opened: AsyncMutex::new(HashMap::new()),
191 })
192 }
193 }
194
195 #[async_trait]
196 impl LspTransport for StdioLspTransport {
197 async fn diagnostics_for(
198 &self,
199 path: &Path,
200 text: &str,
201 wait: Duration,
202 ) -> Result<Vec<Diagnostic>> {
203 let path_buf = path.to_path_buf();
204 let uri = uri_from_path(&path_buf);
205
206 // Either send didOpen (first time) or didChange (subsequent edits).
207 let mut opened = self.opened.lock().await;
208 let is_new = !opened.contains_key(&path_buf);
209 let new_version = opened.get(&path_buf).copied().unwrap_or(0) + 1;
210 opened.insert(path_buf.clone(), new_version);
211 drop(opened);
212
213 let payload = if is_new {
214 json!({
215 "jsonrpc": "2.0",
216 "method": "textDocument/didOpen",
217 "params": {
218 "textDocument": {
219 "uri": uri.clone(),
220 "languageId": self.language_id,
221 "version": new_version,
222 "text": text
223 }
224 }
225 })
226 } else {
227 json!({
228 "jsonrpc": "2.0",
229 "method": "textDocument/didChange",
230 "params": {
231 "textDocument": {
232 "uri": uri.clone(),
233 "version": new_version
234 },
235 "contentChanges": [{ "text": text }]
236 }
237 })
238 };
239 send_message(&self.tx_outbound, &payload).await?;
240
241 // Drain matching `publishDiagnostics` notifications until `wait`
242 // elapses. Servers typically publish within a few hundred ms; for
243 // initial cold-start (rust-analyzer) it can be many seconds — but
244 // the manager guards us with a separate timeout.
245 let deadline = tokio::time::Instant::now() + wait;
246 let mut latest: Option<Vec<Diagnostic>> = None;
247
248 loop {
249 let now = tokio::time::Instant::now();
250 if now >= deadline {
251 break;
252 }
253 let remaining = deadline - now;
254 let mut rx = self.diagnostics_rx.lock().await;
255 let next = match timeout(remaining, rx.recv()).await {
256 Ok(Some(item)) => item,
257 Ok(None) => break, // channel closed
258 Err(_) => break, // timed out
259 };
260 drop(rx);
261 let (file, items) = next;
262 if file == path_buf {
263 latest = Some(items);
264 // We have a payload — return immediately. If the server
265 // re-publishes after rapid edits, the next call will sync.
266 break;
267 }
268 // Otherwise: notification was for a different file we previously
269 // opened. Discard and continue waiting.
270 }
271 Ok(latest.unwrap_or_default())
272 }
273
274 async fn shutdown(&self) {
275 let mut child = self.child.lock().await;
276 if let Some(mut c) = child.take() {
277 let _ = c.start_kill();
278 let _ = c.wait().await;
279 }
280 }
281 }
282
283 /// Send a JSON value as one Content-Length-framed JSON-RPC message.
284 async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
285 let body = serde_json::to_vec(value).context("serialize LSP message")?;
286 let header = format!("Content-Length: {}\r\n\r\n", body.len());
287 let mut frame = Vec::with_capacity(header.len() + body.len());
288 frame.extend_from_slice(header.as_bytes());
289 frame.extend_from_slice(&body);
290 tx.send(frame)
291 .await
292 .map_err(|_| anyhow!("LSP outbound channel closed"))?;
293 Ok(())
294 }
295
296 /// Background task that drains the outbound queue and writes each frame to
297 /// the LSP server's stdin. Exits cleanly when the channel closes.
298 async fn writer_task(mut stdin: tokio::process::ChildStdin, mut rx: mpsc::Receiver<Vec<u8>>) {
299 while let Some(frame) = rx.recv().await {
300 if stdin.write_all(&frame).await.is_err() {
301 break;
302 }
303 if stdin.flush().await.is_err() {
304 break;
305 }
306 }
307 }
308
309 /// Background task that parses `Content-Length`-framed JSON-RPC frames from
310 /// the LSP server's stdout. Pushes each parsed JSON value to `tx`. Exits
311 /// when stdout closes or a frame is malformed (we choose to fail closed
312 /// rather than risk hanging).
313 async fn reader_task(mut stdout: tokio::process::ChildStdout, tx: mpsc::Sender<Value>) {
314 let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
315 let mut tmp = [0u8; 4096];
316 loop {
317 let n = match stdout.read(&mut tmp).await {
318 Ok(0) => return,
319 Ok(n) => n,
320 Err(_) => return,
321 };
322 buf.extend_from_slice(&tmp[..n]);
323 // Try to parse as many frames as we can from the accumulated buffer.
324 while let Some((header_end, content_length)) = parse_header(&buf) {
325 if buf.len() < header_end + content_length {
326 break; // need more bytes
327 }
328 let body = &buf[header_end..header_end + content_length];
329 let parsed = serde_json::from_slice::<Value>(body).ok();
330 // Drop the consumed bytes regardless of parse result so a bad frame
331 // does not stall the loop.
332 buf.drain(..header_end + content_length);
333 if let Some(value) = parsed
334 && tx.send(value).await.is_err()
335 {
336 return;
337 }
338 }
339 }
340 }
341
342 /// Parse a JSON-RPC header block. Returns `Some((header_end, content_length))`
343 /// where `header_end` is the byte offset of the first body byte. The header
344 /// terminator is `\r\n\r\n`. We require a `Content-Length` header.
345 fn parse_header(buf: &[u8]) -> Option<(usize, usize)> {
346 let term = b"\r\n\r\n";
347 let pos = buf.windows(term.len()).position(|window| window == term)?;
348 let header = std::str::from_utf8(&buf[..pos]).ok()?;
349 let mut content_length: Option<usize> = None;
350 for line in header.split("\r\n") {
351 if let Some(rest) = line.strip_prefix("Content-Length:") {
352 content_length = rest.trim().parse::<usize>().ok();
353 }
354 }
355 content_length.map(|cl| (pos + term.len(), cl))
356 }
357
358 /// Background task that consumes inbound JSON values, classifies them as
359 /// notifications/responses, and routes accordingly.
360 async fn dispatcher_task(
361 mut rx: mpsc::Receiver<Value>,
362 tx_diag: mpsc::Sender<(PathBuf, Vec<Diagnostic>)>,
363 pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
364 ) {
365 while let Some(value) = rx.recv().await {
366 // Notifications have a `method` and no `id`.
367 let method = value.get("method").and_then(|v| v.as_str());
368 if method == Some("textDocument/publishDiagnostics") {
369 if let Some((path, diags)) = parse_publish_diagnostics(&value) {
370 let _ = tx_diag.send((path, diags)).await;
371 }
372 continue;
373 }
374 // Replies have an `id` and a `result` or `error`.
375 if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
376 let mut map = pending.lock().await;
377 if let Some(slot) = map.remove(&id) {
378 let _ = slot.send(value);
379 }
380 }
381 }
382 }
383
384 /// Decode a `textDocument/publishDiagnostics` notification.
385 fn parse_publish_diagnostics(value: &Value) -> Option<(PathBuf, Vec<Diagnostic>)> {
386 let params = value.get("params")?;
387 let uri = params.get("uri")?.as_str()?;
388 let path = path_from_uri(uri)?;
389 let raw = params.get("diagnostics")?.as_array()?;
390 let mut out = Vec::with_capacity(raw.len());
391 for d in raw {
392 let range = d.get("range")?;
393 let start = range.get("start")?;
394 let line = start.get("line")?.as_u64()? as u32 + 1;
395 let column = start.get("character")?.as_u64()? as u32 + 1;
396 let severity = Severity::from_lsp(d.get("severity").and_then(|v| v.as_i64()))
397 .unwrap_or(Severity::Error);
398 let message = d
399 .get("message")
400 .and_then(|v| v.as_str())
401 .unwrap_or("")
402 .to_string();
403 out.push(Diagnostic {
404 line,
405 column,
406 severity,
407 message,
408 });
409 }
410 Some((path, out))
411 }
412
413 /// Convert a filesystem path to a `file://` URI. Best-effort — we do not
414 /// support Windows drive letters perfectly, but the LSP servers in our
415 /// registry accept percent-encoded paths well enough for the post-edit
416 /// diagnostics use case.
417 fn uri_from_path(path: &Path) -> String {
418 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
419 let s = canonical.to_string_lossy();
420 if s.starts_with('/') {
421 format!("file://{s}")
422 } else {
423 format!("file:///{}", s.trim_start_matches('/'))
424 }
425 }
426
427 /// Inverse of [`uri_from_path`]. Returns `None` when the URI is not a `file://`.
428 fn path_from_uri(uri: &str) -> Option<PathBuf> {
429 let stripped = uri.strip_prefix("file://")?;
430 Some(PathBuf::from(stripped))
431 }
432
433 #[cfg(test)]
434 mod tests {
435 use super::*;
436
437 #[test]
438 fn parses_lsp_header() {
439 let frame = b"Content-Length: 5\r\n\r\nhello";
440 let (end, len) = parse_header(frame).expect("header parses");
441 assert_eq!(end, 21);
442 assert_eq!(len, 5);
443 }
444
445 #[test]
446 fn parse_header_returns_none_when_truncated() {
447 let frame = b"Content-Length: 5\r\nMissingTerm";
448 assert!(parse_header(frame).is_none());
449 }
450
451 #[test]
452 fn parses_publish_diagnostics_payload() {
453 let payload = json!({
454 "jsonrpc": "2.0",
455 "method": "textDocument/publishDiagnostics",
456 "params": {
457 "uri": "file:///tmp/foo.rs",
458 "diagnostics": [
459 {
460 "range": {
461 "start": { "line": 11, "character": 7 },
462 "end": { "line": 11, "character": 8 }
463 },
464 "severity": 1,
465 "message": "missing semicolon"
466 }
467 ]
468 }
469 });
470 let (path, diags) = parse_publish_diagnostics(&payload).expect("parses");
471 assert_eq!(path, PathBuf::from("/tmp/foo.rs"));
472 assert_eq!(diags.len(), 1);
473 assert_eq!(diags[0].line, 12);
474 assert_eq!(diags[0].column, 8);
475 assert_eq!(diags[0].severity, Severity::Error);
476 assert_eq!(diags[0].message, "missing semicolon");
477 }
478
479 #[test]
480 fn round_trips_uri_path() {
481 let path = PathBuf::from("/tmp/example/foo.rs");
482 let uri = format!("file://{}", path.display());
483 assert_eq!(path_from_uri(&uri), Some(path));
484 }
485 }
486
486 lines RUST