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, 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                    next = next.discard_empty_lines();
235                    next_block_must_be_indented = true;
236                    continue;
237                } else if blocks.len() > 1 {
238                    // Item already has content beyond principal text (e.g.,
239                    // continuation-attached blocks or nested lists). Consume
240                    // all blank lines at this level.
241                    next = next.discard_empty_lines();
242                    break;
243                } else {
244                    // Item has only principal text. Consume one blank line
245                    // per level to support ancestor list continuation, where
246                    // each blank line signals moving up one nesting level.
247                    next = next_line_mi.after;
248                    break;
249                }
250            }
251
252            let is_indented = next.starts_with(' ') || next.starts_with('\t');
253            let metadata = BlockMetadata::parse(next, parser);
254
255            if let Some(list_item_marker_mi) =
256                ListItemMarker::parse(metadata.item.block_start, parser)
257            {
258                // We've found a new list item. How does it compare with the existing item in
259                // the hierarchy?
260                let new_item_marker = list_item_marker_mi.item;
261
262                if marker.is_match_for(&new_item_marker) {
263                    // New item is a peer to this item; nothing further for the current item.
264                    break;
265                }
266
267                if parent_list_markers
268                    .iter()
269                    .any(|parent| parent.is_match_for(&new_item_marker))
270                {
271                    // We matched a parent marker type. This list is complete; roll up the
272                    // hierarchy.
273                    break;
274                }
275
276                // We haven't encountered this marker before. Add a new nesting level. The new
277                // list will be a child block of this list item.
278
279                // But if we're after a blank line and the block is not indented
280                // (and no continuation is active), and there is a block attribute
281                // line or anchor before the new list marker, break the list
282                // instead of nesting. A blank line followed by a block attribute
283                // line signals the start of a new, separate list.
284                if next_block_must_be_indented
285                    && !is_indented
286                    && !continuation_active
287                    && !blocks.is_empty()
288                    && (metadata.item.attrlist.is_some() || metadata.item.anchor.is_some())
289                {
290                    break;
291                }
292
293                // Bound native recursion before descending into a nested list
294                // (issue #885). If the nesting limit is already reached, stop
295                // here and warn: this list item is finalized without the nested
296                // list, and the deeper markers bubble up to be reparsed as
297                // shallower siblings rather than overflow the stack.
298                if parser.block_nesting_limit_reached() {
299                    parser.warn_block_nesting_exceeded(new_item_marker.span(), warnings);
300                    break;
301                }
302
303                let mut nested_list_markers = parent_list_markers.to_owned();
304                nested_list_markers.push(marker.clone());
305
306                // NOTE: The call to `ListBlock::parse` *should* succeed (as in I can't think of
307                // a test case where it would fail). We use the `?` to provide a safe escape in
308                // case it doesn't.
309                parser.block_nesting_depth += 1;
310                let nested_list_result = ListBlock::parse_inside_list(
311                    &metadata.item,
312                    &nested_list_markers,
313                    parser,
314                    warnings,
315                );
316                parser.block_nesting_depth -= 1;
317                let nested_list_mi = nested_list_result?;
318
319                blocks.push(Block::List(nested_list_mi.item));
320
321                next = nested_list_mi.after;
322                continuation_active = false;
323                next_block_must_be_indented = true;
324                continue;
325            }
326
327            // If no list marker found directly after metadata, try extending
328            // metadata past empty lines. This handles block attribute lines
329            // (anchors, attrlists) separated by empty lines above nested lists.
330            if !metadata.item.is_empty() {
331                let mut ext_block_start = metadata.item.block_start;
332                let mut ext_anchor = metadata.item.anchor;
333                let mut ext_anchor_reftext = metadata.item.anchor_reftext;
334                let mut ext_attrlist = metadata.item.attrlist.clone();
335                let mut ext_title_source = metadata.item.title_source;
336                let mut ext_title = metadata.item.title.clone();
337
338                // Try to consume additional metadata past empty lines.
339                loop {
340                    let gap = ext_block_start.discard_empty_lines();
341                    if gap == ext_block_start {
342                        break;
343                    }
344
345                    let more_maw = BlockMetadata::parse(gap, parser);
346                    if more_maw.item.is_empty() {
347                        ext_block_start = gap;
348                        break;
349                    }
350
351                    // Merge additional metadata.
352                    if ext_anchor.is_none() {
353                        ext_anchor = more_maw.item.anchor;
354                        ext_anchor_reftext = more_maw.item.anchor_reftext;
355                    }
356
357                    if ext_attrlist.is_none() {
358                        ext_attrlist = more_maw.item.attrlist;
359                    }
360
361                    if ext_title_source.is_none() {
362                        ext_title_source = more_maw.item.title_source;
363                        ext_title = more_maw.item.title;
364                    }
365
366                    ext_block_start = more_maw.item.block_start;
367                }
368
369                if let Some(ext_marker_mi) = ListItemMarker::parse(ext_block_start, parser) {
370                    let new_item_marker = ext_marker_mi.item;
371
372                    if marker.is_match_for(&new_item_marker) {
373                        next = ext_block_start;
374                        break;
375                    }
376
377                    if parent_list_markers
378                        .iter()
379                        .any(|parent| parent.is_match_for(&new_item_marker))
380                    {
381                        next = ext_block_start;
382                        break;
383                    }
384
385                    // Found a nested list after metadata separated by empty lines.
386                    let ext_metadata = BlockMetadata {
387                        title_source: ext_title_source,
388                        title: ext_title,
389                        anchor: ext_anchor,
390                        anchor_reftext: ext_anchor_reftext,
391                        attrlist: ext_attrlist,
392                        source: metadata.item.source,
393                        block_start: ext_block_start,
394                    };
395
396                    // Bound native recursion before descending into a nested
397                    // list (issue #885); see the matching guard above.
398                    if parser.block_nesting_limit_reached() {
399                        parser.warn_block_nesting_exceeded(new_item_marker.span(), warnings);
400                        break;
401                    }
402
403                    let mut nested_list_markers = parent_list_markers.to_owned();
404                    nested_list_markers.push(marker.clone());
405
406                    parser.block_nesting_depth += 1;
407                    let nested_list_result = ListBlock::parse_inside_list(
408                        &ext_metadata,
409                        &nested_list_markers,
410                        parser,
411                        warnings,
412                    );
413                    parser.block_nesting_depth -= 1;
414                    let nested_list_mi = nested_list_result?;
415
416                    blocks.push(Block::List(nested_list_mi.item));
417
418                    next = nested_list_mi.after;
419                    continuation_active = false;
420                    next_block_must_be_indented = true;
421                    continue;
422                }
423            }
424
425            if next_block_must_be_indented && !is_indented {
426                break;
427            }
428
429            // A delimited block without a continuation marker breaks the list.
430            if !continuation_active {
431                let next_block_line = metadata.item.block_start.take_normalized_line().item;
432                if RawDelimitedBlock::is_valid_delimiter(&next_block_line)
433                    || CompoundDelimitedBlock::is_valid_delimiter(&next_block_line)
434                {
435                    break;
436                }
437            }
438
439            // A block attribute line or block anchor without a continuation marker
440            // breaks the list.
441            if !continuation_active
442                && (metadata.item.attrlist.is_some() || metadata.item.anchor.is_some())
443            {
444                break;
445            }
446
447            // If there's block metadata but no block, just discard it and continue.
448            if metadata
449                .item
450                .block_start
451                .take_normalized_line()
452                .item
453                .is_empty()
454            {
455                next = metadata.item.block_start.discard_empty_lines();
456                continue;
457            }
458
459            // A list item does not terminate if subsequent blocks are indented (i.e. use
460            // literal syntax).
461            let indented_block_maw = Block::parse_for_list_item(
462                next,
463                parser,
464                &list_markers_including_peer,
465                continuation_active,
466            );
467            warnings.extend(indented_block_maw.warnings);
468
469            // A block dropped at parse time (`attribute-missing=drop-line` on a
470            // block-macro target) attaches nothing, but – like a real block –
471            // it consumes any active continuation and requires the next block
472            // to be indented or reintroduced with a `+`. (A dropped block is
473            // always a block macro, never `+`-prefixed content, so it can't set
474            // `had_content_starting_with_plus`.)
475            if let BlockParseOutcome::Dropped(after) = indented_block_maw.item {
476                next = after;
477                continuation_active = false;
478                next_block_must_be_indented = true;
479                continue;
480            }
481
482            // `NoMatch` only arises for empty/blank input, which is filtered out
483            // above before we get here; the defensive `break` mirrors the
484            // pre-drop-line code path.
485            let BlockParseOutcome::Parsed(indented_block_mi) = indented_block_maw.item else {
486                break;
487            };
488
489            // After a continuation marker, subsequent blocks don't need to be indented.
490            // However, document attributes don't consume the continuation status.
491            let is_document_attribute =
492                matches!(indented_block_mi.item, Block::DocumentAttribute(_));
493
494            // Document attributes should not be added to the list item blocks.
495            // They're processed for their side effects but don't appear in the output.
496            // Similarly, orphaned metadata blocks shouldn't be added; they'll be
497            // re-parsed on the next iteration where they can attach to a real block.
498            if !is_document_attribute {
499                blocks.push(indented_block_mi.item);
500            }
501            next = indented_block_mi.after;
502
503            if is_document_attribute {
504                // Document attributes and orphaned metadata are transparent to
505                // continuation logic. Keep continuation_active
506                // and next_block_must_be_indented unchanged.
507            } else if continuation_active {
508                // This block consumed the continuation.
509                // The next block after this one will need to be indented (or have another
510                // continuation).
511                //
512                // If the block started with `+` as content (not as continuation), mark it
513                // so we don't allow more continuation markers. This handles odd input like
514                // consecutive `+` markers.
515                if next_line_mi.item.data() == "+" {
516                    had_content_starting_with_plus = true;
517                }
518                continuation_active = false;
519                next_block_must_be_indented = true;
520            } else {
521                // No active continuation; next block must be indented.
522                next_block_must_be_indented = true;
523            }
524        }
525
526        let source = source.trim_remainder(next).trim_trailing_whitespace();
527
528        Some(MatchedItem {
529            item: Self {
530                marker,
531                blocks,
532                source,
533                anchor: metadata.anchor,
534                anchor_reftext: metadata.anchor_reftext,
535                attrlist: metadata.attrlist.clone(),
536                checkbox,
537            },
538            after: next,
539        })
540    }
541
542    /// Returns the list item marker that was used for this item.
543    pub fn list_item_marker(&self) -> ListItemMarker<'src> {
544        self.marker.clone()
545    }
546
547    /// Returns the checklist (i.e. task list) state of this item.
548    ///
549    /// An unordered list item whose principal text begins with a checkbox
550    /// marker (`[ ] `, `[x] `, or `[*] `) is a checklist item. The returned
551    /// value is `Some(true)` for a checked item (`[x]`/`[*]`), `Some(false)`
552    /// for an unchecked item (`[ ]`), or `None` if the item is not a checklist
553    /// item.
554    pub fn checkbox(&self) -> Option<bool> {
555        self.checkbox
556    }
557}
558
559impl<'src> IsBlock<'src> for ListItem<'src> {
560    fn content_model(&self) -> ContentModel {
561        ContentModel::Compound
562    }
563
564    fn content_mut(&mut self) -> Option<&mut Content<'src>> {
565        // A description-list item's resolvable content is its term.
566        self.marker.term_mut()
567    }
568
569    fn child_blocks_mut(&mut self) -> &mut [Block<'src>] {
570        &mut self.blocks
571    }
572
573    fn raw_context(&self) -> CowStr<'src> {
574        "list_item".into()
575    }
576
577    fn title_source(&'src self) -> Option<Span<'src>> {
578        None
579    }
580
581    fn title(&self) -> Option<&str> {
582        None
583    }
584
585    fn anchor(&'src self) -> Option<Span<'src>> {
586        self.anchor
587    }
588
589    fn anchor_reftext(&'src self) -> Option<Span<'src>> {
590        self.anchor_reftext
591    }
592
593    fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
594        self.attrlist.as_ref()
595    }
596}
597
598impl<'src> HasSpan<'src> for ListItem<'src> {
599    fn span(&self) -> Span<'src> {
600        self.source
601    }
602}
603
604impl std::fmt::Debug for ListItem<'_> {
605    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
606        f.debug_struct("ListItem")
607            .field("marker", &self.marker)
608            .field("blocks", &DebugSliceReference(&self.blocks))
609            .field("source", &self.source)
610            .field("anchor", &self.anchor)
611            .field("anchor_reftext", &self.anchor_reftext)
612            .field("attrlist", &self.attrlist)
613            .field("checkbox", &self.checkbox)
614            .finish()
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    #![allow(clippy::panic)]
621    #![allow(clippy::unwrap_used)]
622
623    use crate::{
624        blocks::{ContentModel, metadata::BlockMetadata},
625        span::MatchedItem,
626        tests::prelude::*,
627        warnings::Warning,
628    };
629
630    fn li_parse<'a>(source: &'a str) -> Option<MatchedItem<'a, crate::blocks::ListItem<'a>>> {
631        let mut parser = crate::Parser::default();
632        let mut warnings: Vec<Warning<'a>> = vec![];
633
634        let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
635
636        let result = crate::blocks::list_item::ListItem::parse(
637            &metadata,
638            &[],
639            false,
640            &mut parser,
641            &mut warnings,
642        );
643
644        assert!(warnings.is_empty());
645
646        result
647    }
648
649    #[test]
650    fn hyphen() {
651        assert!(li_parse("-xyz").is_none());
652        assert!(li_parse("-- x").is_none());
653
654        let li = li_parse("- blah").unwrap();
655
656        assert_eq!(
657            li.item,
658            ListItem {
659                marker: ListItemMarker::Hyphen(Span {
660                    data: "-",
661                    line: 1,
662                    col: 1,
663                    offset: 0,
664                },),
665                blocks: &[Block::Simple(SimpleBlock {
666                    content: Content {
667                        original: Span {
668                            data: "blah",
669                            line: 1,
670                            col: 3,
671                            offset: 2,
672                        },
673                        rendered: "blah",
674                    },
675                    source: Span {
676                        data: "blah",
677                        line: 1,
678                        col: 3,
679                        offset: 2,
680                    },
681                    style: SimpleBlockStyle::Paragraph,
682                    title_source: None,
683                    title: None,
684                    caption: None,
685                    number: None,
686                    anchor: None,
687                    anchor_reftext: None,
688                    attrlist: None,
689                },),],
690                source: Span {
691                    data: "- blah",
692                    line: 1,
693                    col: 1,
694                    offset: 0,
695                },
696                anchor: None,
697                anchor_reftext: None,
698                attrlist: None,
699            }
700        );
701
702        assert_eq!(li.item.content_model(), ContentModel::Compound);
703        assert_eq!(li.item.raw_context().as_ref(), "list_item");
704
705        let mut li_blocks = li.item.child_blocks();
706
707        assert_eq!(
708            li_blocks.next().unwrap(),
709            &Block::Simple(SimpleBlock {
710                content: Content {
711                    original: Span {
712                        data: "blah",
713                        line: 1,
714                        col: 3,
715                        offset: 2,
716                    },
717                    rendered: "blah",
718                },
719                source: Span {
720                    data: "blah",
721                    line: 1,
722                    col: 3,
723                    offset: 2,
724                },
725                style: SimpleBlockStyle::Paragraph,
726                title_source: None,
727                title: None,
728                caption: None,
729                number: None,
730                anchor: None,
731                anchor_reftext: None,
732                attrlist: None,
733            })
734        );
735        assert!(li_blocks.next().is_none());
736
737        assert!(li.item.title_source().is_none());
738        assert!(li.item.title().is_none());
739        assert!(li.item.anchor().is_none());
740        assert!(li.item.anchor_reftext().is_none());
741        assert!(li.item.attrlist().is_none());
742
743        assert_eq!(
744            li.item.span(),
745            Span {
746                data: "- blah",
747                line: 1,
748                col: 1,
749                offset: 0,
750            }
751        );
752
753        assert_eq!(
754            li.after,
755            Span {
756                data: "",
757                line: 1,
758                col: 7,
759                offset: 6,
760            }
761        );
762
763        assert_eq!(
764            format!("{:#?}", li.item),
765            "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}"
766        );
767    }
768
769    #[test]
770    fn non_description_list_marker_with_no_content() {
771        // A non-description-list marker with no content after it returns None.
772        assert!(li_parse("* ").is_none());
773    }
774
775    #[test]
776    fn asterisks() {
777        assert!(li_parse("*").is_none());
778        assert!(li_parse("*xyz").is_none());
779        assert!(li_parse("*- xyz").is_none());
780
781        let li = li_parse("* blah").unwrap();
782
783        assert_eq!(
784            li.item,
785            ListItem {
786                marker: ListItemMarker::Asterisks(Span {
787                    data: "*",
788                    line: 1,
789                    col: 1,
790                    offset: 0,
791                },),
792                blocks: &[Block::Simple(SimpleBlock {
793                    content: Content {
794                        original: Span {
795                            data: "blah",
796                            line: 1,
797                            col: 3,
798                            offset: 2,
799                        },
800                        rendered: "blah",
801                    },
802                    source: Span {
803                        data: "blah",
804                        line: 1,
805                        col: 3,
806                        offset: 2,
807                    },
808                    style: SimpleBlockStyle::Paragraph,
809                    title_source: None,
810                    title: None,
811                    caption: None,
812                    number: None,
813                    anchor: None,
814                    anchor_reftext: None,
815                    attrlist: None,
816                },),],
817                source: Span {
818                    data: "* blah",
819                    line: 1,
820                    col: 1,
821                    offset: 0,
822                },
823                anchor: None,
824                anchor_reftext: None,
825                attrlist: None,
826            }
827        );
828
829        assert_eq!(
830            li.item.span(),
831            Span {
832                data: "* blah",
833                line: 1,
834                col: 1,
835                offset: 0,
836            }
837        );
838
839        assert_eq!(
840            li.after,
841            Span {
842                data: "",
843                line: 1,
844                col: 7,
845                offset: 6,
846            }
847        );
848    }
849}