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