Skip to main content

lex_analysis/
utils.rs

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
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    for annotation in document.annotations() {
17        visit_annotation_text(annotation, f);
18    }
19    visit_session_text(&document.root, true, f);
20}
21
22/// Visits every annotation in the document, invoking the callback for each.
23///
24/// Traverses the full document tree to find annotations at all levels:
25/// document-level, session-level, and nested within content items like
26/// paragraphs, lists, definitions, and verbatim blocks. Annotations are
27/// visited in document order (top to bottom).
28///
29/// Use this for annotation-related features like navigation, resolution
30/// toggling, or collecting annotation labels for completion.
31pub 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
41/// Collects all annotations in the document into a vector.
42///
43/// Returns annotations in document order (top to bottom), including those
44/// at document-level, session-level, and nested within content items.
45/// This is a convenience wrapper around [`for_each_annotation`] for cases
46/// where you need a collected result rather than a streaming callback.
47pub 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 &paragraph.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 &paragraph.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 &paragraph.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
647/// Finds the Notes/Footnotes session in a document.
648///
649/// A session is considered a Notes session if:
650/// 1. Its title is "Notes" or "Footnotes" (case-insensitive), OR
651/// 2. It's the last session and contains only list items (implicit notes section)
652///
653/// Returns `None` if no Notes session is found.
654pub fn find_notes_session(document: &Document) -> Option<&Session> {
655    // Check root session first
656    let root_title = document.root.title.as_string();
657    if is_notes_title(root_title) {
658        return Some(&document.root);
659    }
660
661    // Check last session - either by title or by content (list-only = implicit notes)
662    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            // A last session with only list content is an implicit Notes session
669            if is_list_only_session(session) {
670                return Some(session);
671            }
672            // Only check the last session
673            break;
674        }
675    }
676    None
677}
678
679/// Checks if a title indicates a Notes/Footnotes session.
680fn 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
686/// Checks if a session contains only list items (no paragraphs, definitions, etc.).
687fn 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
697/// Collects all footnote definitions from a document.
698///
699/// Footnotes can be defined in two ways:
700/// 1. **Annotations**: `:: 1 ::` style definitions anywhere in the document
701/// 2. **List items**: Numbered list items within a Notes/Footnotes session
702///
703/// Returns a vector of (label, range) pairs for each definition found.
704pub fn collect_footnote_definitions(
705    document: &Document,
706) -> Vec<(String, lex_core::lex::ast::Range)> {
707    let mut defs = Vec::new();
708
709    // Annotations with non-empty labels
710    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    // List items in Notes session
718    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                // Iterate manually because ListContainer iteration yields &ContentItem
732                // If ListContainer implements IntoIterator, iterate it.
733                // l.items is the container.
734                // In previous steps we saw iterating `l.items` yields `ContentItem`.
735                for entry in &l.items {
736                    if let ContentItem::ListItem(li) = entry {
737                        let marker = li.marker();
738                        // "1." -> "1", "1)" -> "1"
739                        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        // Last session with only list content is an implicit Notes session
783        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        // Last session with mixed content is NOT an implicit Notes session
792        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}