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/// Checks whether a list is annotated with `:: notes ::`.
675fn is_notes_list(list: &lex_core::lex::ast::List) -> bool {
676    list.annotations()
677        .iter()
678        .any(|a| a.data.label.value.trim().eq_ignore_ascii_case("notes"))
679}
680
681/// Checks whether a container has a `:: notes ::` annotation (which may
682/// attach to the container itself rather than to the list, depending on
683/// blank line distance).
684fn has_notes_annotation(annotations: &[Annotation]) -> bool {
685    annotations
686        .iter()
687        .any(|a| a.data.label.value.trim().eq_ignore_ascii_case("notes"))
688}
689
690/// Collects all footnote definitions from a document.
691///
692/// Footnote definitions are list items inside lists marked by a `:: notes ::`
693/// annotation. The annotation can attach either to the list itself or to the
694/// containing session/document (depending on blank line proximity). Both cases
695/// are handled.
696///
697/// Returns a vector of (label, range) pairs for each definition found.
698pub fn collect_footnote_definitions(
699    document: &Document,
700) -> Vec<(String, lex_core::lex::ast::Range)> {
701    let mut defs = Vec::new();
702    // Check document-level :: notes :: annotations
703    if has_notes_annotation(document.annotations()) {
704        collect_first_list_items(&document.root.children, &mut defs);
705    }
706    collect_notes_items_in_session(&document.root, &mut defs);
707    defs
708}
709
710fn collect_notes_items_in_session(
711    session: &Session,
712    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
713) {
714    // Check session-level :: notes :: annotations
715    if has_notes_annotation(session.annotations()) {
716        collect_first_list_items(&session.children, out);
717    }
718    for item in session.children.iter() {
719        match item {
720            ContentItem::List(l) if is_notes_list(l) => {
721                collect_list_item_labels(l, out);
722            }
723            ContentItem::Session(s) => collect_notes_items_in_session(s, out),
724            ContentItem::Definition(d) => collect_notes_items_in_children(d.children.iter(), out),
725            _ => {}
726        }
727    }
728}
729
730fn collect_notes_items_in_children<'a>(
731    items: impl Iterator<Item = &'a ContentItem>,
732    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
733) {
734    for item in items {
735        match item {
736            ContentItem::List(l) if is_notes_list(l) => {
737                collect_list_item_labels(l, out);
738            }
739            ContentItem::Session(s) => collect_notes_items_in_session(s, out),
740            _ => {}
741        }
742    }
743}
744
745/// When a `:: notes ::` annotation attaches to a container rather than
746/// a list, find the first list child and collect its items.
747fn collect_first_list_items(
748    children: &[ContentItem],
749    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
750) {
751    for item in children {
752        if let ContentItem::List(l) = item {
753            collect_list_item_labels(l, out);
754            return;
755        }
756    }
757}
758
759fn collect_list_item_labels(
760    list: &lex_core::lex::ast::List,
761    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
762) {
763    for entry in &list.items {
764        if let ContentItem::ListItem(li) = entry {
765            let marker = li.marker();
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
777#[cfg(test)]
778mod tests {
779    use super::*;
780    use lex_core::lex::testing::lexplore::Lexplore;
781
782    #[test]
783    fn collects_footnotes_from_notes_annotated_list() {
784        let doc = Lexplore::footnotes(3).parse().unwrap();
785        let defs = collect_footnote_definitions(&doc);
786        let labels: Vec<&str> = defs.iter().map(|(l, _)| l.as_str()).collect();
787        assert_eq!(labels, vec!["1", "2"]);
788    }
789
790    #[test]
791    fn no_footnotes_without_notes_annotation() {
792        // A plain list in a "Notes" session is NOT a footnote list without :: notes ::
793        let doc = Lexplore::footnotes(4).parse().unwrap();
794        let defs = collect_footnote_definitions(&doc);
795        assert!(defs.is_empty());
796    }
797
798    #[test]
799    fn collects_footnotes_at_document_root() {
800        let doc = Lexplore::footnotes(2).parse().unwrap();
801        let defs = collect_footnote_definitions(&doc);
802        let labels: Vec<&str> = defs.iter().map(|(l, _)| l.as_str()).collect();
803        assert_eq!(labels, vec!["1", "2"]);
804    }
805
806    #[test]
807    fn multiple_notes_lists_in_different_sessions() {
808        // Each chapter has its own :: notes :: list with 2 items
809        let doc = Lexplore::footnotes(5).parse().unwrap();
810        let defs = collect_footnote_definitions(&doc);
811        assert_eq!(defs.len(), 4); // 2 items × 2 chapters
812    }
813}