Skip to main content

asciidoc_parser/blocks/
list_item.rs

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