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