Skip to main content

asciidoc_parser/blocks/
list.rs

1use std::slice::Iter;
2
3use crate::{
4    HasSpan, Parser, Span,
5    attributes::Attrlist,
6    blocks::{Block, ContentModel, IsBlock, ListItem, ListItemMarker, metadata::BlockMetadata},
7    internal::debug::DebugSliceReference,
8    span::MatchedItem,
9    strings::CowStr,
10    warnings::{Warning, WarningType},
11};
12
13/// A list contains a sequence of items prefixed with symbol, such as a disc
14/// (aka bullet). Each individual item in the list is represented by a
15/// [`ListItem`].
16///
17/// [`ListItem`]: crate::blocks::ListItem
18#[derive(Clone, Eq, PartialEq)]
19pub struct ListBlock<'src> {
20    type_: ListType,
21    items: Vec<Block<'src>>,
22    source: Span<'src>,
23    title_source: Option<Span<'src>>,
24    title: Option<String>,
25    anchor: Option<Span<'src>>,
26    anchor_reftext: Option<Span<'src>>,
27    attrlist: Option<Attrlist<'src>>,
28    is_checklist: bool,
29    is_bibliography: bool,
30}
31
32impl<'src> ListBlock<'src> {
33    pub(crate) fn parse(
34        metadata: &BlockMetadata<'src>,
35        parser: &mut Parser,
36        warnings: &mut Vec<Warning<'src>>,
37    ) -> Option<MatchedItem<'src, Self>> {
38        Self::parse_inside_list(metadata, &[], parser, warnings)
39    }
40
41    pub(crate) fn parse_inside_list(
42        metadata: &BlockMetadata<'src>,
43        parent_list_markers: &[ListItemMarker<'src>],
44        parser: &mut Parser,
45        warnings: &mut Vec<Warning<'src>>,
46    ) -> Option<MatchedItem<'src, Self>> {
47        let source = metadata.block_start.discard_empty_lines();
48
49        // A list carries the `bibliography` style in two ways, which differ in
50        // scope (matching Asciidoctor):
51        //
52        // * An explicit `[bibliography]` attribute marks the list a bibliography
53        //   regardless of its type (even an ordered list).
54        // * A `bibliography` section implicitly marks each of its top-level *unordered*
55        //   lists (only) a bibliography. A nested list never inherits the section
56        //   style, so this is gated on `parent_list_markers` being empty; the list-type
57        //   restriction is applied below, once the type is known.
58        let own_style_bibliography = metadata
59            .attrlist
60            .as_ref()
61            .and_then(|attrlist| attrlist.block_style())
62            == Some("bibliography");
63        let section_propagated_bibliography =
64            parent_list_markers.is_empty() && parser.parsing_bibliography_section_body;
65
66        let mut items: Vec<Block<'src>> = vec![];
67        let mut next_item_source = source;
68        let mut first_marker: Option<ListItemMarker<'src>> = None;
69        let mut expected_ordinal: Option<u32> = None;
70
71        loop {
72            let next_line_mi = next_item_source.take_normalized_line();
73
74            if next_line_mi.item.data().is_empty() || next_line_mi.item.data() == "+" {
75                if next_item_source.is_empty() || !parent_list_markers.is_empty() {
76                    break;
77                } else {
78                    next_item_source = next_line_mi.after;
79                    continue;
80                }
81            }
82
83            // TEMPORARY: Ignore block metadata for list items.
84            let list_item_metadata = BlockMetadata {
85                title_source: None,
86                title: None,
87                anchor: None,
88                anchor_reftext: None,
89                attrlist: None,
90                source: next_item_source,
91                block_start: next_item_source,
92            };
93
94            let Some(list_item_marker_mi) =
95                ListItemMarker::parse(list_item_metadata.block_start, parser)
96            else {
97                break;
98            };
99
100            let this_item_marker = list_item_marker_mi.item;
101
102            // If this item's marker doesn't match the existing list marker, we are changing
103            // levels in the list hierarchy.
104            if let Some(ref first_marker) = first_marker {
105                if !first_marker.is_match_for(&this_item_marker)
106                    && parent_list_markers
107                        .iter()
108                        .any(|parent| parent.is_match_for(&this_item_marker))
109                {
110                    // We matched a parent marker type. This list is complete; roll up the
111                    // hierarchy.
112                    break;
113                }
114
115                // Check if the marker is in sequence for explicit ordered lists.
116                if let Some(actual_ordinal) = this_item_marker.ordinal_value() {
117                    if let Some(expected) = expected_ordinal
118                        && actual_ordinal != expected
119                    {
120                        // Warn about out-of-sequence marker.
121                        if let (Some(expected_text), Some(actual_text)) = (
122                            first_marker.ordinal_to_marker_text(expected),
123                            first_marker.ordinal_to_marker_text(actual_ordinal),
124                        ) {
125                            warnings.push(Warning {
126                                source: this_item_marker.span(),
127                                warning: WarningType::ListItemOutOfSequence(
128                                    expected_text,
129                                    actual_text,
130                                ),
131                            });
132                        }
133                    }
134                    expected_ordinal = Some(actual_ordinal + 1);
135                }
136            } else {
137                first_marker = Some(this_item_marker.clone());
138
139                // Initialize expected ordinal from first marker's value.
140                if let Some(ordinal) = this_item_marker.ordinal_value() {
141                    expected_ordinal = Some(ordinal + 1);
142                }
143            }
144
145            // The bibliography anchor (`[[[id]]]`) is recognized in the principal
146            // text of any item of an explicitly-styled bibliography list, or of an
147            // unordered-list item when the style is inherited from the section.
148            // Pass that context down so the item's inline substitution can detect
149            // it.
150            let item_is_bibliography = own_style_bibliography
151                || (section_propagated_bibliography
152                    && matches!(
153                        this_item_marker,
154                        ListItemMarker::Asterisks(_)
155                            | ListItemMarker::Hyphen(_)
156                            | ListItemMarker::Bullet(_)
157                    ));
158
159            let Some(list_item_mi) = ListItem::parse(
160                &list_item_metadata,
161                parent_list_markers,
162                item_is_bibliography,
163                parser,
164                warnings,
165            ) else {
166                break;
167            };
168
169            items.push(Block::ListItem(list_item_mi.item));
170            next_item_source = list_item_mi.after;
171        }
172
173        if items.is_empty() {
174            return None;
175        }
176
177        let first_marker = first_marker?;
178        let type_ = match first_marker {
179            ListItemMarker::Asterisks(_) => ListType::Unordered,
180            ListItemMarker::Hyphen(_) => ListType::Unordered,
181            ListItemMarker::Bullet(_) => ListType::Unordered,
182            ListItemMarker::Dots(_) => ListType::Ordered,
183            ListItemMarker::AlphaListCapital(_) => ListType::Ordered,
184            ListItemMarker::AlphaListLower(_) => ListType::Ordered,
185            ListItemMarker::RomanNumeralLower(_) => ListType::Ordered,
186            ListItemMarker::RomanNumeralUpper(_) => ListType::Ordered,
187            ListItemMarker::ArabicNumeral(_) => ListType::Ordered,
188            ListItemMarker::Callout(_) => ListType::Callout,
189
190            ListItemMarker::DefinedTerm {
191                term: _,
192                marker: _,
193                source: _,
194            } => ListType::Description,
195        };
196
197        // A callout list annotates the callouts of a preceding verbatim block.
198        // For each item (by position): an explicit `<N>` marker that doesn't
199        // match the item's position is out of sequence, and an item position
200        // with no callout registered while substituting the block has no
201        // matching callout. Both mirror Asciidoctor's `parse_callout_list`
202        // warnings. The list is then closed so the next block's callouts start
203        // fresh.
204        if type_ == ListType::Callout {
205            for (index, item) in items.iter().enumerate() {
206                let position = (index + 1) as u32;
207
208                if let Some(marker_number) = item
209                    .as_list_item()
210                    .and_then(|li| li.list_item_marker().callout_number())
211                    && marker_number != position
212                {
213                    warnings.push(Warning {
214                        source: item.span(),
215                        warning: WarningType::CalloutListItemOutOfSequence(
216                            position as usize,
217                            marker_number as usize,
218                        ),
219                    });
220                }
221
222                if !parser.callout_defined(position) {
223                    warnings.push(Warning {
224                        source: item.span(),
225                        warning: WarningType::NoCalloutFound(position as usize),
226                    });
227                }
228            }
229            parser.close_callout_list();
230        }
231
232        // An unordered list is a checklist (i.e. task list) when at least one of
233        // its items has checkbox syntax. This mirrors Asciidoctor, which sets the
234        // `checklist` option on the list once any item carries a checkbox.
235        let is_checklist = type_ == ListType::Unordered
236            && items.iter().any(|item| {
237                item.as_list_item()
238                    .is_some_and(|li| li.checkbox().is_some())
239            });
240
241        // An explicit `[bibliography]` style applies to any list type; the style
242        // inherited from a section applies only to unordered lists.
243        let is_bibliography = own_style_bibliography
244            || (section_propagated_bibliography && type_ == ListType::Unordered);
245
246        Some(MatchedItem {
247            item: Self {
248                type_,
249                items,
250                source: metadata
251                    .source
252                    .trim_remainder(next_item_source)
253                    .trim_trailing_line_end()
254                    .trim_trailing_whitespace(),
255                title_source: metadata.title_source,
256                title: metadata.title.clone(),
257                anchor: metadata.anchor,
258                anchor_reftext: metadata.anchor_reftext,
259                attrlist: metadata.attrlist.clone(),
260                is_checklist,
261                is_bibliography,
262            },
263            after: next_item_source,
264        })
265    }
266
267    /// Returns the type of this list.
268    pub fn type_(&self) -> ListType {
269        self.type_
270    }
271
272    /// Returns `true` if this list is a checklist (i.e. task list).
273    ///
274    /// An unordered list becomes a checklist when at least one of its items
275    /// uses checkbox syntax (`[ ]`, `[x]`, or `[*]`). See
276    /// [`ListItem::checkbox`].
277    ///
278    /// [`ListItem::checkbox`]: crate::blocks::ListItem::checkbox
279    pub fn is_checklist(&self) -> bool {
280        self.is_checklist
281    }
282
283    /// Returns `true` if this list carries the `bibliography` style.
284    ///
285    /// A list is a bibliography list when it is an unordered list that is
286    /// either explicitly marked `[bibliography]` or appears as a top-level
287    /// list within a section that carries the `bibliography` style (the
288    /// section implicitly adds the style to each of its unordered lists).
289    /// Each item of such a list may begin with a bibliography anchor
290    /// (`[[[id]]]`).
291    pub fn is_bibliography(&self) -> bool {
292        self.is_bibliography
293    }
294
295    /// Returns the style class for this list based on the marker length.
296    /// For ordered lists, the style is determined by the number of dots:
297    /// - 1 dot: arabic (1, 2, 3, ...)
298    /// - 2 dots: loweralpha (a, b, c, ...)
299    /// - 3 dots: lowerroman (i, ii, iii, ...)
300    /// - 4 dots: upperalpha (A, B, C, ...)
301    /// - 5 dots: upperroman (I, II, III, ...)
302    pub fn marker_style(&self) -> Option<&'static str> {
303        let first_marker = self.items.first()?.as_list_item()?.list_item_marker();
304
305        match first_marker {
306            ListItemMarker::Dots(span) => {
307                let marker_len = span.data().len();
308                match marker_len {
309                    1 => Some("arabic"),
310                    2 => Some("loweralpha"),
311                    3 => Some("lowerroman"),
312                    4 => Some("upperalpha"),
313                    5 => Some("upperroman"),
314                    _ => Some("arabic"),
315                }
316            }
317            ListItemMarker::ArabicNumeral(_) => Some("arabic"),
318            ListItemMarker::Callout(_) => Some("arabic"),
319            ListItemMarker::AlphaListLower(_) => Some("loweralpha"),
320            ListItemMarker::AlphaListCapital(_) => Some("upperalpha"),
321            ListItemMarker::RomanNumeralLower(_) => Some("lowerroman"),
322            ListItemMarker::RomanNumeralUpper(_) => Some("upperroman"),
323            _ => None,
324        }
325    }
326}
327
328impl<'src> IsBlock<'src> for ListBlock<'src> {
329    fn content_model(&self) -> ContentModel {
330        ContentModel::Compound
331    }
332
333    fn raw_context(&self) -> CowStr<'src> {
334        "list".into()
335    }
336
337    fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
338        self.items.iter()
339    }
340
341    fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
342        &mut self.items
343    }
344
345    fn title_source(&'src self) -> Option<Span<'src>> {
346        self.title_source
347    }
348
349    fn title(&self) -> Option<&str> {
350        self.title.as_deref()
351    }
352
353    fn anchor(&'src self) -> Option<Span<'src>> {
354        self.anchor
355    }
356
357    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
358        self.anchor_reftext
359    }
360
361    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
362        self.attrlist.as_ref()
363    }
364}
365
366impl<'src> HasSpan<'src> for ListBlock<'src> {
367    fn span(&self) -> Span<'src> {
368        self.source
369    }
370}
371
372impl std::fmt::Debug for ListBlock<'_> {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.debug_struct("ListBlock")
375            .field("type_", &self.type_)
376            .field("items", &DebugSliceReference(&self.items))
377            .field("source", &self.source)
378            .field("title_source", &self.title_source)
379            .field("title", &self.title)
380            .field("anchor", &self.anchor)
381            .field("anchor_reftext", &self.anchor_reftext)
382            .field("attrlist", &self.attrlist)
383            .field("is_checklist", &self.is_checklist)
384            .field("is_bibliography", &self.is_bibliography)
385            .finish()
386    }
387}
388
389/// Represents the type of a list.
390#[derive(Clone, Copy, Eq, PartialEq)]
391pub enum ListType {
392    /// An unordered list is a list with items prefixed with symbol, such as a
393    /// disc (aka bullet).
394    Unordered,
395
396    /// An ordered list is a list with items prefixed with a number or other
397    /// sequential mark.
398    Ordered,
399
400    /// A description list is an association list that consists of one or more
401    /// terms (or sets of terms) that each have a description.
402    Description,
403
404    /// A callout list provides annotations for lines in a preceding verbatim
405    /// block. Its items are marked with `<1>`, `<2>`, … (or `<.>` for automatic
406    /// numbering).
407    Callout,
408}
409
410impl std::fmt::Debug for ListType {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        match self {
413            ListType::Unordered => write!(f, "ListType::Unordered"),
414            ListType::Ordered => write!(f, "ListType::Ordered"),
415            ListType::Description => write!(f, "ListType::Description"),
416            ListType::Callout => write!(f, "ListType::Callout"),
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    #![allow(clippy::indexing_slicing)]
424    #![allow(clippy::panic)]
425    #![allow(clippy::unwrap_used)]
426
427    use crate::{
428        blocks::{ContentModel, ListType, metadata::BlockMetadata},
429        span::MatchedItem,
430        tests::prelude::*,
431        warnings::Warning,
432    };
433
434    fn list_parse<'a>(source: &'a str) -> Option<MatchedItem<'a, crate::blocks::ListBlock<'a>>> {
435        let mut parser = crate::Parser::default();
436        let mut warnings: Vec<Warning<'a>> = vec![];
437
438        let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
439
440        let result = crate::blocks::list::ListBlock::parse(&metadata, &mut parser, &mut warnings);
441
442        assert!(warnings.is_empty());
443
444        result
445    }
446
447    /// Like [`list_parse`], but also returns the warnings produced. Used for
448    /// callout lists, which warn when an item has no matching callout in a
449    /// preceding verbatim block.
450    fn list_parse_with_warnings<'a>(
451        source: &'a str,
452    ) -> (
453        Option<MatchedItem<'a, crate::blocks::ListBlock<'a>>>,
454        Vec<Warning<'a>>,
455    ) {
456        let mut parser = crate::Parser::default();
457        let mut warnings: Vec<Warning<'a>> = vec![];
458
459        let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
460
461        let result = crate::blocks::list::ListBlock::parse(&metadata, &mut parser, &mut warnings);
462
463        (result, warnings)
464    }
465
466    #[test]
467    fn basic_case() {
468        assert!(list_parse("-xyz").is_none());
469        assert!(list_parse("-- x").is_none());
470
471        let list = list_parse("- blah").unwrap();
472
473        assert_eq!(
474            list.item,
475            ListBlock {
476                type_: ListType::Unordered,
477                items: &[Block::ListItem(ListItem {
478                    marker: ListItemMarker::Hyphen(Span {
479                        data: "-",
480                        line: 1,
481                        col: 1,
482                        offset: 0,
483                    },),
484                    blocks: &[Block::Simple(SimpleBlock {
485                        content: Content {
486                            original: Span {
487                                data: "blah",
488                                line: 1,
489                                col: 3,
490                                offset: 2,
491                            },
492                            rendered: "blah",
493                        },
494                        source: Span {
495                            data: "blah",
496                            line: 1,
497                            col: 3,
498                            offset: 2,
499                        },
500                        style: SimpleBlockStyle::Paragraph,
501                        title_source: None,
502                        title: None,
503                        caption: None,
504                        number: None,
505                        anchor: None,
506                        anchor_reftext: None,
507                        attrlist: None,
508                    },),],
509                    source: Span {
510                        data: "- blah",
511                        line: 1,
512                        col: 1,
513                        offset: 0,
514                    },
515                    anchor: None,
516                    anchor_reftext: None,
517                    attrlist: None,
518                },),],
519                source: Span {
520                    data: "- blah",
521                    line: 1,
522                    col: 1,
523                    offset: 0,
524                },
525                title_source: None,
526                title: None,
527                anchor: None,
528                anchor_reftext: None,
529                attrlist: None,
530            }
531        );
532
533        assert_eq!(list.item.type_(), ListType::Unordered);
534        assert_eq!(list.item.content_model(), ContentModel::Compound);
535        assert_eq!(list.item.raw_context().as_ref(), "list");
536
537        let mut list_blocks = list.item.nested_blocks();
538
539        let list_item = list_blocks.next().unwrap();
540
541        assert_eq!(
542            list_item,
543            &Block::ListItem(ListItem {
544                marker: ListItemMarker::Hyphen(Span {
545                    data: "-",
546                    line: 1,
547                    col: 1,
548                    offset: 0,
549                },),
550                blocks: &[Block::Simple(SimpleBlock {
551                    content: Content {
552                        original: Span {
553                            data: "blah",
554                            line: 1,
555                            col: 3,
556                            offset: 2,
557                        },
558                        rendered: "blah",
559                    },
560                    source: Span {
561                        data: "blah",
562                        line: 1,
563                        col: 3,
564                        offset: 2,
565                    },
566                    style: SimpleBlockStyle::Paragraph,
567                    title_source: None,
568                    title: None,
569                    caption: None,
570                    number: None,
571                    anchor: None,
572                    anchor_reftext: None,
573                    attrlist: None,
574                },),],
575                source: Span {
576                    data: "- blah",
577                    line: 1,
578                    col: 1,
579                    offset: 0,
580                },
581                anchor: None,
582                anchor_reftext: None,
583                attrlist: None,
584            })
585        );
586
587        assert_eq!(list_item.content_model(), ContentModel::Compound);
588        assert_eq!(list_item.raw_context().as_ref(), "list_item");
589
590        let mut li_blocks = list_item.nested_blocks();
591
592        assert_eq!(
593            li_blocks.next().unwrap(),
594            &Block::Simple(SimpleBlock {
595                content: Content {
596                    original: Span {
597                        data: "blah",
598                        line: 1,
599                        col: 3,
600                        offset: 2,
601                    },
602                    rendered: "blah",
603                },
604                source: Span {
605                    data: "blah",
606                    line: 1,
607                    col: 3,
608                    offset: 2,
609                },
610                style: SimpleBlockStyle::Paragraph,
611                title_source: None,
612                title: None,
613                caption: None,
614                number: None,
615                anchor: None,
616                anchor_reftext: None,
617                attrlist: None,
618            })
619        );
620        assert!(li_blocks.next().is_none());
621
622        assert!(list_item.title_source().is_none());
623        assert!(list_item.title().is_none());
624        assert!(list_item.anchor().is_none());
625        assert!(list_item.anchor_reftext().is_none());
626        assert!(list_item.attrlist().is_none());
627        assert_eq!(list_item.substitution_group(), SubstitutionGroup::Normal);
628        assert_eq!(
629            list_item.span(),
630            Span {
631                data: "- blah",
632                line: 1,
633                col: 1,
634                offset: 0,
635            }
636        );
637
638        assert!(list_blocks.next().is_none());
639
640        assert!(list.item.title_source().is_none());
641        assert!(list.item.title().is_none());
642        assert!(list.item.anchor().is_none());
643        assert!(list.item.anchor_reftext().is_none());
644        assert!(list.item.attrlist().is_none());
645
646        assert_eq!(
647            format!("{:#?}", list.item),
648            "ListBlock {\n    type_: ListType::Unordered,\n    items: &[\n        Block::ListItem(\n            ListItem {\n                marker: ListItemMarker::Hyphen(\n                    Span {\n                        data: \"-\",\n                        line: 1,\n                        col: 1,\n                        offset: 0,\n                    },\n                ),\n                blocks: &[\n                    Block::Simple(\n                        SimpleBlock {\n                            content: Content {\n                                original: Span {\n                                    data: \"blah\",\n                                    line: 1,\n                                    col: 3,\n                                    offset: 2,\n                                },\n                                rendered: \"blah\",\n                            },\n                            source: Span {\n                                data: \"blah\",\n                                line: 1,\n                                col: 3,\n                                offset: 2,\n                            },\n                            style: SimpleBlockStyle::Paragraph,\n                            title_source: None,\n                            title: None,\n                            caption: None,\n                            number: None,\n                            anchor: None,\n                            anchor_reftext: None,\n                            attrlist: None,\n                        },\n                    ),\n                ],\n                source: Span {\n                    data: \"- blah\",\n                    line: 1,\n                    col: 1,\n                    offset: 0,\n                },\n                anchor: None,\n                anchor_reftext: None,\n                attrlist: None,\n                checkbox: None,\n            },\n        ),\n    ],\n    source: Span {\n        data: \"- blah\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n    title_source: None,\n    title: None,\n    anchor: None,\n    anchor_reftext: None,\n    attrlist: None,\n    is_checklist: false,\n    is_bibliography: false,\n}"
649        );
650
651        assert_eq!(
652            list.after,
653            Span {
654                data: "",
655                line: 1,
656                col: 7,
657                offset: 6,
658            }
659        );
660    }
661
662    #[test]
663    fn list_type_impl_debug() {
664        assert_eq!(format!("{:#?}", ListType::Unordered), "ListType::Unordered");
665        assert_eq!(format!("{:#?}", ListType::Ordered), "ListType::Ordered");
666
667        assert_eq!(
668            format!("{:#?}", ListType::Description),
669            "ListType::Description"
670        );
671
672        assert_eq!(format!("{:#?}", ListType::Callout), "ListType::Callout");
673    }
674
675    #[test]
676    fn callout_list() {
677        // Parsed in isolation (no preceding verbatim block), so each item warns
678        // that it has no matching callout.
679        let (list, warnings) = list_parse_with_warnings("<1> First\n<2> Second\n");
680        let list = list.unwrap();
681
682        assert_eq!(list.item.type_(), ListType::Callout);
683        assert_eq!(list.item.marker_style(), Some("arabic"));
684
685        let items: Vec<_> = list.item.nested_blocks().collect();
686        assert_eq!(items.len(), 2);
687
688        assert_eq!(
689            items[0].nested_blocks().next().unwrap().rendered_content(),
690            Some("First")
691        );
692        assert_eq!(
693            items[1].nested_blocks().next().unwrap().rendered_content(),
694            Some("Second")
695        );
696
697        let warning_types: Vec<_> = warnings.iter().map(|w| &w.warning).collect();
698        assert_eq!(
699            warning_types,
700            vec![
701                &WarningType::NoCalloutFound(1),
702                &WarningType::NoCalloutFound(2),
703            ]
704        );
705    }
706
707    #[test]
708    fn callout_list_auto_numbered() {
709        // `<.>` markers form a single callout list.
710        let (list, warnings) = list_parse_with_warnings("<.> First\n<.> Second\n<.> Third\n");
711        let list = list.unwrap();
712
713        assert_eq!(list.item.type_(), ListType::Callout);
714        assert_eq!(list.item.nested_blocks().count(), 3);
715
716        // No preceding verbatim block defines these callouts.
717        assert_eq!(warnings.len(), 3);
718    }
719
720    #[test]
721    fn callout_list_marker_only_trailing_bracket_is_not_a_list() {
722        // `1>` (trailing bracket only) is not a callout list marker.
723        assert!(list_parse("1> Not a callout list item\n").is_none());
724    }
725
726    #[test]
727    fn attrlist_doesnt_exit() {
728        let list = list_parse("* Foo\n[loweralpha]\n. Boo\n* Blech").unwrap();
729
730        assert_eq!(
731            list.item,
732            ListBlock {
733                type_: ListType::Unordered,
734                items: &[
735                    Block::ListItem(ListItem {
736                        marker: ListItemMarker::Asterisks(Span {
737                            data: "*",
738                            line: 1,
739                            col: 1,
740                            offset: 0,
741                        },),
742                        blocks: &[
743                            Block::Simple(SimpleBlock {
744                                content: Content {
745                                    original: Span {
746                                        data: "Foo",
747                                        line: 1,
748                                        col: 3,
749                                        offset: 2,
750                                    },
751                                    rendered: "Foo",
752                                },
753                                source: Span {
754                                    data: "Foo",
755                                    line: 1,
756                                    col: 3,
757                                    offset: 2,
758                                },
759                                style: SimpleBlockStyle::Paragraph,
760                                title_source: None,
761                                title: None,
762                                caption: None,
763                                number: None,
764                                anchor: None,
765                                anchor_reftext: None,
766                                attrlist: None,
767                            },),
768                            Block::List(ListBlock {
769                                type_: ListType::Ordered,
770                                items: &[Block::ListItem(ListItem {
771                                    marker: ListItemMarker::Dots(Span {
772                                        data: ".",
773                                        line: 3,
774                                        col: 1,
775                                        offset: 19,
776                                    },),
777                                    blocks: &[Block::Simple(SimpleBlock {
778                                        content: Content {
779                                            original: Span {
780                                                data: "Boo",
781                                                line: 3,
782                                                col: 3,
783                                                offset: 21,
784                                            },
785                                            rendered: "Boo",
786                                        },
787                                        source: Span {
788                                            data: "Boo",
789                                            line: 3,
790                                            col: 3,
791                                            offset: 21,
792                                        },
793                                        style: SimpleBlockStyle::Paragraph,
794                                        title_source: None,
795                                        title: None,
796                                        caption: None,
797                                        number: None,
798                                        anchor: None,
799                                        anchor_reftext: None,
800                                        attrlist: None,
801                                    },),],
802                                    source: Span {
803                                        data: ". Boo",
804                                        line: 3,
805                                        col: 1,
806                                        offset: 19,
807                                    },
808                                    anchor: None,
809                                    anchor_reftext: None,
810                                    attrlist: None,
811                                },),],
812                                source: Span {
813                                    data: "[loweralpha]\n. Boo",
814                                    line: 2,
815                                    col: 1,
816                                    offset: 6,
817                                },
818                                title_source: None,
819                                title: None,
820                                anchor: None,
821                                anchor_reftext: None,
822                                attrlist: Some(Attrlist {
823                                    attributes: &[ElementAttribute {
824                                        name: None,
825                                        value: "loweralpha",
826                                        shorthand_items: &["loweralpha"],
827                                    },],
828                                    anchor: None,
829                                    source: Span {
830                                        data: "loweralpha",
831                                        line: 2,
832                                        col: 2,
833                                        offset: 7,
834                                    },
835                                },),
836                            },),
837                        ],
838                        source: Span {
839                            data: "* Foo\n[loweralpha]\n. Boo",
840                            line: 1,
841                            col: 1,
842                            offset: 0,
843                        },
844                        anchor: None,
845                        anchor_reftext: None,
846                        attrlist: None,
847                    },),
848                    Block::ListItem(ListItem {
849                        marker: ListItemMarker::Asterisks(Span {
850                            data: "*",
851                            line: 4,
852                            col: 1,
853                            offset: 25,
854                        },),
855                        blocks: &[Block::Simple(SimpleBlock {
856                            content: Content {
857                                original: Span {
858                                    data: "Blech",
859                                    line: 4,
860                                    col: 3,
861                                    offset: 27,
862                                },
863                                rendered: "Blech",
864                            },
865                            source: Span {
866                                data: "Blech",
867                                line: 4,
868                                col: 3,
869                                offset: 27,
870                            },
871                            style: SimpleBlockStyle::Paragraph,
872                            title_source: None,
873                            title: None,
874                            caption: None,
875                            number: None,
876                            anchor: None,
877                            anchor_reftext: None,
878                            attrlist: None,
879                        },),],
880                        source: Span {
881                            data: "* Blech",
882                            line: 4,
883                            col: 1,
884                            offset: 25,
885                        },
886                        anchor: None,
887                        anchor_reftext: None,
888                        attrlist: None,
889                    },),
890                ],
891                source: Span {
892                    data: "* Foo\n[loweralpha]\n. Boo\n* Blech",
893                    line: 1,
894                    col: 1,
895                    offset: 0,
896                },
897                title_source: None,
898                title: None,
899                anchor: None,
900                anchor_reftext: None,
901                attrlist: None,
902            }
903        );
904
905        assert_eq!(
906            list.after,
907            Span {
908                data: "",
909                line: 4,
910                col: 8,
911                offset: 32,
912            }
913        );
914    }
915
916    #[test]
917    fn metadata_merged_across_empty_lines_for_nested_list() {
918        // Exercises the `if ext_anchor.is_none()` merge path in
919        // ListItem::parse (circa line 283 of list_item.rs).
920        let list = list_parse("* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo\n* Blech").unwrap();
921
922        assert_eq!(
923            list.item,
924            ListBlock {
925                type_: ListType::Unordered,
926                items: &[
927                    Block::ListItem(ListItem {
928                        marker: ListItemMarker::Asterisks(Span {
929                            data: "*",
930                            line: 1,
931                            col: 1,
932                            offset: 0,
933                        },),
934                        blocks: &[
935                            Block::Simple(SimpleBlock {
936                                content: Content {
937                                    original: Span {
938                                        data: "Foo",
939                                        line: 1,
940                                        col: 3,
941                                        offset: 2,
942                                    },
943                                    rendered: "Foo",
944                                },
945                                source: Span {
946                                    data: "Foo",
947                                    line: 1,
948                                    col: 3,
949                                    offset: 2,
950                                },
951                                style: SimpleBlockStyle::Paragraph,
952                                title_source: None,
953                                title: None,
954                                caption: None,
955                                number: None,
956                                anchor: None,
957                                anchor_reftext: None,
958                                attrlist: None,
959                            },),
960                            Block::List(ListBlock {
961                                type_: ListType::Ordered,
962                                items: &[Block::ListItem(ListItem {
963                                    marker: ListItemMarker::Dots(Span {
964                                        data: ".",
965                                        line: 5,
966                                        col: 1,
967                                        offset: 31,
968                                    },),
969                                    blocks: &[Block::Simple(SimpleBlock {
970                                        content: Content {
971                                            original: Span {
972                                                data: "Boo",
973                                                line: 5,
974                                                col: 3,
975                                                offset: 33,
976                                            },
977                                            rendered: "Boo",
978                                        },
979                                        source: Span {
980                                            data: "Boo",
981                                            line: 5,
982                                            col: 3,
983                                            offset: 33,
984                                        },
985                                        style: SimpleBlockStyle::Paragraph,
986                                        title_source: None,
987                                        title: None,
988                                        caption: None,
989                                        number: None,
990                                        anchor: None,
991                                        anchor_reftext: None,
992                                        attrlist: None,
993                                    },),],
994                                    source: Span {
995                                        data: ". Boo",
996                                        line: 5,
997                                        col: 1,
998                                        offset: 31,
999                                    },
1000                                    anchor: None,
1001                                    anchor_reftext: None,
1002                                    attrlist: None,
1003                                },),],
1004                                source: Span {
1005                                    data: "[loweralpha]\n\n[[anchor]]\n. Boo",
1006                                    line: 2,
1007                                    col: 1,
1008                                    offset: 6,
1009                                },
1010                                title_source: None,
1011                                title: None,
1012                                anchor: Some(Span {
1013                                    data: "anchor",
1014                                    line: 4,
1015                                    col: 3,
1016                                    offset: 22,
1017                                },),
1018                                anchor_reftext: None,
1019                                attrlist: Some(Attrlist {
1020                                    attributes: &[ElementAttribute {
1021                                        name: None,
1022                                        value: "loweralpha",
1023                                        shorthand_items: &["loweralpha"],
1024                                    },],
1025                                    anchor: None,
1026                                    source: Span {
1027                                        data: "loweralpha",
1028                                        line: 2,
1029                                        col: 2,
1030                                        offset: 7,
1031                                    },
1032                                },),
1033                            },),
1034                        ],
1035                        source: Span {
1036                            data: "* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo",
1037                            line: 1,
1038                            col: 1,
1039                            offset: 0,
1040                        },
1041                        anchor: None,
1042                        anchor_reftext: None,
1043                        attrlist: None,
1044                    },),
1045                    Block::ListItem(ListItem {
1046                        marker: ListItemMarker::Asterisks(Span {
1047                            data: "*",
1048                            line: 6,
1049                            col: 1,
1050                            offset: 37,
1051                        },),
1052                        blocks: &[Block::Simple(SimpleBlock {
1053                            content: Content {
1054                                original: Span {
1055                                    data: "Blech",
1056                                    line: 6,
1057                                    col: 3,
1058                                    offset: 39,
1059                                },
1060                                rendered: "Blech",
1061                            },
1062                            source: Span {
1063                                data: "Blech",
1064                                line: 6,
1065                                col: 3,
1066                                offset: 39,
1067                            },
1068                            style: SimpleBlockStyle::Paragraph,
1069                            title_source: None,
1070                            title: None,
1071                            caption: None,
1072                            number: None,
1073                            anchor: None,
1074                            anchor_reftext: None,
1075                            attrlist: None,
1076                        },),],
1077                        source: Span {
1078                            data: "* Blech",
1079                            line: 6,
1080                            col: 1,
1081                            offset: 37,
1082                        },
1083                        anchor: None,
1084                        anchor_reftext: None,
1085                        attrlist: None,
1086                    },),
1087                ],
1088                source: Span {
1089                    data: "* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo\n* Blech",
1090                    line: 1,
1091                    col: 1,
1092                    offset: 0,
1093                },
1094                title_source: None,
1095                title: None,
1096                anchor: None,
1097                anchor_reftext: None,
1098                attrlist: None,
1099            }
1100        );
1101    }
1102
1103    #[test]
1104    fn parent_marker_after_metadata_separated_by_empty_lines() {
1105        // Exercises the parent_list_markers check in ListItem::parse
1106        // (circa line 308) where a list marker found after extending metadata
1107        // past empty lines matches a grandparent marker.
1108        //
1109        // Input: three nesting levels, then [[anchor]] + blank line + * marker.
1110        // The *** item should recognize * as a grandparent marker and break.
1111        let list =
1112            list_parse("* grandparent\n** parent\n*** nested\n[[anchor]]\n\n* back to grandparent")
1113                .unwrap();
1114
1115        // Outer list has two * items.
1116        assert_eq!(list.item.nested_blocks().count(), 2);
1117        assert_eq!(list.item.type_(), ListType::Unordered);
1118
1119        let mut outer_items = list.item.nested_blocks();
1120
1121        // First outer item should contain a nested ** list.
1122        let first_outer = outer_items.next().unwrap();
1123        let first_outer_blocks: Vec<_> = first_outer.nested_blocks().collect();
1124        assert_eq!(first_outer_blocks.len(), 2); // SimpleBlock + ListBlock
1125
1126        // The nested ** list should have one item.
1127        let nested_list = &first_outer_blocks[1];
1128        assert_eq!(nested_list.nested_blocks().count(), 1);
1129
1130        // That ** item should contain a nested *** list.
1131        let parent_item = nested_list.nested_blocks().next().unwrap();
1132        let parent_blocks: Vec<_> = parent_item.nested_blocks().collect();
1133        assert_eq!(parent_blocks.len(), 2); // SimpleBlock + ListBlock
1134
1135        // The *** list should have one item.
1136        let innermost_list = &parent_blocks[1];
1137        assert_eq!(innermost_list.nested_blocks().count(), 1);
1138
1139        // The *** item should have only its principal text.
1140        let innermost_item = innermost_list.nested_blocks().next().unwrap();
1141        assert_eq!(innermost_item.nested_blocks().count(), 1);
1142
1143        // Second outer item is "back to grandparent".
1144        let second_outer = outer_items.next().unwrap();
1145        assert_eq!(second_outer.nested_blocks().count(), 1);
1146        assert!(outer_items.next().is_none());
1147    }
1148
1149    #[test]
1150    fn marker_style_single_dot() {
1151        let list = list_parse(". Item one\n. Item two\n").unwrap();
1152        assert_eq!(list.item.marker_style(), Some("arabic"));
1153    }
1154
1155    #[test]
1156    fn marker_style_double_dots() {
1157        let list = list_parse(".. Item a\n.. Item b\n").unwrap();
1158        assert_eq!(list.item.marker_style(), Some("loweralpha"));
1159    }
1160
1161    #[test]
1162    fn marker_style_triple_dots() {
1163        let list = list_parse("... Item i\n... Item ii\n").unwrap();
1164        assert_eq!(list.item.marker_style(), Some("lowerroman"));
1165    }
1166
1167    #[test]
1168    fn marker_style_four_dots() {
1169        let list = list_parse(".... Item A\n.... Item B\n").unwrap();
1170        assert_eq!(list.item.marker_style(), Some("upperalpha"));
1171    }
1172
1173    #[test]
1174    fn marker_style_five_dots() {
1175        let list = list_parse("..... Item I\n..... Item II\n").unwrap();
1176        assert_eq!(list.item.marker_style(), Some("upperroman"));
1177    }
1178
1179    #[test]
1180    fn marker_style_hyphen_returns_none() {
1181        let list = list_parse("- Item one\n- Item two\n").unwrap();
1182        assert_eq!(list.item.marker_style(), None);
1183    }
1184
1185    #[test]
1186    fn marker_style_asterisk_returns_none() {
1187        let list = list_parse("* Item one\n* Item two\n").unwrap();
1188        assert_eq!(list.item.marker_style(), None);
1189    }
1190
1191    #[test]
1192    fn marker_with_no_content() {
1193        // Exercises the `break` in `parse_inside_list` when
1194        // `ListItemMarker::parse` succeeds but `ListItem::parse`
1195        // returns `None` (marker present, no content after it).
1196        assert!(list_parse("- ").is_none());
1197        assert!(list_parse("* ").is_none());
1198        assert!(list_parse(". ").is_none());
1199    }
1200
1201    #[test]
1202    fn orphaned_title_after_continuation_is_discarded() {
1203        // Exercises the "If there's block metadata but no block, just discard
1204        // it and continue." path in ListItem::parse (circa line 368 of
1205        // list_item.rs). A `+` continuation followed by a block title (`.Title`)
1206        // and then an empty line means the title is orphaned (no block
1207        // immediately follows). The title metadata is discarded and the
1208        // subsequent paragraph is parsed as a continuation block.
1209        let list = list_parse("* item one\n+\n.Title\n\nsecond paragraph").unwrap();
1210
1211        // The list should have one item.
1212        let mut items = list.item.nested_blocks();
1213        let item = items.next().unwrap();
1214        assert!(items.next().is_none());
1215
1216        // The item should have two blocks: the principal text and the
1217        // continuation paragraph. The orphaned `.Title` should be discarded.
1218        let blocks: Vec<_> = item.nested_blocks().collect();
1219        assert_eq!(blocks.len(), 2);
1220
1221        // First block is the principal text.
1222        assert_eq!(
1223            blocks[0],
1224            &Block::Simple(SimpleBlock {
1225                content: Content {
1226                    original: Span {
1227                        data: "item one",
1228                        line: 1,
1229                        col: 3,
1230                        offset: 2,
1231                    },
1232                    rendered: "item one",
1233                },
1234                source: Span {
1235                    data: "item one",
1236                    line: 1,
1237                    col: 3,
1238                    offset: 2,
1239                },
1240                style: SimpleBlockStyle::Paragraph,
1241                title_source: None,
1242                title: None,
1243                caption: None,
1244                number: None,
1245                anchor: None,
1246                anchor_reftext: None,
1247                attrlist: None,
1248            })
1249        );
1250
1251        // Second block is the continuation paragraph (no title attached).
1252        assert_eq!(
1253            blocks[1],
1254            &Block::Simple(SimpleBlock {
1255                content: Content {
1256                    original: Span {
1257                        data: "second paragraph",
1258                        line: 5,
1259                        col: 1,
1260                        offset: 21,
1261                    },
1262                    rendered: "second paragraph",
1263                },
1264                source: Span {
1265                    data: "second paragraph",
1266                    line: 5,
1267                    col: 1,
1268                    offset: 21,
1269                },
1270                style: SimpleBlockStyle::Paragraph,
1271                title_source: None,
1272                title: None,
1273                caption: None,
1274                number: None,
1275                anchor: None,
1276                anchor_reftext: None,
1277                attrlist: None,
1278            })
1279        );
1280    }
1281
1282    #[test]
1283    fn block_list_enum_case() {
1284        let mut parser = crate::Parser::default();
1285
1286        let mi = crate::blocks::Block::parse(crate::Span::new("- blah"), &mut parser)
1287            .unwrap_if_no_warnings()
1288            .unwrap();
1289
1290        assert!(matches!(mi.item, crate::blocks::Block::List(_)));
1291
1292        assert_eq!(mi.item.content_model(), ContentModel::Compound);
1293        assert!(mi.item.rendered_content().is_none());
1294        assert_eq!(mi.item.raw_context().as_ref(), "list");
1295        assert_eq!(mi.item.nested_blocks().count(), 1);
1296        assert!(mi.item.title_source().is_none());
1297        assert!(mi.item.title().is_none());
1298        assert!(mi.item.anchor().is_none());
1299        assert!(mi.item.anchor_reftext().is_none());
1300        assert!(mi.item.attrlist().is_none());
1301        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1302
1303        assert_eq!(
1304            mi.item.span(),
1305            Span {
1306                data: "- blah",
1307                line: 1,
1308                col: 1,
1309                offset: 0,
1310            }
1311        );
1312
1313        let debug_str = format!("{:?}", mi.item);
1314        assert!(debug_str.starts_with("Block::List("));
1315    }
1316}