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::FootnoteLabeled { label } => footnote_hover(document, range.clone(), &label)
39 .or_else(|| Some(generic_reference(range.clone(), raw))),
40 ReferenceType::FootnoteNumber { number } => {
41 footnote_hover(document, range.clone(), &number.to_string())
42 .or_else(|| Some(generic_reference(range.clone(), raw)))
43 }
44 ReferenceType::Citation(data) => {
45 let mut lines = vec![format!("Keys: {}", data.keys.join(", "))];
46 if let Some(locator) = data.locator {
47 lines.push(format!("Locator: {}", locator.raw));
48 }
49 Some(HoverResult {
50 range: range.clone(),
51 contents: format!("**Citation**\n\n{}", lines.join("\n")),
52 })
53 }
54 ReferenceType::General { target } => {
55 definition_hover(document, range.clone(), target.trim())
56 .or_else(|| Some(generic_reference(range.clone(), raw)))
57 }
58 ReferenceType::Url { target } => Some(HoverResult {
59 range: range.clone(),
60 contents: format!("**Link**\n\n{target}"),
61 }),
62 ReferenceType::File { target } => Some(HoverResult {
63 range: range.clone(),
64 contents: format!("**File Reference**\n\n{target}"),
65 }),
66 ReferenceType::Session { target } => Some(HoverResult {
67 range: range.clone(),
68 contents: format!("**Session Reference**\n\n{target}"),
69 }),
70 _ => Some(generic_reference(range.clone(), raw)),
71 }
72}
73
74fn generic_reference(range: Range, raw: &str) -> HoverResult {
75 HoverResult {
76 range,
77 contents: format!("**Reference**\n\n{}", raw.trim()),
78 }
79}
80
81fn footnote_hover(document: &Document, range: Range, label: &str) -> Option<HoverResult> {
82 let annotation = document.find_annotation_by_label(label)?;
83 let mut lines = Vec::new();
84 if let Some(preview) = preview_from_items(annotation.children.iter()) {
85 lines.push(preview);
86 }
87 if lines.is_empty() {
88 lines.push("(no content)".to_string());
89 }
90 Some(HoverResult {
91 range,
92 contents: format!("**Footnote [{}]**\n\n{}", label, lines.join("\n\n")),
93 })
94}
95
96fn definition_hover(document: &Document, range: Range, target: &str) -> Option<HoverResult> {
97 let definition = find_definition_by_subject(document, target)?;
98 let mut body_lines = Vec::new();
99 if let Some(preview) = preview_from_items(definition.children.iter()) {
100 body_lines.push(preview);
101 }
102 Some(HoverResult {
103 range,
104 contents: format!(
105 "**Definition: {}**\n\n{}",
106 target,
107 if body_lines.is_empty() {
108 "(no content)".to_string()
109 } else {
110 body_lines.join("\n\n")
111 }
112 ),
113 })
114}
115
116fn annotation_hover(document: &Document, position: Position) -> Option<HoverResult> {
117 find_annotation_at_position(document, position).map(annotation_hover_result)
118}
119
120fn annotation_hover_result(annotation: &Annotation) -> HoverResult {
121 let mut parts = Vec::new();
122 if !annotation.data.parameters.is_empty() {
123 let params = annotation
124 .data
125 .parameters
126 .iter()
127 .map(|param| format!("{}={}", param.key, param.value))
128 .collect::<Vec<_>>()
129 .join(", ");
130 parts.push(format!("Parameters: {params}"));
131 }
132 if let Some(preview) = preview_from_items(annotation.children.iter()) {
133 parts.push(preview);
134 }
135 if parts.is_empty() {
136 parts.push("(no content)".to_string());
137 }
138 HoverResult {
139 range: annotation.header_location().clone(),
140 contents: format!(
141 "**Annotation :: {} ::**\n\n{}",
142 annotation.data.label.value,
143 parts.join("\n\n")
144 ),
145 }
146}
147
148fn definition_subject_hover(document: &Document, position: Position) -> Option<HoverResult> {
149 let definition = find_definition_at_position(document, position)?;
150 let header = definition.header_location()?;
151 if !header.contains(position) {
152 return None;
153 }
154 let subject = definition.subject.as_string().trim().to_string();
155 let mut body_lines = Vec::new();
156 if let Some(preview) = preview_from_items(definition.children.iter()) {
157 body_lines.push(preview);
158 }
159 Some(HoverResult {
160 range: header.clone(),
161 contents: format!(
162 "**Definition: {}**\n\n{}",
163 subject,
164 if body_lines.is_empty() {
165 "(no content)".to_string()
166 } else {
167 body_lines.join("\n\n")
168 }
169 ),
170 })
171}
172
173fn session_hover(document: &Document, position: Position) -> Option<HoverResult> {
174 let session = find_session_at_position(document, position)?;
175 let header = session.header_location()?;
176
177 let mut parts = Vec::new();
178 let title = session.title.as_string().trim();
179
180 if let Some(identifier) = session_identifier(session) {
181 parts.push(format!("Identifier: {identifier}"));
182 }
183
184 let child_count = session.children.len();
185 if child_count > 0 {
186 parts.push(format!("{child_count} item(s)"));
187 }
188
189 if let Some(preview) = preview_from_items(session.children.iter()) {
190 parts.push(preview);
191 }
192
193 Some(HoverResult {
194 range: header.clone(),
195 contents: format!(
196 "**Session: {}**\n\n{}",
197 title,
198 if parts.is_empty() {
199 "(no content)".to_string()
200 } else {
201 parts.join("\n\n")
202 }
203 ),
204 })
205}
206
207fn preview_from_items<'a>(items: impl Iterator<Item = &'a ContentItem>) -> Option<String> {
208 let mut lines = Vec::new();
209 collect_preview(items, &mut lines, 3);
210 if lines.is_empty() {
211 None
212 } else {
213 Some(lines.join("\n"))
214 }
215}
216
217fn collect_preview<'a>(
218 items: impl Iterator<Item = &'a ContentItem>,
219 lines: &mut Vec<String>,
220 limit: usize,
221) {
222 for item in items {
223 if lines.len() >= limit {
224 break;
225 }
226 match item {
227 ContentItem::Paragraph(paragraph) => {
228 let text = paragraph.text().trim().to_string();
229 if !text.is_empty() {
230 lines.push(text);
231 }
232 }
233 ContentItem::ListItem(list_item) => {
234 let text = list_item.text().trim().to_string();
235 if !text.is_empty() {
236 lines.push(text);
237 }
238 }
239 ContentItem::List(list) => {
240 for entry in list.items.iter() {
241 if let ContentItem::ListItem(list_item) = entry {
242 let text = list_item.text().trim().to_string();
243 if !text.is_empty() {
244 lines.push(text);
245 }
246 if lines.len() >= limit {
247 break;
248 }
249 }
250 }
251 }
252 ContentItem::Definition(definition) => {
253 let subject = definition.subject.as_string().trim().to_string();
254 if !subject.is_empty() {
255 lines.push(subject);
256 }
257 collect_preview(definition.children.iter(), lines, limit);
258 }
259 ContentItem::Annotation(annotation) => {
260 collect_preview(annotation.children.iter(), lines, limit);
261 }
262 ContentItem::Session(session) => {
263 collect_preview(session.children.iter(), lines, limit);
264 }
265 ContentItem::VerbatimBlock(verbatim) => {
266 for group in verbatim.group() {
267 if lines.len() >= limit {
268 break;
269 }
270 let subject = group.subject.as_string().trim().to_string();
271 if !subject.is_empty() {
272 lines.push(subject);
273 }
274 }
275 }
276 ContentItem::Table(_)
277 | ContentItem::TextLine(_)
278 | ContentItem::VerbatimLine(_)
279 | ContentItem::BlankLineGroup(_) => {}
280 }
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::test_support::{sample_document, sample_source};
288
289 fn position_for(needle: &str) -> Position {
290 let source = sample_source();
291 let index = source
292 .find(needle)
293 .unwrap_or_else(|| panic!("{needle} not found"));
294 let mut line = 0;
295 let mut column = 0;
296 for ch in source[..index].chars() {
297 if ch == '\n' {
298 line += 1;
299 column = 0;
300 } else {
301 column += ch.len_utf8();
302 }
303 }
304 Position::new(line, column)
305 }
306
307 #[test]
308 fn hover_shows_definition_preview_for_general_reference() {
309 }
320
321 #[test]
322 fn hover_shows_footnote_content() {
323 let document = sample_document();
324 let position = position_for("^source]");
325 let hover = hover(&document, position).expect("hover expected");
326 assert!(hover.contents.contains("source"));
329 }
330
331 #[test]
332 fn hover_shows_citation_details() {
333 let document = sample_document();
334 let position = position_for("@spec2025 p.4]");
335 let hover = hover(&document, position).expect("hover expected");
336 assert!(hover.contents.contains("Citation"));
337 assert!(hover.contents.contains("spec2025"));
338 }
339
340 #[test]
341 fn hover_shows_annotation_metadata() {
342 }
364
365 #[test]
366 fn hover_returns_none_for_invalid_position() {
367 let document = sample_document();
368 let position = Position::new(999, 0);
369 assert!(hover(&document, position).is_none());
370 }
371
372 #[test]
373 fn hover_shows_session_info() {
374 let document = sample_document();
375 let position = position_for("1. Intro");
376 let hover = hover(&document, position).expect("hover expected for session");
377 assert!(hover.contents.contains("Session"));
378 assert!(hover.contents.contains("Intro"));
379 }
380
381 #[test]
382 fn hover_on_definition_subject_shows_body_preview() {
383 use lex_core::lex::parsing;
384 let doc = parsing::parse_document("Term:\n The definition body.\n").unwrap();
385 let result =
387 hover(&doc, Position::new(0, 1)).expect("hover expected on definition subject");
388 assert!(result.contents.contains("Definition"));
389 assert!(result.contents.contains("Term"));
390 assert!(result.contents.contains("definition body"));
391 }
392
393 #[test]
394 fn hover_on_definition_body_returns_none() {
395 use lex_core::lex::parsing;
396 let doc = parsing::parse_document("Term:\n The definition body.\n").unwrap();
397 let result = hover(&doc, Position::new(1, 6));
399 assert!(result.is_none());
400 }
401}