1use crate::inline::{extract_inline_spans, InlineSpan, InlineSpanKind};
2use lex_core::lex::ast::traits::AstNode;
3use lex_core::lex::ast::{
4 Annotation, ContentItem, Definition, Document, Position, Session, TextContent,
5};
6
7pub fn for_each_text_content<F>(document: &Document, f: &mut F)
13where
14 F: FnMut(&TextContent),
15{
16 for annotation in document.annotations() {
17 visit_annotation_text(annotation, f);
18 }
19 visit_session_text(&document.root, true, f);
20}
21
22pub fn for_each_annotation<F>(document: &Document, f: &mut F)
32where
33 F: FnMut(&Annotation),
34{
35 for annotation in document.annotations() {
36 visit_annotation_recursive(annotation, f);
37 }
38 visit_session_annotations(&document.root, f);
39}
40
41pub fn collect_all_annotations(document: &Document) -> Vec<&Annotation> {
48 let mut annotations = Vec::new();
49 for annotation in document.annotations() {
50 collect_annotation_recursive(annotation, &mut annotations);
51 }
52 collect_annotations_into(&document.root, &mut annotations);
53 annotations
54}
55
56fn collect_annotations_into<'a>(session: &'a Session, out: &mut Vec<&'a Annotation>) {
57 for annotation in session.annotations() {
58 collect_annotation_recursive(annotation, out);
59 }
60 for child in session.children.iter() {
61 collect_content_annotations(child, out);
62 }
63}
64
65fn collect_annotation_recursive<'a>(annotation: &'a Annotation, out: &mut Vec<&'a Annotation>) {
66 out.push(annotation);
67 for child in annotation.children.iter() {
68 collect_content_annotations(child, out);
69 }
70}
71
72fn collect_content_annotations<'a>(item: &'a ContentItem, out: &mut Vec<&'a Annotation>) {
73 match item {
74 ContentItem::Annotation(annotation) => {
75 collect_annotation_recursive(annotation, out);
76 }
77 ContentItem::Paragraph(paragraph) => {
78 for annotation in paragraph.annotations() {
79 collect_annotation_recursive(annotation, out);
80 }
81 for line in ¶graph.lines {
82 collect_content_annotations(line, out);
83 }
84 }
85 ContentItem::List(list) => {
86 for annotation in list.annotations() {
87 collect_annotation_recursive(annotation, out);
88 }
89 for entry in &list.items {
90 collect_content_annotations(entry, out);
91 }
92 }
93 ContentItem::ListItem(list_item) => {
94 for annotation in list_item.annotations() {
95 collect_annotation_recursive(annotation, out);
96 }
97 for child in list_item.children.iter() {
98 collect_content_annotations(child, out);
99 }
100 }
101 ContentItem::Definition(definition) => {
102 for annotation in definition.annotations() {
103 collect_annotation_recursive(annotation, out);
104 }
105 for child in definition.children.iter() {
106 collect_content_annotations(child, out);
107 }
108 }
109 ContentItem::Session(session) => collect_annotations_into(session, out),
110 ContentItem::VerbatimBlock(verbatim) => {
111 for annotation in verbatim.annotations() {
112 collect_annotation_recursive(annotation, out);
113 }
114 }
115 ContentItem::TextLine(_)
116 | ContentItem::VerbatimLine(_)
117 | ContentItem::BlankLineGroup(_) => {}
118 }
119}
120
121fn visit_annotation_recursive<F>(annotation: &Annotation, f: &mut F)
122where
123 F: FnMut(&Annotation),
124{
125 f(annotation);
126 for child in annotation.children.iter() {
127 visit_content_annotations(child, f);
128 }
129}
130
131fn visit_session_annotations<F>(session: &Session, f: &mut F)
132where
133 F: FnMut(&Annotation),
134{
135 for annotation in session.annotations() {
136 visit_annotation_recursive(annotation, f);
137 }
138 for child in session.children.iter() {
139 visit_content_annotations(child, f);
140 }
141}
142
143fn visit_content_annotations<F>(item: &ContentItem, f: &mut F)
144where
145 F: FnMut(&Annotation),
146{
147 match item {
148 ContentItem::Annotation(annotation) => {
149 visit_annotation_recursive(annotation, f);
150 }
151 ContentItem::Paragraph(paragraph) => {
152 for annotation in paragraph.annotations() {
153 visit_annotation_recursive(annotation, f);
154 }
155 for line in ¶graph.lines {
156 visit_content_annotations(line, f);
157 }
158 }
159 ContentItem::List(list) => {
160 for annotation in list.annotations() {
161 visit_annotation_recursive(annotation, f);
162 }
163 for entry in &list.items {
164 visit_content_annotations(entry, f);
165 }
166 }
167 ContentItem::ListItem(list_item) => {
168 for annotation in list_item.annotations() {
169 visit_annotation_recursive(annotation, f);
170 }
171 for child in list_item.children.iter() {
172 visit_content_annotations(child, f);
173 }
174 }
175 ContentItem::Definition(definition) => {
176 for annotation in definition.annotations() {
177 visit_annotation_recursive(annotation, f);
178 }
179 for child in definition.children.iter() {
180 visit_content_annotations(child, f);
181 }
182 }
183 ContentItem::Session(session) => visit_session_annotations(session, f),
184 ContentItem::VerbatimBlock(verbatim) => {
185 for annotation in verbatim.annotations() {
186 visit_annotation_recursive(annotation, f);
187 }
188 }
189 ContentItem::TextLine(_)
190 | ContentItem::VerbatimLine(_)
191 | ContentItem::BlankLineGroup(_) => {}
192 }
193}
194
195pub fn find_definition_by_subject<'a>(
196 document: &'a Document,
197 target: &str,
198) -> Option<&'a Definition> {
199 find_definitions_by_subject(document, target)
200 .into_iter()
201 .next()
202}
203
204pub fn find_definitions_by_subject<'a>(
205 document: &'a Document,
206 target: &str,
207) -> Vec<&'a Definition> {
208 let normalized = normalize_key(target);
209 if normalized.is_empty() {
210 return Vec::new();
211 }
212 let mut matches = Vec::new();
213 for annotation in document.annotations() {
214 collect_definitions(annotation.children.iter(), &normalized, &mut matches);
215 }
216 collect_definitions(document.root.children.iter(), &normalized, &mut matches);
217 matches
218}
219
220pub fn find_definition_at_position(document: &Document, position: Position) -> Option<&Definition> {
221 for annotation in document.annotations() {
222 if let Some(definition) = find_definition_in_items(annotation.children.iter(), position) {
223 return Some(definition);
224 }
225 }
226 find_definition_in_items(document.root.children.iter(), position)
227}
228
229pub fn find_annotation_at_position(document: &Document, position: Position) -> Option<&Annotation> {
230 for annotation in document.annotations() {
231 if annotation.header_location().contains(position) {
232 return Some(annotation);
233 }
234 if let Some(found) = find_annotation_in_items(annotation.children.iter(), position) {
235 return Some(found);
236 }
237 }
238 find_annotation_in_session(&document.root, position, true)
239}
240
241pub fn find_session_at_position(document: &Document, position: Position) -> Option<&Session> {
242 find_session_in_branch(&document.root, position, true)
243}
244
245pub fn find_sessions_by_identifier<'a>(
246 document: &'a Document,
247 identifier: &str,
248) -> Vec<&'a Session> {
249 let normalized = normalize_key(identifier);
250 if normalized.is_empty() {
251 return Vec::new();
252 }
253 let mut matches = Vec::new();
254 collect_sessions_by_identifier(&document.root, &normalized, &mut matches, true);
255 matches
256}
257
258pub fn session_identifier(session: &Session) -> Option<String> {
259 extract_session_identifier(session.title.as_string())
260}
261
262pub fn reference_span_at_position(document: &Document, position: Position) -> Option<InlineSpan> {
263 let mut result = None;
264 for_each_text_content(document, &mut |text| {
265 if result.is_some() {
266 return;
267 }
268 for span in extract_inline_spans(text) {
269 if matches!(span.kind, InlineSpanKind::Reference(_)) && span.range.contains(position) {
270 result = Some(span);
271 break;
272 }
273 }
274 });
275 result
276}
277
278fn visit_session_text<F>(session: &Session, is_root: bool, f: &mut F)
279where
280 F: FnMut(&TextContent),
281{
282 if !is_root {
283 f(&session.title);
284 }
285 for annotation in session.annotations() {
286 visit_annotation_text(annotation, f);
287 }
288 for child in session.children.iter() {
289 visit_content_text(child, f);
290 }
291}
292
293fn visit_annotation_text<F>(annotation: &Annotation, f: &mut F)
294where
295 F: FnMut(&TextContent),
296{
297 for child in annotation.children.iter() {
298 visit_content_text(child, f);
299 }
300}
301
302fn visit_content_text<F>(item: &ContentItem, f: &mut F)
303where
304 F: FnMut(&TextContent),
305{
306 match item {
307 ContentItem::Paragraph(paragraph) => {
308 for line in ¶graph.lines {
309 if let ContentItem::TextLine(text_line) = line {
310 f(&text_line.content);
311 }
312 }
313 for annotation in paragraph.annotations() {
314 visit_annotation_text(annotation, f);
315 }
316 }
317 ContentItem::Session(session) => visit_session_text(session, false, f),
318 ContentItem::List(list) => {
319 for annotation in list.annotations() {
320 visit_annotation_text(annotation, f);
321 }
322 for entry in &list.items {
323 if let ContentItem::ListItem(list_item) = entry {
324 for text in &list_item.text {
325 f(text);
326 }
327 for annotation in list_item.annotations() {
328 visit_annotation_text(annotation, f);
329 }
330 for child in list_item.children.iter() {
331 visit_content_text(child, f);
332 }
333 }
334 }
335 }
336 ContentItem::ListItem(list_item) => {
337 for text in &list_item.text {
338 f(text);
339 }
340 for annotation in list_item.annotations() {
341 visit_annotation_text(annotation, f);
342 }
343 for child in list_item.children.iter() {
344 visit_content_text(child, f);
345 }
346 }
347 ContentItem::Definition(definition) => {
348 f(&definition.subject);
349 for annotation in definition.annotations() {
350 visit_annotation_text(annotation, f);
351 }
352 for child in definition.children.iter() {
353 visit_content_text(child, f);
354 }
355 }
356 ContentItem::Annotation(annotation) => visit_annotation_text(annotation, f),
357 _ => {}
358 }
359}
360
361fn collect_definitions<'a>(
362 items: impl Iterator<Item = &'a ContentItem>,
363 target: &str,
364 matches: &mut Vec<&'a Definition>,
365) {
366 for item in items {
367 collect_definitions_in_content(item, target, matches);
368 }
369}
370
371fn collect_definitions_in_content<'a>(
372 item: &'a ContentItem,
373 target: &str,
374 matches: &mut Vec<&'a Definition>,
375) {
376 match item {
377 ContentItem::Definition(definition) => {
378 if subject_matches(definition, target) {
379 matches.push(definition);
380 }
381 collect_definitions(definition.children.iter(), target, matches);
382 }
383 ContentItem::Session(session) => {
384 collect_definitions(session.children.iter(), target, matches);
385 }
386 ContentItem::List(list) => {
387 for entry in &list.items {
388 if let ContentItem::ListItem(list_item) = entry {
389 collect_definitions(list_item.children.iter(), target, matches);
390 }
391 }
392 }
393 ContentItem::ListItem(list_item) => {
394 collect_definitions(list_item.children.iter(), target, matches);
395 }
396 ContentItem::Annotation(annotation) => {
397 collect_definitions(annotation.children.iter(), target, matches);
398 }
399 ContentItem::Paragraph(paragraph) => {
400 for annotation in paragraph.annotations() {
401 collect_definitions(annotation.children.iter(), target, matches);
402 }
403 }
404 _ => {}
405 }
406}
407
408fn find_definition_in_items<'a>(
409 items: impl Iterator<Item = &'a ContentItem>,
410 position: Position,
411) -> Option<&'a Definition> {
412 for item in items {
413 if let Some(definition) = find_definition_in_content(item, position) {
414 return Some(definition);
415 }
416 }
417 None
418}
419
420fn find_definition_in_content(item: &ContentItem, position: Position) -> Option<&Definition> {
421 match item {
422 ContentItem::Definition(definition) => {
423 if definition
424 .header_location()
425 .map(|range| range.contains(position))
426 .unwrap_or_else(|| definition.range().contains(position))
427 {
428 return Some(definition);
429 }
430 find_definition_in_items(definition.children.iter(), position)
431 }
432 ContentItem::Session(session) => {
433 find_definition_in_items(session.children.iter(), position)
434 }
435 ContentItem::List(list) => list.items.iter().find_map(|entry| match entry {
436 ContentItem::ListItem(list_item) => {
437 find_definition_in_items(list_item.children.iter(), position)
438 }
439 _ => None,
440 }),
441 ContentItem::ListItem(list_item) => {
442 find_definition_in_items(list_item.children.iter(), position)
443 }
444 ContentItem::Annotation(annotation) => {
445 find_definition_in_items(annotation.children.iter(), position)
446 }
447 ContentItem::Paragraph(paragraph) => paragraph
448 .annotations()
449 .iter()
450 .find_map(|annotation| find_definition_in_items(annotation.children.iter(), position)),
451 _ => None,
452 }
453}
454
455fn find_annotation_in_session(
456 session: &Session,
457 position: Position,
458 is_root: bool,
459) -> Option<&Annotation> {
460 if !is_root {
461 if let Some(annotation) = session
462 .annotations()
463 .iter()
464 .find(|ann| ann.header_location().contains(position))
465 {
466 return Some(annotation);
467 }
468 }
469 for child in session.children.iter() {
470 if let Some(annotation) = find_annotation_in_content(child, position) {
471 return Some(annotation);
472 }
473 }
474 None
475}
476
477fn find_annotation_in_content(item: &ContentItem, position: Position) -> Option<&Annotation> {
478 match item {
479 ContentItem::Paragraph(paragraph) => paragraph
480 .annotations()
481 .iter()
482 .find(|ann| ann.header_location().contains(position))
483 .or_else(|| find_annotation_in_items(paragraph.lines.iter(), position)),
484 ContentItem::Session(session) => find_annotation_in_session(session, position, false),
485 ContentItem::List(list) => {
486 if let Some(annotation) = list
487 .annotations()
488 .iter()
489 .find(|ann| ann.header_location().contains(position))
490 {
491 return Some(annotation);
492 }
493 for entry in &list.items {
494 if let ContentItem::ListItem(list_item) = entry {
495 if let Some(annotation) = list_item
496 .annotations()
497 .iter()
498 .find(|ann| ann.header_location().contains(position))
499 {
500 return Some(annotation);
501 }
502 if let Some(found) =
503 find_annotation_in_items(list_item.children.iter(), position)
504 {
505 return Some(found);
506 }
507 }
508 }
509 None
510 }
511 ContentItem::ListItem(list_item) => list_item
512 .annotations()
513 .iter()
514 .find(|ann| ann.header_location().contains(position))
515 .or_else(|| find_annotation_in_items(list_item.children.iter(), position)),
516 ContentItem::Definition(definition) => definition
517 .annotations()
518 .iter()
519 .find(|ann| ann.header_location().contains(position))
520 .or_else(|| find_annotation_in_items(definition.children.iter(), position)),
521 ContentItem::Annotation(annotation) => {
522 if annotation.header_location().contains(position) {
523 return Some(annotation);
524 }
525 find_annotation_in_items(annotation.children.iter(), position)
526 }
527 ContentItem::VerbatimBlock(verbatim) => verbatim
528 .annotations()
529 .iter()
530 .find(|ann| ann.header_location().contains(position))
531 .or_else(|| find_annotation_in_items(verbatim.children.iter(), position)),
532 ContentItem::TextLine(_) => None,
533 _ => None,
534 }
535}
536
537fn find_annotation_in_items<'a>(
538 items: impl Iterator<Item = &'a ContentItem>,
539 position: Position,
540) -> Option<&'a Annotation> {
541 for item in items {
542 if let Some(annotation) = find_annotation_in_content(item, position) {
543 return Some(annotation);
544 }
545 }
546 None
547}
548
549fn find_session_in_branch(
550 session: &Session,
551 position: Position,
552 is_root: bool,
553) -> Option<&Session> {
554 if !is_root {
555 if let Some(header) = session.header_location() {
556 if header.contains(position) {
557 return Some(session);
558 }
559 }
560 }
561 for child in session.children.iter() {
562 if let ContentItem::Session(child_session) = child {
563 if let Some(found) = find_session_in_branch(child_session, position, false) {
564 return Some(found);
565 }
566 }
567 }
568 None
569}
570
571fn collect_sessions_by_identifier<'a>(
572 session: &'a Session,
573 target: &str,
574 matches: &mut Vec<&'a Session>,
575 is_root: bool,
576) {
577 if !is_root {
578 let title = session.title.as_string();
579 let normalized_title = title.trim().to_ascii_lowercase();
580 let title_matches =
581 normalized_title.starts_with(target) && has_session_boundary(title, target.len());
582 let identifier_matches = session_identifier(session)
583 .as_deref()
584 .map(|id| id.to_ascii_lowercase() == target)
585 .unwrap_or(false);
586 if title_matches || identifier_matches {
587 matches.push(session);
588 }
589 }
590 for child in session.children.iter() {
591 if let ContentItem::Session(child_session) = child {
592 collect_sessions_by_identifier(child_session, target, matches, false);
593 }
594 }
595}
596
597fn has_session_boundary(title: &str, len: usize) -> bool {
598 let trimmed = title.trim();
599 if trimmed.len() <= len {
600 return trimmed.len() == len;
601 }
602 matches!(
603 trimmed.chars().nth(len),
604 Some(ch) if matches!(ch, ' ' | '\t' | ':' | '.')
605 )
606}
607
608fn subject_matches(definition: &Definition, target: &str) -> bool {
609 normalize_key(definition.subject.as_string()).eq(target)
610}
611
612fn normalize_key(input: &str) -> String {
613 input.trim().to_ascii_lowercase()
614}
615
616fn extract_session_identifier(title: &str) -> Option<String> {
617 let trimmed = title.trim();
618 if trimmed.is_empty() {
619 return None;
620 }
621 let mut identifier = String::new();
622 for ch in trimmed.chars() {
623 if ch.is_ascii_digit() || ch == '.' {
624 identifier.push(ch);
625 } else {
626 break;
627 }
628 }
629 if identifier.ends_with('.') {
630 identifier.pop();
631 }
632 if identifier.is_empty() {
633 None
634 } else {
635 Some(identifier)
636 }
637}
638
639pub fn find_notes_session(document: &Document) -> Option<&Session> {
647 let root_title = document.root.title.as_string();
649 if is_notes_title(root_title) {
650 return Some(&document.root);
651 }
652
653 for item in document.root.children.iter().rev() {
655 if let ContentItem::Session(session) = item {
656 let title = session.title.as_string();
657 if is_notes_title(title) {
658 return Some(session);
659 }
660 if is_list_only_session(session) {
662 return Some(session);
663 }
664 break;
666 }
667 }
668 None
669}
670
671fn is_notes_title(title: impl AsRef<str>) -> bool {
673 let title = title.as_ref();
674 let normalized = title.trim().trim_end_matches(':').to_lowercase();
675 normalized == "notes" || normalized == "footnotes"
676}
677
678fn is_list_only_session(session: &Session) -> bool {
680 if session.children.is_empty() {
681 return false;
682 }
683 session
684 .children
685 .iter()
686 .all(|child| matches!(child, ContentItem::List(_) | ContentItem::BlankLineGroup(_)))
687}
688
689pub fn collect_footnote_definitions(
697 document: &Document,
698) -> Vec<(String, lex_core::lex::ast::Range)> {
699 let mut defs = Vec::new();
700
701 for annotation in collect_all_annotations(document) {
703 let label = &annotation.data.label.value;
704 if !label.trim().is_empty() {
705 defs.push((label.clone(), annotation.header_location().clone()));
706 }
707 }
708
709 if let Some(session) = find_notes_session(document) {
711 collect_footnote_items_in_container(&session.children, &mut defs);
712 }
713 defs
714}
715
716fn collect_footnote_items_in_container(
717 items: &[ContentItem],
718 out: &mut Vec<(String, lex_core::lex::ast::Range)>,
719) {
720 for item in items {
721 match item {
722 ContentItem::List(l) => {
723 for entry in &l.items {
728 if let ContentItem::ListItem(li) = entry {
729 let marker = li.marker();
730 let label = marker
732 .trim()
733 .trim_end_matches(['.', ')', ':'].as_ref())
734 .trim();
735 if !label.is_empty() {
736 out.push((label.to_string(), li.range().clone()));
737 }
738 }
739 }
740 }
741 ContentItem::Session(s) => collect_footnote_items_in_container(&s.children, out),
742 _ => {}
743 }
744 }
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use lex_core::lex::parsing;
751
752 fn parse(source: &str) -> Document {
753 parsing::parse_document(source).expect("parse failed")
754 }
755
756 #[test]
757 fn find_notes_session_by_title() {
758 let doc = parse("Content\n\nNotes\n\n 1. A note\n");
759 let notes = find_notes_session(&doc);
760 assert!(notes.is_some());
761 assert_eq!(notes.unwrap().title.as_string().trim(), "Notes");
762 }
763
764 #[test]
765 fn find_notes_session_by_footnotes_title() {
766 let doc = parse("Content\n\nFootnotes\n\n 1. A note\n");
767 let notes = find_notes_session(&doc);
768 assert!(notes.is_some());
769 assert_eq!(notes.unwrap().title.as_string().trim(), "Footnotes");
770 }
771
772 #[test]
773 fn find_notes_session_implicit_list_only() {
774 let doc = parse("Content\n\nReferences\n\n 1. First ref\n 2. Second ref\n");
776 let notes = find_notes_session(&doc);
777 assert!(notes.is_some());
778 assert_eq!(notes.unwrap().title.as_string().trim(), "References");
779 }
780
781 #[test]
782 fn find_notes_session_none_when_last_has_paragraphs() {
783 let doc = parse("Content\n\nConclusion\n\n This is a paragraph.\n");
785 let notes = find_notes_session(&doc);
786 assert!(notes.is_none());
787 }
788
789 #[test]
790 fn find_notes_session_root_is_notes() {
791 let doc = parse("Notes\n\n 1. A note\n");
792 let notes = find_notes_session(&doc);
793 assert!(notes.is_some());
794 }
795}