Skip to main content

asciidoc_parser/blocks/
list.rs

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