Skip to main content

lex_analysis/
utils.rs

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