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