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