Skip to main content

ralph/
lsp_client.rs

1/// JSON-RPC over stdio LSP client.
2///
3/// Lazy — the server process is spawned on the first tool call, not at
4/// construction time.  This avoids startup latency when LSP tools are never
5/// used in a session.
6use crate::errors::{RalphError, Result};
7use serde_json::{json, Value};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
11use tokio::process::ChildStdin;
12
13// ── Public types ──────────────────────────────────────────────────────────────
14
15pub struct LspClient {
16    language: String,
17    server_cmd: String,
18    workspace: PathBuf,
19    inner: Option<RunningLsp>,
20}
21
22struct RunningLsp {
23    /// Keep the child alive for the session.
24    _child: tokio::process::Child,
25    stdin: ChildStdin,
26    reader: BufReader<tokio::process::ChildStdout>,
27    next_id: u64,
28    /// Files already sent via textDocument/didOpen.
29    opened: HashSet<PathBuf>,
30}
31
32// ── LspClient impl ────────────────────────────────────────────────────────────
33
34impl LspClient {
35    pub fn new(language: &str, workspace: &Path) -> Self {
36        Self {
37            language: language.to_lowercase(),
38            server_cmd: server_cmd_for(language),
39            workspace: workspace.to_path_buf(),
40            inner: None,
41        }
42    }
43
44    pub fn language(&self) -> &str {
45        &self.language
46    }
47
48    /// Returns true if this client handles files with the given extension.
49    pub fn handles_extension(&self, ext: &str) -> bool {
50        extensions_for_language(&self.language).contains(&ext)
51    }
52
53    /// Spawn the server and run the initialize handshake (once).
54    async fn ensure_running(&mut self) -> Result<()> {
55        if self.inner.is_some() {
56            return Ok(());
57        }
58
59        let parts: Vec<&str> = self.server_cmd.split_whitespace().collect();
60        if parts.is_empty() {
61            return Err(RalphError::ToolFailed {
62                tool: "lsp".to_string(),
63                message: format!(
64                    "No server command configured for language '{}'",
65                    self.language
66                ),
67            });
68        }
69
70        let mut child = tokio::process::Command::new(parts[0])
71            .args(&parts[1..])
72            .stdin(std::process::Stdio::piped())
73            .stdout(std::process::Stdio::piped())
74            .stderr(std::process::Stdio::null())
75            .spawn()
76            .map_err(|e| RalphError::ToolFailed {
77                tool: "lsp".to_string(),
78                message: format!(
79                    "Failed to start '{}': {}. Is it installed and on PATH?",
80                    parts[0], e
81                ),
82            })?;
83
84        let stdin = child.stdin.take().ok_or_else(|| RalphError::ToolFailed {
85            tool: "lsp".to_string(),
86            message: format!("Failed to capture stdin for '{}' LSP server", parts[0]),
87        })?;
88        let stdout = child.stdout.take().ok_or_else(|| RalphError::ToolFailed {
89            tool: "lsp".to_string(),
90            message: format!("Failed to capture stdout for '{}' LSP server", parts[0]),
91        })?;
92        let reader = BufReader::new(stdout);
93
94        let mut r = RunningLsp {
95            _child: child,
96            stdin,
97            reader,
98            next_id: 1,
99            opened: HashSet::new(),
100        };
101
102        // initialize ↔ InitializeResult
103        let workspace_uri = path_to_uri(&self.workspace);
104        tokio::time::timeout(
105            std::time::Duration::from_secs(30),
106            r.request(
107                "initialize",
108                json!({
109                    "processId": std::process::id(),
110                    "rootUri": workspace_uri,
111                    "rootPath": self.workspace.display().to_string(),
112                    "capabilities": {
113                        "textDocument": {
114                            "definition": {},
115                            "references": {},
116                            "hover": { "contentFormat": ["plaintext", "markdown"] },
117                        }
118                    },
119                    "workspaceFolders": [{ "uri": workspace_uri, "name": "workspace" }],
120                }),
121            ),
122        )
123        .await
124        .map_err(|_| RalphError::ToolFailed {
125            tool: "lsp".to_string(),
126            message: "LSP server timed out during initialization (30 s)".to_string(),
127        })??;
128
129        r.notify("initialized", json!({})).await?;
130        self.inner = Some(r);
131        Ok(())
132    }
133
134    /// Jump to the definition of the symbol at `file:line:col` (1-based).
135    pub async fn go_to_definition(&mut self, file: &Path, line: u32, col: u32) -> Result<String> {
136        self.ensure_running().await?;
137        let r = self.inner.as_mut().unwrap();
138        r.open_if_needed(file, &self.workspace).await?;
139        let uri = path_to_uri(&self.workspace.join(file));
140        let result = r
141            .request(
142                "textDocument/definition",
143                json!({
144                    "textDocument": { "uri": uri },
145                    "position": lsp_pos(line, col),
146                }),
147            )
148            .await?;
149        Ok(format_locations(&result, &self.workspace))
150    }
151
152    /// Find all references to the symbol at `file:line:col` (1-based).
153    pub async fn find_references(&mut self, file: &Path, line: u32, col: u32) -> Result<String> {
154        self.ensure_running().await?;
155        let r = self.inner.as_mut().unwrap();
156        r.open_if_needed(file, &self.workspace).await?;
157        let uri = path_to_uri(&self.workspace.join(file));
158        let result = r
159            .request(
160                "textDocument/references",
161                json!({
162                    "textDocument": { "uri": uri },
163                    "position": lsp_pos(line, col),
164                    "context": { "includeDeclaration": false },
165                }),
166            )
167            .await?;
168        Ok(format_locations(&result, &self.workspace))
169    }
170
171    /// Return hover info (type, docs) for the symbol at `file:line:col` (1-based).
172    pub async fn hover(&mut self, file: &Path, line: u32, col: u32) -> Result<String> {
173        self.ensure_running().await?;
174        let r = self.inner.as_mut().unwrap();
175        r.open_if_needed(file, &self.workspace).await?;
176        let uri = path_to_uri(&self.workspace.join(file));
177        let result = r
178            .request(
179                "textDocument/hover",
180                json!({
181                    "textDocument": { "uri": uri },
182                    "position": lsp_pos(line, col),
183                }),
184            )
185            .await?;
186        Ok(format_hover(&result))
187    }
188
189    /// Graceful shutdown.  Called when the session ends.
190    pub async fn shutdown(&mut self) {
191        if let Some(r) = self.inner.as_mut() {
192            let _ = r.request("shutdown", json!(null)).await;
193            let _ = r.notify("exit", json!(null)).await;
194        }
195        self.inner = None;
196    }
197}
198
199// ── RunningLsp impl ───────────────────────────────────────────────────────────
200
201impl RunningLsp {
202    async fn send_raw(&mut self, body: &str) -> Result<()> {
203        let header = format!("Content-Length: {}\r\n\r\n", body.len());
204        self.stdin
205            .write_all(header.as_bytes())
206            .await
207            .map_err(io_err)?;
208        self.stdin
209            .write_all(body.as_bytes())
210            .await
211            .map_err(io_err)?;
212        self.stdin.flush().await.map_err(io_err)?;
213        Ok(())
214    }
215
216    async fn read_message(&mut self) -> Result<Value> {
217        // Read HTTP-style headers until blank line
218        let mut content_length: Option<usize> = None;
219        loop {
220            let mut line = String::new();
221            let n = self.reader.read_line(&mut line).await.map_err(io_err)?;
222            if n == 0 {
223                return Err(RalphError::ToolFailed {
224                    tool: "lsp".to_string(),
225                    message: "LSP server closed its stdout unexpectedly".to_string(),
226                });
227            }
228            let trimmed = line.trim_end_matches(|c| c == '\r' || c == '\n');
229            if trimmed.is_empty() {
230                break; // blank line — end of headers
231            }
232            if let Some(val) = trimmed.strip_prefix("Content-Length: ") {
233                content_length = val.trim().parse().ok();
234            }
235        }
236
237        let len = content_length.ok_or_else(|| RalphError::ToolFailed {
238            tool: "lsp".to_string(),
239            message: "LSP message missing Content-Length header".to_string(),
240        })?;
241
242        let mut buf = vec![0u8; len];
243        self.reader.read_exact(&mut buf).await.map_err(io_err)?;
244
245        serde_json::from_slice(&buf).map_err(|e| RalphError::ToolFailed {
246            tool: "lsp".to_string(),
247            message: format!("LSP JSON parse error: {}", e),
248        })
249    }
250
251    /// Send a request and return its result, discarding notifications and
252    /// replying with `null` to any server-initiated requests in between.
253    async fn request(&mut self, method: &str, params: Value) -> Result<Value> {
254        let id = self.next_id;
255        self.next_id += 1;
256
257        let body = serde_json::to_string(&json!({
258            "jsonrpc": "2.0",
259            "id": id,
260            "method": method,
261            "params": params,
262        }))
263        .map_err(|e| RalphError::ToolFailed {
264            tool: "lsp".to_string(),
265            message: format!("JSON encode error: {}", e),
266        })?;
267        self.send_raw(&body).await?;
268
269        loop {
270            let msg = self.read_message().await?;
271
272            match msg.get("id") {
273                // Notification (no id) — discard
274                None => continue,
275
276                Some(msg_id) => {
277                    // Server-to-client request (has both "id" and "method") —
278                    // respond with null so the server doesn't block.
279                    if msg.get("method").is_some() {
280                        let resp = serde_json::to_string(&json!({
281                            "jsonrpc": "2.0",
282                            "id": msg_id,
283                            "result": null,
284                        }))
285                        .map_err(|e| RalphError::ToolFailed {
286                            tool: "lsp".to_string(),
287                            message: format!("JSON encode error: {}", e),
288                        })?;
289                        self.send_raw(&resp).await?;
290                        continue;
291                    }
292
293                    // Our response
294                    if *msg_id == json!(id) {
295                        if let Some(err) = msg.get("error") {
296                            return Err(RalphError::ToolFailed {
297                                tool: "lsp".to_string(),
298                                message: format!("LSP error response: {}", err),
299                            });
300                        }
301                        return Ok(msg.get("result").cloned().unwrap_or(Value::Null));
302                    }
303                    // Different id — skip (shouldn't happen in sequential usage)
304                }
305            }
306        }
307    }
308
309    async fn notify(&mut self, method: &str, params: Value) -> Result<()> {
310        let body = serde_json::to_string(&json!({
311            "jsonrpc": "2.0",
312            "method": method,
313            "params": params,
314        }))
315        .map_err(|e| RalphError::ToolFailed {
316            tool: "lsp".to_string(),
317            message: format!("JSON encode error: {}", e),
318        })?;
319        self.send_raw(&body).await
320    }
321
322    /// Send textDocument/didOpen for `file` (relative to `workspace`) if not
323    /// already opened this session.
324    async fn open_if_needed(&mut self, file: &Path, workspace: &Path) -> Result<()> {
325        let full = workspace.join(file);
326        if self.opened.contains(&full) {
327            return Ok(());
328        }
329        let content = std::fs::read_to_string(&full).map_err(|e| RalphError::ToolFailed {
330            tool: "lsp".to_string(),
331            message: format!("Cannot read '{}' for LSP: {}", full.display(), e),
332        })?;
333        let lang_id = ext_to_language_id(file.extension().and_then(|e| e.to_str()).unwrap_or(""));
334        self.notify(
335            "textDocument/didOpen",
336            json!({
337                "textDocument": {
338                    "uri": path_to_uri(&full),
339                    "languageId": lang_id,
340                    "version": 1,
341                    "text": content,
342                }
343            }),
344        )
345        .await?;
346        self.opened.insert(full);
347        Ok(())
348    }
349}
350
351// ── Public helpers ────────────────────────────────────────────────────────────
352
353/// Return the server command string for a language name/alias.
354pub fn server_cmd_for(language: &str) -> String {
355    match language.to_lowercase().as_str() {
356        "rust" | "rs" => "rust-analyzer".to_string(),
357        "ts" | "typescript" => "typescript-language-server --stdio".to_string(),
358        "js" | "javascript" => "typescript-language-server --stdio".to_string(),
359        "python" | "py" => "pylsp".to_string(),
360        "go" => "gopls".to_string(),
361        "java" => "jdtls".to_string(),
362        "c" => "clangd".to_string(),
363        "cpp" | "c++" | "cxx" => "clangd".to_string(),
364        "kotlin" | "kt" => "kotlin-language-server".to_string(),
365        other => other.to_string(),
366    }
367}
368
369/// Return the file extensions handled by a language.
370pub fn extensions_for_language(language: &str) -> &'static [&'static str] {
371    match language.to_lowercase().as_str() {
372        "rust" | "rs" => &["rs"],
373        "ts" | "typescript" => &["ts", "tsx"],
374        "js" | "javascript" => &["js", "jsx"],
375        "python" | "py" => &["py"],
376        "go" => &["go"],
377        "java" => &["java"],
378        "c" => &["c", "h"],
379        "cpp" | "c++" | "cxx" => &["cpp", "cc", "cxx", "hpp", "hxx", "h"],
380        "kotlin" | "kt" => &["kt"],
381        _ => &[],
382    }
383}
384
385// ── Private helpers ───────────────────────────────────────────────────────────
386
387fn path_to_uri(path: &Path) -> String {
388    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
389    let s = canonical.display().to_string();
390    if s.starts_with('/') {
391        format!("file://{}", s)
392    } else {
393        // Windows: C:\foo → file:///C:/foo
394        format!("file:///{}", s.replace(std::path::MAIN_SEPARATOR, "/"))
395    }
396}
397
398fn uri_to_rel_path(uri: &str, workspace: &Path) -> String {
399    // Strip "file://" and reconstruct absolute path
400    let without_scheme = uri.strip_prefix("file://").unwrap_or(uri);
401    let abs = if without_scheme.starts_with('/') {
402        PathBuf::from(without_scheme)
403    } else {
404        PathBuf::from(format!("/{}", without_scheme))
405    };
406    let ws = workspace
407        .canonicalize()
408        .unwrap_or_else(|_| workspace.to_path_buf());
409    abs.strip_prefix(&ws)
410        .map(|p| p.display().to_string())
411        .unwrap_or_else(|_| abs.display().to_string())
412}
413
414/// Convert 1-based line/col to an LSP Position (0-based).
415fn lsp_pos(line: u32, col: u32) -> Value {
416    json!({
417        "line": line.saturating_sub(1),
418        "character": col.saturating_sub(1),
419    })
420}
421
422fn format_locations(result: &Value, workspace: &Path) -> String {
423    if result.is_null() {
424        return "Not found.".to_string();
425    }
426    let locs: Vec<&Value> = match result.as_array() {
427        Some(arr) => arr.iter().collect(),
428        None => vec![result],
429    };
430    if locs.is_empty() {
431        return "Not found.".to_string();
432    }
433    let mut out = String::new();
434    for loc in locs {
435        // Supports both Location and LocationLink shapes
436        let uri = loc
437            .get("uri")
438            .or_else(|| loc.get("targetUri"))
439            .and_then(|v| v.as_str())
440            .unwrap_or("?");
441        let range = loc
442            .get("range")
443            .or_else(|| loc.get("targetSelectionRange"))
444            .or_else(|| loc.get("targetRange"));
445        let line = range
446            .and_then(|r| r.get("start"))
447            .and_then(|s| s.get("line"))
448            .and_then(|l| l.as_u64())
449            .map(|l| l + 1) // 0-based → 1-based
450            .unwrap_or(0);
451        out.push_str(&format!("  {}:{}\n", uri_to_rel_path(uri, workspace), line));
452    }
453    out.trim_end().to_string()
454}
455
456fn format_hover(result: &Value) -> String {
457    if result.is_null() {
458        return "No hover information available.".to_string();
459    }
460    match result.get("contents") {
461        Some(c) => extract_hover_text(c),
462        None => "No hover information available.".to_string(),
463    }
464}
465
466fn extract_hover_text(v: &Value) -> String {
467    match v {
468        Value::String(s) => s.clone(),
469        Value::Object(obj) => obj
470            .get("value")
471            .and_then(|v| v.as_str())
472            .unwrap_or("(empty)")
473            .to_string(),
474        Value::Array(arr) => arr
475            .iter()
476            .map(extract_hover_text)
477            .collect::<Vec<_>>()
478            .join("\n"),
479        _ => v.to_string(),
480    }
481}
482
483fn ext_to_language_id(ext: &str) -> &'static str {
484    match ext {
485        "rs" => "rust",
486        "ts" | "tsx" => "typescript",
487        "js" | "jsx" => "javascript",
488        "py" => "python",
489        "go" => "go",
490        "java" => "java",
491        "c" | "h" => "c",
492        "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
493        "kt" => "kotlin",
494        _ => "plaintext",
495    }
496}
497
498fn io_err(e: std::io::Error) -> RalphError {
499    RalphError::ToolFailed {
500        tool: "lsp".to_string(),
501        message: format!("LSP I/O error: {}", e),
502    }
503}