Skip to main content

rust_meth/
lsp.rs

1// Minimal LSP transport: write Content-Length-framed JSON over stdin,
2// read framed responses from stdout.  No async, no external LSP crate
3// just `BufReader` + `serde_json`.
4
5use std::io::{BufRead, BufReader, Read, Write};
6use std::process::{Child, ChildStdin, ChildStdout};
7
8use serde_json::{Value, json};
9
10/// A synchronous transport layer for communicating with a Language Server Protocol (LSP) server.
11pub struct LspTransport {
12    /// The standard input stream used to send messages to the LSP server child process.
13    stdin: ChildStdin,
14    reader: BufReader<ChildStdout>,
15}
16
17impl LspTransport {
18    /// Creates a new `LspTransport` by taking ownership of the `stdin` and `stdout`
19    /// streams from the provided child process.
20    ///
21    /// # Panics
22    ///
23    /// Panics if the child process does not have a captured `stdin` or `stdout` stream
24    /// (e.g., if they weren't configured with `Stdio::piped()`).
25    pub fn new(child: &mut Child) -> Self {
26        let stdin = child.stdin.take().expect("stdin");
27        let stdout = child.stdout.take().expect("stdout");
28        Self {
29            stdin,
30            reader: BufReader::new(stdout),
31        }
32    }
33
34    /// Send a JSON-RPC message.
35    ///
36    /// # Errors
37    ///
38    /// Returns an [`std::io::Error`] if writing to or flushing the underlying `stdin` stream fails.
39    pub fn send(&mut self, msg: &Value) -> std::io::Result<()> {
40        let body = msg.to_string();
41        write!(self.stdin, "Content-Length: {}\r\n\r\n{}", body.len(), body)?;
42        self.stdin.flush()
43    }
44
45    /// Read the next LSP message (blocks until one arrives).
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if:
50    /// - An I/O error occurs while reading from the stdout stream.
51    /// - The stream ends before a valid `Content-Length` header is parsed.
52    /// - The header value cannot be parsed into a valid size.
53    /// - The message body cannot be parsed as valid JSON.
54    pub fn recv(&mut self) -> anyhow::Result<Value> {
55        // Read headers until blank line.
56        let mut content_length: Option<usize> = None;
57        loop {
58            let mut line = String::new();
59            self.reader.read_line(&mut line)?;
60            let line = line.trim_end_matches(['\r', '\n']);
61            if line.is_empty() {
62                break;
63            }
64            if let Some(val) = line.strip_prefix("Content-Length: ") {
65                content_length = Some(val.trim().parse()?);
66            }
67        }
68
69        let length = content_length.ok_or_else(|| anyhow::anyhow!("No Content-Length header"))?;
70        let mut body = vec![0u8; length];
71        self.reader.read_exact(&mut body)?;
72        Ok(serde_json::from_slice(&body)?)
73    }
74
75    /// Read messages until `predicate` returns `Some(T)`, discarding everything else.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if:
80    /// - The underlying [`Self::recv`] call fails.
81    /// - The `limit` number of messages is exhausted without the `predicate` returning `Some(T)`.
82    pub fn recv_until<T>(
83        &mut self,
84        limit: usize,
85        mut predicate: impl FnMut(&Value) -> Option<T>,
86    ) -> anyhow::Result<T> {
87        for _ in 0..limit {
88            let msg = self.recv()?;
89            if let Some(result) = predicate(&msg) {
90                return Ok(result);
91            }
92        }
93        anyhow::bail!("recv_until: exhausted {limit} messages without a match")
94    }
95
96    // ── Convenience constructors for common messages ─────────────────────────
97
98    /// Constructs an LSP `initialize` request message.
99    #[must_use]
100    pub fn initialize(process_id: u32, root_uri: &str) -> Value {
101        json!({
102            "jsonrpc": "2.0",
103            "id": 1,
104            "method": "initialize",
105            "params": {
106                "processId": process_id,
107                "rootUri": root_uri,
108                "capabilities": {
109                    "textDocument": {
110                        "completion": {
111                            "completionItem": { "snippetSupport": false }
112                        }
113                    }
114                },
115                "initializationOptions": {
116                    // Ask RA not to load proc-macros — speeds up cold indexing.
117                    "procMacro": { "enable": false }
118                }
119            }
120        })
121    }
122
123    /// Constructs an LSP `initialized` notification message.
124    #[must_use]
125    pub fn initialized() -> Value {
126        json!({ "jsonrpc": "2.0", "method": "initialized", "params": {} })
127    }
128
129    /// Constructs an LSP `textDocument/didOpen` notification message.
130    #[must_use]
131    pub fn did_open(uri: &str, text: &str) -> Value {
132        json!({
133            "jsonrpc": "2.0",
134            "method": "textDocument/didOpen",
135            "params": {
136                "textDocument": {
137                    "uri": uri,
138                    "languageId": "rust",
139                    "version": 1,
140                    "text": text
141                }
142            }
143        })
144    }
145
146    /// Constructs an LSP `textDocument/definition` request message.
147    #[must_use]
148    pub fn definition(id: u64, uri: &str, line: u32, character: u32) -> Value {
149        json!({
150            "jsonrpc": "2.0",
151            "id": id,
152            "method": "textDocument/definition",
153            "params": {
154                "textDocument": { "uri": uri },
155                "position": { "line": line, "character": character }
156            }
157        })
158    }
159
160    /// Constructs an LSP `textDocument/completion` request message.
161    #[must_use]
162    pub fn completion(id: u64, uri: &str, line: u32, character: u32) -> Value {
163        json!({
164            "jsonrpc": "2.0",
165            "id": id,
166            "method": "textDocument/completion",
167            "params": {
168                "textDocument": { "uri": uri },
169                "position": { "line": line, "character": character },
170                "context": { "triggerKind": 2, "triggerCharacter": "." }
171            }
172        })
173    }
174
175    /// Constructs an LSP `shutdown` request message.
176    #[must_use]
177    pub fn shutdown(id: u64) -> Value {
178        json!({ "jsonrpc": "2.0", "id": id, "method": "shutdown", "params": null })
179    }
180
181    /// Constructs an LSP `exit` notification message.
182    #[must_use]
183    pub fn exit() -> Value {
184        json!({ "jsonrpc": "2.0", "method": "exit", "params": null })
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    // ═══════════════════════════════════════════════════════════════
193    // UNIT TESTS: LSP Message Construction
194    // ═══════════════════════════════════════════════════════════════
195
196    #[test]
197    fn test_initialize_message_structure() {
198        let msg = LspTransport::initialize(12345, "file:///test");
199
200        assert_eq!(msg["jsonrpc"], "2.0");
201        assert_eq!(msg["id"], 1);
202        assert_eq!(msg["method"], "initialize");
203        assert_eq!(msg["params"]["processId"], 12345);
204        assert_eq!(msg["params"]["rootUri"], "file:///test");
205    }
206
207    #[test]
208    fn test_completion_message_structure() {
209        let msg = LspTransport::completion(42, "file:///test.rs", 10, 5);
210
211        assert_eq!(msg["jsonrpc"], "2.0");
212        assert_eq!(msg["id"], 42);
213        assert_eq!(msg["method"], "textDocument/completion");
214        assert_eq!(msg["params"]["position"]["line"], 10);
215        assert_eq!(msg["params"]["position"]["character"], 5);
216        assert_eq!(msg["params"]["context"]["triggerKind"], 2);
217        assert_eq!(msg["params"]["context"]["triggerCharacter"], ".");
218    }
219
220    #[test]
221    fn test_definition_message_structure() {
222        let msg = LspTransport::definition(99, "file:///main.rs", 20, 15);
223
224        assert_eq!(msg["jsonrpc"], "2.0");
225        assert_eq!(msg["id"], 99);
226        assert_eq!(msg["method"], "textDocument/definition");
227        assert_eq!(msg["params"]["textDocument"]["uri"], "file:///main.rs");
228        assert_eq!(msg["params"]["position"]["line"], 20);
229        assert_eq!(msg["params"]["position"]["character"], 15);
230    }
231
232    #[test]
233    fn test_shutdown_and_exit() {
234        let shutdown = LspTransport::shutdown(100);
235        assert_eq!(shutdown["method"], "shutdown");
236        assert_eq!(shutdown["id"], 100);
237
238        let exit = LspTransport::exit();
239        assert_eq!(exit["method"], "exit");
240        assert!(exit["id"].is_null());
241    }
242}