Skip to main content

rust_analyzer_cli/lsp/
client.rs

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        // Spawn rust-analyzer process
48        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        // Stdio Writer Task
70        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        // Stdio Reader Task
82        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, // EOF
89                    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                                // Read blank line
95                                let mut blank = String::new();
96                                let _ = reader.read_line(&mut blank).await;
97
98                                // Read payload
99                                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        // Initialize handshake
131        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        // The public CLI/API uses 1-based coordinates; LSP text positions are 0-based.
447        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        // Call hierarchy uses a breadth-first walk so depth is a predictable maximum level.
556        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        // LSP uses null when the position has no documentation or type information.
694        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    // Expand short LSP ranges to the complete Rust item before applying the output limit.
894    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    // A zero limit is the documented opt-in for unlimited body output.
924    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}