1use crate::logic::utf8::substring_by_chars;
4use crate::parser::{Document, Node, NodeKind, Position, Span};
5
6#[derive(Debug, Clone)]
7pub struct HoverInfo {
8 pub contents: String,
9 pub range: Option<Span>,
10}
11
12pub fn get_hover_info(position: Position, document: &Document) -> Option<HoverInfo> {
13 for node in &document.children {
14 if let Some(hover) = find_hover_at_position(node, position) {
15 return Some(hover);
16 }
17 }
18
19 None
20}
21
22fn find_hover_at_position(node: &Node, position: Position) -> Option<HoverInfo> {
23 for child in &node.children {
25 if let Some(hover) = find_hover_at_position(child, position) {
26 return Some(hover);
27 }
28 }
29
30 if let Some(span) = &node.span {
31 if position_in_span(position, span) {
32 return match &node.kind {
33 NodeKind::Link { url, title } => {
34 let mut contents = format!("**Link**\n\nURL: `{}`", url);
35 if let Some(t) = title {
36 if !t.is_empty() {
37 contents.push_str(&format!("\n\nTitle: \"{}\"", t));
38 }
39 }
40 Some(HoverInfo {
41 contents,
42 range: Some(*span),
43 })
44 }
45 NodeKind::Image { url, alt } => {
46 let mut contents = format!("**Image**\n\nURL: `{}`", url);
47 if !alt.is_empty() {
48 contents.push_str(&format!("\n\nAlt text: \"{}\"", alt));
49 }
50 Some(HoverInfo {
51 contents,
52 range: Some(*span),
53 })
54 }
55 NodeKind::CodeBlock { language, code } => {
56 let lang_info = language
57 .as_ref()
58 .map(|l| format!(" ({})", l))
59 .unwrap_or_default();
60 let line_count = code.lines().count();
61 Some(HoverInfo {
62 contents: format!(
63 "**Code Block{}**\n\n{} line{}",
64 lang_info,
65 line_count,
66 if line_count == 1 { "" } else { "s" }
67 ),
68 range: Some(*span),
69 })
70 }
71 NodeKind::CodeSpan(code) => Some(HoverInfo {
72 contents: format!("**Code Span**\n\n`{}`", code),
73 range: Some(*span),
74 }),
75 NodeKind::Heading { level, text, .. } => Some(HoverInfo {
76 contents: format!("**Heading Level {}**\n\n{}", level, text),
77 range: Some(*span),
78 }),
79 NodeKind::Emphasis => Some(HoverInfo {
80 contents: "**Emphasis** (italic)".to_string(),
81 range: Some(*span),
82 }),
83 NodeKind::Strong => Some(HoverInfo {
84 contents: "**Strong** (bold)".to_string(),
85 range: Some(*span),
86 }),
87 NodeKind::StrongEmphasis => Some(HoverInfo {
88 contents: "**Strong + Emphasis** (bold + italic)".to_string(),
89 range: Some(*span),
90 }),
91 NodeKind::Strikethrough => Some(HoverInfo {
92 contents: "**Strikethrough** (deleted text)".to_string(),
93 range: Some(*span),
94 }),
95 NodeKind::Mark => Some(HoverInfo {
96 contents: "**Mark** (highlight)".to_string(),
97 range: Some(*span),
98 }),
99 NodeKind::Superscript => Some(HoverInfo {
100 contents: "**Superscript**".to_string(),
101 range: Some(*span),
102 }),
103 NodeKind::Subscript => Some(HoverInfo {
104 contents: "**Subscript**".to_string(),
105 range: Some(*span),
106 }),
107 NodeKind::InlineHtml(html) => {
108 let preview = if html.chars().count() > 50 {
109 format!("{}...", substring_by_chars(html, 0, 50))
110 } else {
111 html.clone()
112 };
113 Some(HoverInfo {
114 contents: format!("**Inline HTML**\n\n```html\n{}\n```", preview),
115 range: Some(*span),
116 })
117 }
118 NodeKind::HardBreak => Some(HoverInfo {
119 contents: "**Hard Line Break**\n\nForces a line break in the output (renders as `<br />`)".to_string(),
120 range: Some(*span),
121 }),
122 NodeKind::SoftBreak => Some(HoverInfo {
123 contents: "**Soft Line Break**\n\nRendered as a space or newline depending on context".to_string(),
124 range: Some(*span),
125 }),
126 NodeKind::ThematicBreak => Some(HoverInfo {
127 contents: "**Thematic Break**\n\nHorizontal rule (renders as `<hr />`)".to_string(),
128 range: Some(*span),
129 }),
130 NodeKind::Blockquote => {
131 let child_count = node.children.len();
132 Some(HoverInfo {
133 contents: format!(
134 "**Block Quote**\n\nContains {} block element{}",
135 child_count,
136 if child_count == 1 { "" } else { "s" }
137 ),
138 range: Some(*span),
139 })
140 }
141 _ => None,
142 };
143 }
144 }
145
146 None
147}
148
149pub fn get_position_span(position: Position, document: &Document) -> Option<Span> {
156 for node in &document.children {
157 if let Some(span) = find_tightest_span_at(node, position) {
158 return Some(span);
159 }
160 }
161 None
162}
163
164fn find_tightest_span_at(node: &Node, position: Position) -> Option<Span> {
165 for child in &node.children {
167 if let Some(span) = find_tightest_span_at(child, position) {
168 return Some(span);
169 }
170 }
171 if let Some(span) = &node.span {
172 if position_in_span(position, span) {
173 return Some(*span);
174 }
175 }
176 None
177}
178
179fn position_in_span(position: Position, span: &Span) -> bool {
180 let pos_offset = position.offset;
181 pos_offset >= span.start.offset && pos_offset < span.end.offset
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::parser::parse;
189
190 fn pos(line: usize, column: usize, offset: usize) -> Position {
191 Position {
192 line,
193 column,
194 offset,
195 }
196 }
197
198 fn span(start_offset: usize, end_offset: usize) -> Span {
199 Span {
200 start: pos(1, start_offset + 1, start_offset),
201 end: pos(1, end_offset + 1, end_offset),
202 }
203 }
204
205 #[test]
206 fn smoke_test_hover_span_start_inclusive_end_exclusive() {
207 let link_span = span(5, 10);
208 let heading_span = span(0, 20);
209
210 let doc = Document {
211 children: vec![Node {
212 kind: NodeKind::Heading {
213 level: 2,
214 text: "Parent heading".to_string(),
215 id: None,
216 },
217 span: Some(heading_span),
218 children: vec![Node {
219 kind: NodeKind::Link {
220 url: "https://example.com".to_string(),
221 title: None,
222 },
223 span: Some(link_span),
224 children: vec![Node {
225 kind: NodeKind::Text("link".to_string()),
226 span: Some(link_span),
227 children: vec![],
228 }],
229 }],
230 }],
231 ..Default::default()
232 };
233
234 let at_start = get_hover_info(pos(1, 6, 5), &doc).expect("hover at child start");
236 assert!(at_start.contents.contains("**Link**"));
237
238 let at_end = get_hover_info(pos(1, 11, 10), &doc).expect("hover at child end");
240 assert!(at_end.contents.contains("**Heading Level 2**"));
241 assert!(!at_end.contents.contains("**Link**"));
242 }
243
244 #[test]
245 fn smoke_test_hover_deepest_node_wins_over_parent() {
246 let strong_span = span(2, 15);
247 let link_span = span(5, 12);
248
249 let doc = Document {
250 children: vec![Node {
251 kind: NodeKind::Paragraph,
252 span: Some(span(0, 20)),
253 children: vec![Node {
254 kind: NodeKind::Strong,
255 span: Some(strong_span),
256 children: vec![Node {
257 kind: NodeKind::Link {
258 url: "https://deep.example".to_string(),
259 title: Some("deep".to_string()),
260 },
261 span: Some(link_span),
262 children: vec![Node {
263 kind: NodeKind::Text("deep".to_string()),
264 span: Some(link_span),
265 children: vec![],
266 }],
267 }],
268 }],
269 }],
270 ..Default::default()
271 };
272
273 let hover = get_hover_info(pos(1, 7, 6), &doc).expect("hover inside nested nodes");
274 assert!(hover.contents.contains("**Link**"));
275 assert!(hover.contents.contains("https://deep.example"));
276 assert!(!hover.contents.contains("**Strong**"));
277 }
278
279 #[test]
280 fn smoke_test_hover_returns_none_at_top_level_end_boundary() {
281 let heading_span = span(0, 4);
282 let doc = Document {
283 children: vec![Node {
284 kind: NodeKind::Heading {
285 level: 1,
286 text: "Test".to_string(),
287 id: None,
288 },
289 span: Some(heading_span),
290 children: vec![],
291 }],
292 ..Default::default()
293 };
294
295 assert!(get_hover_info(pos(1, 5, 4), &doc).is_none());
297 }
298
299 fn offset_to_position(source: &str, offset: usize) -> Position {
300 let mut line = 1usize;
301 let mut line_start_offset = 0usize;
302
303 for (idx, ch) in source.char_indices() {
304 if idx >= offset {
305 break;
306 }
307 if ch == '\n' {
308 line += 1;
309 line_start_offset = idx + ch.len_utf8();
310 }
311 }
312
313 Position {
314 line,
315 column: offset.saturating_sub(line_start_offset) + 1,
316 offset,
317 }
318 }
319
320 fn first_link_span_in_doc(document: &Document) -> Option<Span> {
321 fn visit(node: &Node) -> Option<Span> {
322 if let (NodeKind::Link { .. }, Some(span)) = (&node.kind, node.span) {
323 return Some(span);
324 }
325
326 for child in &node.children {
327 if let Some(span) = visit(child) {
328 return Some(span);
329 }
330 }
331
332 None
333 }
334
335 for node in &document.children {
336 if let Some(span) = visit(node) {
337 return Some(span);
338 }
339 }
340
341 None
342 }
343
344 #[test]
345 fn smoke_test_parser_driven_hover_deepest_node_precedence() {
346 let source = "**[deep](https://example.com)**";
347 let doc = parse(source).expect("parse failed");
348
349 let inside_link_offset = source.find("deep").expect("missing token") + 1;
350 let position = offset_to_position(source, inside_link_offset);
351
352 let hover = get_hover_info(position, &doc).expect("hover should exist");
353 assert!(hover.contents.contains("**Link**"));
354 assert!(hover.contents.contains("https://example.com"));
355 assert!(!hover.contents.contains("**Strong**"));
356 }
357
358 #[test]
359 fn smoke_test_parser_driven_hover_link_span_boundaries() {
360 let source = "[hello](https://example.com) tail";
361 let doc = parse(source).expect("parse failed");
362 let link_span = first_link_span_in_doc(&doc).expect("link span not found");
363
364 let at_start = get_hover_info(offset_to_position(source, link_span.start.offset), &doc)
365 .expect("hover at link start");
366 assert!(at_start.contents.contains("**Link**"));
367
368 let at_end = get_hover_info(offset_to_position(source, link_span.end.offset), &doc);
370 assert!(at_end.is_none());
371 }
372
373 #[test]
374 fn smoke_test_parser_driven_hover_utf8_link_text_offsets() {
375 let source = "préfix [lïnk🎨](https://example.com) sufix";
376 let doc = parse(source).expect("parse failed");
377
378 let i_umlaut_offset = source.find("ï").expect("missing ï");
379 let emoji_offset = source.find("🎨").expect("missing emoji");
380
381 let hover_umlaut = get_hover_info(offset_to_position(source, i_umlaut_offset), &doc)
382 .expect("hover should exist at multibyte Latin character");
383 assert!(hover_umlaut.contents.contains("**Link**"));
384
385 let hover_emoji = get_hover_info(offset_to_position(source, emoji_offset), &doc)
386 .expect("hover should exist at emoji character");
387 assert!(hover_emoji.contents.contains("**Link**"));
388 assert!(hover_emoji.contents.contains("https://example.com"));
389 }
390
391 #[test]
392 fn smoke_test_parser_driven_hover_utf8_multiline_boundaries() {
393 let source = "αβγ\n[🎨x](https://example.com)\nend";
394 let doc = parse(source).expect("parse failed");
395 let link_span = first_link_span_in_doc(&doc).expect("link span not found");
396
397 let inside_offset = source.find("🎨").expect("missing emoji");
399 let hover_inside = get_hover_info(offset_to_position(source, inside_offset), &doc)
400 .expect("hover should exist inside utf8 multiline link");
401 assert!(hover_inside.contents.contains("**Link**"));
402
403 let at_end = get_hover_info(offset_to_position(source, link_span.end.offset), &doc);
405 assert!(at_end.is_none());
406 }
407
408 #[test]
409 fn smoke_test_hover_inline_html_preview_utf8_safe_truncation() {
410 let html = format!("{}🎨{}", "a".repeat(49), "b".repeat(10));
411 let doc = Document {
412 children: vec![Node {
413 kind: NodeKind::InlineHtml(html),
414 span: Some(span(0, 80)),
415 children: vec![],
416 }],
417 ..Default::default()
418 };
419
420 let hover = get_hover_info(pos(1, 2, 1), &doc)
421 .expect("hover should exist for inline html with utf8 preview");
422
423 assert!(hover.contents.contains("**Inline HTML**"));
424 assert!(hover.contents.contains("🎨"));
425 assert!(hover.contents.contains("..."));
426 }
427}