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