1use crate::inline::{extract_inline_spans, InlineSpanKind};
2use crate::utils::{
3 find_annotation_at_position, find_definition_by_subject, find_session_at_position,
4 for_each_text_content, session_identifier,
5};
6use lex_core::lex::ast::{Annotation, ContentItem, Document, Position, Range};
7use lex_core::lex::inlines::ReferenceType;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct HoverResult {
11 pub range: Range,
12 pub contents: String,
13}
14
15pub fn hover(document: &Document, position: Position) -> Option<HoverResult> {
16 inline_hover(document, position)
17 .or_else(|| annotation_hover(document, position))
18 .or_else(|| session_hover(document, position))
19}
20
21fn inline_hover(document: &Document, position: Position) -> Option<HoverResult> {
22 let mut result = None;
23 for_each_text_content(document, &mut |text| {
24 if result.is_some() {
25 return;
26 }
27 for span in extract_inline_spans(text) {
28 if span.range.contains(position) {
29 result = match span.kind {
30 InlineSpanKind::Reference(reference_type) => {
31 hover_for_reference(document, &span.range, &span.raw, reference_type)
32 }
33 _ => None,
34 };
35 if result.is_some() {
36 break;
37 }
38 }
39 }
40 });
41 result
42}
43
44fn hover_for_reference(
45 document: &Document,
46 range: &Range,
47 raw: &str,
48 reference_type: ReferenceType,
49) -> Option<HoverResult> {
50 match reference_type {
51 ReferenceType::FootnoteLabeled { label } => footnote_hover(document, range.clone(), &label)
52 .or_else(|| Some(generic_reference(range.clone(), raw))),
53 ReferenceType::FootnoteNumber { number } => {
54 footnote_hover(document, range.clone(), &number.to_string())
55 .or_else(|| Some(generic_reference(range.clone(), raw)))
56 }
57 ReferenceType::Citation(data) => {
58 let mut lines = vec![format!("Keys: {}", data.keys.join(", "))];
59 if let Some(locator) = data.locator {
60 lines.push(format!("Locator: {}", locator.raw));
61 }
62 Some(HoverResult {
63 range: range.clone(),
64 contents: format!("**Citation**\n\n{}", lines.join("\n")),
65 })
66 }
67 ReferenceType::General { target } => {
68 definition_hover(document, range.clone(), target.trim())
69 .or_else(|| Some(generic_reference(range.clone(), raw)))
70 }
71 ReferenceType::Url { target } => Some(HoverResult {
72 range: range.clone(),
73 contents: format!("**Link**\n\n{target}"),
74 }),
75 ReferenceType::File { target } => Some(HoverResult {
76 range: range.clone(),
77 contents: format!("**File Reference**\n\n{target}"),
78 }),
79 ReferenceType::Session { target } => Some(HoverResult {
80 range: range.clone(),
81 contents: format!("**Session Reference**\n\n{target}"),
82 }),
83 _ => Some(generic_reference(range.clone(), raw)),
84 }
85}
86
87fn generic_reference(range: Range, raw: &str) -> HoverResult {
88 HoverResult {
89 range,
90 contents: format!("**Reference**\n\n{}", raw.trim()),
91 }
92}
93
94fn footnote_hover(document: &Document, range: Range, label: &str) -> Option<HoverResult> {
95 let annotation = document.find_annotation_by_label(label)?;
96 let mut lines = Vec::new();
97 if let Some(preview) = preview_from_items(annotation.children.iter()) {
98 lines.push(preview);
99 }
100 if lines.is_empty() {
101 lines.push("(no content)".to_string());
102 }
103 Some(HoverResult {
104 range,
105 contents: format!("**Footnote [{}]**\n\n{}", label, lines.join("\n\n")),
106 })
107}
108
109fn definition_hover(document: &Document, range: Range, target: &str) -> Option<HoverResult> {
110 let definition = find_definition_by_subject(document, target)?;
111 let mut body_lines = Vec::new();
112 if let Some(preview) = preview_from_items(definition.children.iter()) {
113 body_lines.push(preview);
114 }
115 Some(HoverResult {
116 range,
117 contents: format!(
118 "**Definition: {}**\n\n{}",
119 target,
120 if body_lines.is_empty() {
121 "(no content)".to_string()
122 } else {
123 body_lines.join("\n\n")
124 }
125 ),
126 })
127}
128
129fn annotation_hover(document: &Document, position: Position) -> Option<HoverResult> {
130 find_annotation_at_position(document, position).map(annotation_hover_result)
131}
132
133fn annotation_hover_result(annotation: &Annotation) -> HoverResult {
134 let mut parts = Vec::new();
135 if !annotation.data.parameters.is_empty() {
136 let params = annotation
137 .data
138 .parameters
139 .iter()
140 .map(|param| format!("{}={}", param.key, param.value))
141 .collect::<Vec<_>>()
142 .join(", ");
143 parts.push(format!("Parameters: {params}"));
144 }
145 if let Some(preview) = preview_from_items(annotation.children.iter()) {
146 parts.push(preview);
147 }
148 if parts.is_empty() {
149 parts.push("(no content)".to_string());
150 }
151 HoverResult {
152 range: annotation.header_location().clone(),
153 contents: format!(
154 "**Annotation :: {} ::**\n\n{}",
155 annotation.data.label.value,
156 parts.join("\n\n")
157 ),
158 }
159}
160
161fn session_hover(document: &Document, position: Position) -> Option<HoverResult> {
162 let session = find_session_at_position(document, position)?;
163 let header = session.header_location()?;
164
165 let mut parts = Vec::new();
166 let title = session.title.as_string().trim();
167
168 if let Some(identifier) = session_identifier(session) {
169 parts.push(format!("Identifier: {identifier}"));
170 }
171
172 let child_count = session.children.len();
173 if child_count > 0 {
174 parts.push(format!("{child_count} item(s)"));
175 }
176
177 if let Some(preview) = preview_from_items(session.children.iter()) {
178 parts.push(preview);
179 }
180
181 Some(HoverResult {
182 range: header.clone(),
183 contents: format!(
184 "**Session: {}**\n\n{}",
185 title,
186 if parts.is_empty() {
187 "(no content)".to_string()
188 } else {
189 parts.join("\n\n")
190 }
191 ),
192 })
193}
194
195fn preview_from_items<'a>(items: impl Iterator<Item = &'a ContentItem>) -> Option<String> {
196 let mut lines = Vec::new();
197 collect_preview(items, &mut lines, 3);
198 if lines.is_empty() {
199 None
200 } else {
201 Some(lines.join("\n"))
202 }
203}
204
205fn collect_preview<'a>(
206 items: impl Iterator<Item = &'a ContentItem>,
207 lines: &mut Vec<String>,
208 limit: usize,
209) {
210 for item in items {
211 if lines.len() >= limit {
212 break;
213 }
214 match item {
215 ContentItem::Paragraph(paragraph) => {
216 let text = paragraph.text().trim().to_string();
217 if !text.is_empty() {
218 lines.push(text);
219 }
220 }
221 ContentItem::ListItem(list_item) => {
222 let text = list_item.text().trim().to_string();
223 if !text.is_empty() {
224 lines.push(text);
225 }
226 }
227 ContentItem::List(list) => {
228 for entry in list.items.iter() {
229 if let ContentItem::ListItem(list_item) = entry {
230 let text = list_item.text().trim().to_string();
231 if !text.is_empty() {
232 lines.push(text);
233 }
234 if lines.len() >= limit {
235 break;
236 }
237 }
238 }
239 }
240 ContentItem::Definition(definition) => {
241 let subject = definition.subject.as_string().trim().to_string();
242 if !subject.is_empty() {
243 lines.push(subject);
244 }
245 collect_preview(definition.children.iter(), lines, limit);
246 }
247 ContentItem::Annotation(annotation) => {
248 collect_preview(annotation.children.iter(), lines, limit);
249 }
250 ContentItem::Session(session) => {
251 collect_preview(session.children.iter(), lines, limit);
252 }
253 ContentItem::VerbatimBlock(verbatim) => {
254 let subject = verbatim.subject.as_string().trim().to_string();
255 if !subject.is_empty() {
256 lines.push(subject);
257 }
258 }
259 ContentItem::TextLine(_)
260 | ContentItem::VerbatimLine(_)
261 | ContentItem::BlankLineGroup(_) => {}
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::test_support::{sample_document, sample_source};
270
271 fn position_for(needle: &str) -> Position {
272 let source = sample_source();
273 let index = source
274 .find(needle)
275 .unwrap_or_else(|| panic!("{needle} not found"));
276 let mut line = 0;
277 let mut column = 0;
278 for ch in source[..index].chars() {
279 if ch == '\n' {
280 line += 1;
281 column = 0;
282 } else {
283 column += ch.len_utf8();
284 }
285 }
286 Position::new(line, column)
287 }
288
289 #[test]
290 fn hover_shows_definition_preview_for_general_reference() {
291 }
302
303 #[test]
304 fn hover_shows_footnote_content() {
305 let document = sample_document();
306 let position = position_for("^source]");
307 let hover = hover(&document, position).expect("hover expected");
308 assert!(hover.contents.contains("source"));
311 }
312
313 #[test]
314 fn hover_shows_citation_details() {
315 let document = sample_document();
316 let position = position_for("@spec2025 p.4]");
317 let hover = hover(&document, position).expect("hover expected");
318 assert!(hover.contents.contains("Citation"));
319 assert!(hover.contents.contains("spec2025"));
320 }
321
322 #[test]
323 fn hover_shows_annotation_metadata() {
324 }
346
347 #[test]
348 fn hover_returns_none_for_invalid_position() {
349 let document = sample_document();
350 let position = Position::new(999, 0);
351 assert!(hover(&document, position).is_none());
352 }
353
354 #[test]
355 fn hover_shows_session_info() {
356 let document = sample_document();
357 let position = position_for("1. Intro");
358 let hover = hover(&document, position).expect("hover expected for session");
359 assert!(hover.contents.contains("Session"));
360 assert!(hover.contents.contains("Intro"));
361 }
362}