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
167fn 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 }
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 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 }
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 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 let result = hover(&doc, Position::new(1, 6));
437 assert!(result.is_none());
438 }
439}