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