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