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 ContentItem::VerbatimBlock(verbatim) => {
358 f(&verbatim.subject);
359 for annotation in verbatim.annotations() {
360 visit_annotation_text(annotation, f);
361 }
362 }
363 ContentItem::TextLine(_)
364 | ContentItem::VerbatimLine(_)
365 | ContentItem::BlankLineGroup(_) => {}
366 }
367}
368
369fn collect_definitions<'a>(
370 items: impl Iterator<Item = &'a ContentItem>,
371 target: &str,
372 matches: &mut Vec<&'a Definition>,
373) {
374 for item in items {
375 collect_definitions_in_content(item, target, matches);
376 }
377}
378
379fn collect_definitions_in_content<'a>(
380 item: &'a ContentItem,
381 target: &str,
382 matches: &mut Vec<&'a Definition>,
383) {
384 match item {
385 ContentItem::Definition(definition) => {
386 if subject_matches(definition, target) {
387 matches.push(definition);
388 }
389 collect_definitions(definition.children.iter(), target, matches);
390 }
391 ContentItem::Session(session) => {
392 collect_definitions(session.children.iter(), target, matches);
393 }
394 ContentItem::List(list) => {
395 for entry in &list.items {
396 if let ContentItem::ListItem(list_item) = entry {
397 collect_definitions(list_item.children.iter(), target, matches);
398 }
399 }
400 }
401 ContentItem::ListItem(list_item) => {
402 collect_definitions(list_item.children.iter(), target, matches);
403 }
404 ContentItem::Annotation(annotation) => {
405 collect_definitions(annotation.children.iter(), target, matches);
406 }
407 ContentItem::Paragraph(paragraph) => {
408 for annotation in paragraph.annotations() {
409 collect_definitions(annotation.children.iter(), target, matches);
410 }
411 }
412 _ => {}
413 }
414}
415
416fn find_definition_in_items<'a>(
417 items: impl Iterator<Item = &'a ContentItem>,
418 position: Position,
419) -> Option<&'a Definition> {
420 for item in items {
421 if let Some(definition) = find_definition_in_content(item, position) {
422 return Some(definition);
423 }
424 }
425 None
426}
427
428fn find_definition_in_content(item: &ContentItem, position: Position) -> Option<&Definition> {
429 match item {
430 ContentItem::Definition(definition) => {
431 if definition
432 .header_location()
433 .map(|range| range.contains(position))
434 .unwrap_or_else(|| definition.range().contains(position))
435 {
436 return Some(definition);
437 }
438 find_definition_in_items(definition.children.iter(), position)
439 }
440 ContentItem::Session(session) => {
441 find_definition_in_items(session.children.iter(), position)
442 }
443 ContentItem::List(list) => list.items.iter().find_map(|entry| match entry {
444 ContentItem::ListItem(list_item) => {
445 find_definition_in_items(list_item.children.iter(), position)
446 }
447 _ => None,
448 }),
449 ContentItem::ListItem(list_item) => {
450 find_definition_in_items(list_item.children.iter(), position)
451 }
452 ContentItem::Annotation(annotation) => {
453 find_definition_in_items(annotation.children.iter(), position)
454 }
455 ContentItem::Paragraph(paragraph) => paragraph
456 .annotations()
457 .iter()
458 .find_map(|annotation| find_definition_in_items(annotation.children.iter(), position)),
459 _ => None,
460 }
461}
462
463fn find_annotation_in_session(
464 session: &Session,
465 position: Position,
466 is_root: bool,
467) -> Option<&Annotation> {
468 if !is_root {
469 if let Some(annotation) = session
470 .annotations()
471 .iter()
472 .find(|ann| ann.header_location().contains(position))
473 {
474 return Some(annotation);
475 }
476 }
477 for child in session.children.iter() {
478 if let Some(annotation) = find_annotation_in_content(child, position) {
479 return Some(annotation);
480 }
481 }
482 None
483}
484
485fn find_annotation_in_content(item: &ContentItem, position: Position) -> Option<&Annotation> {
486 match item {
487 ContentItem::Paragraph(paragraph) => paragraph
488 .annotations()
489 .iter()
490 .find(|ann| ann.header_location().contains(position))
491 .or_else(|| find_annotation_in_items(paragraph.lines.iter(), position)),
492 ContentItem::Session(session) => find_annotation_in_session(session, position, false),
493 ContentItem::List(list) => {
494 if let Some(annotation) = list
495 .annotations()
496 .iter()
497 .find(|ann| ann.header_location().contains(position))
498 {
499 return Some(annotation);
500 }
501 for entry in &list.items {
502 if let ContentItem::ListItem(list_item) = entry {
503 if let Some(annotation) = list_item
504 .annotations()
505 .iter()
506 .find(|ann| ann.header_location().contains(position))
507 {
508 return Some(annotation);
509 }
510 if let Some(found) =
511 find_annotation_in_items(list_item.children.iter(), position)
512 {
513 return Some(found);
514 }
515 }
516 }
517 None
518 }
519 ContentItem::ListItem(list_item) => list_item
520 .annotations()
521 .iter()
522 .find(|ann| ann.header_location().contains(position))
523 .or_else(|| find_annotation_in_items(list_item.children.iter(), position)),
524 ContentItem::Definition(definition) => definition
525 .annotations()
526 .iter()
527 .find(|ann| ann.header_location().contains(position))
528 .or_else(|| find_annotation_in_items(definition.children.iter(), position)),
529 ContentItem::Annotation(annotation) => {
530 if annotation.header_location().contains(position) {
531 return Some(annotation);
532 }
533 find_annotation_in_items(annotation.children.iter(), position)
534 }
535 ContentItem::VerbatimBlock(verbatim) => verbatim
536 .annotations()
537 .iter()
538 .find(|ann| ann.header_location().contains(position))
539 .or_else(|| find_annotation_in_items(verbatim.children.iter(), position)),
540 ContentItem::TextLine(_) => None,
541 _ => None,
542 }
543}
544
545fn find_annotation_in_items<'a>(
546 items: impl Iterator<Item = &'a ContentItem>,
547 position: Position,
548) -> Option<&'a Annotation> {
549 for item in items {
550 if let Some(annotation) = find_annotation_in_content(item, position) {
551 return Some(annotation);
552 }
553 }
554 None
555}
556
557fn find_session_in_branch(
558 session: &Session,
559 position: Position,
560 is_root: bool,
561) -> Option<&Session> {
562 if !is_root {
563 if let Some(header) = session.header_location() {
564 if header.contains(position) {
565 return Some(session);
566 }
567 }
568 }
569 for child in session.children.iter() {
570 if let ContentItem::Session(child_session) = child {
571 if let Some(found) = find_session_in_branch(child_session, position, false) {
572 return Some(found);
573 }
574 }
575 }
576 None
577}
578
579fn collect_sessions_by_identifier<'a>(
580 session: &'a Session,
581 target: &str,
582 matches: &mut Vec<&'a Session>,
583 is_root: bool,
584) {
585 if !is_root {
586 let title = session.title.as_string();
587 let normalized_title = title.trim().to_ascii_lowercase();
588 let title_matches =
589 normalized_title.starts_with(target) && has_session_boundary(title, target.len());
590 let identifier_matches = session_identifier(session)
591 .as_deref()
592 .map(|id| id.to_ascii_lowercase() == target)
593 .unwrap_or(false);
594 if title_matches || identifier_matches {
595 matches.push(session);
596 }
597 }
598 for child in session.children.iter() {
599 if let ContentItem::Session(child_session) = child {
600 collect_sessions_by_identifier(child_session, target, matches, false);
601 }
602 }
603}
604
605fn has_session_boundary(title: &str, len: usize) -> bool {
606 let trimmed = title.trim();
607 if trimmed.len() <= len {
608 return trimmed.len() == len;
609 }
610 matches!(
611 trimmed.chars().nth(len),
612 Some(ch) if matches!(ch, ' ' | '\t' | ':' | '.')
613 )
614}
615
616fn subject_matches(definition: &Definition, target: &str) -> bool {
617 normalize_key(definition.subject.as_string()).eq(target)
618}
619
620fn normalize_key(input: &str) -> String {
621 input.trim().to_ascii_lowercase()
622}
623
624fn extract_session_identifier(title: &str) -> Option<String> {
625 let trimmed = title.trim();
626 if trimmed.is_empty() {
627 return None;
628 }
629 let mut identifier = String::new();
630 for ch in trimmed.chars() {
631 if ch.is_ascii_digit() || ch == '.' {
632 identifier.push(ch);
633 } else {
634 break;
635 }
636 }
637 if identifier.ends_with('.') {
638 identifier.pop();
639 }
640 if identifier.is_empty() {
641 None
642 } else {
643 Some(identifier)
644 }
645}
646
647pub fn find_notes_session(document: &Document) -> Option<&Session> {
655 let root_title = document.root.title.as_string();
657 if is_notes_title(root_title) {
658 return Some(&document.root);
659 }
660
661 for item in document.root.children.iter().rev() {
663 if let ContentItem::Session(session) = item {
664 let title = session.title.as_string();
665 if is_notes_title(title) {
666 return Some(session);
667 }
668 if is_list_only_session(session) {
670 return Some(session);
671 }
672 break;
674 }
675 }
676 None
677}
678
679fn is_notes_title(title: impl AsRef<str>) -> bool {
681 let title = title.as_ref();
682 let normalized = title.trim().trim_end_matches(':').to_lowercase();
683 normalized == "notes" || normalized == "footnotes"
684}
685
686fn is_list_only_session(session: &Session) -> bool {
688 if session.children.is_empty() {
689 return false;
690 }
691 session
692 .children
693 .iter()
694 .all(|child| matches!(child, ContentItem::List(_) | ContentItem::BlankLineGroup(_)))
695}
696
697pub fn collect_footnote_definitions(
705 document: &Document,
706) -> Vec<(String, lex_core::lex::ast::Range)> {
707 let mut defs = Vec::new();
708
709 for annotation in collect_all_annotations(document) {
711 let label = &annotation.data.label.value;
712 if !label.trim().is_empty() {
713 defs.push((label.clone(), annotation.header_location().clone()));
714 }
715 }
716
717 if let Some(session) = find_notes_session(document) {
719 collect_footnote_items_in_container(&session.children, &mut defs);
720 }
721 defs
722}
723
724fn collect_footnote_items_in_container(
725 items: &[ContentItem],
726 out: &mut Vec<(String, lex_core::lex::ast::Range)>,
727) {
728 for item in items {
729 match item {
730 ContentItem::List(l) => {
731 for entry in &l.items {
736 if let ContentItem::ListItem(li) = entry {
737 let marker = li.marker();
738 let label = marker
740 .trim()
741 .trim_end_matches(['.', ')', ':'].as_ref())
742 .trim();
743 if !label.is_empty() {
744 out.push((label.to_string(), li.range().clone()));
745 }
746 }
747 }
748 }
749 ContentItem::Session(s) => collect_footnote_items_in_container(&s.children, out),
750 _ => {}
751 }
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use lex_core::lex::parsing;
759
760 fn parse(source: &str) -> Document {
761 parsing::parse_document(source).expect("parse failed")
762 }
763
764 #[test]
765 fn find_notes_session_by_title() {
766 let doc = parse("Content\n\nNotes\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(), "Notes");
770 }
771
772 #[test]
773 fn find_notes_session_by_footnotes_title() {
774 let doc = parse("Content\n\nFootnotes\n\n 1. A note\n");
775 let notes = find_notes_session(&doc);
776 assert!(notes.is_some());
777 assert_eq!(notes.unwrap().title.as_string().trim(), "Footnotes");
778 }
779
780 #[test]
781 fn find_notes_session_implicit_list_only() {
782 let doc = parse("Content\n\nReferences\n\n 1. First ref\n 2. Second ref\n");
784 let notes = find_notes_session(&doc);
785 assert!(notes.is_some());
786 assert_eq!(notes.unwrap().title.as_string().trim(), "References");
787 }
788
789 #[test]
790 fn find_notes_session_none_when_last_has_paragraphs() {
791 let doc = parse("Content\n\nConclusion\n\n This is a paragraph.\n");
793 let notes = find_notes_session(&doc);
794 assert!(notes.is_none());
795 }
796
797 #[test]
798 fn find_notes_session_root_is_notes() {
799 let doc = parse("Notes\n\n 1. A note\n");
800 let notes = find_notes_session(&doc);
801 assert!(notes.is_some());
802 }
803}