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