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