Skip to main content

marco_core/intelligence/editor/
hover.rs

1// Hover information: show link URLs, image alt text, etc.
2
3use crate::logic::utf8::substring_by_chars;
4use crate::parser::{Document, Node, NodeKind, Position, Span};
5
6#[derive(Debug, Clone)]
7/// Hover payload returned for a cursor position.
8pub struct HoverInfo {
9    /// Markdown-formatted hover contents.
10    pub contents: String,
11    /// Optional source range covered by this hover payload.
12    pub range: Option<Span>,
13}
14
15/// Resolve hover information at the given source position.
16pub 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    // Deepest node wins: search children first.
28    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
153/// Returns the span of the tightest (deepest) AST node covering `position`,
154/// regardless of whether that node has meaningful hover content.
155///
156/// This is used to detect when a diagnostic's span is wider than the specific
157/// node the cursor is actually on — in that case the diagnostic should be
158/// suppressed rather than shown over unrelated plain text.
159pub 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    // Deepest node (tightest span) wins: check children first.
170    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    // Span is [start, end) end-exclusive.
186    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        // Child start offset is included.
239        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        // Child end offset is excluded; parent hover should win here.
243        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        // End boundary is exclusive, so hover should not trigger at offset=end.
300        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        // End boundary is exclusive.
373        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        // Confirm hover resolves correctly on line 2 despite multibyte chars on line 1.
402        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        // End is still exclusive even with UTF-8 and line breaks involved.
408        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}