Skip to main content

lex_analysis/
hover.rs

1use crate::utils::{
2    find_annotation_at_position, find_definition_at_position, find_definition_by_subject,
3    find_session_at_position, reference_at_position, session_identifier,
4};
5use lex_core::lex::ast::{Annotation, ContentItem, Document, Position, Range};
6use lex_core::lex::inlines::ReferenceType;
7
8#[derive(Debug, Clone, PartialEq)]
9pub struct HoverResult {
10    pub range: Range,
11    pub contents: String,
12}
13
14pub fn hover(document: &Document, position: Position) -> Option<HoverResult> {
15    inline_hover(document, position)
16        .or_else(|| annotation_hover(document, position))
17        .or_else(|| definition_subject_hover(document, position))
18        .or_else(|| session_hover(document, position))
19}
20
21fn inline_hover(document: &Document, position: Position) -> Option<HoverResult> {
22    let reference = reference_at_position(document, position)?;
23    hover_for_reference(
24        document,
25        &reference.range,
26        &reference.raw,
27        reference.reference_type,
28    )
29}
30
31fn hover_for_reference(
32    document: &Document,
33    range: &Range,
34    raw: &str,
35    reference_type: ReferenceType,
36) -> Option<HoverResult> {
37    match reference_type {
38        ReferenceType::AnnotationReference { label } => {
39            annotation_ref_hover(document, range.clone(), &label)
40                .or_else(|| Some(generic_reference(range.clone(), raw)))
41        }
42        ReferenceType::FootnoteNumber { number } => {
43            footnote_number_hover(document, range.clone(), number)
44                .or_else(|| Some(generic_reference(range.clone(), raw)))
45        }
46        ReferenceType::Citation(data) => {
47            let mut lines = vec![format!("Keys: {}", data.keys.join(", "))];
48            if let Some(locator) = data.locator {
49                lines.push(format!("Locator: {}", locator.raw));
50            }
51            Some(HoverResult {
52                range: range.clone(),
53                contents: format!("**Citation**\n\n{}", lines.join("\n")),
54            })
55        }
56        ReferenceType::General { target } => {
57            definition_hover(document, range.clone(), target.trim())
58                .or_else(|| Some(generic_reference(range.clone(), raw)))
59        }
60        ReferenceType::Url { target } => Some(HoverResult {
61            range: range.clone(),
62            contents: format!("**Link**\n\n{target}"),
63        }),
64        ReferenceType::File { target } => Some(HoverResult {
65            range: range.clone(),
66            contents: format!("**File Reference**\n\n{target}"),
67        }),
68        ReferenceType::Session { target } => Some(HoverResult {
69            range: range.clone(),
70            contents: format!("**Session Reference**\n\n{target}"),
71        }),
72        _ => Some(generic_reference(range.clone(), raw)),
73    }
74}
75
76fn generic_reference(range: Range, raw: &str) -> HoverResult {
77    HoverResult {
78        range,
79        contents: format!("**Reference**\n\n{}", raw.trim()),
80    }
81}
82
83fn annotation_ref_hover(document: &Document, range: Range, label: &str) -> Option<HoverResult> {
84    let annotation = document.find_annotation_by_label(label)?;
85    let mut lines = Vec::new();
86    if let Some(preview) = preview_from_items(annotation.children.iter()) {
87        lines.push(preview);
88    }
89    if lines.is_empty() {
90        lines.push("(no content)".to_string());
91    }
92    Some(HoverResult {
93        range,
94        contents: format!("**Annotation [::{}]**\n\n{}", label, lines.join("\n\n")),
95    })
96}
97
98fn footnote_number_hover(document: &Document, range: Range, number: u32) -> Option<HoverResult> {
99    let defs = crate::utils::collect_footnote_definitions(document);
100    let number_str = number.to_string();
101    for (label, _) in &defs {
102        if label == &number_str {
103            return Some(HoverResult {
104                range,
105                contents: format!("**Footnote [{number}]**"),
106            });
107        }
108    }
109    None
110}
111
112fn definition_hover(document: &Document, range: Range, target: &str) -> Option<HoverResult> {
113    let definition = find_definition_by_subject(document, target)?;
114    let mut body_lines = Vec::new();
115    if let Some(preview) = preview_from_items(definition.children.iter()) {
116        body_lines.push(preview);
117    }
118    Some(HoverResult {
119        range,
120        contents: format!(
121            "**Definition: {}**\n\n{}",
122            target,
123            if body_lines.is_empty() {
124                "(no content)".to_string()
125            } else {
126                body_lines.join("\n\n")
127            }
128        ),
129    })
130}
131
132fn annotation_hover(document: &Document, position: Position) -> Option<HoverResult> {
133    find_annotation_at_position(document, position).map(annotation_hover_result)
134}
135
136fn annotation_hover_result(annotation: &Annotation) -> HoverResult {
137    let mut parts = Vec::new();
138    if let Some(form_line) = label_form_hover_line(&annotation.data.label) {
139        parts.push(form_line);
140    }
141    if !annotation.data.parameters.is_empty() {
142        let params = annotation
143            .data
144            .parameters
145            .iter()
146            .map(|param| format!("{}={}", param.key, param.value))
147            .collect::<Vec<_>>()
148            .join(", ");
149        parts.push(format!("Parameters: {params}"));
150    }
151    if let Some(preview) = preview_from_items(annotation.children.iter()) {
152        parts.push(preview);
153    }
154    if parts.is_empty() {
155        parts.push("(no content)".to_string());
156    }
157    HoverResult {
158        range: annotation.header_location().clone(),
159        contents: format!(
160            "**Annotation :: {} ::**\n\n{}",
161            annotation.data.label.value,
162            parts.join("\n\n")
163        ),
164    }
165}
166
167/// Build a one-liner explaining what alias form a label was authored
168/// in, for hover content. Consults the parser-recorded
169/// [`Label.form`](lex_core::lex::ast::elements::label::Label) field
170/// directly — by the time hover runs, `NormalizeLabels` has already
171/// rewritten `label.value` to the canonical for accepted labels, so
172/// re-classifying the value would always return `Canonical`. The
173/// `form` field is the source of truth for what the user wrote;
174/// `label.value` is the resolved canonical to pair it with. PR 4 of
175/// #584.
176fn label_form_hover_line(label: &lex_core::lex::ast::Label) -> Option<String> {
177    use lex_core::lex::ast::elements::label::LabelForm;
178    match label.form {
179        LabelForm::Shortcut => Some(format!("Shortcut for `{}`", label.value)),
180        LabelForm::Stripped => Some(format!("Prefix-stripped form of `{}`", label.value)),
181        LabelForm::Community => Some("Community label".to_string()),
182        LabelForm::Canonical => None,
183    }
184}
185
186fn definition_subject_hover(document: &Document, position: Position) -> Option<HoverResult> {
187    let definition = find_definition_at_position(document, position)?;
188    let header = definition.header_location()?;
189    if !header.contains(position) {
190        return None;
191    }
192    let subject = definition.subject.as_string().trim().to_string();
193    let mut body_lines = Vec::new();
194    if let Some(preview) = preview_from_items(definition.children.iter()) {
195        body_lines.push(preview);
196    }
197    Some(HoverResult {
198        range: header.clone(),
199        contents: format!(
200            "**Definition: {}**\n\n{}",
201            subject,
202            if body_lines.is_empty() {
203                "(no content)".to_string()
204            } else {
205                body_lines.join("\n\n")
206            }
207        ),
208    })
209}
210
211fn session_hover(document: &Document, position: Position) -> Option<HoverResult> {
212    let session = find_session_at_position(document, position)?;
213    let header = session.header_location()?;
214
215    let mut parts = Vec::new();
216    let title = session.title.as_string().trim();
217
218    if let Some(identifier) = session_identifier(session) {
219        parts.push(format!("Identifier: {identifier}"));
220    }
221
222    let child_count = session.children.len();
223    if child_count > 0 {
224        parts.push(format!("{child_count} item(s)"));
225    }
226
227    if let Some(preview) = preview_from_items(session.children.iter()) {
228        parts.push(preview);
229    }
230
231    Some(HoverResult {
232        range: header.clone(),
233        contents: format!(
234            "**Session: {}**\n\n{}",
235            title,
236            if parts.is_empty() {
237                "(no content)".to_string()
238            } else {
239                parts.join("\n\n")
240            }
241        ),
242    })
243}
244
245fn preview_from_items<'a>(items: impl Iterator<Item = &'a ContentItem>) -> Option<String> {
246    let mut lines = Vec::new();
247    collect_preview(items, &mut lines, 3);
248    if lines.is_empty() {
249        None
250    } else {
251        Some(lines.join("\n"))
252    }
253}
254
255fn collect_preview<'a>(
256    items: impl Iterator<Item = &'a ContentItem>,
257    lines: &mut Vec<String>,
258    limit: usize,
259) {
260    for item in items {
261        if lines.len() >= limit {
262            break;
263        }
264        match item {
265            ContentItem::Paragraph(paragraph) => {
266                let text = paragraph.text().trim().to_string();
267                if !text.is_empty() {
268                    lines.push(text);
269                }
270            }
271            ContentItem::ListItem(list_item) => {
272                let text = list_item.text().trim().to_string();
273                if !text.is_empty() {
274                    lines.push(text);
275                }
276            }
277            ContentItem::List(list) => {
278                for entry in list.items.iter() {
279                    if let ContentItem::ListItem(list_item) = entry {
280                        let text = list_item.text().trim().to_string();
281                        if !text.is_empty() {
282                            lines.push(text);
283                        }
284                        if lines.len() >= limit {
285                            break;
286                        }
287                    }
288                }
289            }
290            ContentItem::Definition(definition) => {
291                let subject = definition.subject.as_string().trim().to_string();
292                if !subject.is_empty() {
293                    lines.push(subject);
294                }
295                collect_preview(definition.children.iter(), lines, limit);
296            }
297            ContentItem::Annotation(annotation) => {
298                collect_preview(annotation.children.iter(), lines, limit);
299            }
300            ContentItem::Session(session) => {
301                collect_preview(session.children.iter(), lines, limit);
302            }
303            ContentItem::VerbatimBlock(verbatim) => {
304                for group in verbatim.group() {
305                    if lines.len() >= limit {
306                        break;
307                    }
308                    let subject = group.subject.as_string().trim().to_string();
309                    if !subject.is_empty() {
310                        lines.push(subject);
311                    }
312                }
313            }
314            ContentItem::Table(_)
315            | ContentItem::TextLine(_)
316            | ContentItem::VerbatimLine(_)
317            | ContentItem::BlankLineGroup(_) => {}
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::test_support::{sample_document, sample_source};
326
327    fn position_for(needle: &str) -> Position {
328        let source = sample_source();
329        let index = source
330            .find(needle)
331            .unwrap_or_else(|| panic!("{needle} not found"));
332        let mut line = 0;
333        let mut column = 0;
334        for ch in source[..index].chars() {
335            if ch == '\n' {
336                line += 1;
337                column = 0;
338            } else {
339                column += ch.len_utf8();
340            }
341        }
342        Position::new(line, column)
343    }
344
345    #[test]
346    fn hover_shows_definition_preview_for_general_reference() {
347        // Disabled: "Cache" is parsed as a Verbatim Block in the current benchmark fixture
348        // because it is followed by an indented block and a line starting with "::" (callout),
349        // which matches the Verbatim Block pattern (Subject + Container + Closing Marker).
350        /*
351        let document = sample_document();
352        let position = position_for("Cache]");
353        let hover = hover(&document, position).expect("hover expected");
354        assert!(hover.contents.contains("Definition"));
355        assert!(hover.contents.contains("definition body"));
356        */
357    }
358
359    #[test]
360    fn hover_shows_footnote_content() {
361        let document = sample_document();
362        let position = position_for("::source]");
363        let hover = hover(&document, position).expect("hover expected");
364        // In the updated fixture, footnotes are list items, not annotations
365        // So hover shows generic reference info
366        assert!(hover.contents.contains("source"));
367    }
368
369    #[test]
370    fn hover_shows_citation_details() {
371        let document = sample_document();
372        let position = position_for("@spec2025 p.4]");
373        let hover = hover(&document, position).expect("hover expected");
374        assert!(hover.contents.contains("Citation"));
375        assert!(hover.contents.contains("spec2025"));
376    }
377
378    #[test]
379    fn hover_shows_annotation_metadata() {
380        // Disabled: ":: callout ::" is consumed as the footer of the "Cache" Verbatim Block.
381        /*
382        let document = sample_document();
383        let mut position = None;
384        for item in document.root.children.iter() {
385            if let ContentItem::Session(session) = item {
386                for child in session.children.iter() {
387                    if let ContentItem::Definition(definition) = child {
388                        if let Some(annotation) = definition.annotations().first() {
389                            position = Some(annotation.header_location().start);
390                        }
391                    }
392                }
393            }
394        }
395        let position = position.expect("annotation position");
396        let hover = hover(&document, position).expect("hover expected");
397        assert!(hover.contents.contains("Annotation"));
398        assert!(hover.contents.contains("callout"));
399        assert!(hover.contents.contains("Session-level annotation body"));
400        */
401    }
402
403    #[test]
404    fn hover_returns_none_for_invalid_position() {
405        let document = sample_document();
406        let position = Position::new(999, 0);
407        assert!(hover(&document, position).is_none());
408    }
409
410    #[test]
411    fn hover_shows_session_info() {
412        let document = sample_document();
413        let position = position_for("1. Intro");
414        let hover = hover(&document, position).expect("hover expected for session");
415        assert!(hover.contents.contains("Session"));
416        assert!(hover.contents.contains("Intro"));
417    }
418
419    #[test]
420    fn hover_on_definition_subject_shows_body_preview() {
421        use lex_core::lex::parsing;
422        let doc = parsing::parse_document("Term:\n    The definition body.\n").unwrap();
423        // Position on the subject line "Term"
424        let result =
425            hover(&doc, Position::new(0, 1)).expect("hover expected on definition subject");
426        assert!(result.contents.contains("Definition"));
427        assert!(result.contents.contains("Term"));
428        assert!(result.contents.contains("definition body"));
429    }
430
431    #[test]
432    fn hover_on_definition_body_returns_none() {
433        use lex_core::lex::parsing;
434        let doc = parsing::parse_document("Term:\n    The definition body.\n").unwrap();
435        // Position inside the body, not on the subject
436        let result = hover(&doc, Position::new(1, 6));
437        assert!(result.is_none());
438    }
439}