use crate::utils::{
find_annotation_at_position, find_definition_at_position, find_definition_by_subject,
find_session_at_position, reference_at_position, session_identifier,
};
use lex_core::lex::ast::{Annotation, ContentItem, Document, Position, Range};
use lex_core::lex::inlines::ReferenceType;
#[derive(Debug, Clone, PartialEq)]
pub struct HoverResult {
pub range: Range,
pub contents: String,
}
pub fn hover(document: &Document, position: Position) -> Option<HoverResult> {
inline_hover(document, position)
.or_else(|| annotation_hover(document, position))
.or_else(|| definition_subject_hover(document, position))
.or_else(|| session_hover(document, position))
}
fn inline_hover(document: &Document, position: Position) -> Option<HoverResult> {
let reference = reference_at_position(document, position)?;
hover_for_reference(
document,
&reference.range,
&reference.raw,
reference.reference_type,
)
}
fn hover_for_reference(
document: &Document,
range: &Range,
raw: &str,
reference_type: ReferenceType,
) -> Option<HoverResult> {
match reference_type {
ReferenceType::AnnotationReference { label } => {
annotation_ref_hover(document, range.clone(), &label)
.or_else(|| Some(generic_reference(range.clone(), raw)))
}
ReferenceType::FootnoteNumber { number } => {
footnote_number_hover(document, range.clone(), number)
.or_else(|| Some(generic_reference(range.clone(), raw)))
}
ReferenceType::Citation(data) => {
let mut lines = vec![format!("Keys: {}", data.keys.join(", "))];
if let Some(locator) = data.locator {
lines.push(format!("Locator: {}", locator.raw));
}
Some(HoverResult {
range: range.clone(),
contents: format!("**Citation**\n\n{}", lines.join("\n")),
})
}
ReferenceType::General { target } => {
definition_hover(document, range.clone(), target.trim())
.or_else(|| Some(generic_reference(range.clone(), raw)))
}
ReferenceType::Url { target } => Some(HoverResult {
range: range.clone(),
contents: format!("**Link**\n\n{target}"),
}),
ReferenceType::File { target } => Some(HoverResult {
range: range.clone(),
contents: format!("**File Reference**\n\n{target}"),
}),
ReferenceType::Session { target } => Some(HoverResult {
range: range.clone(),
contents: format!("**Session Reference**\n\n{target}"),
}),
_ => Some(generic_reference(range.clone(), raw)),
}
}
fn generic_reference(range: Range, raw: &str) -> HoverResult {
HoverResult {
range,
contents: format!("**Reference**\n\n{}", raw.trim()),
}
}
fn annotation_ref_hover(document: &Document, range: Range, label: &str) -> Option<HoverResult> {
let annotation = document.find_annotation_by_label(label)?;
let mut lines = Vec::new();
if let Some(preview) = preview_from_items(annotation.children.iter()) {
lines.push(preview);
}
if lines.is_empty() {
lines.push("(no content)".to_string());
}
Some(HoverResult {
range,
contents: format!("**Annotation [::{}]**\n\n{}", label, lines.join("\n\n")),
})
}
fn footnote_number_hover(document: &Document, range: Range, number: u32) -> Option<HoverResult> {
let defs = crate::utils::collect_footnote_definitions(document);
let number_str = number.to_string();
for (label, _) in &defs {
if label == &number_str {
return Some(HoverResult {
range,
contents: format!("**Footnote [{number}]**"),
});
}
}
None
}
fn definition_hover(document: &Document, range: Range, target: &str) -> Option<HoverResult> {
let definition = find_definition_by_subject(document, target)?;
let mut body_lines = Vec::new();
if let Some(preview) = preview_from_items(definition.children.iter()) {
body_lines.push(preview);
}
Some(HoverResult {
range,
contents: format!(
"**Definition: {}**\n\n{}",
target,
if body_lines.is_empty() {
"(no content)".to_string()
} else {
body_lines.join("\n\n")
}
),
})
}
fn annotation_hover(document: &Document, position: Position) -> Option<HoverResult> {
find_annotation_at_position(document, position).map(annotation_hover_result)
}
fn annotation_hover_result(annotation: &Annotation) -> HoverResult {
let mut parts = Vec::new();
if let Some(form_line) = label_form_hover_line(&annotation.data.label) {
parts.push(form_line);
}
if !annotation.data.parameters.is_empty() {
let params = annotation
.data
.parameters
.iter()
.map(|param| format!("{}={}", param.key, param.value))
.collect::<Vec<_>>()
.join(", ");
parts.push(format!("Parameters: {params}"));
}
if let Some(preview) = preview_from_items(annotation.children.iter()) {
parts.push(preview);
}
if parts.is_empty() {
parts.push("(no content)".to_string());
}
HoverResult {
range: annotation.header_location().clone(),
contents: format!(
"**Annotation :: {} ::**\n\n{}",
annotation.data.label.value,
parts.join("\n\n")
),
}
}
fn label_form_hover_line(label: &lex_core::lex::ast::Label) -> Option<String> {
use lex_core::lex::ast::elements::label::LabelForm;
match label.form {
LabelForm::Shortcut => Some(format!("Shortcut for `{}`", label.value)),
LabelForm::Stripped => Some(format!("Prefix-stripped form of `{}`", label.value)),
LabelForm::Community => Some("Community label".to_string()),
LabelForm::Canonical => None,
}
}
fn definition_subject_hover(document: &Document, position: Position) -> Option<HoverResult> {
let definition = find_definition_at_position(document, position)?;
let header = definition.header_location()?;
if !header.contains(position) {
return None;
}
let subject = definition.subject.as_string().trim().to_string();
let mut body_lines = Vec::new();
if let Some(preview) = preview_from_items(definition.children.iter()) {
body_lines.push(preview);
}
Some(HoverResult {
range: header.clone(),
contents: format!(
"**Definition: {}**\n\n{}",
subject,
if body_lines.is_empty() {
"(no content)".to_string()
} else {
body_lines.join("\n\n")
}
),
})
}
fn session_hover(document: &Document, position: Position) -> Option<HoverResult> {
let session = find_session_at_position(document, position)?;
let header = session.header_location()?;
let mut parts = Vec::new();
let title = session.title.as_string().trim();
if let Some(identifier) = session_identifier(session) {
parts.push(format!("Identifier: {identifier}"));
}
let child_count = session.children.len();
if child_count > 0 {
parts.push(format!("{child_count} item(s)"));
}
if let Some(preview) = preview_from_items(session.children.iter()) {
parts.push(preview);
}
Some(HoverResult {
range: header.clone(),
contents: format!(
"**Session: {}**\n\n{}",
title,
if parts.is_empty() {
"(no content)".to_string()
} else {
parts.join("\n\n")
}
),
})
}
fn preview_from_items<'a>(items: impl Iterator<Item = &'a ContentItem>) -> Option<String> {
let mut lines = Vec::new();
collect_preview(items, &mut lines, 3);
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn collect_preview<'a>(
items: impl Iterator<Item = &'a ContentItem>,
lines: &mut Vec<String>,
limit: usize,
) {
for item in items {
if lines.len() >= limit {
break;
}
match item {
ContentItem::Paragraph(paragraph) => {
let text = paragraph.text().trim().to_string();
if !text.is_empty() {
lines.push(text);
}
}
ContentItem::ListItem(list_item) => {
let text = list_item.text().trim().to_string();
if !text.is_empty() {
lines.push(text);
}
}
ContentItem::List(list) => {
for entry in list.items.iter() {
if let ContentItem::ListItem(list_item) = entry {
let text = list_item.text().trim().to_string();
if !text.is_empty() {
lines.push(text);
}
if lines.len() >= limit {
break;
}
}
}
}
ContentItem::Definition(definition) => {
let subject = definition.subject.as_string().trim().to_string();
if !subject.is_empty() {
lines.push(subject);
}
collect_preview(definition.children.iter(), lines, limit);
}
ContentItem::Annotation(annotation) => {
collect_preview(annotation.children.iter(), lines, limit);
}
ContentItem::Session(session) => {
collect_preview(session.children.iter(), lines, limit);
}
ContentItem::VerbatimBlock(verbatim) => {
for group in verbatim.group() {
if lines.len() >= limit {
break;
}
let subject = group.subject.as_string().trim().to_string();
if !subject.is_empty() {
lines.push(subject);
}
}
}
ContentItem::Table(_)
| ContentItem::TextLine(_)
| ContentItem::VerbatimLine(_)
| ContentItem::BlankLineGroup(_) => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{sample_document, sample_source};
fn position_for(needle: &str) -> Position {
let source = sample_source();
let index = source
.find(needle)
.unwrap_or_else(|| panic!("{needle} not found"));
let mut line = 0;
let mut column = 0;
for ch in source[..index].chars() {
if ch == '\n' {
line += 1;
column = 0;
} else {
column += ch.len_utf8();
}
}
Position::new(line, column)
}
#[test]
fn hover_shows_definition_preview_for_general_reference() {
}
#[test]
fn hover_shows_footnote_content() {
let document = sample_document();
let position = position_for("::source]");
let hover = hover(&document, position).expect("hover expected");
assert!(hover.contents.contains("source"));
}
#[test]
fn hover_shows_citation_details() {
let document = sample_document();
let position = position_for("@spec2025 p.4]");
let hover = hover(&document, position).expect("hover expected");
assert!(hover.contents.contains("Citation"));
assert!(hover.contents.contains("spec2025"));
}
#[test]
fn hover_shows_annotation_metadata() {
}
#[test]
fn hover_returns_none_for_invalid_position() {
let document = sample_document();
let position = Position::new(999, 0);
assert!(hover(&document, position).is_none());
}
#[test]
fn hover_shows_session_info() {
let document = sample_document();
let position = position_for("1. Intro");
let hover = hover(&document, position).expect("hover expected for session");
assert!(hover.contents.contains("Session"));
assert!(hover.contents.contains("Intro"));
}
#[test]
fn hover_on_definition_subject_shows_body_preview() {
use lex_core::lex::parsing;
let doc = parsing::parse_document("Term:\n The definition body.\n").unwrap();
let result =
hover(&doc, Position::new(0, 1)).expect("hover expected on definition subject");
assert!(result.contents.contains("Definition"));
assert!(result.contents.contains("Term"));
assert!(result.contents.contains("definition body"));
}
#[test]
fn hover_on_definition_body_returns_none() {
use lex_core::lex::parsing;
let doc = parsing::parse_document("Term:\n The definition body.\n").unwrap();
let result = hover(&doc, Position::new(1, 6));
assert!(result.is_none());
}
}