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
152            ListItemMarker::DefinedTerm {
153                term: _,
154                marker: _,
155                source: _,
156            } => ListType::Description,
157        };
158
159        Some(MatchedItem {
160            item: Self {
161                type_,
162                items,
163                source: metadata
164                    .source
165                    .trim_remainder(next_item_source)
166                    .trim_trailing_line_end()
167                    .trim_trailing_whitespace(),
168                title_source: metadata.title_source,
169                title: metadata.title.clone(),
170                anchor: metadata.anchor,
171                anchor_reftext: metadata.anchor_reftext,
172                attrlist: metadata.attrlist.clone(),
173            },
174            after: next_item_source,
175        })
176    }
177
178    /// Returns the type of this list.
179    pub fn type_(&self) -> ListType {
180        self.type_
181    }
182
183    /// Returns the style class for this list based on the marker length.
184    /// For ordered lists, the style is determined by the number of dots:
185    /// - 1 dot: arabic (1, 2, 3, ...)
186    /// - 2 dots: loweralpha (a, b, c, ...)
187    /// - 3 dots: lowerroman (i, ii, iii, ...)
188    /// - 4 dots: upperalpha (A, B, C, ...)
189    /// - 5 dots: upperroman (I, II, III, ...)
190    pub fn marker_style(&self) -> Option<&'static str> {
191        let first_marker = self.items.first()?.as_list_item()?.list_item_marker();
192
193        match first_marker {
194            ListItemMarker::Dots(span) => {
195                let marker_len = span.data().len();
196                match marker_len {
197                    1 => Some("arabic"),
198                    2 => Some("loweralpha"),
199                    3 => Some("lowerroman"),
200                    4 => Some("upperalpha"),
201                    5 => Some("upperroman"),
202                    _ => Some("arabic"),
203                }
204            }
205            ListItemMarker::ArabicNumeral(_) => Some("arabic"),
206            ListItemMarker::AlphaListLower(_) => Some("loweralpha"),
207            ListItemMarker::AlphaListCapital(_) => Some("upperalpha"),
208            ListItemMarker::RomanNumeralLower(_) => Some("lowerroman"),
209            ListItemMarker::RomanNumeralUpper(_) => Some("upperroman"),
210            _ => None,
211        }
212    }
213}
214
215impl<'src> IsBlock<'src> for ListBlock<'src> {
216    fn content_model(&self) -> ContentModel {
217        ContentModel::Compound
218    }
219
220    fn raw_context(&self) -> CowStr<'src> {
221        "list".into()
222    }
223
224    fn nested_blocks(&'src self) -> Iter<'src, Block<'src>> {
225        self.items.iter()
226    }
227
228    fn nested_blocks_mut(&mut self) -> &mut [Block<'src>] {
229        &mut self.items
230    }
231
232    fn title_source(&'src self) -> Option<Span<'src>> {
233        self.title_source
234    }
235
236    fn title(&self) -> Option<&str> {
237        self.title.as_deref()
238    }
239
240    fn anchor(&'src self) -> Option<Span<'src>> {
241        self.anchor
242    }
243
244    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
245        self.anchor_reftext
246    }
247
248    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
249        self.attrlist.as_ref()
250    }
251}
252
253impl<'src> HasSpan<'src> for ListBlock<'src> {
254    fn span(&self) -> Span<'src> {
255        self.source
256    }
257}
258
259impl std::fmt::Debug for ListBlock<'_> {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        f.debug_struct("ListBlock")
262            .field("type_", &self.type_)
263            .field("items", &DebugSliceReference(&self.items))
264            .field("source", &self.source)
265            .field("title_source", &self.title_source)
266            .field("title", &self.title)
267            .field("anchor", &self.anchor)
268            .field("anchor_reftext", &self.anchor_reftext)
269            .field("attrlist", &self.attrlist)
270            .finish()
271    }
272}
273
274/// Represents the type of a list.
275#[derive(Clone, Copy, Eq, PartialEq)]
276pub enum ListType {
277    /// An unordered list is a list with items prefixed with symbol, such as a
278    /// disc (aka bullet).
279    Unordered,
280
281    /// An ordered list is a list with items prefixed with a number or other
282    /// sequential mark.
283    Ordered,
284
285    /// A description list is an association list that consists of one or more
286    /// terms (or sets of terms) that each have a description.
287    Description,
288}
289
290impl std::fmt::Debug for ListType {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        match self {
293            ListType::Unordered => write!(f, "ListType::Unordered"),
294            ListType::Ordered => write!(f, "ListType::Ordered"),
295            ListType::Description => write!(f, "ListType::Description"),
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    #![allow(clippy::indexing_slicing)]
303    #![allow(clippy::panic)]
304    #![allow(clippy::unwrap_used)]
305
306    use crate::{
307        blocks::{ContentModel, ListType, metadata::BlockMetadata},
308        span::MatchedItem,
309        tests::prelude::*,
310        warnings::Warning,
311    };
312
313    fn list_parse<'a>(source: &'a str) -> Option<MatchedItem<'a, crate::blocks::ListBlock<'a>>> {
314        let mut parser = crate::Parser::default();
315        let mut warnings: Vec<Warning<'a>> = vec![];
316
317        let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
318
319        let result = crate::blocks::list::ListBlock::parse(&metadata, &mut parser, &mut warnings);
320
321        assert!(warnings.is_empty());
322
323        result
324    }
325
326    #[test]
327    fn basic_case() {
328        assert!(list_parse("-xyz").is_none());
329        assert!(list_parse("-- x").is_none());
330
331        let list = list_parse("- blah").unwrap();
332
333        assert_eq!(
334            list.item,
335            ListBlock {
336                type_: ListType::Unordered,
337                items: &[Block::ListItem(ListItem {
338                    marker: ListItemMarker::Hyphen(Span {
339                        data: "-",
340                        line: 1,
341                        col: 1,
342                        offset: 0,
343                    },),
344                    blocks: &[Block::Simple(SimpleBlock {
345                        content: Content {
346                            original: Span {
347                                data: "blah",
348                                line: 1,
349                                col: 3,
350                                offset: 2,
351                            },
352                            rendered: "blah",
353                        },
354                        source: Span {
355                            data: "blah",
356                            line: 1,
357                            col: 3,
358                            offset: 2,
359                        },
360                        style: SimpleBlockStyle::Paragraph,
361                        title_source: None,
362                        title: None,
363                        anchor: None,
364                        anchor_reftext: None,
365                        attrlist: None,
366                    },),],
367                    source: Span {
368                        data: "- blah",
369                        line: 1,
370                        col: 1,
371                        offset: 0,
372                    },
373                    anchor: None,
374                    anchor_reftext: None,
375                    attrlist: None,
376                },),],
377                source: Span {
378                    data: "- blah",
379                    line: 1,
380                    col: 1,
381                    offset: 0,
382                },
383                title_source: None,
384                title: None,
385                anchor: None,
386                anchor_reftext: None,
387                attrlist: None,
388            }
389        );
390
391        assert_eq!(list.item.type_(), ListType::Unordered);
392        assert_eq!(list.item.content_model(), ContentModel::Compound);
393        assert_eq!(list.item.raw_context().as_ref(), "list");
394
395        let mut list_blocks = list.item.nested_blocks();
396
397        let list_item = list_blocks.next().unwrap();
398
399        assert_eq!(
400            list_item,
401            &Block::ListItem(ListItem {
402                marker: ListItemMarker::Hyphen(Span {
403                    data: "-",
404                    line: 1,
405                    col: 1,
406                    offset: 0,
407                },),
408                blocks: &[Block::Simple(SimpleBlock {
409                    content: Content {
410                        original: Span {
411                            data: "blah",
412                            line: 1,
413                            col: 3,
414                            offset: 2,
415                        },
416                        rendered: "blah",
417                    },
418                    source: Span {
419                        data: "blah",
420                        line: 1,
421                        col: 3,
422                        offset: 2,
423                    },
424                    style: SimpleBlockStyle::Paragraph,
425                    title_source: None,
426                    title: 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        );
442
443        assert_eq!(list_item.content_model(), ContentModel::Compound);
444        assert_eq!(list_item.raw_context().as_ref(), "list_item");
445
446        let mut li_blocks = list_item.nested_blocks();
447
448        assert_eq!(
449            li_blocks.next().unwrap(),
450            &Block::Simple(SimpleBlock {
451                content: Content {
452                    original: Span {
453                        data: "blah",
454                        line: 1,
455                        col: 3,
456                        offset: 2,
457                    },
458                    rendered: "blah",
459                },
460                source: Span {
461                    data: "blah",
462                    line: 1,
463                    col: 3,
464                    offset: 2,
465                },
466                style: SimpleBlockStyle::Paragraph,
467                title_source: None,
468                title: None,
469                anchor: None,
470                anchor_reftext: None,
471                attrlist: None,
472            })
473        );
474        assert!(li_blocks.next().is_none());
475
476        assert!(list_item.title_source().is_none());
477        assert!(list_item.title().is_none());
478        assert!(list_item.anchor().is_none());
479        assert!(list_item.anchor_reftext().is_none());
480        assert!(list_item.attrlist().is_none());
481        assert_eq!(list_item.substitution_group(), SubstitutionGroup::Normal);
482        assert_eq!(
483            list_item.span(),
484            Span {
485                data: "- blah",
486                line: 1,
487                col: 1,
488                offset: 0,
489            }
490        );
491
492        assert!(list_blocks.next().is_none());
493
494        assert!(list.item.title_source().is_none());
495        assert!(list.item.title().is_none());
496        assert!(list.item.anchor().is_none());
497        assert!(list.item.anchor_reftext().is_none());
498        assert!(list.item.attrlist().is_none());
499
500        assert_eq!(
501            format!("{:#?}", list.item),
502            "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                            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}"
503        );
504
505        assert_eq!(
506            list.after,
507            Span {
508                data: "",
509                line: 1,
510                col: 7,
511                offset: 6,
512            }
513        );
514    }
515
516    #[test]
517    fn list_type_impl_debug() {
518        assert_eq!(format!("{:#?}", ListType::Unordered), "ListType::Unordered");
519        assert_eq!(format!("{:#?}", ListType::Ordered), "ListType::Ordered");
520
521        assert_eq!(
522            format!("{:#?}", ListType::Description),
523            "ListType::Description"
524        );
525    }
526
527    #[test]
528    fn attrlist_doesnt_exit() {
529        let list = list_parse("* Foo\n[loweralpha]\n. Boo\n* Blech").unwrap();
530
531        assert_eq!(
532            list.item,
533            ListBlock {
534                type_: ListType::Unordered,
535                items: &[
536                    Block::ListItem(ListItem {
537                        marker: ListItemMarker::Asterisks(Span {
538                            data: "*",
539                            line: 1,
540                            col: 1,
541                            offset: 0,
542                        },),
543                        blocks: &[
544                            Block::Simple(SimpleBlock {
545                                content: Content {
546                                    original: Span {
547                                        data: "Foo",
548                                        line: 1,
549                                        col: 3,
550                                        offset: 2,
551                                    },
552                                    rendered: "Foo",
553                                },
554                                source: Span {
555                                    data: "Foo",
556                                    line: 1,
557                                    col: 3,
558                                    offset: 2,
559                                },
560                                style: SimpleBlockStyle::Paragraph,
561                                title_source: None,
562                                title: None,
563                                anchor: None,
564                                anchor_reftext: None,
565                                attrlist: None,
566                            },),
567                            Block::List(ListBlock {
568                                type_: ListType::Ordered,
569                                items: &[Block::ListItem(ListItem {
570                                    marker: ListItemMarker::Dots(Span {
571                                        data: ".",
572                                        line: 3,
573                                        col: 1,
574                                        offset: 19,
575                                    },),
576                                    blocks: &[Block::Simple(SimpleBlock {
577                                        content: Content {
578                                            original: Span {
579                                                data: "Boo",
580                                                line: 3,
581                                                col: 3,
582                                                offset: 21,
583                                            },
584                                            rendered: "Boo",
585                                        },
586                                        source: Span {
587                                            data: "Boo",
588                                            line: 3,
589                                            col: 3,
590                                            offset: 21,
591                                        },
592                                        style: SimpleBlockStyle::Paragraph,
593                                        title_source: None,
594                                        title: None,
595                                        anchor: None,
596                                        anchor_reftext: None,
597                                        attrlist: None,
598                                    },),],
599                                    source: Span {
600                                        data: ". Boo",
601                                        line: 3,
602                                        col: 1,
603                                        offset: 19,
604                                    },
605                                    anchor: None,
606                                    anchor_reftext: None,
607                                    attrlist: None,
608                                },),],
609                                source: Span {
610                                    data: "[loweralpha]\n. Boo",
611                                    line: 2,
612                                    col: 1,
613                                    offset: 6,
614                                },
615                                title_source: None,
616                                title: None,
617                                anchor: None,
618                                anchor_reftext: None,
619                                attrlist: Some(Attrlist {
620                                    attributes: &[ElementAttribute {
621                                        name: None,
622                                        value: "loweralpha",
623                                        shorthand_items: &["loweralpha"],
624                                    },],
625                                    anchor: None,
626                                    source: Span {
627                                        data: "loweralpha",
628                                        line: 2,
629                                        col: 2,
630                                        offset: 7,
631                                    },
632                                },),
633                            },),
634                        ],
635                        source: Span {
636                            data: "* Foo\n[loweralpha]\n. Boo",
637                            line: 1,
638                            col: 1,
639                            offset: 0,
640                        },
641                        anchor: None,
642                        anchor_reftext: None,
643                        attrlist: None,
644                    },),
645                    Block::ListItem(ListItem {
646                        marker: ListItemMarker::Asterisks(Span {
647                            data: "*",
648                            line: 4,
649                            col: 1,
650                            offset: 25,
651                        },),
652                        blocks: &[Block::Simple(SimpleBlock {
653                            content: Content {
654                                original: Span {
655                                    data: "Blech",
656                                    line: 4,
657                                    col: 3,
658                                    offset: 27,
659                                },
660                                rendered: "Blech",
661                            },
662                            source: Span {
663                                data: "Blech",
664                                line: 4,
665                                col: 3,
666                                offset: 27,
667                            },
668                            style: SimpleBlockStyle::Paragraph,
669                            title_source: None,
670                            title: None,
671                            anchor: None,
672                            anchor_reftext: None,
673                            attrlist: None,
674                        },),],
675                        source: Span {
676                            data: "* Blech",
677                            line: 4,
678                            col: 1,
679                            offset: 25,
680                        },
681                        anchor: None,
682                        anchor_reftext: None,
683                        attrlist: None,
684                    },),
685                ],
686                source: Span {
687                    data: "* Foo\n[loweralpha]\n. Boo\n* Blech",
688                    line: 1,
689                    col: 1,
690                    offset: 0,
691                },
692                title_source: None,
693                title: None,
694                anchor: None,
695                anchor_reftext: None,
696                attrlist: None,
697            }
698        );
699
700        assert_eq!(
701            list.after,
702            Span {
703                data: "",
704                line: 4,
705                col: 8,
706                offset: 32,
707            }
708        );
709    }
710
711    #[test]
712    fn metadata_merged_across_empty_lines_for_nested_list() {
713        // Exercises the `if ext_anchor.is_none()` merge path in
714        // ListItem::parse (circa line 283 of list_item.rs).
715        let list = list_parse("* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo\n* Blech").unwrap();
716
717        assert_eq!(
718            list.item,
719            ListBlock {
720                type_: ListType::Unordered,
721                items: &[
722                    Block::ListItem(ListItem {
723                        marker: ListItemMarker::Asterisks(Span {
724                            data: "*",
725                            line: 1,
726                            col: 1,
727                            offset: 0,
728                        },),
729                        blocks: &[
730                            Block::Simple(SimpleBlock {
731                                content: Content {
732                                    original: Span {
733                                        data: "Foo",
734                                        line: 1,
735                                        col: 3,
736                                        offset: 2,
737                                    },
738                                    rendered: "Foo",
739                                },
740                                source: Span {
741                                    data: "Foo",
742                                    line: 1,
743                                    col: 3,
744                                    offset: 2,
745                                },
746                                style: SimpleBlockStyle::Paragraph,
747                                title_source: None,
748                                title: None,
749                                anchor: None,
750                                anchor_reftext: None,
751                                attrlist: None,
752                            },),
753                            Block::List(ListBlock {
754                                type_: ListType::Ordered,
755                                items: &[Block::ListItem(ListItem {
756                                    marker: ListItemMarker::Dots(Span {
757                                        data: ".",
758                                        line: 5,
759                                        col: 1,
760                                        offset: 31,
761                                    },),
762                                    blocks: &[Block::Simple(SimpleBlock {
763                                        content: Content {
764                                            original: Span {
765                                                data: "Boo",
766                                                line: 5,
767                                                col: 3,
768                                                offset: 33,
769                                            },
770                                            rendered: "Boo",
771                                        },
772                                        source: Span {
773                                            data: "Boo",
774                                            line: 5,
775                                            col: 3,
776                                            offset: 33,
777                                        },
778                                        style: SimpleBlockStyle::Paragraph,
779                                        title_source: None,
780                                        title: None,
781                                        anchor: None,
782                                        anchor_reftext: None,
783                                        attrlist: None,
784                                    },),],
785                                    source: Span {
786                                        data: ". Boo",
787                                        line: 5,
788                                        col: 1,
789                                        offset: 31,
790                                    },
791                                    anchor: None,
792                                    anchor_reftext: None,
793                                    attrlist: None,
794                                },),],
795                                source: Span {
796                                    data: "[loweralpha]\n\n[[anchor]]\n. Boo",
797                                    line: 2,
798                                    col: 1,
799                                    offset: 6,
800                                },
801                                title_source: None,
802                                title: None,
803                                anchor: Some(Span {
804                                    data: "anchor",
805                                    line: 4,
806                                    col: 3,
807                                    offset: 22,
808                                },),
809                                anchor_reftext: None,
810                                attrlist: Some(Attrlist {
811                                    attributes: &[ElementAttribute {
812                                        name: None,
813                                        value: "loweralpha",
814                                        shorthand_items: &["loweralpha"],
815                                    },],
816                                    anchor: None,
817                                    source: Span {
818                                        data: "loweralpha",
819                                        line: 2,
820                                        col: 2,
821                                        offset: 7,
822                                    },
823                                },),
824                            },),
825                        ],
826                        source: Span {
827                            data: "* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo",
828                            line: 1,
829                            col: 1,
830                            offset: 0,
831                        },
832                        anchor: None,
833                        anchor_reftext: None,
834                        attrlist: None,
835                    },),
836                    Block::ListItem(ListItem {
837                        marker: ListItemMarker::Asterisks(Span {
838                            data: "*",
839                            line: 6,
840                            col: 1,
841                            offset: 37,
842                        },),
843                        blocks: &[Block::Simple(SimpleBlock {
844                            content: Content {
845                                original: Span {
846                                    data: "Blech",
847                                    line: 6,
848                                    col: 3,
849                                    offset: 39,
850                                },
851                                rendered: "Blech",
852                            },
853                            source: Span {
854                                data: "Blech",
855                                line: 6,
856                                col: 3,
857                                offset: 39,
858                            },
859                            style: SimpleBlockStyle::Paragraph,
860                            title_source: None,
861                            title: None,
862                            anchor: None,
863                            anchor_reftext: None,
864                            attrlist: None,
865                        },),],
866                        source: Span {
867                            data: "* Blech",
868                            line: 6,
869                            col: 1,
870                            offset: 37,
871                        },
872                        anchor: None,
873                        anchor_reftext: None,
874                        attrlist: None,
875                    },),
876                ],
877                source: Span {
878                    data: "* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo\n* Blech",
879                    line: 1,
880                    col: 1,
881                    offset: 0,
882                },
883                title_source: None,
884                title: None,
885                anchor: None,
886                anchor_reftext: None,
887                attrlist: None,
888            }
889        );
890    }
891
892    #[test]
893    fn parent_marker_after_metadata_separated_by_empty_lines() {
894        // Exercises the parent_list_markers check in ListItem::parse
895        // (circa line 308) where a list marker found after extending metadata
896        // past empty lines matches a grandparent marker.
897        //
898        // Input: three nesting levels, then [[anchor]] + blank line + * marker.
899        // The *** item should recognize * as a grandparent marker and break.
900        let list =
901            list_parse("* grandparent\n** parent\n*** nested\n[[anchor]]\n\n* back to grandparent")
902                .unwrap();
903
904        // Outer list has two * items.
905        assert_eq!(list.item.nested_blocks().count(), 2);
906        assert_eq!(list.item.type_(), ListType::Unordered);
907
908        let mut outer_items = list.item.nested_blocks();
909
910        // First outer item should contain a nested ** list.
911        let first_outer = outer_items.next().unwrap();
912        let first_outer_blocks: Vec<_> = first_outer.nested_blocks().collect();
913        assert_eq!(first_outer_blocks.len(), 2); // SimpleBlock + ListBlock
914
915        // The nested ** list should have one item.
916        let nested_list = &first_outer_blocks[1];
917        assert_eq!(nested_list.nested_blocks().count(), 1);
918
919        // That ** item should contain a nested *** list.
920        let parent_item = nested_list.nested_blocks().next().unwrap();
921        let parent_blocks: Vec<_> = parent_item.nested_blocks().collect();
922        assert_eq!(parent_blocks.len(), 2); // SimpleBlock + ListBlock
923
924        // The *** list should have one item.
925        let innermost_list = &parent_blocks[1];
926        assert_eq!(innermost_list.nested_blocks().count(), 1);
927
928        // The *** item should have only its principal text.
929        let innermost_item = innermost_list.nested_blocks().next().unwrap();
930        assert_eq!(innermost_item.nested_blocks().count(), 1);
931
932        // Second outer item is "back to grandparent".
933        let second_outer = outer_items.next().unwrap();
934        assert_eq!(second_outer.nested_blocks().count(), 1);
935        assert!(outer_items.next().is_none());
936    }
937
938    #[test]
939    fn marker_style_single_dot() {
940        let list = list_parse(". Item one\n. Item two\n").unwrap();
941        assert_eq!(list.item.marker_style(), Some("arabic"));
942    }
943
944    #[test]
945    fn marker_style_double_dots() {
946        let list = list_parse(".. Item a\n.. Item b\n").unwrap();
947        assert_eq!(list.item.marker_style(), Some("loweralpha"));
948    }
949
950    #[test]
951    fn marker_style_triple_dots() {
952        let list = list_parse("... Item i\n... Item ii\n").unwrap();
953        assert_eq!(list.item.marker_style(), Some("lowerroman"));
954    }
955
956    #[test]
957    fn marker_style_four_dots() {
958        let list = list_parse(".... Item A\n.... Item B\n").unwrap();
959        assert_eq!(list.item.marker_style(), Some("upperalpha"));
960    }
961
962    #[test]
963    fn marker_style_five_dots() {
964        let list = list_parse("..... Item I\n..... Item II\n").unwrap();
965        assert_eq!(list.item.marker_style(), Some("upperroman"));
966    }
967
968    #[test]
969    fn marker_style_hyphen_returns_none() {
970        let list = list_parse("- Item one\n- Item two\n").unwrap();
971        assert_eq!(list.item.marker_style(), None);
972    }
973
974    #[test]
975    fn marker_style_asterisk_returns_none() {
976        let list = list_parse("* Item one\n* Item two\n").unwrap();
977        assert_eq!(list.item.marker_style(), None);
978    }
979
980    #[test]
981    fn marker_with_no_content() {
982        // Exercises the `break` in `parse_inside_list` when
983        // `ListItemMarker::parse` succeeds but `ListItem::parse`
984        // returns `None` (marker present, no content after it).
985        assert!(list_parse("- ").is_none());
986        assert!(list_parse("* ").is_none());
987        assert!(list_parse(". ").is_none());
988    }
989
990    #[test]
991    fn orphaned_title_after_continuation_is_discarded() {
992        // Exercises the "If there's block metadata but no block, just discard
993        // it and continue." path in ListItem::parse (circa line 368 of
994        // list_item.rs). A `+` continuation followed by a block title (`.Title`)
995        // and then an empty line means the title is orphaned (no block
996        // immediately follows). The title metadata is discarded and the
997        // subsequent paragraph is parsed as a continuation block.
998        let list = list_parse("* item one\n+\n.Title\n\nsecond paragraph").unwrap();
999
1000        // The list should have one item.
1001        let mut items = list.item.nested_blocks();
1002        let item = items.next().unwrap();
1003        assert!(items.next().is_none());
1004
1005        // The item should have two blocks: the principal text and the
1006        // continuation paragraph. The orphaned `.Title` should be discarded.
1007        let blocks: Vec<_> = item.nested_blocks().collect();
1008        assert_eq!(blocks.len(), 2);
1009
1010        // First block is the principal text.
1011        assert_eq!(
1012            blocks[0],
1013            &Block::Simple(SimpleBlock {
1014                content: Content {
1015                    original: Span {
1016                        data: "item one",
1017                        line: 1,
1018                        col: 3,
1019                        offset: 2,
1020                    },
1021                    rendered: "item one",
1022                },
1023                source: Span {
1024                    data: "item one",
1025                    line: 1,
1026                    col: 3,
1027                    offset: 2,
1028                },
1029                style: SimpleBlockStyle::Paragraph,
1030                title_source: None,
1031                title: None,
1032                anchor: None,
1033                anchor_reftext: None,
1034                attrlist: None,
1035            })
1036        );
1037
1038        // Second block is the continuation paragraph (no title attached).
1039        assert_eq!(
1040            blocks[1],
1041            &Block::Simple(SimpleBlock {
1042                content: Content {
1043                    original: Span {
1044                        data: "second paragraph",
1045                        line: 5,
1046                        col: 1,
1047                        offset: 21,
1048                    },
1049                    rendered: "second paragraph",
1050                },
1051                source: Span {
1052                    data: "second paragraph",
1053                    line: 5,
1054                    col: 1,
1055                    offset: 21,
1056                },
1057                style: SimpleBlockStyle::Paragraph,
1058                title_source: None,
1059                title: None,
1060                anchor: None,
1061                anchor_reftext: None,
1062                attrlist: None,
1063            })
1064        );
1065    }
1066
1067    #[test]
1068    fn block_list_enum_case() {
1069        let mut parser = crate::Parser::default();
1070
1071        let mi = crate::blocks::Block::parse(crate::Span::new("- blah"), &mut parser)
1072            .unwrap_if_no_warnings()
1073            .unwrap();
1074
1075        assert!(matches!(mi.item, crate::blocks::Block::List(_)));
1076
1077        assert_eq!(mi.item.content_model(), ContentModel::Compound);
1078        assert!(mi.item.rendered_content().is_none());
1079        assert_eq!(mi.item.raw_context().as_ref(), "list");
1080        assert_eq!(mi.item.nested_blocks().count(), 1);
1081        assert!(mi.item.title_source().is_none());
1082        assert!(mi.item.title().is_none());
1083        assert!(mi.item.anchor().is_none());
1084        assert!(mi.item.anchor_reftext().is_none());
1085        assert!(mi.item.attrlist().is_none());
1086        assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1087
1088        assert_eq!(
1089            mi.item.span(),
1090            Span {
1091                data: "- blah",
1092                line: 1,
1093                col: 1,
1094                offset: 0,
1095            }
1096        );
1097
1098        let debug_str = format!("{:?}", mi.item);
1099        assert!(debug_str.starts_with("Block::List("));
1100    }
1101}