1use 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
13pub struct LspClient {
16 language: String,
17 server_cmd: String,
18 workspace: PathBuf,
19 inner: Option<RunningLsp>,
20}
21
22struct RunningLsp {
23 _child: tokio::process::Child,
25 stdin: ChildStdin,
26 reader: BufReader<tokio::process::ChildStdout>,
27 next_id: u64,
28 opened: HashSet<PathBuf>,
30}
31
32impl 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 pub fn handles_extension(&self, ext: &str) -> bool {
50 extensions_for_language(&self.language).contains(&ext)
51 }
52
53 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 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 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 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 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 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
199impl 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 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; }
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 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 None => continue,
275
276 Some(msg_id) => {
277 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 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 }
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 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
351pub 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
369pub 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
385fn 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 format!("file:///{}", s.replace(std::path::MAIN_SEPARATOR, "/"))
395 }
396}
397
398fn uri_to_rel_path(uri: &str, workspace: &Path) -> String {
399 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
414fn 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 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) .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}