ts_bridge/protocol/text_document/
hover.rs

1//! =============================================================================
2//! textDocument/hover
3//! =============================================================================
4//!
5//! Translating the LSP request into a tsserver `quickinfo`
6//! command and shaping the resulting response into an LSP
7//! `Hover`. The handler keeps the formatting decisions (code fence for the
8//! `displayString`, plain-text docs, and `_@tag_` renders) so the Neovim UX
9//! matches the original plugin.
10
11use anyhow::{Context, Result};
12use lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind};
13use serde_json::{Value, json};
14
15use crate::protocol::{AdapterResult, RequestSpec};
16use crate::rpc::{Priority, Route};
17use crate::utils::{tsserver_range_from_value_lsp, uri_to_file_path};
18
19pub fn handle(params: lsp_types::HoverParams) -> RequestSpec {
20    let text_document = params.text_document_position_params.text_document;
21    let uri_string = text_document.uri.to_string();
22    let file_name = uri_to_file_path(text_document.uri.as_str()).unwrap_or(uri_string);
23    let position = params.text_document_position_params.position;
24    let request = json!({
25        "command": "quickinfo",
26        "arguments": {
27            "file": file_name,
28            "line": position.line + 1,
29            "offset": position.character + 1,
30        }
31    });
32
33    RequestSpec {
34        route: Route::Syntax,
35        payload: request,
36        priority: Priority::Normal,
37        on_response: Some(adapt_quickinfo),
38        response_context: None,
39    }
40}
41
42fn adapt_quickinfo(payload: &Value, _context: Option<&Value>) -> Result<AdapterResult> {
43    let body = payload
44        .get("body")
45        .context("tsserver quickinfo missing body")?;
46    let mut sections = Vec::new();
47
48    if let Some(display) = body.get("displayString").and_then(|v| v.as_str()) {
49        if !display.is_empty() {
50            sections.push(format!("```typescript\n{}\n```", display));
51        }
52    }
53
54    if let Some(documentation) = body
55        .get("documentation")
56        .and_then(|doc| flatten_symbol_display(doc, "", false).filter(|text| !text.is_empty()))
57    {
58        sections.push(documentation);
59    }
60
61    if let Some(tags) = body
62        .get("tags")
63        .and_then(|tags| render_tags(tags).filter(|text| !text.is_empty()))
64    {
65        sections.push(tags);
66    }
67
68    let hover = Hover {
69        contents: HoverContents::Markup(MarkupContent {
70            kind: MarkupKind::Markdown,
71            value: sections.join("\n\n"),
72        }),
73        range: tsserver_range_from_value_lsp(body),
74    };
75
76    Ok(AdapterResult::ready(serde_json::to_value(hover)?))
77}
78
79fn flatten_symbol_display(
80    value: &Value,
81    delimiter: &str,
82    format_parameter: bool,
83) -> Option<String> {
84    if let Some(s) = value.as_str() {
85        return Some(s.to_string());
86    }
87
88    let parts = value.as_array()?;
89    let mut buffer = Vec::new();
90    for part in parts {
91        let text = part.get("text").and_then(|v| v.as_str())?;
92        if format_parameter && part.get("kind").and_then(|k| k.as_str()) == Some("parameterName") {
93            buffer.push(format!("`{}`", text));
94        } else {
95            buffer.push(text.to_string());
96        }
97    }
98
99    if buffer.is_empty() {
100        None
101    } else {
102        Some(buffer.join(delimiter))
103    }
104}
105
106fn render_tags(tags_value: &Value) -> Option<String> {
107    let tags = tags_value.as_array()?;
108    let mut lines = Vec::new();
109    for tag in tags {
110        let name = match tag.get("name").and_then(|v| v.as_str()) {
111            Some(name) if !name.is_empty() => name,
112            _ => continue,
113        };
114
115        let mut line = format!("_@{}_", name);
116        if let Some(text_value) = tag.get("text") {
117            if let Some(text) = flatten_symbol_display(text_value, "", true) {
118                if !text.is_empty() {
119                    line.push_str(" — ");
120                    line.push_str(&text);
121                }
122            }
123        }
124        lines.push(line);
125    }
126
127    if lines.is_empty() {
128        None
129    } else {
130        Some(lines.join("\n"))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use lsp_types::{
138        Hover as LspHover, HoverParams, Position, TextDocumentIdentifier,
139        TextDocumentPositionParams, Uri,
140    };
141    use std::str::FromStr;
142
143    #[test]
144    fn handle_builds_quickinfo_request() {
145        let params = HoverParams {
146            text_document_position_params: TextDocumentPositionParams {
147                text_document: TextDocumentIdentifier {
148                    uri: Uri::from_str("file:///workspace/foo.ts").unwrap(),
149                },
150                position: Position {
151                    line: 4,
152                    character: 2,
153                },
154            },
155            work_done_progress_params: Default::default(),
156        };
157
158        let spec = handle(params);
159        assert_eq!(spec.route, Route::Syntax);
160        assert_eq!(spec.priority, Priority::Normal);
161        let args = spec.payload.get("arguments").expect("arguments missing");
162        assert_eq!(
163            args.get("file").and_then(|v| v.as_str()),
164            Some("/workspace/foo.ts")
165        );
166        assert_eq!(args.get("line").and_then(|v| v.as_u64()), Some(5));
167        assert_eq!(args.get("offset").and_then(|v| v.as_u64()), Some(3));
168    }
169
170    #[test]
171    fn adapt_quickinfo_formats_markdown() {
172        let payload = json!({
173            "body": {
174                "displayString": "const greet: () => void",
175                "documentation": [{ "text": "Greets the user." }],
176                "tags": [{
177                    "name": "deprecated",
178                    "text": [{ "text": "Use greetAsync instead." }]
179                }],
180                "start": { "line": 1, "offset": 1 },
181                "end": { "line": 1, "offset": 6 }
182            }
183        });
184
185        let adapted = adapt_quickinfo(&payload, None).expect("hover should adapt");
186        let hover_value = match adapted {
187            AdapterResult::Ready(value) => value,
188            AdapterResult::Continue(_) => panic!("expected ready hover response"),
189        };
190        let hover: LspHover = serde_json::from_value(hover_value).expect("hover deserializes");
191        let HoverContents::Markup(content) = hover.contents else {
192            panic!("expected markup hover");
193        };
194        assert_eq!(
195            content.value,
196            "```typescript\nconst greet: () => void\n```\n\nGreets the user.\n\n_@deprecated_ — Use greetAsync instead."
197        );
198        let range = hover.range.expect("hover should include range");
199        assert_eq!(range.start.line, 0);
200        assert_eq!(range.start.character, 0);
201        assert_eq!(range.end.line, 0);
202        assert_eq!(range.end.character, 5);
203    }
204}