ts_bridge/protocol/text_document/
hover.rs1use 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}