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, Verbatim,
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
258/// Locate a verbatim block whose source range contains `position`. Walks
259/// the document tree under both the root session and any document-level
260/// annotations. Used by extension dispatch to identify the labelled
261/// verbatim under the cursor for hover / completion / code-action
262/// requests.
263pub fn find_verbatim_at_position(document: &Document, position: Position) -> Option<&Verbatim> {
264    for annotation in document.annotations() {
265        if let Some(found) = find_verbatim_in_items(annotation.children.iter(), position) {
266            return Some(found);
267        }
268    }
269    find_verbatim_in_session(&document.root, position)
270}
271
272fn find_verbatim_in_session(session: &Session, position: Position) -> Option<&Verbatim> {
273    find_verbatim_in_items(session.children.iter(), position)
274}
275
276fn find_verbatim_in_items<'a, I>(items: I, position: Position) -> Option<&'a Verbatim>
277where
278    I: IntoIterator<Item = &'a ContentItem>,
279{
280    for item in items {
281        match item {
282            ContentItem::VerbatimBlock(v) => {
283                if v.location.contains(position) {
284                    return Some(v);
285                }
286            }
287            ContentItem::Session(s) => {
288                if let Some(found) = find_verbatim_in_session(s, position) {
289                    return Some(found);
290                }
291            }
292            ContentItem::Definition(d) => {
293                if let Some(found) = find_verbatim_in_items(d.children.iter(), position) {
294                    return Some(found);
295                }
296            }
297            ContentItem::List(list) => {
298                for entry in &list.items {
299                    if let ContentItem::ListItem(li) = entry {
300                        if let Some(found) = find_verbatim_in_items(li.children.iter(), position) {
301                            return Some(found);
302                        }
303                    }
304                }
305            }
306            ContentItem::Annotation(a) => {
307                if let Some(found) = find_verbatim_in_items(a.children.iter(), position) {
308                    return Some(found);
309                }
310            }
311            ContentItem::Table(table) => {
312                // Tables can hold block-level children (including
313                // verbatim blocks) under each cell; walk every cell's
314                // children. `cell_children_iter` flattens rows ×
315                // cells × children for us.
316                if let Some(found) = find_verbatim_in_items(table.cell_children_iter(), position) {
317                    return Some(found);
318                }
319            }
320            _ => {}
321        }
322    }
323    None
324}
325
326pub fn find_sessions_by_identifier<'a>(
327    document: &'a Document,
328    identifier: &str,
329) -> Vec<&'a Session> {
330    let normalized = normalize_key(identifier);
331    if normalized.is_empty() {
332        return Vec::new();
333    }
334    let mut matches = Vec::new();
335    collect_sessions_by_identifier(&document.root, &normalized, &mut matches, true);
336    matches
337}
338
339pub fn session_identifier(session: &Session) -> Option<String> {
340    extract_session_identifier(session.title.as_string())
341}
342
343pub fn reference_at_position(
344    document: &Document,
345    position: Position,
346) -> Option<PositionedReference> {
347    let mut result = None;
348    for_each_text_content(document, &mut |text| {
349        if result.is_some() {
350            return;
351        }
352        for reference in extract_references(text) {
353            if reference.range.contains(position) {
354                result = Some(reference);
355                break;
356            }
357        }
358    });
359    result
360}
361
362fn visit_session_text<F>(session: &Session, is_root: bool, f: &mut F)
363where
364    F: FnMut(&TextContent),
365{
366    if !is_root {
367        f(&session.title);
368    }
369    for annotation in session.annotations() {
370        visit_annotation_text(annotation, f);
371    }
372    for child in session.children.iter() {
373        visit_content_text(child, f);
374    }
375}
376
377fn visit_annotation_text<F>(annotation: &Annotation, f: &mut F)
378where
379    F: FnMut(&TextContent),
380{
381    for child in annotation.children.iter() {
382        visit_content_text(child, f);
383    }
384}
385
386fn visit_content_text<F>(item: &ContentItem, f: &mut F)
387where
388    F: FnMut(&TextContent),
389{
390    match item {
391        ContentItem::Paragraph(paragraph) => {
392            for line in &paragraph.lines {
393                if let ContentItem::TextLine(text_line) = line {
394                    f(&text_line.content);
395                }
396            }
397            for annotation in paragraph.annotations() {
398                visit_annotation_text(annotation, f);
399            }
400        }
401        ContentItem::Session(session) => visit_session_text(session, false, f),
402        ContentItem::List(list) => {
403            for annotation in list.annotations() {
404                visit_annotation_text(annotation, f);
405            }
406            for entry in &list.items {
407                if let ContentItem::ListItem(list_item) = entry {
408                    for text in &list_item.text {
409                        f(text);
410                    }
411                    for annotation in list_item.annotations() {
412                        visit_annotation_text(annotation, f);
413                    }
414                    for child in list_item.children.iter() {
415                        visit_content_text(child, f);
416                    }
417                }
418            }
419        }
420        ContentItem::ListItem(list_item) => {
421            for text in &list_item.text {
422                f(text);
423            }
424            for annotation in list_item.annotations() {
425                visit_annotation_text(annotation, f);
426            }
427            for child in list_item.children.iter() {
428                visit_content_text(child, f);
429            }
430        }
431        ContentItem::Definition(definition) => {
432            f(&definition.subject);
433            for annotation in definition.annotations() {
434                visit_annotation_text(annotation, f);
435            }
436            for child in definition.children.iter() {
437                visit_content_text(child, f);
438            }
439        }
440        ContentItem::Annotation(annotation) => visit_annotation_text(annotation, f),
441        ContentItem::VerbatimBlock(verbatim) => {
442            f(&verbatim.subject);
443            for annotation in verbatim.annotations() {
444                visit_annotation_text(annotation, f);
445            }
446        }
447        ContentItem::Table(table) => {
448            f(&table.subject);
449            for row in table.all_rows() {
450                for cell in &row.cells {
451                    f(&cell.content);
452                }
453            }
454            for annotation in table.annotations() {
455                visit_annotation_text(annotation, f);
456            }
457        }
458        ContentItem::TextLine(_)
459        | ContentItem::VerbatimLine(_)
460        | ContentItem::BlankLineGroup(_) => {}
461    }
462}
463
464fn collect_definitions<'a>(
465    items: impl Iterator<Item = &'a ContentItem>,
466    target: &str,
467    matches: &mut Vec<&'a Definition>,
468) {
469    for item in items {
470        collect_definitions_in_content(item, target, matches);
471    }
472}
473
474fn collect_definitions_in_content<'a>(
475    item: &'a ContentItem,
476    target: &str,
477    matches: &mut Vec<&'a Definition>,
478) {
479    match item {
480        ContentItem::Definition(definition) => {
481            if subject_matches(definition, target) {
482                matches.push(definition);
483            }
484            collect_definitions(definition.children.iter(), target, matches);
485        }
486        ContentItem::Session(session) => {
487            collect_definitions(session.children.iter(), target, matches);
488        }
489        ContentItem::List(list) => {
490            for entry in &list.items {
491                if let ContentItem::ListItem(list_item) = entry {
492                    collect_definitions(list_item.children.iter(), target, matches);
493                }
494            }
495        }
496        ContentItem::ListItem(list_item) => {
497            collect_definitions(list_item.children.iter(), target, matches);
498        }
499        ContentItem::Annotation(annotation) => {
500            collect_definitions(annotation.children.iter(), target, matches);
501        }
502        ContentItem::Paragraph(paragraph) => {
503            for annotation in paragraph.annotations() {
504                collect_definitions(annotation.children.iter(), target, matches);
505            }
506        }
507        _ => {}
508    }
509}
510
511fn find_definition_in_items<'a>(
512    items: impl Iterator<Item = &'a ContentItem>,
513    position: Position,
514) -> Option<&'a Definition> {
515    for item in items {
516        if let Some(definition) = find_definition_in_content(item, position) {
517            return Some(definition);
518        }
519    }
520    None
521}
522
523fn find_definition_in_content(item: &ContentItem, position: Position) -> Option<&Definition> {
524    match item {
525        ContentItem::Definition(definition) => {
526            if definition
527                .header_location()
528                .map(|range| range.contains(position))
529                .unwrap_or_else(|| definition.range().contains(position))
530            {
531                return Some(definition);
532            }
533            find_definition_in_items(definition.children.iter(), position)
534        }
535        ContentItem::Session(session) => {
536            find_definition_in_items(session.children.iter(), position)
537        }
538        ContentItem::List(list) => list.items.iter().find_map(|entry| match entry {
539            ContentItem::ListItem(list_item) => {
540                find_definition_in_items(list_item.children.iter(), position)
541            }
542            _ => None,
543        }),
544        ContentItem::ListItem(list_item) => {
545            find_definition_in_items(list_item.children.iter(), position)
546        }
547        ContentItem::Annotation(annotation) => {
548            find_definition_in_items(annotation.children.iter(), position)
549        }
550        ContentItem::Paragraph(paragraph) => paragraph
551            .annotations()
552            .iter()
553            .find_map(|annotation| find_definition_in_items(annotation.children.iter(), position)),
554        _ => None,
555    }
556}
557
558fn find_annotation_in_session(
559    session: &Session,
560    position: Position,
561    is_root: bool,
562) -> Option<&Annotation> {
563    if !is_root {
564        if let Some(annotation) = session
565            .annotations()
566            .iter()
567            .find(|ann| ann.header_location().contains(position))
568        {
569            return Some(annotation);
570        }
571    }
572    for child in session.children.iter() {
573        if let Some(annotation) = find_annotation_in_content(child, position) {
574            return Some(annotation);
575        }
576    }
577    None
578}
579
580fn find_annotation_in_content(item: &ContentItem, position: Position) -> Option<&Annotation> {
581    match item {
582        ContentItem::Paragraph(paragraph) => paragraph
583            .annotations()
584            .iter()
585            .find(|ann| ann.header_location().contains(position))
586            .or_else(|| find_annotation_in_items(paragraph.lines.iter(), position)),
587        ContentItem::Session(session) => find_annotation_in_session(session, position, false),
588        ContentItem::List(list) => {
589            if let Some(annotation) = list
590                .annotations()
591                .iter()
592                .find(|ann| ann.header_location().contains(position))
593            {
594                return Some(annotation);
595            }
596            for entry in &list.items {
597                if let ContentItem::ListItem(list_item) = entry {
598                    if let Some(annotation) = list_item
599                        .annotations()
600                        .iter()
601                        .find(|ann| ann.header_location().contains(position))
602                    {
603                        return Some(annotation);
604                    }
605                    if let Some(found) =
606                        find_annotation_in_items(list_item.children.iter(), position)
607                    {
608                        return Some(found);
609                    }
610                }
611            }
612            None
613        }
614        ContentItem::ListItem(list_item) => list_item
615            .annotations()
616            .iter()
617            .find(|ann| ann.header_location().contains(position))
618            .or_else(|| find_annotation_in_items(list_item.children.iter(), position)),
619        ContentItem::Definition(definition) => definition
620            .annotations()
621            .iter()
622            .find(|ann| ann.header_location().contains(position))
623            .or_else(|| find_annotation_in_items(definition.children.iter(), position)),
624        ContentItem::Annotation(annotation) => {
625            if annotation.header_location().contains(position) {
626                return Some(annotation);
627            }
628            find_annotation_in_items(annotation.children.iter(), position)
629        }
630        ContentItem::VerbatimBlock(verbatim) => verbatim
631            .annotations()
632            .iter()
633            .find(|ann| ann.header_location().contains(position))
634            .or_else(|| find_annotation_in_items(verbatim.children.iter(), position)),
635        ContentItem::TextLine(_) => None,
636        _ => None,
637    }
638}
639
640fn find_annotation_in_items<'a>(
641    items: impl Iterator<Item = &'a ContentItem>,
642    position: Position,
643) -> Option<&'a Annotation> {
644    for item in items {
645        if let Some(annotation) = find_annotation_in_content(item, position) {
646            return Some(annotation);
647        }
648    }
649    None
650}
651
652fn find_session_in_branch(
653    session: &Session,
654    position: Position,
655    is_root: bool,
656) -> Option<&Session> {
657    if !is_root {
658        if let Some(header) = session.header_location() {
659            if header.contains(position) {
660                return Some(session);
661            }
662        }
663    }
664    for child in session.children.iter() {
665        if let ContentItem::Session(child_session) = child {
666            if let Some(found) = find_session_in_branch(child_session, position, false) {
667                return Some(found);
668            }
669        }
670    }
671    None
672}
673
674fn collect_sessions_by_identifier<'a>(
675    session: &'a Session,
676    target: &str,
677    matches: &mut Vec<&'a Session>,
678    is_root: bool,
679) {
680    if !is_root {
681        let title = session.title.as_string();
682        let normalized_title = title.trim().to_ascii_lowercase();
683        let title_matches =
684            normalized_title.starts_with(target) && has_session_boundary(title, target.len());
685        let identifier_matches = session_identifier(session)
686            .as_deref()
687            .map(|id| id.to_ascii_lowercase() == target)
688            .unwrap_or(false);
689        if title_matches || identifier_matches {
690            matches.push(session);
691        }
692    }
693    for child in session.children.iter() {
694        if let ContentItem::Session(child_session) = child {
695            collect_sessions_by_identifier(child_session, target, matches, false);
696        }
697    }
698}
699
700fn has_session_boundary(title: &str, len: usize) -> bool {
701    let trimmed = title.trim();
702    if trimmed.len() <= len {
703        return trimmed.len() == len;
704    }
705    matches!(
706        trimmed.chars().nth(len),
707        Some(ch) if matches!(ch, ' ' | '\t' | ':' | '.')
708    )
709}
710
711fn subject_matches(definition: &Definition, target: &str) -> bool {
712    normalize_key(definition.subject.as_string()).eq(target)
713}
714
715fn normalize_key(input: &str) -> String {
716    input.trim().to_ascii_lowercase()
717}
718
719fn extract_session_identifier(title: &str) -> Option<String> {
720    let trimmed = title.trim();
721    if trimmed.is_empty() {
722        return None;
723    }
724    let mut identifier = String::new();
725    for ch in trimmed.chars() {
726        if ch.is_ascii_digit() || ch == '.' {
727            identifier.push(ch);
728        } else {
729            break;
730        }
731    }
732    if identifier.ends_with('.') {
733        identifier.pop();
734    }
735    if identifier.is_empty() {
736        None
737    } else {
738        Some(identifier)
739    }
740}
741
742/// Checks whether a list is annotated with `:: notes ::`. The
743/// parser's `NormalizeLabels` stage resolves the shortcut to its
744/// canonical (`lex.notes`); both forms are accepted here in case a
745/// caller hands an un-normalized AST.
746fn is_notes_list(list: &lex_core::lex::ast::List) -> bool {
747    list.annotations().iter().any(label_is_notes)
748}
749
750/// Checks whether a container has a `:: notes ::` annotation (which may
751/// attach to the container itself rather than to the list, depending on
752/// blank line distance).
753fn has_notes_annotation(annotations: &[Annotation]) -> bool {
754    annotations.iter().any(label_is_notes)
755}
756
757fn label_is_notes(a: &Annotation) -> bool {
758    let v = a.data.label.value.trim();
759    v.eq_ignore_ascii_case("lex.notes") || v.eq_ignore_ascii_case("notes")
760}
761
762/// Collects all footnote definitions from a document.
763///
764/// Footnote definitions are list items inside lists marked by a `:: notes ::`
765/// annotation. The annotation can attach either to the list itself or to the
766/// containing session/document (depending on blank line proximity). Both cases
767/// are handled.
768///
769/// Returns a vector of (label, range) pairs for each definition found.
770pub fn collect_footnote_definitions(
771    document: &Document,
772) -> Vec<(String, lex_core::lex::ast::Range)> {
773    let mut defs = Vec::new();
774    // Check document-level :: notes :: annotations
775    if has_notes_annotation(document.annotations()) {
776        collect_first_list_items(&document.root.children, &mut defs);
777    }
778    collect_notes_items_in_session(&document.root, &mut defs);
779    defs
780}
781
782fn collect_notes_items_in_session(
783    session: &Session,
784    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
785) {
786    // Check session-level :: notes :: annotations
787    if has_notes_annotation(session.annotations()) {
788        collect_first_list_items(&session.children, out);
789    }
790    for item in session.children.iter() {
791        match item {
792            ContentItem::List(l) if is_notes_list(l) => {
793                collect_list_item_labels(l, out);
794            }
795            ContentItem::Session(s) => collect_notes_items_in_session(s, out),
796            ContentItem::Definition(d) => collect_notes_items_in_children(d.children.iter(), out),
797            _ => {}
798        }
799    }
800}
801
802fn collect_notes_items_in_children<'a>(
803    items: impl Iterator<Item = &'a ContentItem>,
804    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
805) {
806    for item in items {
807        match item {
808            ContentItem::List(l) if is_notes_list(l) => {
809                collect_list_item_labels(l, out);
810            }
811            ContentItem::Session(s) => collect_notes_items_in_session(s, out),
812            _ => {}
813        }
814    }
815}
816
817/// When a `:: notes ::` annotation attaches to a container rather than
818/// a list, find the first list child and collect its items.
819fn collect_first_list_items(
820    children: &[ContentItem],
821    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
822) {
823    for item in children {
824        if let ContentItem::List(l) = item {
825            collect_list_item_labels(l, out);
826            return;
827        }
828    }
829}
830
831fn collect_list_item_labels(
832    list: &lex_core::lex::ast::List,
833    out: &mut Vec<(String, lex_core::lex::ast::Range)>,
834) {
835    for entry in &list.items {
836        if let ContentItem::ListItem(li) = entry {
837            let marker = li.marker();
838            let label = marker
839                .trim()
840                .trim_end_matches(['.', ')', ':'].as_ref())
841                .trim();
842            if !label.is_empty() {
843                out.push((label.to_string(), li.range().clone()));
844            }
845        }
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use lex_core::lex::testing::lexplore::Lexplore;
853
854    #[test]
855    fn collects_footnotes_from_notes_annotated_list() {
856        let doc = Lexplore::footnotes(3).parse().unwrap();
857        let defs = collect_footnote_definitions(&doc);
858        let labels: Vec<&str> = defs.iter().map(|(l, _)| l.as_str()).collect();
859        assert_eq!(labels, vec!["1", "2"]);
860    }
861
862    #[test]
863    fn no_footnotes_without_notes_annotation() {
864        // A plain list in a "Notes" session is NOT a footnote list without :: notes ::
865        let doc = Lexplore::footnotes(4).parse().unwrap();
866        let defs = collect_footnote_definitions(&doc);
867        assert!(defs.is_empty());
868    }
869
870    #[test]
871    fn collects_footnotes_at_document_root() {
872        let doc = Lexplore::footnotes(2).parse().unwrap();
873        let defs = collect_footnote_definitions(&doc);
874        let labels: Vec<&str> = defs.iter().map(|(l, _)| l.as_str()).collect();
875        assert_eq!(labels, vec!["1", "2"]);
876    }
877
878    #[test]
879    fn multiple_notes_lists_in_different_sessions() {
880        // Each chapter has its own :: notes :: list with 2 items
881        let doc = Lexplore::footnotes(5).parse().unwrap();
882        let defs = collect_footnote_definitions(&doc);
883        assert_eq!(defs.len(), 4); // 2 items × 2 chapters
884    }
885}