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 !annotation.data.parameters.is_empty() {
139 let params = annotation
140 .data
141 .parameters
142 .iter()
143 .map(|param| format!("{}={}", param.key, param.value))
144 .collect::<Vec<_>>()
145 .join(", ");
146 parts.push(format!("Parameters: {params}"));
147 }
148 if let Some(preview) = preview_from_items(annotation.children.iter()) {
149 parts.push(preview);
150 }
151 if parts.is_empty() {
152 parts.push("(no content)".to_string());
153 }
154 HoverResult {
155 range: annotation.header_location().clone(),
156 contents: format!(
157 "**Annotation :: {} ::**\n\n{}",
158 annotation.data.label.value,
159 parts.join("\n\n")
160 ),
161 }
162}
163
164fn definition_subject_hover(document: &Document, position: Position) -> Option<HoverResult> {
165 let definition = find_definition_at_position(document, position)?;
166 let header = definition.header_location()?;
167 if !header.contains(position) {
168 return None;
169 }
170 let subject = definition.subject.as_string().trim().to_string();
171 let mut body_lines = Vec::new();
172 if let Some(preview) = preview_from_items(definition.children.iter()) {
173 body_lines.push(preview);
174 }
175 Some(HoverResult {
176 range: header.clone(),
177 contents: format!(
178 "**Definition: {}**\n\n{}",
179 subject,
180 if body_lines.is_empty() {
181 "(no content)".to_string()
182 } else {
183 body_lines.join("\n\n")
184 }
185 ),
186 })
187}
188
189fn session_hover(document: &Document, position: Position) -> Option<HoverResult> {
190 let session = find_session_at_position(document, position)?;
191 let header = session.header_location()?;
192
193 let mut parts = Vec::new();
194 let title = session.title.as_string().trim();
195
196 if let Some(identifier) = session_identifier(session) {
197 parts.push(format!("Identifier: {identifier}"));
198 }
199
200 let child_count = session.children.len();
201 if child_count > 0 {
202 parts.push(format!("{child_count} item(s)"));
203 }
204
205 if let Some(preview) = preview_from_items(session.children.iter()) {
206 parts.push(preview);
207 }
208
209 Some(HoverResult {
210 range: header.clone(),
211 contents: format!(
212 "**Session: {}**\n\n{}",
213 title,
214 if parts.is_empty() {
215 "(no content)".to_string()
216 } else {
217 parts.join("\n\n")
218 }
219 ),
220 })
221}
222
223fn preview_from_items<'a>(items: impl Iterator<Item = &'a ContentItem>) -> Option<String> {
224 let mut lines = Vec::new();
225 collect_preview(items, &mut lines, 3);
226 if lines.is_empty() {
227 None
228 } else {
229 Some(lines.join("\n"))
230 }
231}
232
233fn collect_preview<'a>(
234 items: impl Iterator<Item = &'a ContentItem>,
235 lines: &mut Vec<String>,
236 limit: usize,
237) {
238 for item in items {
239 if lines.len() >= limit {
240 break;
241 }
242 match item {
243 ContentItem::Paragraph(paragraph) => {
244 let text = paragraph.text().trim().to_string();
245 if !text.is_empty() {
246 lines.push(text);
247 }
248 }
249 ContentItem::ListItem(list_item) => {
250 let text = list_item.text().trim().to_string();
251 if !text.is_empty() {
252 lines.push(text);
253 }
254 }
255 ContentItem::List(list) => {
256 for entry in list.items.iter() {
257 if let ContentItem::ListItem(list_item) = entry {
258 let text = list_item.text().trim().to_string();
259 if !text.is_empty() {
260 lines.push(text);
261 }
262 if lines.len() >= limit {
263 break;
264 }
265 }
266 }
267 }
268 ContentItem::Definition(definition) => {
269 let subject = definition.subject.as_string().trim().to_string();
270 if !subject.is_empty() {
271 lines.push(subject);
272 }
273 collect_preview(definition.children.iter(), lines, limit);
274 }
275 ContentItem::Annotation(annotation) => {
276 collect_preview(annotation.children.iter(), lines, limit);
277 }
278 ContentItem::Session(session) => {
279 collect_preview(session.children.iter(), lines, limit);
280 }
281 ContentItem::VerbatimBlock(verbatim) => {
282 for group in verbatim.group() {
283 if lines.len() >= limit {
284 break;
285 }
286 let subject = group.subject.as_string().trim().to_string();
287 if !subject.is_empty() {
288 lines.push(subject);
289 }
290 }
291 }
292 ContentItem::Table(_)
293 | ContentItem::TextLine(_)
294 | ContentItem::VerbatimLine(_)
295 | ContentItem::BlankLineGroup(_) => {}
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::test_support::{sample_document, sample_source};
304
305 fn position_for(needle: &str) -> Position {
306 let source = sample_source();
307 let index = source
308 .find(needle)
309 .unwrap_or_else(|| panic!("{needle} not found"));
310 let mut line = 0;
311 let mut column = 0;
312 for ch in source[..index].chars() {
313 if ch == '\n' {
314 line += 1;
315 column = 0;
316 } else {
317 column += ch.len_utf8();
318 }
319 }
320 Position::new(line, column)
321 }
322
323 #[test]
324 fn hover_shows_definition_preview_for_general_reference() {
325 }
336
337 #[test]
338 fn hover_shows_footnote_content() {
339 let document = sample_document();
340 let position = position_for("::source]");
341 let hover = hover(&document, position).expect("hover expected");
342 assert!(hover.contents.contains("source"));
345 }
346
347 #[test]
348 fn hover_shows_citation_details() {
349 let document = sample_document();
350 let position = position_for("@spec2025 p.4]");
351 let hover = hover(&document, position).expect("hover expected");
352 assert!(hover.contents.contains("Citation"));
353 assert!(hover.contents.contains("spec2025"));
354 }
355
356 #[test]
357 fn hover_shows_annotation_metadata() {
358 }
380
381 #[test]
382 fn hover_returns_none_for_invalid_position() {
383 let document = sample_document();
384 let position = Position::new(999, 0);
385 assert!(hover(&document, position).is_none());
386 }
387
388 #[test]
389 fn hover_shows_session_info() {
390 let document = sample_document();
391 let position = position_for("1. Intro");
392 let hover = hover(&document, position).expect("hover expected for session");
393 assert!(hover.contents.contains("Session"));
394 assert!(hover.contents.contains("Intro"));
395 }
396
397 #[test]
398 fn hover_on_definition_subject_shows_body_preview() {
399 use lex_core::lex::parsing;
400 let doc = parsing::parse_document("Term:\n The definition body.\n").unwrap();
401 let result =
403 hover(&doc, Position::new(0, 1)).expect("hover expected on definition subject");
404 assert!(result.contents.contains("Definition"));
405 assert!(result.contents.contains("Term"));
406 assert!(result.contents.contains("definition body"));
407 }
408
409 #[test]
410 fn hover_on_definition_body_returns_none() {
411 use lex_core::lex::parsing;
412 let doc = parsing::parse_document("Term:\n The definition body.\n").unwrap();
413 let result = hover(&doc, Position::new(1, 6));
415 assert!(result.is_none());
416 }
417}