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}