Skip to main content

asciidoc_parser/blocks/
list_item.rs

1use crate::{
2    HasSpan, Parser, Span,
3    attributes::Attrlist,
4    blocks::{
5        Block, ChildBlocks, CompoundDelimitedBlock, ContentModel, IsBlock, ListBlock,
6        ListItemMarker, RawDelimitedBlock, SimpleBlock, block::BlockParseOutcome,
7        metadata::BlockMetadata,
8    },
9    content::Content,
10    internal::debug::DebugSliceReference,
11    span::MatchedItem,
12    strings::CowStr,
13    warnings::Warning,
14};
15
16/// A list item is a special kind of block that contains one or more blocks
17/// attached to it. In the simplest case, this will be a single [`SimpleBlock`]
18/// with the principal text for the list item. In other cases, it may be any
19/// number of blocks of any type which, together, form an entry in a list which
20/// is the immediate parent of this block.
21///
22/// [`SimpleBlock`]: crate::blocks::SimpleBlock
23#[derive(Clone, Eq, Hash, PartialEq)]
24pub struct ListItem<'src> {
25    marker: ListItemMarker<'src>,
26    blocks: Vec<Block<'src>>,
27    source: Span<'src>,
28    anchor: Option<Span<'src>>,
29    anchor_reftext: Option<Span<'src>>,
30    attrlist: Option<Attrlist<'src>>,
31    checkbox: Option<bool>,
32}
33
34impl<'src> ListItem<'src> {
35    /// Returns a document-order iterator over this list item's direct child
36    /// blocks.
37    ///
38    /// For the full subtree, or to search from a [`Block`] or [`Document`], use
39    /// [`FindBlocks`](crate::blocks::FindBlocks).
40    ///
41    /// [`Document`]: crate::Document
42    pub fn child_blocks(&'src self) -> ChildBlocks<'src> {
43        ChildBlocks::from_slice(&self.blocks)
44    }
45
46    pub(crate) fn parse(
47        metadata: &BlockMetadata<'src>,
48        parent_list_markers: &[ListItemMarker<'src>],
49        is_bibliography: bool,
50        parser: &mut Parser,
51        warnings: &mut Vec<Warning<'src>>,
52    ) -> Option<MatchedItem<'src, Self>> {
53        let source = metadata.block_start.discard_empty_lines();
54
55        let marker_mi = ListItemMarker::parse(source, parser)?;
56        let mut marker = marker_mi.item;
57
58        // Register any leading inline anchors in the description list term and apply
59        // macros substitution to render the anchor.
60        marker.register_leading_anchors(parser, warnings);
61
62        let mut list_markers_including_peer = parent_list_markers.to_vec();
63        list_markers_including_peer.push(marker.clone());
64
65        let mut blocks: Vec<Block<'src>> = vec![];
66
67        // Detect checklist (i.e. task list) syntax on unordered list items. When
68        // the principal text begins with `[ ] `, `[x] `, or `[*] ` (the closing
69        // bracket immediately followed by a single space character), the item is
70        // a checklist item. `[ ]` is unchecked; `[x]`/`[*]` are checked. The
71        // four-character checkbox marker is then stripped from the principal text.
72        // This mirrors Asciidoctor's `parse_list_item`.
73        let checkbox: Option<bool> = if matches!(
74            marker,
75            ListItemMarker::Hyphen(_) | ListItemMarker::Asterisks(_) | ListItemMarker::Bullet(_)
76        ) {
77            let text = marker_mi.after.data();
78            if text.starts_with("[ ] ") {
79                Some(false)
80            } else if text.starts_with("[x] ") || text.starts_with("[*] ") {
81                Some(true)
82            } else {
83                None
84            }
85        } else {
86            None
87        };
88
89        // When a checkbox marker is present, the principal text begins four
90        // characters later (after `[x] `). Any whitespace re-exposed by stripping
91        // the checkbox is discarded so the principal text never starts indented
92        // (which would otherwise be misread as a literal block).
93        let principal_start = if checkbox.is_some() {
94            marker_mi.after.slice_from(4..).discard_whitespace()
95        } else {
96            marker_mi.after
97        };
98
99        // Text after list item marker is always a simple block with no metadata.
100        let no_metadata = BlockMetadata {
101            title_source: None,
102            title: None,
103            anchor: None,
104            anchor_reftext: None,
105            attrlist: None,
106            source: principal_start,
107            block_start: principal_start,
108        };
109
110        // In a bibliography list, the principal text may begin with a
111        // bibliography anchor (`[[[id]]]`). Flag the parser so the inline macros
112        // substitution applied while parsing this principal text recognizes it.
113        // The flag is cleared immediately afterward so it never leaks into any
114        // nested blocks (e.g. a nested list) attached to this item.
115        parser.in_bibliography_list_item.set(is_bibliography);
116
117        // For description lists, the content after the marker can be empty.
118        // For other list types, we require content.
119        let simple_block_for_list_item = SimpleBlock::parse_for_list_item(
120            &no_metadata,
121            parser,
122            false,
123            &list_markers_including_peer,
124        );
125
126        parser.in_bibliography_list_item.set(false);
127
128        let mut next = if let Some(simple_block_mi) = simple_block_for_list_item {
129            // If the principal text is empty (e.g. from {empty} attribute reference),
130            // drop it from the parse tree.
131            if !simple_block_mi.item.content().is_empty() {
132                blocks.push(Block::Simple(simple_block_mi.item));
133            }
134            simple_block_mi.after
135        } else if matches!(marker, ListItemMarker::DefinedTerm { .. }) {
136            // Description list items can have empty content on the same line as the marker.
137            // The content may be on subsequent lines, so we try to parse from the next
138            // non-empty line.
139            let mut next_source = marker_mi.after.discard_empty_lines();
140
141            // Skip comment lines (// but not ///) between term and continuation/content.
142            loop {
143                let peek = next_source.take_normalized_line();
144                if peek.item.data().starts_with("//") && !peek.item.data().starts_with("///") {
145                    next_source = peek.after.discard_empty_lines();
146                } else {
147                    break;
148                }
149            }
150
151            // Check for continuation marker before parsing. If a continuation marker is
152            // present, skip directly to the main loop which handles continuations properly.
153            let next_line_mi = next_source.take_normalized_line();
154
155            if next_line_mi.item.data() == "+" {
156                // Continuation marker found; skip straight to the main loop.
157                // Use next_source (not marker_mi.after) since we already skipped empty lines.
158                next_source
159            } else if ListItemMarker::parse(next_source, parser).is_some() {
160                // Next line is another list item marker (possibly a sibling term).
161                // Don't parse it as content; let the list parser handle it.
162                marker_mi.after
163            } else if RawDelimitedBlock::is_valid_delimiter(&next_line_mi.item)
164                || CompoundDelimitedBlock::is_valid_delimiter(&next_line_mi.item)
165            {
166                // Delimited block breaks the list.
167                marker_mi.after
168            } else if next_line_mi.item.data().starts_with('[')
169                && !next_line_mi.item.data().starts_with("[[")
170                && next_line_mi.item.data().ends_with(']')
171            {
172                // Block attribute line breaks the list.
173                marker_mi.after
174            } else if next_line_mi.item.data().starts_with("[[")
175                && next_line_mi.item.data().ends_with("]]")
176            {
177                // Block anchor line breaks the list.
178                marker_mi.after
179            } else {
180                let next_line_metadata = BlockMetadata {
181                    title_source: None,
182                    title: None,
183                    anchor: None,
184                    anchor_reftext: None,
185                    attrlist: None,
186                    source: next_source,
187                    block_start: next_source,
188                };
189
190                // For definition lists, indented content is treated as a paragraph
191                // (not literal), with the indentation stripped.
192                if let Some(simple_block_mi) =
193                    SimpleBlock::parse_for_definition_list(&next_line_metadata, parser)
194                {
195                    blocks.push(Block::Simple(simple_block_mi.item));
196                    simple_block_mi.after
197                } else {
198                    marker_mi.after
199                }
200            }
201        } else {
202            // Other list types require content after the marker.
203            return None;
204        };
205
206        let mut next_block_must_be_indented = false;
207        let mut continuation_active = false;
208        let mut had_content_starting_with_plus = false;
209
210        loop {
211            if next.is_empty() {
212                break;
213            }
214
215            let next_line_mi: MatchedItem<'_, Span<'_>> = next.take_normalized_line();
216
217            // Don't consume `+` as continuation if:
218            // - A continuation is already active (consecutive `+` - second one becomes
219            //   content)
220            // - We've already had a block that started with `+` as content (trailing `+`
221            //   markers)
222            if next_line_mi.item.data() == "+"
223                && !continuation_active
224                && !had_content_starting_with_plus
225            {
226                next = next_line_mi.after;
227                next_block_must_be_indented = false;
228                continuation_active = true;
229                continue;
230            }
231
232            if next_line_mi.item.data().is_empty() {
233                if parent_list_markers.is_empty() {
234                    let after_blanks = next.discard_empty_lines();
235
236                    // A blank line that genuinely separates this item's content
237                    // from following block metadata (a title, anchor, or
238                    // attribute list) ends the list: that metadata decorates a
239                    // new, separate block rather than the next item (matching
240                    // Asciidoctor). Leave `next` at the blank line so
241                    // `ListBlock::parse` stops here.
242                    //
243                    // Existing content is required so the phantom line-end after
244                    // an empty description-list term - not a real blank line -
245                    // still lets that term's own metadata-prefixed nested list
246                    // attach. A continuation marker likewise keeps such metadata
247                    // as part of this item.
248                    if !continuation_active
249                        && !blocks.is_empty()
250                        && !BlockMetadata::parse(after_blanks, parser).item.is_empty()
251                    {
252                        break;
253                    }
254
255                    next = after_blanks;
256                    next_block_must_be_indented = true;
257                    continue;
258                } else if blocks.len() > 1 {
259                    // Item already has content beyond principal text (e.g.,
260                    // continuation-attached blocks or nested lists). Consume
261                    // all blank lines at this level.
262                    next = next.discard_empty_lines();
263                    break;
264                } else {
265                    // Item has only principal text. Consume one blank line
266                    // per level to support ancestor list continuation, where
267                    // each blank line signals moving up one nesting level.
268                    next = next_line_mi.after;
269                    break;
270                }
271            }
272
273            let is_indented = next.starts_with(' ') || next.starts_with('\t');
274            let metadata = BlockMetadata::parse(next, parser);
275
276            if let Some(list_item_marker_mi) =
277                ListItemMarker::parse(metadata.item.block_start, parser)
278            {
279                // We've found a new list item. How does it compare with the existing item in
280                // the hierarchy?
281                let new_item_marker = list_item_marker_mi.item;
282
283                if marker.is_match_for(&new_item_marker) {
284                    // New item is a peer to this item; nothing further for the current item.
285                    break;
286                }
287
288                if parent_list_markers
289                    .iter()
290                    .any(|parent| parent.is_match_for(&new_item_marker))
291                {
292                    // We matched a parent marker type. This list is complete; roll up the
293                    // hierarchy.
294                    break;
295                }
296
297                // We haven't encountered this marker before. Add a new nesting level. The new
298                // list will be a child block of this list item.
299
300                // But if we're after a blank line and the block is not indented
301                // (and no continuation is active), and there is a block attribute
302                // line or anchor before the new list marker, break the list
303                // instead of nesting. A blank line followed by a block attribute
304                // line signals the start of a new, separate list.
305                if next_block_must_be_indented
306                    && !is_indented
307                    && !continuation_active
308                    && !blocks.is_empty()
309                    && (metadata.item.attrlist.is_some() || metadata.item.anchor.is_some())
310                {
311                    break;
312                }
313
314                // Bound native recursion before descending into a nested list
315                // (issue #885). If the nesting limit is already reached, stop
316                // here and warn: this list item is finalized without the nested
317                // list, and the deeper markers bubble up to be reparsed as
318                // shallower siblings rather than overflow the stack.
319                if parser.block_nesting_limit_reached() {
320                    parser.warn_block_nesting_exceeded(new_item_marker.span(), warnings);
321                    break;
322                }
323
324                let mut nested_list_markers = parent_list_markers.to_owned();
325                nested_list_markers.push(marker.clone());
326
327                // NOTE: The call to `ListBlock::parse` *should* succeed (as in I can't think of
328                // a test case where it would fail). We use the `?` to provide a safe escape in
329                // case it doesn't.
330                parser.block_nesting_depth += 1;
331                let nested_list_result = ListBlock::parse_inside_list(
332                    &metadata.item,
333                    &nested_list_markers,
334                    parser,
335                    warnings,
336                );
337                parser.block_nesting_depth -= 1;
338                let nested_list_mi = nested_list_result?;
339
340                blocks.push(Block::List(nested_list_mi.item));
341
342                next = nested_list_mi.after;
343                continuation_active = false;
344                next_block_must_be_indented = true;
345                continue;
346            }
347
348            // If no list marker found directly after metadata, try extending
349            // metadata past empty lines. This handles block attribute lines
350            // (anchors, attrlists) separated by empty lines above nested lists.
351            if !metadata.item.is_empty() {
352                let mut ext_block_start = metadata.item.block_start;
353                let mut ext_anchor = metadata.item.anchor;
354                let mut ext_anchor_reftext = metadata.item.anchor_reftext;
355                let mut ext_attrlist = metadata.item.attrlist.clone();
356                let mut ext_title_source = metadata.item.title_source;
357                let mut ext_title = metadata.item.title.clone();
358
359                // Try to consume additional metadata past empty lines.
360                loop {
361                    let gap = ext_block_start.discard_empty_lines();
362                    if gap == ext_block_start {
363                        break;
364                    }
365
366                    let more_maw = BlockMetadata::parse(gap, parser);
367                    if more_maw.item.is_empty() {
368                        ext_block_start = gap;
369                        break;
370                    }
371
372                    // Merge additional metadata.
373                    if ext_anchor.is_none() {
374                        ext_anchor = more_maw.item.anchor;
375                        ext_anchor_reftext = more_maw.item.anchor_reftext;
376                    }
377
378                    if ext_attrlist.is_none() {
379                        ext_attrlist = more_maw.item.attrlist;
380                    }
381
382                    if ext_title_source.is_none() {
383                        ext_title_source = more_maw.item.title_source;
384                        ext_title = more_maw.item.title;
385                    }
386
387                    ext_block_start = more_maw.item.block_start;
388                }
389
390                if let Some(ext_marker_mi) = ListItemMarker::parse(ext_block_start, parser) {
391                    let new_item_marker = ext_marker_mi.item;
392
393                    if marker.is_match_for(&new_item_marker) {
394                        next = ext_block_start;
395                        break;
396                    }
397
398                    if parent_list_markers
399                        .iter()
400                        .any(|parent| parent.is_match_for(&new_item_marker))
401                    {
402                        next = ext_block_start;
403                        break;
404                    }
405
406                    // Found a nested list after metadata separated by empty lines.
407                    let ext_metadata = BlockMetadata {
408                        title_source: ext_title_source,
409                        title: ext_title,
410                        anchor: ext_anchor,
411                        anchor_reftext: ext_anchor_reftext,
412                        attrlist: ext_attrlist,
413                        source: metadata.item.source,
414                        block_start: ext_block_start,
415                    };
416
417                    // Bound native recursion before descending into a nested
418                    // list (issue #885); see the matching guard above.
419                    if parser.block_nesting_limit_reached() {
420                        parser.warn_block_nesting_exceeded(new_item_marker.span(), warnings);
421                        break;
422                    }
423
424                    let mut nested_list_markers = parent_list_markers.to_owned();
425                    nested_list_markers.push(marker.clone());
426
427                    parser.block_nesting_depth += 1;
428                    let nested_list_result = ListBlock::parse_inside_list(
429                        &ext_metadata,
430                        &nested_list_markers,
431                        parser,
432                        warnings,
433                    );
434                    parser.block_nesting_depth -= 1;
435                    let nested_list_mi = nested_list_result?;
436
437                    blocks.push(Block::List(nested_list_mi.item));
438
439                    next = nested_list_mi.after;
440                    continuation_active = false;
441                    next_block_must_be_indented = true;
442                    continue;
443                }
444            }
445
446            if next_block_must_be_indented && !is_indented {
447                break;
448            }
449
450            // A delimited block without a continuation marker breaks the list.
451            if !continuation_active {
452                let next_block_line = metadata.item.block_start.take_normalized_line().item;
453                if RawDelimitedBlock::is_valid_delimiter(&next_block_line)
454                    || CompoundDelimitedBlock::is_valid_delimiter(&next_block_line)
455                {
456                    break;
457                }
458            }
459
460            // A block attribute line or block anchor without a continuation marker
461            // breaks the list.
462            if !continuation_active
463                && (metadata.item.attrlist.is_some() || metadata.item.anchor.is_some())
464            {
465                break;
466            }
467
468            // If there's block metadata but no block, just discard it and continue.
469            if metadata
470                .item
471                .block_start
472                .take_normalized_line()
473                .item
474                .is_empty()
475            {
476                next = metadata.item.block_start.discard_empty_lines();
477                continue;
478            }
479
480            // A list item does not terminate if subsequent blocks are indented (i.e. use
481            // literal syntax).
482            let indented_block_maw = Block::parse_for_list_item(
483                next,
484                parser,
485                &list_markers_including_peer,
486                continuation_active,
487            );
488            warnings.extend(indented_block_maw.warnings);
489
490            // A block dropped at parse time (`attribute-missing=drop-line` on a
491            // block-macro target) attaches nothing, but – like a real block –
492            // it consumes any active continuation and requires the next block
493            // to be indented or reintroduced with a `+`. (A dropped block is
494            // always a block macro, never `+`-prefixed content, so it can't set
495            // `had_content_starting_with_plus`.)
496            if let BlockParseOutcome::Dropped(after) = indented_block_maw.item {
497                next = after;
498                continuation_active = false;
499                next_block_must_be_indented = true;
500                continue;
501            }
502
503            // `NoMatch` only arises for empty/blank input, which is filtered out
504            // above before we get here; the defensive `break` mirrors the
505            // pre-drop-line code path.
506            let BlockParseOutcome::Parsed(indented_block_mi) = indented_block_maw.item else {
507                break;
508            };
509
510            // After a continuation marker, subsequent blocks don't need to be indented.
511            // However, document attributes don't consume the continuation status.
512            let is_document_attribute =
513                matches!(indented_block_mi.item, Block::DocumentAttribute(_));
514
515            // Document attributes should not be added to the list item blocks.
516            // They're processed for their side effects but don't appear in the output.
517            // Similarly, orphaned metadata blocks shouldn't be added; they'll be
518            // re-parsed on the next iteration where they can attach to a real block.
519            if !is_document_attribute {
520                blocks.push(indented_block_mi.item);
521            }
522            next = indented_block_mi.after;
523
524            if is_document_attribute {
525                // Document attributes and orphaned metadata are transparent to
526                // continuation logic. Keep continuation_active
527                // and next_block_must_be_indented unchanged.
528            } else if continuation_active {
529                // This block consumed the continuation.
530                // The next block after this one will need to be indented (or have another
531                // continuation).
532                //
533                // If the block started with `+` as content (not as continuation), mark it
534                // so we don't allow more continuation markers. This handles odd input like
535                // consecutive `+` markers.
536                if next_line_mi.item.data() == "+" {
537                    had_content_starting_with_plus = true;
538                }
539                continuation_active = false;
540                next_block_must_be_indented = true;
541            } else {
542                // No active continuation; next block must be indented.
543                next_block_must_be_indented = true;
544            }
545        }
546
547        let source = source.trim_remainder(next).trim_trailing_whitespace();
548
549        Some(MatchedItem {
550            item: Self {
551                marker,
552                blocks,
553                source,
554                anchor: metadata.anchor,
555                anchor_reftext: metadata.anchor_reftext,
556                attrlist: metadata.attrlist.clone(),
557                checkbox,
558            },
559            after: next,
560        })
561    }
562
563    /// Returns the list item marker that was used for this item.
564    pub fn list_item_marker(&self) -> ListItemMarker<'src> {
565        self.marker.clone()
566    }
567
568    /// Returns the checklist (i.e. task list) state of this item.
569    ///
570    /// An unordered list item whose principal text begins with a checkbox
571    /// marker (`[ ] `, `[x] `, or `[*] `) is a checklist item. The returned
572    /// value is `Some(true)` for a checked item (`[x]`/`[*]`), `Some(false)`
573    /// for an unchecked item (`[ ]`), or `None` if the item is not a checklist
574    /// item.
575    pub fn checkbox(&self) -> Option<bool> {
576        self.checkbox
577    }
578}
579
580impl<'src> IsBlock<'src> for ListItem<'src> {
581    fn content_model(&self) -> ContentModel {
582        ContentModel::Compound
583    }
584
585    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
586        // A description-list item's resolvable content is its term.
587        self.marker.term_mut()
588    }
589
590    fn child_blocks_mut(&mut self) -> &mut [Block<'src>] {
591        &mut self.blocks
592    }
593
594    fn raw_context(&self) -> CowStr<'src> {
595        "list_item".into()
596    }
597
598    fn title_source(&'src self) -> Option<Span<'src>> {
599        None
600    }
601
602    fn title(&self) -> Option<&str> {
603        None
604    }
605
606    fn anchor(&'src self) -> Option<Span<'src>> {
607        self.anchor
608    }
609
610    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
611        self.anchor_reftext
612    }
613
614    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
615        self.attrlist.as_ref()
616    }
617}
618
619impl<'src> HasSpan<'src> for ListItem<'src> {
620    fn span(&self) -> Span<'src> {
621        self.source
622    }
623}
624
625impl std::fmt::Debug for ListItem<'_> {
626    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627        f.debug_struct("ListItem")
628            .field("marker", &self.marker)
629            .field("blocks", &DebugSliceReference(&self.blocks))
630            .field("source", &self.source)
631            .field("anchor", &self.anchor)
632            .field("anchor_reftext", &self.anchor_reftext)
633            .field("attrlist", &self.attrlist)
634            .field("checkbox", &self.checkbox)
635            .finish()
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    #![allow(clippy::panic)]
642    #![allow(clippy::unwrap_used)]
643
644    use crate::{
645        blocks::{ContentModel, metadata::BlockMetadata},
646        span::MatchedItem,
647        tests::prelude::*,
648        warnings::Warning,
649    };
650
651    fn li_parse<'a>(source: &'a str) -> Option<MatchedItem<'a, crate::blocks::ListItem<'a>>> {
652        let mut parser = crate::Parser::default();
653        let mut warnings: Vec<Warning<'a>> = vec![];
654
655        let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
656
657        let result = crate::blocks::list_item::ListItem::parse(
658            &metadata,
659            &[],
660            false,
661            &mut parser,
662            &mut warnings,
663        );
664
665        assert!(warnings.is_empty());
666
667        result
668    }
669
670    #[test]
671    fn hyphen() {
672        assert!(li_parse("-xyz").is_none());
673        assert!(li_parse("-- x").is_none());
674
675        let li = li_parse("- blah").unwrap();
676
677        assert_eq!(
678            li.item,
679            ListItem {
680                marker: ListItemMarker::Hyphen(Span {
681                    data: "-",
682                    line: 1,
683                    col: 1,
684                    offset: 0,
685                },),
686                blocks: &[Block::Simple(SimpleBlock {
687                    content: Content {
688                        original: Span {
689                            data: "blah",
690                            line: 1,
691                            col: 3,
692                            offset: 2,
693                        },
694                        rendered: "blah",
695                    },
696                    source: Span {
697                        data: "blah",
698                        line: 1,
699                        col: 3,
700                        offset: 2,
701                    },
702                    style: SimpleBlockStyle::Paragraph,
703                    title_source: None,
704                    title: None,
705                    caption: None,
706                    number: None,
707                    anchor: None,
708                    anchor_reftext: None,
709                    attrlist: None,
710                },),],
711                source: Span {
712                    data: "- blah",
713                    line: 1,
714                    col: 1,
715                    offset: 0,
716                },
717                anchor: None,
718                anchor_reftext: None,
719                attrlist: None,
720            }
721        );
722
723        assert_eq!(li.item.content_model(), ContentModel::Compound);
724        assert_eq!(li.item.raw_context().as_ref(), "list_item");
725
726        let mut li_blocks = li.item.child_blocks();
727
728        assert_eq!(
729            li_blocks.next().unwrap(),
730            &Block::Simple(SimpleBlock {
731                content: Content {
732                    original: Span {
733                        data: "blah",
734                        line: 1,
735                        col: 3,
736                        offset: 2,
737                    },
738                    rendered: "blah",
739                },
740                source: Span {
741                    data: "blah",
742                    line: 1,
743                    col: 3,
744                    offset: 2,
745                },
746                style: SimpleBlockStyle::Paragraph,
747                title_source: None,
748                title: None,
749                caption: None,
750                number: None,
751                anchor: None,
752                anchor_reftext: None,
753                attrlist: None,
754            })
755        );
756        assert!(li_blocks.next().is_none());
757
758        assert!(li.item.title_source().is_none());
759        assert!(li.item.title().is_none());
760        assert!(li.item.anchor().is_none());
761        assert!(li.item.anchor_reftext().is_none());
762        assert!(li.item.attrlist().is_none());
763
764        assert_eq!(
765            li.item.span(),
766            Span {
767                data: "- blah",
768                line: 1,
769                col: 1,
770                offset: 0,
771            }
772        );
773
774        assert_eq!(
775            li.after,
776            Span {
777                data: "",
778                line: 1,
779                col: 7,
780                offset: 6,
781            }
782        );
783
784        assert_eq!(
785            format!("{:#?}", li.item),
786            "ListItem {\n    marker: ListItemMarker::Hyphen(\n        Span {\n            data: \"-\",\n            line: 1,\n            col: 1,\n            offset: 0,\n        },\n    ),\n    blocks: &[\n        Block::Simple(\n            SimpleBlock {\n                content: Content {\n                    original: Span {\n                        data: \"blah\",\n                        line: 1,\n                        col: 3,\n                        offset: 2,\n                    },\n                    rendered: \"blah\",\n                },\n                source: Span {\n                    data: \"blah\",\n                    line: 1,\n                    col: 3,\n                    offset: 2,\n                },\n                style: SimpleBlockStyle::Paragraph,\n                title_source: None,\n                title: None,\n                caption: None,\n                number: None,\n                anchor: None,\n                anchor_reftext: None,\n                attrlist: None,\n            },\n        ),\n    ],\n    source: Span {\n        data: \"- blah\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n    anchor: None,\n    anchor_reftext: None,\n    attrlist: None,\n    checkbox: None,\n}"
787        );
788    }
789
790    #[test]
791    fn non_description_list_marker_with_no_content() {
792        // A non-description-list marker with no content after it returns None.
793        assert!(li_parse("* ").is_none());
794    }
795
796    #[test]
797    fn asterisks() {
798        assert!(li_parse("*").is_none());
799        assert!(li_parse("*xyz").is_none());
800        assert!(li_parse("*- xyz").is_none());
801
802        let li = li_parse("* blah").unwrap();
803
804        assert_eq!(
805            li.item,
806            ListItem {
807                marker: ListItemMarker::Asterisks(Span {
808                    data: "*",
809                    line: 1,
810                    col: 1,
811                    offset: 0,
812                },),
813                blocks: &[Block::Simple(SimpleBlock {
814                    content: Content {
815                        original: Span {
816                            data: "blah",
817                            line: 1,
818                            col: 3,
819                            offset: 2,
820                        },
821                        rendered: "blah",
822                    },
823                    source: Span {
824                        data: "blah",
825                        line: 1,
826                        col: 3,
827                        offset: 2,
828                    },
829                    style: SimpleBlockStyle::Paragraph,
830                    title_source: None,
831                    title: None,
832                    caption: None,
833                    number: None,
834                    anchor: None,
835                    anchor_reftext: None,
836                    attrlist: None,
837                },),],
838                source: Span {
839                    data: "* blah",
840                    line: 1,
841                    col: 1,
842                    offset: 0,
843                },
844                anchor: None,
845                anchor_reftext: None,
846                attrlist: None,
847            }
848        );
849
850        assert_eq!(
851            li.item.span(),
852            Span {
853                data: "* blah",
854                line: 1,
855                col: 1,
856                offset: 0,
857            }
858        );
859
860        assert_eq!(
861            li.after,
862            Span {
863                data: "",
864                line: 1,
865                col: 7,
866                offset: 6,
867            }
868        );
869    }
870}