1use crate::lsp::types::*;
2use anyhow::{Context, Result, anyhow};
3use lsp_types::*;
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::{Path, PathBuf};
8use std::process::Stdio;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicI64, Ordering};
11use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
12use tokio::sync::{Mutex, mpsc, oneshot};
13use url::Url;
14
15pub struct LspClient {
16 tx_request: mpsc::Sender<Value>,
17 pending_requests: Arc<Mutex<HashMap<i64, oneshot::Sender<Result<Value>>>>>,
18 workspace_root: PathBuf,
19 pub process_id: u32,
20}
21
22#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
23pub struct BodySnippet {
24 pub body: String,
25 pub total_lines: usize,
26 pub is_truncated: bool,
27 pub end_line: u32,
28}
29
30impl BodySnippet {
31 fn empty(end_line: u32) -> Self {
32 Self {
33 body: String::new(),
34 total_lines: 0,
35 is_truncated: false,
36 end_line,
37 }
38 }
39}
40
41impl LspClient {
42 pub async fn start(workspace_root: &Path) -> Result<Self> {
43 let canonical_root = workspace_root
44 .canonicalize()
45 .unwrap_or_else(|_| workspace_root.to_path_buf());
46
47 let mut child = tokio::process::Command::new("rust-analyzer")
49 .stdin(Stdio::piped())
50 .stdout(Stdio::piped())
51 .stderr(Stdio::null())
52 .spawn()
53 .context("Failed to spawn rust-analyzer. Please make sure `rust-analyzer` is installed and in PATH.")?;
54
55 let pid = child.id().unwrap_or(0);
56 let stdin = child
57 .stdin
58 .take()
59 .ok_or_else(|| anyhow!("Failed to open rust-analyzer stdin"))?;
60 let stdout = child
61 .stdout
62 .take()
63 .ok_or_else(|| anyhow!("Failed to open rust-analyzer stdout"))?;
64
65 let (tx_request, mut rx_request) = mpsc::channel::<Value>(100);
66 let pending_requests: Arc<Mutex<HashMap<i64, oneshot::Sender<Result<Value>>>>> =
67 Arc::new(Mutex::new(HashMap::new()));
68
69 let mut writer_stdin = stdin;
71 tokio::spawn(async move {
72 while let Some(req) = rx_request.recv().await {
73 let body = serde_json::to_string(&req).unwrap_or_default();
74 let header = format!("Content-Length: {}\r\n\r\n", body.len());
75 let _ = writer_stdin.write_all(header.as_bytes()).await;
76 let _ = writer_stdin.write_all(body.as_bytes()).await;
77 let _ = writer_stdin.flush().await;
78 }
79 });
80
81 let pending_map = pending_requests.clone();
83 tokio::spawn(async move {
84 let mut reader = BufReader::new(stdout);
85 loop {
86 let mut line = String::new();
87 match reader.read_line(&mut line).await {
88 Ok(0) => break, Ok(_) => {
90 let line_trimmed = line.trim();
91 if line_trimmed.starts_with("Content-Length:") {
92 let len_str = line_trimmed.trim_start_matches("Content-Length:").trim();
93 if let Ok(len) = len_str.parse::<usize>() {
94 let mut blank = String::new();
96 let _ = reader.read_line(&mut blank).await;
97
98 let mut buf = vec![0u8; len];
100 if reader.read_exact(&mut buf).await.is_ok()
101 && let Ok(v) = serde_json::from_slice::<Value>(&buf)
102 && let Some(id) = v.get("id").and_then(|i| i.as_i64())
103 {
104 let mut map = pending_map.lock().await;
105 if let Some(sender) = map.remove(&id) {
106 if let Some(err) = v.get("error") {
107 let _ = sender.send(Err(anyhow!("LSP error: {}", err)));
108 } else {
109 let result =
110 v.get("result").cloned().unwrap_or(Value::Null);
111 let _ = sender.send(Ok(result));
112 }
113 }
114 }
115 }
116 }
117 }
118 Err(_) => break,
119 }
120 }
121 });
122
123 let client = Self {
124 tx_request,
125 pending_requests,
126 workspace_root: canonical_root,
127 process_id: pid,
128 };
129
130 client.initialize().await?;
132
133 Ok(client)
134 }
135
136 async fn send_request(&self, method: &str, params: Value) -> Result<Value> {
137 static REQ_ID: AtomicI64 = AtomicI64::new(1);
138 let id = REQ_ID.fetch_add(1, Ordering::SeqCst);
139 let req = json!({
140 "jsonrpc": "2.0",
141 "id": id,
142 "method": method,
143 "params": params
144 });
145
146 let (tx, rx) = oneshot::channel();
147 {
148 let mut map = self.pending_requests.lock().await;
149 map.insert(id, tx);
150 }
151
152 self.tx_request
153 .send(req)
154 .await
155 .map_err(|_| anyhow!("LSP writer task channel closed"))?;
156
157 let result = rx
158 .await
159 .map_err(|_| anyhow!("LSP response channel dropped"))?;
160 result.with_context(|| format!("LSP request '{method}' failed"))
161 }
162
163 async fn send_notification(&self, method: &str, params: Value) -> Result<()> {
164 let req = json!({
165 "jsonrpc": "2.0",
166 "method": method,
167 "params": params
168 });
169 self.tx_request
170 .send(req)
171 .await
172 .map_err(|_| anyhow!("LSP writer task channel closed while sending {method}"))?;
173 Ok(())
174 }
175
176 fn validate_position(file: &Path, line: u32, col: u32) -> Result<()> {
177 if line == 0 || col == 0 {
178 return Err(anyhow!(
179 "Invalid position for '{}': line and column numbers are 1-based and must be at least 1.",
180 file.display()
181 ));
182 }
183 Ok(())
184 }
185
186 async fn initialize(&self) -> Result<()> {
187 let root_uri = Url::from_directory_path(&self.workspace_root)
188 .map_err(|_| anyhow!("Failed to convert workspace path to URI"))?;
189
190 let init_params = json!({
191 "processId": self.process_id,
192 "rootUri": root_uri.as_str(),
193 "capabilities": {
194 "textDocument": {
195 "documentSymbol": { "hierarchicalDocumentSymbolSupport": true },
196 "definition": { "dynamicRegistration": false },
197 "hover": {
198 "dynamicRegistration": false,
199 "contentFormat": ["markdown", "plaintext"]
200 },
201 "references": { "dynamicRegistration": false },
202 "callHierarchy": { "dynamicRegistration": false },
203 "typeHierarchy": { "dynamicRegistration": false }
204 },
205 "workspace": {
206 "symbol": { "dynamicRegistration": false }
207 }
208 }
209 });
210
211 let _res = self.send_request("initialize", init_params).await?;
212 self.send_notification("initialized", json!({})).await?;
213 Ok(())
214 }
215
216 pub async fn query_symbol(
217 &self,
218 name: &str,
219 kind_filter: &str,
220 exact: bool,
221 include_body: bool,
222 max_lines: usize,
223 ) -> Result<Vec<SymbolItem>> {
224 let params = json!({ "query": name });
225 let res = self.send_request("workspace/symbol", params).await?;
226 let symbols: Vec<SymbolInformation> =
227 match serde_json::from_value::<WorkspaceSymbolResponse>(res.clone()) {
228 Ok(WorkspaceSymbolResponse::Flat(syms)) => syms,
229 Ok(WorkspaceSymbolResponse::Nested(syms)) => syms
230 .into_iter()
231 .map(|s| SymbolInformation {
232 name: s.name,
233 kind: s.kind,
234 tags: s.tags,
235 #[allow(deprecated)]
236 deprecated: None,
237
238 location: match s.location {
239 lsp_types::OneOf::Left(loc) => loc,
240 lsp_types::OneOf::Right(w_loc) => Location {
241 uri: w_loc.uri,
242 range: Range::default(),
243 },
244 },
245 container_name: s.container_name,
246 })
247 .collect(),
248 Err(_) => serde_json::from_value(res).unwrap_or_default(),
249 };
250
251 let mut result = Vec::new();
252 for sym in symbols {
253 if exact && sym.name != name {
254 continue;
255 }
256 let kind_str = format!("{:?}", sym.kind).to_lowercase();
257 if kind_filter != "any" && !kind_str.contains(&kind_filter.to_lowercase()) {
258 continue;
259 }
260
261 let file_path = uri_to_file_path(&sym.location.uri);
262 let relative_file = file_path
263 .strip_prefix(&self.workspace_root)
264 .unwrap_or(&file_path)
265 .to_string_lossy()
266 .to_string();
267
268 let snippet = if include_body {
269 extract_body_snippet(
270 &file_path,
271 sym.location.range.start.line + 1,
272 sym.location.range.end.line + 1,
273 max_lines,
274 )
275 } else {
276 BodySnippet::empty(0)
277 };
278
279 let body_opt = if include_body && !snippet.body.is_empty() {
280 Some(snippet.body)
281 } else {
282 None
283 };
284
285 result.push(SymbolItem {
286 name: sym.name,
287 kind: kind_str,
288 file: relative_file,
289 line: sym.location.range.start.line + 1,
290 col: sym.location.range.start.character + 1,
291 container_name: sym.container_name,
292 body: body_opt,
293 });
294 }
295
296 Ok(result)
297 }
298
299 async fn ensure_file_open(&self, canonical_file: &Path) -> Result<Url> {
300 let uri = Url::from_file_path(canonical_file)
301 .map_err(|_| anyhow!("Failed to convert file path to URI"))?;
302 let text = tokio::fs::read_to_string(canonical_file)
303 .await
304 .with_context(|| {
305 format!(
306 "Failed to read Rust source file '{}'.",
307 canonical_file.display()
308 )
309 })?;
310 self.send_notification(
311 "textDocument/didOpen",
312 json!({
313 "textDocument": {
314 "uri": uri.as_str(),
315 "languageId": "rust",
316 "version": 1,
317 "text": text
318 }
319 }),
320 )
321 .await
322 .context("Failed to notify rust-analyzer that the source file was opened")?;
323 Ok(uri)
324 }
325
326 pub async fn query_outline(
327 &self,
328 file: &Path,
329 include_body: bool,
330 max_lines: usize,
331 ) -> Result<Vec<OutlineItem>> {
332 let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
333 let uri = self.ensure_file_open(&canonical_file).await?;
334
335 let params = json!({
336 "textDocument": { "uri": uri.as_str() }
337 });
338
339 let res = self
340 .send_request("textDocument/documentSymbol", params)
341 .await?;
342
343 fn convert_symbols(
344 symbols: Vec<DocumentSymbol>,
345 file_path: &Path,
346 include_body: bool,
347 max_lines: usize,
348 ) -> Vec<OutlineItem> {
349 symbols
350 .into_iter()
351 .map(|s| {
352 let snippet = if include_body {
353 extract_body_snippet(
354 file_path,
355 s.range.start.line + 1,
356 s.range.end.line + 1,
357 max_lines,
358 )
359 } else {
360 BodySnippet::empty(0)
361 };
362 let body_opt = if include_body && !snippet.body.is_empty() {
363 Some(snippet.body)
364 } else {
365 None
366 };
367
368 OutlineItem {
369 name: s.name,
370 kind: format!("{:?}", s.kind).to_lowercase(),
371 detail: s.detail,
372 line: s.range.start.line + 1,
373 col: s.range.start.character + 1,
374 end_line: s.range.end.line + 1,
375 children: convert_symbols(
376 s.children.unwrap_or_default(),
377 file_path,
378 include_body,
379 max_lines,
380 ),
381 body: body_opt,
382 }
383 })
384 .collect()
385 }
386
387 let items = match serde_json::from_value::<DocumentSymbolResponse>(res.clone()) {
388 Ok(DocumentSymbolResponse::Nested(symbols)) => {
389 convert_symbols(symbols, &canonical_file, include_body, max_lines)
390 }
391 Ok(DocumentSymbolResponse::Flat(syms)) => syms
392 .into_iter()
393 .map(|s| {
394 let snippet = if include_body {
395 extract_body_snippet(
396 &canonical_file,
397 s.location.range.start.line + 1,
398 s.location.range.end.line + 1,
399 max_lines,
400 )
401 } else {
402 BodySnippet::empty(0)
403 };
404 let body_opt = if include_body && !snippet.body.is_empty() {
405 Some(snippet.body)
406 } else {
407 None
408 };
409
410 OutlineItem {
411 name: s.name,
412 kind: format!("{:?}", s.kind).to_lowercase(),
413 detail: s.container_name,
414 line: s.location.range.start.line + 1,
415 col: s.location.range.start.character + 1,
416 end_line: s.location.range.end.line + 1,
417 children: vec![],
418 body: body_opt,
419 }
420 })
421 .collect(),
422 Err(_) => {
423 if let Ok(syms) = serde_json::from_value::<Vec<DocumentSymbol>>(res) {
424 convert_symbols(syms, &canonical_file, include_body, max_lines)
425 } else {
426 vec![]
427 }
428 }
429 };
430
431 Ok(items)
432 }
433
434 pub async fn query_definition(
435 &self,
436 file: &Path,
437 line: u32,
438 col: u32,
439 include_body: bool,
440 max_lines: usize,
441 ) -> Result<Vec<DefinitionItem>> {
442 Self::validate_position(file, line, col)?;
443 let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
444 let uri = self.ensure_file_open(&canonical_file).await?;
445
446 let params = json!({
448 "textDocument": { "uri": uri.as_str() },
449 "position": { "line": line - 1, "character": col - 1 }
450 });
451
452 let res = self.send_request("textDocument/definition", params).await?;
453 let locations: Vec<Location> = match serde_json::from_value::<GotoDefinitionResponse>(res) {
454 Ok(GotoDefinitionResponse::Scalar(loc)) => vec![loc],
455 Ok(GotoDefinitionResponse::Array(locs)) => locs,
456 Ok(GotoDefinitionResponse::Link(links)) => links
457 .into_iter()
458 .map(|l| Location {
459 uri: l.target_uri,
460 range: l.target_range,
461 })
462 .collect(),
463 Err(_) => vec![],
464 };
465
466 let mut items = Vec::new();
467 for loc in locations {
468 let loc_file = uri_to_file_path(&loc.uri);
469 let rel_file = loc_file
470 .strip_prefix(&self.workspace_root)
471 .unwrap_or(&loc_file)
472 .to_string_lossy()
473 .to_string();
474
475 let snippet = if include_body {
476 extract_body_snippet(
477 &loc_file,
478 loc.range.start.line + 1,
479 loc.range.end.line + 1,
480 max_lines,
481 )
482 } else {
483 BodySnippet::empty(0)
484 };
485
486 let body_opt = if include_body && !snippet.body.is_empty() {
487 Some(snippet.body.clone())
488 } else {
489 None
490 };
491
492 items.push(DefinitionItem {
493 file: rel_file,
494 line: loc.range.start.line + 1,
495 col: loc.range.start.character + 1,
496 end_line: loc.range.end.line + 1,
497 end_col: loc.range.end.character + 1,
498 snippet: body_opt.clone(),
499 body: body_opt,
500 });
501 }
502
503 Ok(items)
504 }
505
506 pub async fn query_cursor(
507 &self,
508 file: &Path,
509 line: u32,
510 col: u32,
511 mode: &str,
512 depth: u32,
513 ) -> Result<Vec<CursorItem>> {
514 Self::validate_position(file, line, col)?;
515 if !matches!(mode, "incoming" | "outgoing" | "references") {
516 return Err(anyhow!(
517 "Invalid cursor mode '{mode}'. Allowed values: incoming, outgoing, references."
518 ));
519 }
520 if depth == 0 {
521 return Err(anyhow!("Cursor depth must be at least 1."));
522 }
523 let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
524 let uri = self.ensure_file_open(&canonical_file).await?;
525
526 if mode == "references" {
527 let params = json!({
528 "textDocument": { "uri": uri.as_str() },
529 "position": { "line": line - 1, "character": col - 1 },
530 "context": { "includeDeclaration": true }
531 });
532 let res = self.send_request("textDocument/references", params).await?;
533 let locs: Vec<Location> = serde_json::from_value(res).unwrap_or_default();
534
535 let mut items = Vec::new();
536 for loc in locs {
537 let loc_file = uri_to_file_path(&loc.uri);
538 let rel_file = loc_file
539 .strip_prefix(&self.workspace_root)
540 .unwrap_or(&loc_file)
541 .to_string_lossy()
542 .to_string();
543 items.push(CursorItem {
544 name: "reference".to_string(),
545 kind: "reference".to_string(),
546 file: rel_file,
547 line: loc.range.start.line + 1,
548 col: loc.range.start.character + 1,
549 caller_or_callee: None,
550 });
551 }
552 return Ok(items);
553 }
554
555 let prep_params = json!({
557 "textDocument": { "uri": uri.as_str() },
558 "position": { "line": line - 1, "character": col - 1 }
559 });
560 let prep_res = self
561 .send_request("textDocument/prepareCallHierarchy", prep_params)
562 .await?;
563 let roots: Vec<CallHierarchyItem> = serde_json::from_value(prep_res).unwrap_or_default();
564 let Some(root) = roots.into_iter().next() else {
565 return Ok(Vec::new());
566 };
567
568 let mut visited = HashSet::from([call_hierarchy_key(&root)]);
569 let mut queue = VecDeque::from([(root, 0u32)]);
570 let mut results = Vec::new();
571 while let Some((item, level)) = queue.pop_front() {
572 if level >= depth {
573 continue;
574 }
575 let item_json = serde_json::to_value(&item)?;
576
577 if mode == "outgoing" {
578 let out_res = self
579 .send_request("callHierarchy/outgoingCalls", json!({ "item": item_json }))
580 .await?;
581 let out_calls: Vec<CallHierarchyOutgoingCall> =
582 serde_json::from_value(out_res).unwrap_or_default();
583 for call in out_calls {
584 let target = call.to;
585 if !visited.insert(call_hierarchy_key(&target)) {
586 continue;
587 }
588 results.push(cursor_item_from_call(
589 &target,
590 &self.workspace_root,
591 "callee",
592 ));
593 queue.push_back((target, level + 1));
594 }
595 } else {
596 let in_res = self
597 .send_request("callHierarchy/incomingCalls", json!({ "item": item_json }))
598 .await?;
599 let in_calls: Vec<CallHierarchyIncomingCall> =
600 serde_json::from_value(in_res).unwrap_or_default();
601 for call in in_calls {
602 let target = call.from;
603 if !visited.insert(call_hierarchy_key(&target)) {
604 continue;
605 }
606 results.push(cursor_item_from_call(
607 &target,
608 &self.workspace_root,
609 "caller",
610 ));
611 queue.push_back((target, level + 1));
612 }
613 }
614 }
615
616 Ok(results)
617 }
618
619 pub async fn query_type_hierarchy(
620 &self,
621 file: &Path,
622 line: u32,
623 col: u32,
624 mode: &str,
625 depth: u32,
626 ) -> Result<Vec<TypeHierarchyItemResult>> {
627 Self::validate_position(file, line, col)?;
628 if !matches!(mode, "supertypes" | "subtypes") {
629 return Err(anyhow!(
630 "Invalid type hierarchy mode '{mode}'. Allowed values: supertypes, subtypes."
631 ));
632 }
633 if depth == 0 {
634 return Err(anyhow!("Type hierarchy depth must be at least 1."));
635 }
636 let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
637 let uri = self.ensure_file_open(&canonical_file).await?;
638
639 let prep_params = json!({
640 "textDocument": { "uri": uri.as_str() },
641 "position": { "line": line - 1, "character": col - 1 }
642 });
643 let prep_res = self
644 .send_request("textDocument/prepareTypeHierarchy", prep_params)
645 .await?;
646 let roots: Vec<lsp_types::TypeHierarchyItem> =
647 serde_json::from_value(prep_res).unwrap_or_default();
648 let Some(root) = roots.into_iter().next() else {
649 return Ok(Vec::new());
650 };
651
652 let endpoint = if mode == "subtypes" {
653 "typeHierarchy/subtypes"
654 } else {
655 "typeHierarchy/supertypes"
656 };
657 let mut visited = HashSet::from([type_hierarchy_key(&root)]);
658 let mut queue = VecDeque::from([(root, 0u32)]);
659 let mut results = Vec::new();
660 while let Some((item, level)) = queue.pop_front() {
661 if level >= depth {
662 continue;
663 }
664 let item_json = serde_json::to_value(&item)?;
665 let type_res = self
666 .send_request(endpoint, json!({ "item": item_json }))
667 .await?;
668 let type_items: Vec<lsp_types::TypeHierarchyItem> =
669 serde_json::from_value(type_res).unwrap_or_default();
670 for target in type_items {
671 if !visited.insert(type_hierarchy_key(&target)) {
672 continue;
673 }
674 results.push(type_hierarchy_result(&target, &self.workspace_root));
675 queue.push_back((target, level + 1));
676 }
677 }
678
679 Ok(results)
680 }
681
682 pub async fn query_hover(&self, file: &Path, line: u32, col: u32) -> Result<Option<HoverItem>> {
683 Self::validate_position(file, line, col)?;
684 let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
685 let uri = self.ensure_file_open(&canonical_file).await?;
686
687 let params = json!({
688 "textDocument": { "uri": uri.as_str() },
689 "position": { "line": line - 1, "character": col - 1 }
690 });
691
692 let res = self.send_request("textDocument/hover", params).await?;
693 let hover: Option<Hover> = serde_json::from_value(res)?;
695 let Some(hover) = hover else {
696 return Ok(None);
697 };
698
699 let loc_file = canonical_file;
700 let rel_file = loc_file
701 .strip_prefix(&self.workspace_root)
702 .unwrap_or(&loc_file)
703 .to_string_lossy()
704 .to_string();
705 let range = hover.range.map(|range| HoverRange {
706 start_line: range.start.line + 1,
707 start_col: range.start.character + 1,
708 end_line: range.end.line + 1,
709 end_col: range.end.character + 1,
710 });
711
712 Ok(Some(HoverItem {
713 file: rel_file,
714 line,
715 col,
716 contents: hover_contents_to_markdown(hover.contents),
717 range,
718 }))
719 }
720
721 pub async fn query_body(
722 &self,
723 file: &Path,
724 line: u32,
725 col: u32,
726 max_lines: usize,
727 ) -> Result<BodyItem> {
728 Self::validate_position(file, line, col)?;
729 let canonical_file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
730 let uri = self.ensure_file_open(&canonical_file).await?;
731
732 let params = json!({
733 "textDocument": { "uri": uri.as_str() },
734 "position": { "line": line - 1, "character": col - 1 }
735 });
736
737 let res = self.send_request("textDocument/definition", params).await?;
738 let locations: Vec<Location> = match serde_json::from_value::<GotoDefinitionResponse>(res) {
739 Ok(GotoDefinitionResponse::Scalar(loc)) => vec![loc],
740 Ok(GotoDefinitionResponse::Array(locs)) => locs,
741 Ok(GotoDefinitionResponse::Link(links)) => links
742 .into_iter()
743 .map(|l| Location {
744 uri: l.target_uri,
745 range: l.target_range,
746 })
747 .collect(),
748 Err(_) => vec![],
749 };
750
751 if let Some(loc) = locations.first() {
752 let loc_file = uri_to_file_path(&loc.uri);
753 let rel_file = loc_file
754 .strip_prefix(&self.workspace_root)
755 .unwrap_or(&loc_file)
756 .to_string_lossy()
757 .to_string();
758
759 let snippet = extract_body_snippet(
760 &loc_file,
761 loc.range.start.line + 1,
762 loc.range.end.line + 1,
763 max_lines,
764 );
765
766 Ok(BodyItem {
767 file: rel_file,
768 line: loc.range.start.line + 1,
769 col: loc.range.start.character + 1,
770 end_line: snippet.end_line,
771 end_col: loc.range.end.character + 1,
772 body: snippet.body,
773 total_lines: snippet.total_lines,
774 is_truncated: snippet.is_truncated,
775 })
776 } else {
777 Err(anyhow::anyhow!(
778 "No symbol/entity definition found at specified position"
779 ))
780 }
781 }
782}
783
784fn call_hierarchy_key(item: &CallHierarchyItem) -> String {
785 format!(
786 "{}:{}:{}:{}:{}",
787 item.uri.as_str(),
788 item.range.start.line,
789 item.range.start.character,
790 item.range.end.line,
791 item.range.end.character
792 )
793}
794
795fn cursor_item_from_call(
796 item: &CallHierarchyItem,
797 workspace_root: &Path,
798 caller_or_callee: &str,
799) -> CursorItem {
800 let file_path = uri_to_file_path(&item.uri);
801 let rel_file = file_path
802 .strip_prefix(workspace_root)
803 .unwrap_or(&file_path)
804 .to_string_lossy()
805 .to_string();
806 CursorItem {
807 name: item.name.clone(),
808 kind: format!("{:?}", item.kind).to_lowercase(),
809 file: rel_file,
810 line: item.range.start.line + 1,
811 col: item.range.start.character + 1,
812 caller_or_callee: Some(caller_or_callee.to_string()),
813 }
814}
815
816fn type_hierarchy_key(item: &lsp_types::TypeHierarchyItem) -> String {
817 format!(
818 "{}:{}:{}:{}:{}",
819 item.uri.as_str(),
820 item.range.start.line,
821 item.range.start.character,
822 item.range.end.line,
823 item.range.end.character
824 )
825}
826
827fn type_hierarchy_result(
828 item: &lsp_types::TypeHierarchyItem,
829 workspace_root: &Path,
830) -> TypeHierarchyItemResult {
831 let file_path = uri_to_file_path(&item.uri);
832 let rel_file = file_path
833 .strip_prefix(workspace_root)
834 .unwrap_or(&file_path)
835 .to_string_lossy()
836 .to_string();
837 TypeHierarchyItemResult {
838 name: item.name.clone(),
839 kind: format!("{:?}", item.kind).to_lowercase(),
840 file: rel_file,
841 line: item.range.start.line + 1,
842 col: item.range.start.character + 1,
843 detail: item.detail.clone(),
844 }
845}
846
847fn uri_to_file_path(uri: &lsp_types::Uri) -> PathBuf {
848 Url::parse(uri.as_str())
849 .ok()
850 .and_then(|u| u.to_file_path().ok())
851 .unwrap_or_default()
852}
853
854fn marked_string_to_markdown(marked: MarkedString) -> String {
855 match marked {
856 MarkedString::LanguageString(language_string) => format!(
857 "```{}\n{}\n```",
858 language_string.language, language_string.value
859 ),
860 MarkedString::String(value) => value,
861 }
862}
863
864fn hover_contents_to_markdown(contents: HoverContents) -> String {
865 match contents {
866 HoverContents::Markup(markup) => markup.value,
867 HoverContents::Scalar(marked) => marked_string_to_markdown(marked),
868 HoverContents::Array(items) => items
869 .into_iter()
870 .map(marked_string_to_markdown)
871 .collect::<Vec<_>>()
872 .join("\n\n"),
873 }
874}
875
876pub fn extract_body_snippet(
877 file: &Path,
878 start_line: u32,
879 mut end_line: u32,
880 max_lines: usize,
881) -> BodySnippet {
882 let content = match std::fs::read_to_string(file) {
883 Ok(c) => c,
884 Err(_) => return BodySnippet::empty(end_line),
885 };
886 let lines: Vec<&str> = content.lines().collect();
887 let start_idx = (start_line.saturating_sub(1)) as usize;
888
889 if start_idx >= lines.len() {
890 return BodySnippet::empty(end_line);
891 }
892
893 if end_line <= start_line || end_line <= start_line + 1 {
895 let mut depth = 0i32;
896 let mut found_brace = false;
897 for (i, line) in lines[start_idx..].iter().enumerate() {
898 for ch in line.chars() {
899 if ch == '{' {
900 depth += 1;
901 found_brace = true;
902 } else if ch == '}' {
903 depth -= 1;
904 }
905 }
906 if (found_brace && depth <= 0) || (!found_brace && line.trim().ends_with(';')) {
907 end_line = start_line + i as u32 + 1;
908 break;
909 }
910 if i >= 100 {
911 end_line = start_line + i as u32 + 1;
912 break;
913 }
914 }
915 }
916
917 let end_idx = (end_line as usize).min(lines.len());
918 let total = if end_idx > start_idx {
919 end_idx - start_idx
920 } else {
921 1
922 };
923 let limit = if max_lines == 0 {
925 total
926 } else {
927 max_lines.min(total)
928 };
929 let is_truncated = limit < total;
930 let slice = &lines[start_idx..start_idx + limit];
931 BodySnippet {
932 body: slice.join("\n"),
933 total_lines: total,
934 is_truncated,
935 end_line,
936 }
937}
938
939pub fn format_body_with_line_numbers(snippet: &str, start_line: u32) -> String {
940 snippet
941 .lines()
942 .enumerate()
943 .map(|(idx, line)| format!("{:4} | {}", start_line as usize + idx, line))
944 .collect::<Vec<_>>()
945 .join("\n")
946}
947
948#[cfg(test)]
949mod tests {
950 use super::*;
951 use lsp_types::{MarkupContent, MarkupKind};
952
953 #[test]
954 fn test_hover_markup_is_preserved() {
955 let contents = HoverContents::Markup(MarkupContent {
956 kind: MarkupKind::Markdown,
957 value: "/// Documentation\n\n```rust\nfn example() {}\n```".to_string(),
958 });
959
960 assert_eq!(
961 hover_contents_to_markdown(contents),
962 "/// Documentation\n\n```rust\nfn example() {}\n```"
963 );
964 }
965
966 #[test]
967 fn test_hover_language_string_becomes_markdown_code_block() {
968 let contents = HoverContents::Scalar(MarkedString::LanguageString(LanguageString {
969 language: "rust".to_string(),
970 value: "fn example() {}".to_string(),
971 }));
972
973 assert_eq!(
974 hover_contents_to_markdown(contents),
975 "```rust\nfn example() {}\n```"
976 );
977 }
978
979 #[test]
980 fn test_hover_array_joins_marked_strings() {
981 let contents = HoverContents::Array(vec![
982 MarkedString::String("Summary".to_string()),
983 MarkedString::LanguageString(LanguageString {
984 language: "rust".to_string(),
985 value: "fn example() {}".to_string(),
986 }),
987 ]);
988
989 assert_eq!(
990 hover_contents_to_markdown(contents),
991 "Summary\n\n```rust\nfn example() {}\n```"
992 );
993 }
994
995 #[test]
996 fn test_body_snippet_json_roundtrip() {
997 let snippet = BodySnippet {
998 body: "fn example() {}".to_string(),
999 total_lines: 1,
1000 is_truncated: false,
1001 end_line: 12,
1002 };
1003 let json = serde_json::to_string(&snippet).unwrap();
1004 let decoded: BodySnippet = serde_json::from_str(&json).unwrap();
1005 assert_eq!(decoded, snippet);
1006 }
1007
1008 #[test]
1009 fn test_body_snippet_reports_truncation() {
1010 let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/code_example.rs");
1011 let snippet = extract_body_snippet(&file, 39, 39, 2);
1012 assert_eq!(snippet.body.lines().count(), 2);
1013 assert!(snippet.total_lines > 100);
1014 assert!(snippet.is_truncated);
1015 assert!(snippet.end_line >= 39);
1016 }
1017}