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)]
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    // Deepest node wins: search children first.
24    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
149/// Returns the span of the tightest (deepest) AST node covering `position`,
150/// regardless of whether that node has meaningful hover content.
151///
152/// This is used to detect when a diagnostic's span is wider than the specific
153/// node the cursor is actually on — in that case the diagnostic should be
154/// suppressed rather than shown over unrelated plain text.
155pub 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    // Deepest node (tightest span) wins: check children first.
166    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    // Span is [start, end) end-exclusive.
182    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        // Child start offset is included.
235        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        // Child end offset is excluded; parent hover should win here.
239        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        // End boundary is exclusive, so hover should not trigger at offset=end.
296        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        // End boundary is exclusive.
369        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        // Confirm hover resolves correctly on line 2 despite multibyte chars on line 1.
398        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        // End is still exclusive even with UTF-8 and line breaks involved.
404        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}