Skip to main content

krait/commands/
hover.rs

1use std::path::Path;
2
3use anyhow::Context;
4use serde_json::{json, Value};
5
6use crate::commands::find::resolve_symbol_location;
7use crate::lsp::client::{path_to_uri, LspClient};
8use crate::lsp::files::FileTracker;
9
10/// Fetch hover information for a named symbol.
11///
12/// Resolves the symbol's location via `workspace/symbol`, then issues a
13/// `textDocument/hover` request at that position.
14///
15/// # Errors
16/// Returns an error if the symbol is not found or the LSP request fails.
17pub async fn handle_hover(
18    name: &str,
19    client: &mut LspClient,
20    file_tracker: &mut FileTracker,
21    project_root: &Path,
22) -> anyhow::Result<Value> {
23    let (abs_path, line, character) = resolve_symbol_location(name, client, project_root).await?;
24
25    file_tracker
26        .ensure_open(&abs_path, client.transport_mut())
27        .await?;
28
29    let uri = path_to_uri(&abs_path)?;
30    let params = json!({
31        "textDocument": { "uri": uri.as_str() },
32        "position": { "line": line, "character": character }
33    });
34
35    let request_id = client
36        .transport_mut()
37        .send_request("textDocument/hover", params)
38        .await?;
39
40    let response = client
41        .wait_for_response_public(request_id)
42        .await
43        .context("textDocument/hover request failed")?;
44
45    let hover_content = extract_hover_content(&response);
46    let rel_path = abs_path
47        .strip_prefix(project_root)
48        .unwrap_or(&abs_path)
49        .to_string_lossy()
50        .to_string();
51
52    Ok(json!({
53        "hover_content": hover_content,
54        "path": rel_path,
55        "line": line + 1,
56    }))
57}
58
59/// Extract a plain-text string from a hover response's `contents` field.
60fn extract_hover_content(response: &Value) -> String {
61    let Some(contents) = response.get("contents") else {
62        return String::new();
63    };
64
65    // MarkupContent: { kind: "markdown"|"plaintext", value: "..." }
66    if let Some(value) = contents.get("value").and_then(Value::as_str) {
67        return value.trim().to_string();
68    }
69
70    // MarkedString: plain string
71    if let Some(s) = contents.as_str() {
72        return s.trim().to_string();
73    }
74
75    // Array of MarkedString | string
76    if let Some(arr) = contents.as_array() {
77        let parts: Vec<&str> = arr
78            .iter()
79            .filter_map(|v| {
80                v.as_str()
81                    .or_else(|| v.get("value").and_then(Value::as_str))
82            })
83            .collect();
84        return parts.join("\n").trim().to_string();
85    }
86
87    String::new()
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use serde_json::json;
94
95    #[test]
96    fn extract_markup_content() {
97        let resp = json!({ "contents": { "kind": "markdown", "value": "fn greet()" } });
98        assert_eq!(extract_hover_content(&resp), "fn greet()");
99    }
100
101    #[test]
102    fn extract_string_contents() {
103        let resp = json!({ "contents": "hello world" });
104        assert_eq!(extract_hover_content(&resp), "hello world");
105    }
106
107    #[test]
108    fn extract_array_contents() {
109        let resp = json!({ "contents": ["type A", { "language": "rust", "value": "fn a()" }] });
110        let out = extract_hover_content(&resp);
111        assert!(out.contains("type A"));
112        assert!(out.contains("fn a()"));
113    }
114
115    #[test]
116    fn extract_missing_contents() {
117        let resp = json!({ "range": {} });
118        assert_eq!(extract_hover_content(&resp), "");
119    }
120}