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