返回 CodeWhale
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::{AsyncRead, 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 crate::utils::spawn_supervised;
43
44 const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(5);
45 const MAX_LSP_HEADER_BYTES: usize = 8 * 1024;
46 const MAX_LSP_FRAME_BYTES: usize = 16 * 1024 * 1024;
47
48 /// A publication retains the server's document version instead of pretending
49 /// that a matching URI alone proves which text was checked.
50 #[derive(Debug)]
51 pub struct DiagnosticPublication {
52 pub items: Vec<Diagnostic>,
53 pub document_version: Option<i64>,
54 pub diagnostic_version: Option<i64>,
55 }
56
57 impl DiagnosticPublication {
58 #[must_use]
59 pub fn freshness(&self) -> &'static str {
60 if self.document_version.is_some() && self.document_version == self.diagnostic_version {
61 "verified"
62 } else {
63 "unverified"
64 }
65 }
66 }
67
68 // Diagnostic-only transports have no document-version proof. Existing callers
69 // may still use their results, but must not claim freshness from an empty list.
70 impl From<Vec<Diagnostic>> for DiagnosticPublication {
71 fn from(items: Vec<Diagnostic>) -> Self {
72 Self {
73 items,
74 document_version: None,
75 diagnostic_version: None,
76 }
77 }
78 }
79
80 /// Source synchronization proof for a semantic reply. Legacy transports keep
81 /// the raw result but cannot assert which document version served it.
82 #[derive(Debug)]
83 pub struct SemanticReply {
84 pub result: Value,
85 pub document_version: Option<i64>,
86 }
87
88 /// Trait the LSP manager talks to. A real LSP server speaks this via stdio;
89 /// tests use an in-process fake.
90 #[async_trait]
91 pub trait LspTransport: Send + Sync {
92 /// Notify the server that a file was opened or its contents updated, then
93 /// wait up to `wait` for a `publishDiagnostics` notification for that
94 /// file. Returns the diagnostics list (possibly empty). Implementations
95 /// must NOT block past `wait`.
96 async fn diagnostics_for(
97 &self,
98 path: &Path,
99 text: &str,
100 wait: Duration,
101 ) -> Result<DiagnosticPublication>;
102
103 /// Send a JSON-RPC request and wait up to `wait` for the reply.
104 ///
105 /// Default returns "unsupported" so diagnostic-only fakes keep working.
106 /// Real transports implement this for go-to-definition, symbols, and
107 /// references without spawning a second server lifecycle.
108 async fn request(&self, _method: &str, _params: Value, _wait: Duration) -> Result<Value> {
109 Err(anyhow!("LSP request not supported by this transport"))
110 }
111
112 /// Synchronize and query one document atomically when the transport can
113 /// prove that ordering. Diagnostic-only/legacy transports stay unverified.
114 async fn request_for_document(
115 &self,
116 path: &Path,
117 text: &str,
118 method: &str,
119 params: Value,
120 wait: Duration,
121 ) -> Result<SemanticReply> {
122 timeout(wait, async {
123 self.ensure_open(path, text).await?;
124 Ok(SemanticReply {
125 result: self.request(method, params, wait).await?,
126 document_version: None,
127 })
128 })
129 .await
130 .map_err(|_| anyhow!("LSP semantic request timed out"))?
131 }
132
133 /// Ensure `path` is open with `text` (didOpen/didChange) so position-based
134 /// requests can target it. Default is a no-op; real transports track opens.
135 async fn ensure_open(&self, _path: &Path, _text: &str) -> Result<()> {
136 Ok(())
137 }
138
139 /// A closed transport is never valid cache evidence. Diagnostic-only
140 /// in-process implementations remain usable until their owner removes them.
141 fn is_alive(&self) -> bool {
142 true
143 }
144
145 /// Best-effort shutdown. Called via `LspManager::shutdown_all`.
146 async fn shutdown(&self);
147 }
148
149 type DiagnosticMessage = (PathBuf, Option<i64>, Vec<Diagnostic>);
150
151 /// Stdio-backed transport. Spawns the LSP server as a child process and
152 /// pipes JSON-RPC over stdin/stdout. Stderr is drained without retaining or
153 /// exposing arbitrary server output.
154 pub struct StdioLspTransport {
155 /// JoinHandle for the running server. Held so the child stays alive for
156 /// the transport's lifetime; consumed during `shutdown`.
157 child: Arc<AsyncMutex<Option<Child>>>,
158 tasks: Vec<tokio::task::JoinHandle<()>>,
159 /// Outgoing message sender to the writer task.
160 tx_outbound: mpsc::Sender<Vec<u8>>,
161 /// Inbound diagnostics queue. We push every `publishDiagnostics`
162 /// notification into here and the public API drains the relevant entries.
163 diagnostics_gate: AsyncMutex<()>,
164 diagnostics_rx: AsyncMutex<mpsc::Receiver<DiagnosticMessage>>,
165 /// Map of in-flight request id -> reply slot for model-facing intelligence
166 /// requests (definition, references, symbols).
167 pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
168 /// Monotonic request id counter for JSON-RPC request/reply methods.
169 next_id: AsyncMutex<i64>,
170 /// Language id passed in `textDocument/didOpen` (e.g. "rust").
171 language_id: String,
172 /// Track which files we have opened so the second touch sends
173 /// `didChange` instead of `didOpen`.
174 opened: AsyncMutex<HashMap<PathBuf, i64>>,
175 }
176
177 impl StdioLspTransport {
178 /// Spawn `command args…` and run the LSP `initialize` handshake. Returns
179 /// `Err` immediately if the binary is not on PATH or `initialize` fails.
180 pub async fn spawn(
181 command: &str,
182 args: &[String],
183 language_id: &str,
184 workspace: PathBuf,
185 ) -> Result<Self> {
186 Self::spawn_with_timeout(command, args, language_id, workspace, INITIALIZE_TIMEOUT).await
187 }
188
189 async fn spawn_with_timeout(
190 command: &str,
191 args: &[String],
192 language_id: &str,
193 workspace: PathBuf,
194 initialize_wait: Duration,
195 ) -> Result<Self> {
196 let mut cmd = Command::new(command);
197 cmd.args(args);
198 cmd.stdin(Stdio::piped());
199 cmd.stdout(Stdio::piped());
200 cmd.stderr(Stdio::piped());
201 cmd.kill_on_drop(true);
202
203 let mut child = cmd
204 .spawn()
205 .with_context(|| format!("failed to spawn LSP server `{command}`"))?;
206
207 let stdin = child
208 .stdin
209 .take()
210 .context("LSP child has no stdin handle")?;
211 let stdout = child
212 .stdout
213 .take()
214 .context("LSP child has no stdout handle")?;
215
216 let mut stderr = child
217 .stderr
218 .take()
219 .context("LSP child has no stderr handle")?;
220 let stderr_task =
221 spawn_supervised("lsp-stderr", std::panic::Location::caller(), async move {
222 // Drain bytes, not lines: even a single unbounded log line must
223 // neither block the child nor accumulate in host memory.
224 let _ = tokio::io::copy(&mut stderr, &mut tokio::io::sink()).await;
225 });
226
227 let (tx_outbound, rx_outbound) = mpsc::channel::<Vec<u8>>(64);
228 let (tx_inbound, rx_inbound) = mpsc::channel::<Value>(64);
229 let (tx_diag, rx_diag) = mpsc::channel::<DiagnosticMessage>(64);
230
231 // Writer task: drain outbound channel, frame with Content-Length, write to stdin.
232 let writer_task = spawn_supervised(
233 "lsp-writer",
234 std::panic::Location::caller(),
235 writer_task(stdin, rx_outbound),
236 );
237 // Reader task: parse Content-Length frames from stdout, push to inbound queue.
238 let reader_task = spawn_supervised(
239 "lsp-reader",
240 std::panic::Location::caller(),
241 reader_task(stdout, tx_inbound),
242 );
243 // Inbound dispatcher: routes notifications to `tx_diag`, replies to a
244 // pending map. We keep the pending map for completeness even though
245 // diagnostics polling itself does not reuse it.
246 let pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>> =
247 Arc::new(AsyncMutex::new(HashMap::new()));
248 let child = Arc::new(AsyncMutex::new(Some(child)));
249 let dispatcher_child = child.clone();
250 let dispatcher_pending = pending.clone();
251 let dispatcher_task = spawn_supervised(
252 "lsp-dispatcher",
253 std::panic::Location::caller(),
254 async move {
255 dispatcher_task(rx_inbound, tx_diag, dispatcher_pending).await;
256 // EOF, malformed frames, or an overflowing diagnostics queue
257 // terminate the producer too, so its pipes cannot stay stuck.
258 if let Some(mut child) = dispatcher_child.lock().await.take() {
259 let _ = child.start_kill();
260 let _ = child.wait().await;
261 }
262 },
263 );
264
265 let transport = Self {
266 child,
267 tasks: vec![stderr_task, writer_task, reader_task, dispatcher_task],
268 tx_outbound,
269 diagnostics_gate: AsyncMutex::new(()),
270 diagnostics_rx: AsyncMutex::new(rx_diag),
271 pending,
272 next_id: AsyncMutex::new(1),
273 language_id: language_id.to_string(),
274 opened: AsyncMutex::new(HashMap::new()),
275 };
276 let result = transport.request("initialize", json!({
277 "processId": std::process::id(),
278 "rootUri": uri_from_path(&workspace),
279 "capabilities": {
280 "general": { "positionEncodings": ["utf-16"] },
281 "textDocument": {
282 "publishDiagnostics": { "relatedInformation": false, "versionSupport": true }
283 }
284 },
285 "workspaceFolders": [{"uri": uri_from_path(&workspace), "name": "workspace"}]
286 }), initialize_wait).await.context("LSP initialization failed")?;
287 if !result.get("capabilities").is_some_and(Value::is_object) {
288 return Err(anyhow!(
289 "LSP initialize response is missing server capabilities"
290 ));
291 }
292 if result
293 .pointer("/capabilities/positionEncoding")
294 .is_some_and(|encoding| encoding.as_str() != Some("utf-16"))
295 {
296 return Err(anyhow!("LSP server must use UTF-16 positions"));
297 }
298 timeout(
299 initialize_wait,
300 send_message(
301 &transport.tx_outbound,
302 &json!({
303 "jsonrpc": "2.0", "method": "initialized", "params": {}
304 }),
305 ),
306 )
307 .await
308 .map_err(|_| anyhow!("LSP initialized notification timed out"))??;
309 Ok(transport)
310 }
311 }
312
313 impl Drop for StdioLspTransport {
314 fn drop(&mut self) {
315 for task in &self.tasks {
316 task.abort();
317 }
318 if let Ok(mut child) = self.child.try_lock()
319 && let Some(child) = child.as_mut()
320 {
321 let _ = child.start_kill();
322 }
323 // If shutdown/dispatcher currently owns the child, abort releases
324 // its local Child and kill_on_drop remains the final fallback.
325 }
326 }
327
328 impl StdioLspTransport {
329 async fn open_or_change(&self, path: &Path, text: &str) -> Result<(String, i64)> {
330 let path_buf = path.to_path_buf();
331 let uri = uri_from_path(&path_buf);
332 let mut opened = self.opened.lock().await;
333 let is_new = !opened.contains_key(&path_buf);
334 let new_version = opened.get(&path_buf).copied().unwrap_or(0) + 1;
335
336 let payload = if is_new {
337 json!({
338 "jsonrpc": "2.0",
339 "method": "textDocument/didOpen",
340 "params": {
341 "textDocument": {
342 "uri": uri.clone(),
343 "languageId": self.language_id,
344 "version": new_version,
345 "text": text
346 }
347 }
348 })
349 } else {
350 json!({
351 "jsonrpc": "2.0",
352 "method": "textDocument/didChange",
353 "params": {
354 "textDocument": {
355 "uri": uri.clone(),
356 "version": new_version
357 },
358 "contentChanges": [{ "text": text }]
359 }
360 })
361 };
362 send_message(&self.tx_outbound, &payload).await?;
363 opened.insert(path_buf, new_version);
364 Ok((uri, new_version))
365 }
366 }
367
368 #[async_trait]
369 impl LspTransport for StdioLspTransport {
370 fn is_alive(&self) -> bool {
371 // stderr may close independently. The writer, reader and dispatcher
372 // are the protocol lifetime; none may have exited or been aborted.
373 !self.tx_outbound.is_closed() && self.tasks.iter().skip(1).all(|task| !task.is_finished())
374 }
375
376 async fn diagnostics_for(
377 &self,
378 path: &Path,
379 text: &str,
380 wait: Duration,
381 ) -> Result<DiagnosticPublication> {
382 // One receiver cannot serve concurrent polling safely: serialize the
383 // open/version/send/wait transaction, including semantic ensure_open.
384 let deadline = tokio::time::Instant::now() + wait;
385 let _gate = timeout(wait, self.diagnostics_gate.lock())
386 .await
387 .map_err(|_| anyhow!("LSP diagnostics timed out waiting for another document"))?;
388 let path_buf = path.to_path_buf();
389 let (_, version) = timeout(
390 deadline.saturating_duration_since(tokio::time::Instant::now()),
391 self.open_or_change(path, text),
392 )
393 .await
394 .map_err(|_| anyhow!("LSP diagnostics timed out sending document"))??;
395 loop {
396 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
397 if remaining.is_zero() {
398 return Err(anyhow!(
399 "LSP diagnostics timed out before a current publication"
400 ));
401 }
402 let mut rx = self.diagnostics_rx.lock().await;
403 let (file, published_version, items) = match timeout(remaining, rx.recv()).await {
404 Ok(Some(item)) => item,
405 Ok(None) => {
406 return Err(anyhow!(
407 "LSP diagnostics channel closed before publishDiagnostics"
408 ));
409 }
410 Err(_) => {
411 return Err(anyhow!(
412 "LSP diagnostics timed out before a current publication"
413 ));
414 }
415 };
416 if file != path_buf || published_version.is_some_and(|published| published != version) {
417 continue;
418 }
419 return Ok(DiagnosticPublication {
420 items,
421 document_version: Some(version),
422 diagnostic_version: published_version,
423 });
424 }
425 }
426
427 async fn request_for_document(
428 &self,
429 path: &Path,
430 text: &str,
431 method: &str,
432 params: Value,
433 wait: Duration,
434 ) -> Result<SemanticReply> {
435 let deadline = tokio::time::Instant::now() + wait;
436 let _gate = timeout(wait, self.diagnostics_gate.lock())
437 .await
438 .map_err(|_| anyhow!("LSP semantic request timed out waiting for another document"))?;
439 let (_, version) = timeout(
440 deadline.saturating_duration_since(tokio::time::Instant::now()),
441 self.open_or_change(path, text),
442 )
443 .await
444 .map_err(|_| anyhow!("LSP semantic request timed out sending document"))??;
445 let result = self
446 .request(
447 method,
448 params,
449 deadline.saturating_duration_since(tokio::time::Instant::now()),
450 )
451 .await?;
452 Ok(SemanticReply {
453 result,
454 document_version: Some(version),
455 })
456 }
457
458 async fn ensure_open(&self, path: &Path, text: &str) -> Result<()> {
459 let _gate = self.diagnostics_gate.lock().await;
460 self.open_or_change(path, text).await?;
461 Ok(())
462 }
463
464 async fn request(&self, method: &str, params: Value, wait: Duration) -> Result<Value> {
465 let id = {
466 let mut next = self.next_id.lock().await;
467 let id = *next;
468 *next = next.saturating_add(1);
469 id
470 };
471 let (tx, rx) = oneshot::channel();
472 {
473 let mut pending = self.pending.lock().await;
474 pending.insert(id, tx);
475 }
476 let payload = json!({
477 "jsonrpc": "2.0",
478 "id": id,
479 "method": method,
480 "params": params,
481 });
482 // The deadline includes queue backpressure, not just the reply.
483 let response = timeout(wait, async {
484 send_message(&self.tx_outbound, &payload).await?;
485 rx.await.map_err(|_| anyhow!("LSP request channel closed"))
486 })
487 .await;
488 self.pending.lock().await.remove(&id);
489 match response {
490 Ok(Ok(reply)) => {
491 if let Some(error) = reply.get("error") {
492 let message = error
493 .get("message")
494 .and_then(Value::as_str)
495 .unwrap_or("LSP request failed");
496 return Err(anyhow!("{message}"));
497 }
498 reply
499 .get("result")
500 .cloned()
501 .ok_or_else(|| anyhow!("LSP response has no result"))
502 }
503 Ok(Err(error)) => Err(error),
504 Err(_) => Err(anyhow!("LSP request timed out for {method}")),
505 }
506 }
507
508 async fn shutdown(&self) {
509 let mut child = self.child.lock().await;
510 if let Some(mut c) = child.take() {
511 let _ = c.start_kill();
512 let _ = c.wait().await;
513 }
514 for task in &self.tasks {
515 task.abort();
516 }
517 self.pending.lock().await.clear();
518 }
519 }
520
521 /// Send a JSON value as one Content-Length-framed JSON-RPC message.
522 async fn send_message(tx: &mpsc::Sender<Vec<u8>>, value: &Value) -> Result<()> {
523 let body = serde_json::to_vec(value).context("serialize LSP message")?;
524 let header = format!("Content-Length: {}\r\n\r\n", body.len());
525 let mut frame = Vec::with_capacity(header.len() + body.len());
526 frame.extend_from_slice(header.as_bytes());
527 frame.extend_from_slice(&body);
528 tx.send(frame)
529 .await
530 .map_err(|_| anyhow!("LSP outbound channel closed"))?;
531 Ok(())
532 }
533
534 /// Background task that drains the outbound queue and writes each frame to
535 /// the LSP server's stdin. Exits cleanly when the channel closes.
536 async fn writer_task(mut stdin: tokio::process::ChildStdin, mut rx: mpsc::Receiver<Vec<u8>>) {
537 while let Some(frame) = rx.recv().await {
538 if stdin.write_all(&frame).await.is_err() {
539 break;
540 }
541 if stdin.flush().await.is_err() {
542 break;
543 }
544 }
545 }
546
547 /// Background task that parses `Content-Length`-framed JSON-RPC frames from
548 /// the LSP server's stdout. Pushes each parsed JSON value to `tx`. Exits
549 /// when stdout closes or a frame is malformed (we choose to fail closed
550 /// rather than risk hanging).
551 async fn reader_task(mut stdout: impl AsyncRead + Unpin, tx: mpsc::Sender<Value>) {
552 let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
553 let mut tmp = [0u8; 4096];
554 loop {
555 let n = match stdout.read(&mut tmp).await {
556 Ok(0) => return,
557 Ok(n) => n,
558 Err(_) => return,
559 };
560 buf.extend_from_slice(&tmp[..n]);
561 loop {
562 let (header_end, content_length) = match parse_header(&buf) {
563 Ok(Some(frame)) => frame,
564 Ok(None) => break,
565 Err(_) => return,
566 };
567 // Both operands are bounded by parse_header.
568 let frame_end = header_end + content_length;
569 if buf.len() < frame_end {
570 break;
571 }
572 let value = match serde_json::from_slice::<Value>(&buf[header_end..frame_end]) {
573 Ok(value) => value,
574 Err(_) => return,
575 };
576 buf.drain(..frame_end);
577 if tx.send(value).await.is_err() {
578 return;
579 }
580 }
581 }
582 }
583
584 /// Distinguish incomplete headers from malformed or oversized frames so a
585 /// broken server cannot cause an indefinitely growing input buffer.
586 fn parse_header(buf: &[u8]) -> Result<Option<(usize, usize)>> {
587 let Some(pos) = buf.windows(4).position(|window| window == b"\r\n\r\n") else {
588 if buf.len() > MAX_LSP_HEADER_BYTES {
589 return Err(anyhow!("LSP header exceeds size limit"));
590 }
591 return Ok(None);
592 };
593 if pos + 4 > MAX_LSP_HEADER_BYTES {
594 return Err(anyhow!("LSP header exceeds size limit"));
595 }
596 let header = std::str::from_utf8(&buf[..pos]).context("invalid LSP header encoding")?;
597 let mut content_length = None;
598 for line in header.split("\r\n") {
599 let (name, value) = line.split_once(':').context("malformed LSP header")?;
600 if name.eq_ignore_ascii_case("Content-Length") {
601 if content_length.is_some() {
602 return Err(anyhow!("duplicate LSP Content-Length"));
603 }
604 let length = value
605 .trim()
606 .parse::<usize>()
607 .context("invalid LSP Content-Length")?;
608 if length == 0 || length > MAX_LSP_FRAME_BYTES {
609 return Err(anyhow!("LSP frame exceeds size limit or is empty"));
610 }
611 content_length = Some(length);
612 }
613 }
614 Ok(Some((
615 pos + 4,
616 content_length.context("missing LSP Content-Length")?,
617 )))
618 }
619
620 /// Background task that consumes inbound JSON values, classifies them as
621 /// notifications/responses, and routes accordingly.
622 async fn dispatcher_task(
623 mut rx: mpsc::Receiver<Value>,
624 tx_diag: mpsc::Sender<DiagnosticMessage>,
625 pending: Arc<AsyncMutex<HashMap<i64, oneshot::Sender<Value>>>>,
626 ) {
627 while let Some(value) = rx.recv().await {
628 // Notifications have a `method` and no `id`.
629 let method = value.get("method").and_then(|v| v.as_str());
630 if method == Some("textDocument/publishDiagnostics") {
631 if let Some(publication) = parse_publish_diagnostics(&value) {
632 // Do not let an unconsumed diagnostics burst prevent reply
633 // delivery or EOF cleanup. Overflow closes this transport's
634 // dispatcher, giving callers an explicit channel error.
635 if tx_diag.try_send(publication).is_err() {
636 break;
637 }
638 }
639 continue;
640 }
641 // Replies have an `id` and a `result` or `error`.
642 if let Some(id) = value.get("id").and_then(|v| v.as_i64()) {
643 let mut map = pending.lock().await;
644 if let Some(slot) = map.remove(&id) {
645 let _ = slot.send(value);
646 }
647 }
648 }
649 // Reader EOF/malformed frames and queue overflow wake every pending
650 // request immediately instead of leaving reply slots until their timeout.
651 pending.lock().await.clear();
652 }
653
654 /// Decode a `textDocument/publishDiagnostics` notification.
655 fn parse_publish_diagnostics(value: &Value) -> Option<DiagnosticMessage> {
656 let params = value.get("params")?;
657 let uri = params.get("uri")?.as_str()?;
658 let path = path_from_uri(uri)?;
659 let version = match params.get("version") {
660 None | Some(Value::Null) => None,
661 Some(value) => Some(value.as_i64()?),
662 };
663 let raw = params.get("diagnostics")?.as_array()?;
664 let mut out = Vec::with_capacity(raw.len());
665 for d in raw {
666 let range = d.get("range")?;
667 let start = range.get("start")?;
668 let line = start.get("line")?.as_u64()? as u32 + 1;
669 let column = start.get("character")?.as_u64()? as u32 + 1;
670 let severity = Severity::from_lsp(d.get("severity").and_then(|v| v.as_i64()))
671 .unwrap_or(Severity::Error);
672 let message = d
673 .get("message")
674 .and_then(|v| v.as_str())
675 .unwrap_or("")
676 .to_string();
677 out.push(Diagnostic {
678 line,
679 column,
680 severity,
681 message,
682 });
683 }
684 Some((path, version, out))
685 }
686
687 /// Encode an absolute filesystem path without following its links.
688 pub(crate) fn uri_from_path(path: &Path) -> String {
689 reqwest::Url::from_file_path(path)
690 .map(|url| url.to_string())
691 .unwrap_or_default()
692 }
693
694 /// File URLs only: no network authority, query or fragment. Decoding happens
695 /// before the workspace no-follow opener validates the target path.
696 pub(super) fn path_from_uri(uri: &str) -> Option<PathBuf> {
697 let url = reqwest::Url::parse(uri).ok()?;
698 if url.scheme() != "file"
699 || url.host_str().is_some_and(|host| !host.is_empty())
700 || !url.username().is_empty()
701 || url.password().is_some()
702 || url.port().is_some()
703 || url.query().is_some()
704 || url.fragment().is_some()
705 {
706 return None;
707 }
708 url.to_file_path().ok()
709 }
710
711 #[cfg(test)]
712 pub(super) mod tests {
713 use super::*;
714
715 fn fixture_path(name: &str) -> PathBuf {
716 std::env::temp_dir()
717 .join("codewhale-lsp-fixture")
718 .join(name)
719 }
720
721 #[test]
722 fn parses_lsp_header() {
723 let frame = b"Content-Length: 5\r\n\r\nhello";
724 let (end, len) = parse_header(frame)
725 .expect("valid header")
726 .expect("header parses");
727 assert_eq!(end, 21);
728 assert_eq!(len, 5);
729 }
730
731 #[test]
732 fn parse_header_returns_none_when_truncated() {
733 let frame = b"Content-Length: 5\r\nMissingTerm";
734 assert!(parse_header(frame).unwrap().is_none());
735 }
736
737 #[test]
738 fn parses_publish_diagnostics_payload() {
739 let payload = json!({
740 "jsonrpc": "2.0",
741 "method": "textDocument/publishDiagnostics",
742 "params": {
743 "uri": uri_from_path(&fixture_path("foo.rs")),
744 "diagnostics": [
745 {
746 "range": {
747 "start": { "line": 11, "character": 7 },
748 "end": { "line": 11, "character": 8 }
749 },
750 "severity": 1,
751 "message": "missing semicolon"
752 }
753 ]
754 }
755 });
756 let (path, version, diags) = parse_publish_diagnostics(&payload).expect("parses");
757 assert_eq!(path, fixture_path("foo.rs"));
758 assert_eq!(version, None);
759 assert_eq!(diags.len(), 1);
760 assert_eq!(diags[0].line, 12);
761 assert_eq!(diags[0].column, 8);
762 assert_eq!(diags[0].severity, Severity::Error);
763 assert_eq!(diags[0].message, "missing semicolon");
764 }
765
766 #[test]
767 fn round_trips_uri_path() {
768 let path = fixture_path("example/foo.rs");
769 let uri = uri_from_path(&path);
770 assert_eq!(path_from_uri(&uri), Some(path));
771 }
772
773 #[tokio::test]
774 async fn closed_diagnostics_channel_is_an_error_not_an_empty_result() {
775 let (tx_outbound, _rx_outbound) = mpsc::channel(1);
776 let (tx_diag, rx_diag) = mpsc::channel(1);
777 drop(tx_diag);
778 let transport = StdioLspTransport {
779 child: Arc::new(AsyncMutex::new(None)),
780 tasks: Vec::new(),
781 tx_outbound,
782 diagnostics_gate: AsyncMutex::new(()),
783 diagnostics_rx: AsyncMutex::new(rx_diag),
784 pending: Arc::new(AsyncMutex::new(HashMap::new())),
785 next_id: AsyncMutex::new(1),
786 language_id: "rust".to_string(),
787 opened: AsyncMutex::new(HashMap::new()),
788 };
789
790 let error = transport
791 .diagnostics_for(
792 &fixture_path("closed-channel.rs"),
793 "fn main() {}\n",
794 Duration::from_millis(10),
795 )
796 .await
797 .expect_err("a closed transport must not look like an empty lint result");
798
799 assert!(
800 error.to_string().contains("diagnostics channel closed"),
801 "unexpected error: {error}"
802 );
803 }
804
805 fn diagnostic_fixture() -> (
806 StdioLspTransport,
807 mpsc::Receiver<Vec<u8>>,
808 mpsc::Sender<DiagnosticMessage>,
809 ) {
810 let (tx_outbound, rx_outbound) = mpsc::channel(8);
811 let (tx_diag, rx_diag) = mpsc::channel(8);
812 (
813 StdioLspTransport {
814 child: Arc::new(AsyncMutex::new(None)),
815 tasks: Vec::new(),
816 tx_outbound,
817 diagnostics_gate: AsyncMutex::new(()),
818 diagnostics_rx: AsyncMutex::new(rx_diag),
819 pending: Arc::new(AsyncMutex::new(HashMap::new())),
820 next_id: AsyncMutex::new(1),
821 language_id: "rust".into(),
822 opened: AsyncMutex::new(HashMap::new()),
823 },
824 rx_outbound,
825 tx_diag,
826 )
827 }
828
829 async fn next_document(rx: &mut mpsc::Receiver<Vec<u8>>) -> Value {
830 let frame = rx.recv().await.unwrap();
831 let (start, _) = parse_header(&frame).unwrap().unwrap();
832 serde_json::from_slice::<Value>(&frame[start..]).unwrap()
833 }
834
835 fn diagnostic(line: u32) -> Diagnostic {
836 Diagnostic {
837 line,
838 column: 1,
839 severity: Severity::Error,
840 message: "fixture".into(),
841 }
842 }
843
844 #[tokio::test]
845 async fn diagnostic_freshness_rejects_old_version_and_accepts_current_empty_publication() {
846 let (transport, mut outbound, diag) = diagnostic_fixture();
847 let server = tokio::spawn(async move {
848 let first = next_document(&mut outbound).await;
849 assert_eq!(first["method"], "textDocument/didOpen");
850 assert_eq!(first["params"]["textDocument"]["version"], 1);
851 let path =
852 path_from_uri(first["params"]["textDocument"]["uri"].as_str().unwrap()).unwrap();
853 diag.send((path.clone(), Some(1), vec![diagnostic(1)]))
854 .await
855 .unwrap();
856 let second = next_document(&mut outbound).await;
857 assert_eq!(second["method"], "textDocument/didChange");
858 assert_eq!(second["params"]["textDocument"]["version"], 2);
859 assert_eq!(second["params"]["contentChanges"][0]["text"], "new 🐋 text");
860 diag.send((path.clone(), Some(1), vec![diagnostic(99)]))
861 .await
862 .unwrap();
863 diag.send((path, Some(2), vec![])).await.unwrap();
864 });
865 let path = &fixture_path("freshness.rs");
866 let first = transport
867 .diagnostics_for(path, "old text", Duration::from_secs(1))
868 .await
869 .unwrap();
870 assert_eq!(first.freshness(), "verified");
871 assert_eq!(first.items[0].line, 1);
872 let second = transport
873 .diagnostics_for(path, "new 🐋 text", Duration::from_secs(1))
874 .await
875 .unwrap();
876 assert_eq!(second.freshness(), "verified");
877 assert_eq!(second.document_version, Some(2));
878 assert_eq!(second.diagnostic_version, Some(2));
879 assert!(
880 second.items.is_empty(),
881 "old error must not apply to the new text"
882 );
883 server.await.unwrap();
884 }
885
886 #[tokio::test]
887 async fn diagnostic_freshness_serializes_concurrent_file_requests() {
888 let (transport, mut outbound, diag) = diagnostic_fixture();
889 let server = tokio::spawn(async move {
890 for _ in 0..2 {
891 let request = next_document(&mut outbound).await;
892 assert!(
893 matches!(outbound.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
894 "second file must not advance before this publication"
895 );
896 let path =
897 path_from_uri(request["params"]["textDocument"]["uri"].as_str().unwrap())
898 .unwrap();
899 let line = if path.ends_with("one.rs") { 1 } else { 2 };
900 diag.send((fixture_path("unrelated.rs"), Some(1), vec![diagnostic(99)]))
901 .await
902 .unwrap();
903 diag.send((path, Some(1), vec![diagnostic(line)]))
904 .await
905 .unwrap();
906 }
907 });
908 let one_path = fixture_path("one.rs");
909 let two_path = fixture_path("two.rs");
910 let (one, two) = tokio::join!(
911 transport.diagnostics_for(&one_path, "one", Duration::from_secs(1)),
912 transport.diagnostics_for(&two_path, "two", Duration::from_secs(1)),
913 );
914 assert_eq!(one.unwrap().items[0].line, 1);
915 assert_eq!(two.unwrap().items[0].line, 2);
916 server.await.unwrap();
917 }
918
919 #[tokio::test]
920 async fn diagnostic_freshness_unversioned_is_unverified_and_silence_is_error() {
921 let (transport, mut outbound, diag) = diagnostic_fixture();
922 let server = tokio::spawn(async move {
923 let request = next_document(&mut outbound).await;
924 let path =
925 path_from_uri(request["params"]["textDocument"]["uri"].as_str().unwrap()).unwrap();
926 diag.send((path, None, vec![])).await.unwrap();
927 // Keep the channels alive while the second request times out.
928 let _ = next_document(&mut outbound).await;
929 tokio::time::sleep(Duration::from_millis(100)).await;
930 });
931 let response = transport
932 .diagnostics_for(
933 &fixture_path("unversioned.rs"),
934 "text",
935 Duration::from_secs(1),
936 )
937 .await
938 .unwrap();
939 assert_eq!(response.freshness(), "unverified");
940 assert_eq!(response.diagnostic_version, None);
941 assert!(
942 transport
943 .diagnostics_for(
944 &fixture_path("silent.rs"),
945 "text",
946 Duration::from_millis(10)
947 )
948 .await
949 .unwrap_err()
950 .to_string()
951 .contains("timed out")
952 );
953 server.abort();
954 }
955
956 #[test]
957 fn diagnostic_freshness_parser_retains_version_and_rejects_malformed_version() {
958 let mut payload = json!({"params":{"uri":uri_from_path(&fixture_path("version.rs")),"version":4,"diagnostics":[]}});
959 assert_eq!(parse_publish_diagnostics(&payload).unwrap().1, Some(4));
960 payload["params"]["version"] = json!("4");
961 assert!(parse_publish_diagnostics(&payload).is_none());
962 }
963 #[cfg(unix)]
964 pub(crate) const STDIO_FIXTURE: &str = r#"
965 import json, os, select, sys, time
966 mode, pid_path = sys.argv[1:]
967 input_stream = os.fdopen(0, 'rb', buffering=0)
968 with open(pid_path, 'a' if mode == 'cache' else 'w') as f:
969 f.write(str(os.getpid()) + ('\n' if mode == 'cache' else ''))
970 def read():
971 headers = {}
972 while True:
973 line = input_stream.readline()
974 if not line:
975 raise SystemExit(0)
976 if line == b'\r\n':
977 break
978 key, value = line.decode().split(':', 1)
979 headers[key.lower()] = value.strip()
980 body = b''
981 length = int(headers['content-length'])
982 while len(body) < length:
983 chunk = input_stream.read(length - len(body))
984 if not chunk:
985 raise SystemExit(0)
986 body += chunk
987 return json.loads(body)
988 def send(value):
989 body = json.dumps(value).encode()
990 sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n' % len(body)).encode() + body)
991 sys.stdout.buffer.flush()
992 request = read()
993 assert request['method'] == 'initialize'
994 if mode == 'eof':
995 raise SystemExit(0)
996 if mode == 'silence':
997 time.sleep(60)
998 if mode == 'error':
999 send({'jsonrpc':'2.0','id':request['id'],'error':{'code':-32002,'message':'fixture rejected initialization'}})
1000 time.sleep(60)
1001 if mode == 'delayed' and select.select([input_stream], [], [], 0.1)[0]:
1002 send({'jsonrpc':'2.0','id':request['id'],'error':{'code':-32002,'message':'notification arrived before initialize reply'}})
1003 raise SystemExit(1)
1004 if mode == 'stderr':
1005 for _ in range(64):
1006 os.write(2, b'x' * 32768)
1007 send({'jsonrpc':'2.0','id':request['id'],'result':{'capabilities':{}}})
1008 assert read()['method'] == 'initialized'
1009 while True:
1010 request = read()
1011 if request.get('method') == 'fixture/exit':
1012 raise SystemExit(0)
1013 if request.get('method') == 'fixture/overflow':
1014 for _ in range(80):
1015 send({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':'file:///tmp/overflow.rs','version':1,'diagnostics':[]}})
1016 time.sleep(60)
1017 elif 'id' in request:
1018 send({'jsonrpc':'2.0','id':request['id'],'result':{'ready':True}})
1019 "#;
1020
1021 #[cfg(unix)]
1022 async fn spawn_stdio_fixture(
1023 mode: &str,
1024 root: &Path,
1025 wait: Duration,
1026 ) -> Result<StdioLspTransport> {
1027 StdioLspTransport::spawn_with_timeout(
1028 "python3",
1029 &[
1030 "-u".into(),
1031 "-c".into(),
1032 STDIO_FIXTURE.into(),
1033 mode.into(),
1034 root.join("pid").to_string_lossy().into_owned(),
1035 ],
1036 "rust",
1037 root.to_path_buf(),
1038 wait,
1039 )
1040 .await
1041 }
1042
1043 #[cfg(unix)]
1044 async fn assert_fixture_exited(root: &Path) {
1045 let pid = std::fs::read_to_string(root.join("pid"))
1046 .expect("fixture started")
1047 .parse::<i32>()
1048 .unwrap();
1049 for _ in 0..100 {
1050 // Signal zero probes only this fixture PID; it never sends a signal.
1051 if unsafe { libc::kill(pid, 0) } == -1 {
1052 assert_eq!(
1053 std::io::Error::last_os_error().raw_os_error(),
1054 Some(libc::ESRCH)
1055 );
1056 return;
1057 }
1058 tokio::time::sleep(Duration::from_millis(10)).await;
1059 }
1060 panic!("fixture child remained alive after transport termination");
1061 }
1062
1063 #[cfg(unix)]
1064 #[tokio::test]
1065 async fn stdio_startup_waits_for_initialize_and_drains_stderr_pressure() {
1066 for mode in ["delayed", "stderr"] {
1067 let root = tempfile::tempdir().unwrap();
1068 let transport = spawn_stdio_fixture(mode, root.path(), Duration::from_secs(3))
1069 .await
1070 .unwrap();
1071 let result = transport
1072 .request("fixture/ready", json!({}), Duration::from_secs(1))
1073 .await
1074 .unwrap();
1075 assert_eq!(result["ready"], true, "{mode}");
1076 transport.shutdown().await;
1077 assert_fixture_exited(root.path()).await;
1078 }
1079 }
1080
1081 #[cfg(unix)]
1082 #[tokio::test]
1083 async fn stdio_startup_error_eof_and_silence_fail_and_terminate_child() {
1084 for (mode, expected) in [
1085 ("error", "fixture rejected initialization"),
1086 ("eof", "channel closed"),
1087 ("silence", "timed out"),
1088 ] {
1089 let root = tempfile::tempdir().unwrap();
1090 let error = match timeout(
1091 Duration::from_secs(3),
1092 spawn_stdio_fixture(mode, root.path(), Duration::from_millis(500)),
1093 )
1094 .await
1095 .unwrap()
1096 {
1097 Ok(_) => panic!("{mode} unexpectedly initialized"),
1098 Err(error) => error,
1099 };
1100 assert!(format!("{error:#}").contains(expected), "{mode}: {error:#}");
1101 assert_fixture_exited(root.path()).await;
1102 }
1103 }
1104
1105 #[cfg(unix)]
1106 #[tokio::test]
1107 async fn stdio_diagnostic_overflow_fails_pending_request_and_terminates_child() {
1108 let root = tempfile::tempdir().unwrap();
1109 let transport = spawn_stdio_fixture("ready", root.path(), Duration::from_secs(3))
1110 .await
1111 .unwrap();
1112 let error = transport
1113 .request("fixture/overflow", json!({}), Duration::from_secs(2))
1114 .await
1115 .unwrap_err();
1116 assert!(error.to_string().contains("channel closed"), "{error:#}");
1117 assert!(transport.pending.lock().await.is_empty());
1118 assert_fixture_exited(root.path()).await;
1119 }
1120
1121 #[tokio::test]
1122 async fn semantic_request_holds_document_gate_until_reply_before_diagnostics() {
1123 timeout(Duration::from_secs(2), async {
1124 let (transport, mut outbound, diag) = diagnostic_fixture();
1125 let transport = Arc::new(transport);
1126 let semantic = {
1127 let transport = transport.clone();
1128 tokio::spawn(async move {
1129 transport
1130 .request_for_document(
1131 &fixture_path("semantic.rs"),
1132 "before",
1133 "textDocument/definition",
1134 json!({}),
1135 Duration::from_secs(1),
1136 )
1137 .await
1138 })
1139 };
1140 let open = next_document(&mut outbound).await;
1141 assert_eq!(open["method"], "textDocument/didOpen");
1142 let request = next_document(&mut outbound).await;
1143 assert_eq!(request["method"], "textDocument/definition");
1144 let diagnostics = {
1145 let transport = transport.clone();
1146 tokio::spawn(async move {
1147 transport
1148 .diagnostics_for(
1149 &fixture_path("semantic.rs"),
1150 "after",
1151 Duration::from_secs(1),
1152 )
1153 .await
1154 })
1155 };
1156 assert!(
1157 timeout(Duration::from_millis(20), outbound.recv())
1158 .await
1159 .is_err()
1160 );
1161 transport
1162 .pending
1163 .lock()
1164 .await
1165 .remove(&request["id"].as_i64().unwrap())
1166 .unwrap()
1167 .send(json!({"result":[]}))
1168 .unwrap();
1169 assert_eq!(semantic.await.unwrap().unwrap().document_version, Some(1));
1170 let change = next_document(&mut outbound).await;
1171 assert_eq!(change["params"]["textDocument"]["version"], 2);
1172 diag.send((fixture_path("semantic.rs"), Some(2), vec![]))
1173 .await
1174 .unwrap();
1175 assert_eq!(
1176 diagnostics.await.unwrap().unwrap().document_version,
1177 Some(2)
1178 );
1179 })
1180 .await
1181 .unwrap();
1182 }
1183
1184 #[tokio::test]
1185 async fn semantic_deadline_includes_gate_wait_and_cleans_pending_request() {
1186 let (transport, _outbound, _diag) = diagnostic_fixture();
1187 let gate = transport.diagnostics_gate.lock().await;
1188 let result = timeout(
1189 Duration::from_millis(250),
1190 transport.request_for_document(
1191 &fixture_path("a.rs"),
1192 "a",
1193 "textDocument/definition",
1194 json!({}),
1195 Duration::from_millis(20),
1196 ),
1197 )
1198 .await
1199 .unwrap();
1200 assert!(result.unwrap_err().to_string().contains("timed out"));
1201 drop(gate);
1202 assert!(transport.pending.lock().await.is_empty());
1203 let result = transport
1204 .request_for_document(
1205 &fixture_path("a.rs"),
1206 "a",
1207 "textDocument/definition",
1208 json!({}),
1209 Duration::from_millis(20),
1210 )
1211 .await;
1212 assert!(result.unwrap_err().to_string().contains("timed out"));
1213 assert!(transport.pending.lock().await.is_empty());
1214 }
1215
1216 #[test]
1217 fn semantic_file_uris_decode_spaces_and_reject_authority_and_query() {
1218 let path = &fixture_path("a b#🐋.rs");
1219 assert_eq!(
1220 path_from_uri(&uri_from_path(path)).as_deref(),
1221 Some(path.as_path())
1222 );
1223 for uri in [
1224 "https://example.test/a.rs",
1225 "file://remote/tmp/a.rs",
1226 "file:///tmp/a.rs?q=1",
1227 "file:///tmp/a.rs#fragment",
1228 ] {
1229 assert!(path_from_uri(uri).is_none(), "{uri}");
1230 }
1231 }
1232
1233 #[tokio::test]
1234 async fn stdio_request_deadline_includes_full_outbound_queue() {
1235 let (transport, _outbound, _diag) = diagnostic_fixture();
1236 for _ in 0..8 {
1237 transport.tx_outbound.try_send(vec![]).unwrap();
1238 }
1239 let error = timeout(
1240 Duration::from_millis(250),
1241 transport.request("fixture/blocked", json!({}), Duration::from_millis(20)),
1242 )
1243 .await
1244 .unwrap()
1245 .unwrap_err();
1246 assert!(error.to_string().contains("timed out"));
1247 assert!(transport.pending.lock().await.is_empty());
1248 }
1249
1250 #[tokio::test]
1251 async fn stdio_reader_bounds_and_malformed_frames_close_pending_replies() {
1252 let frames = [
1253 vec![b'x'; MAX_LSP_HEADER_BYTES + 1],
1254 format!("Content-Length: {}\r\n\r\n", MAX_LSP_FRAME_BYTES + 1).into_bytes(),
1255 b"Content-Length: 999999999999999999999999999999999\r\n\r\n".to_vec(),
1256 b"Content-Length: 1\r\nContent-Length: 1\r\n\r\nx".to_vec(),
1257 b"Missing-Length: 1\r\n\r\nx".to_vec(),
1258 b"Content-Length: 1\r\n\r\n{".to_vec(),
1259 ];
1260 for frame in frames {
1261 let (mut producer, reader) = tokio::io::duplex(MAX_LSP_HEADER_BYTES * 2);
1262 let (tx, rx) = mpsc::channel(8);
1263 let (diag, _diagnostics) = mpsc::channel(8);
1264 let pending = Arc::new(AsyncMutex::new(HashMap::new()));
1265 let (reply, receiver) = oneshot::channel();
1266 pending.lock().await.insert(1, reply);
1267 let read_task = tokio::spawn(reader_task(reader, tx));
1268 let dispatch = tokio::spawn(dispatcher_task(rx, diag, pending.clone()));
1269 producer.write_all(&frame).await.unwrap();
1270 assert!(
1271 timeout(Duration::from_secs(1), receiver)
1272 .await
1273 .unwrap()
1274 .is_err()
1275 );
1276 read_task.await.unwrap();
1277 dispatch.await.unwrap();
1278 assert!(pending.lock().await.is_empty());
1279 }
1280 }
1281
1282 #[tokio::test]
1283 async fn stdio_reader_preserves_fragmented_and_coalesced_valid_frames() {
1284 let (mut producer, reader) = tokio::io::duplex(128);
1285 let (tx, mut rx) = mpsc::channel(8);
1286 let task = tokio::spawn(reader_task(reader, tx));
1287 producer.write_all(b"content-length: 2\r\n").await.unwrap();
1288 producer
1289 .write_all(b"\r\n{}Content-Length: 2\r\n\r\n[]")
1290 .await
1291 .unwrap();
1292 assert_eq!(
1293 timeout(Duration::from_secs(1), rx.recv()).await.unwrap(),
1294 Some(json!({}))
1295 );
1296 assert_eq!(
1297 timeout(Duration::from_secs(1), rx.recv()).await.unwrap(),
1298 Some(json!([]))
1299 );
1300 drop(producer);
1301 task.await.unwrap();
1302 assert!(rx.recv().await.is_none());
1303 }
1304 }
1305
1305 lines RUST