Skip to main content

asciidoc_parser/blocks/
list.rs

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