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