asciidoc_parser/blocks/list.rs
1use crate::{
2 HasSpan, Parser, Span,
3 attributes::Attrlist,
4 blocks::{
5 Block, ChildBlocks, ContentModel, IsBlock, ListItem, ListItemMarker,
6 metadata::BlockMetadata,
7 },
8 content::Content,
9 internal::debug::DebugSliceReference,
10 span::MatchedItem,
11 strings::CowStr,
12 warnings::{Warning, WarningType},
13};
14
15/// A list contains a sequence of items prefixed with symbol, such as a disc
16/// (aka bullet). Each individual item in the list is represented by a
17/// [`ListItem`].
18///
19/// [`ListItem`]: crate::blocks::ListItem
20#[derive(Clone, Eq, Hash, PartialEq)]
21pub struct ListBlock<'src> {
22 type_: ListType,
23 items: Vec<Block<'src>>,
24 source: Span<'src>,
25 title_source: Option<Span<'src>>,
26 title: Option<Content<'src>>,
27 anchor: Option<Span<'src>>,
28 anchor_reftext: Option<Span<'src>>,
29 attrlist: Option<Attrlist<'src>>,
30 is_checklist: bool,
31 is_bibliography: bool,
32}
33
34impl<'src> ListBlock<'src> {
35 /// Returns a document-order iterator over this list's direct child blocks
36 /// (its list items).
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.items)
44 }
45
46 /// Returns the block's title as a mutable [`Content`], if the block has
47 /// one.
48 ///
49 /// This narrow seam exists for the document-order title resolution pass
50 /// (see `document::title_refs`), which installs the re-rendered title
51 /// after resolving any cross-references embedded in it. All other access
52 /// goes through the read-only [`IsBlock::title`] accessor.
53 pub(crate) fn title_content_mut(&mut self) -> Option<&mut Content<'src>> {
54 self.title.as_mut()
55 }
56
57 pub(crate) fn parse(
58 metadata: &BlockMetadata<'src>,
59 parser: &mut Parser,
60 warnings: &mut Vec<Warning<'src>>,
61 ) -> Option<MatchedItem<'src, Self>> {
62 Self::parse_inside_list(metadata, &[], parser, warnings)
63 }
64
65 pub(crate) fn parse_inside_list(
66 metadata: &BlockMetadata<'src>,
67 parent_list_markers: &[ListItemMarker<'src>],
68 parser: &mut Parser,
69 warnings: &mut Vec<Warning<'src>>,
70 ) -> Option<MatchedItem<'src, Self>> {
71 let source = metadata.block_start.discard_empty_lines();
72
73 // A list carries the `bibliography` style in two ways, which differ in
74 // scope (matching Asciidoctor):
75 //
76 // * An explicit `[bibliography]` attribute marks the list a bibliography
77 // regardless of its type (even an ordered list).
78 // * A `bibliography` section implicitly marks each of its top-level *unordered*
79 // lists (only) a bibliography. A nested list never inherits the section
80 // style, so this is gated on `parent_list_markers` being empty; the list-type
81 // restriction is applied below, once the type is known.
82 let own_style_bibliography = metadata
83 .attrlist
84 .as_ref()
85 .and_then(|attrlist| attrlist.block_style())
86 == Some("bibliography");
87 let section_propagated_bibliography =
88 parent_list_markers.is_empty() && parser.parsing_bibliography_section_body;
89
90 let mut items: Vec<Block<'src>> = vec![];
91 let mut next_item_source = source;
92 let mut first_marker: Option<ListItemMarker<'src>> = None;
93 let mut expected_ordinal: Option<u32> = None;
94
95 loop {
96 let next_line_mi = next_item_source.take_normalized_line();
97
98 // A leading blank line ends the list. `ListItem::parse` discards the
99 // blank lines that merely separate items of the same list, so a blank
100 // line surfacing here means it deliberately stopped short of
101 // blank-separated block metadata, which decorates a new, separate
102 // block rather than the next item (matching Asciidoctor).
103 if next_line_mi.item.data().is_empty() {
104 break;
105 }
106
107 // A stray `+` continuation line between items is skipped at the top
108 // level; inside a nested list it ends the list.
109 if next_line_mi.item.data() == "+" {
110 if next_item_source.is_empty() || !parent_list_markers.is_empty() {
111 break;
112 } else {
113 next_item_source = next_line_mi.after;
114 continue;
115 }
116 }
117
118 // Parse any block metadata (title, anchor, attribute list) that
119 // precedes this item's marker so it is captured on the item rather
120 // than dropped. A metadata line with no intervening blank line keeps
121 // the item in this list (matching Asciidoctor); the blank-separated
122 // case is handled in `ListItem::parse`, which finalizes the previous
123 // item before such metadata.
124 //
125 // Only subsequent items can carry their own metadata: the caller has
126 // already consumed any that precedes the list, so the first item's
127 // marker sits at `next_item_source`. Skipping the parse there keeps
128 // the common speculative `ListBlock::parse` on a non-list paragraph
129 // (tried and rejected for every such block) from re-parsing metadata
130 // it has already parsed once.
131 //
132 // The metadata's own warnings are held until the item is actually
133 // committed below, since a rejected speculative parse must not leak
134 // them.
135 let (list_item_metadata, mut list_item_metadata_warnings) = if first_marker.is_none() {
136 (
137 BlockMetadata {
138 title_source: None,
139 title: None,
140 anchor: None,
141 anchor_reftext: None,
142 attrlist: None,
143 source: next_item_source,
144 block_start: next_item_source,
145 },
146 vec![],
147 )
148 } else {
149 let maw = BlockMetadata::parse(next_item_source, parser);
150 (maw.item, maw.warnings)
151 };
152
153 let Some(list_item_marker_mi) =
154 ListItemMarker::parse(list_item_metadata.block_start, parser)
155 else {
156 break;
157 };
158
159 let this_item_marker = list_item_marker_mi.item;
160
161 // If this item's marker doesn't match the existing list marker, we are changing
162 // levels in the list hierarchy.
163 if let Some(ref first_marker) = first_marker {
164 if !first_marker.is_match_for(&this_item_marker)
165 && parent_list_markers
166 .iter()
167 .any(|parent| parent.is_match_for(&this_item_marker))
168 {
169 // We matched a parent marker type. This list is complete; roll up the
170 // hierarchy.
171 break;
172 }
173
174 // Check if the marker is in sequence for explicit ordered lists.
175 if let Some(actual_ordinal) = this_item_marker.ordinal_value() {
176 if let Some(expected) = expected_ordinal
177 && actual_ordinal != expected
178 {
179 // Warn about out-of-sequence marker.
180 if let (Some(expected_text), Some(actual_text)) = (
181 first_marker.ordinal_to_marker_text(expected),
182 first_marker.ordinal_to_marker_text(actual_ordinal),
183 ) {
184 warnings.push(Warning {
185 source: this_item_marker.span(),
186 warning: WarningType::ListItemOutOfSequence(
187 expected_text,
188 actual_text,
189 ),
190 origin: None,
191 });
192 }
193 }
194 expected_ordinal = Some(actual_ordinal + 1);
195 }
196 } else {
197 first_marker = Some(this_item_marker.clone());
198
199 // Initialize expected ordinal from first marker's value.
200 if let Some(ordinal) = this_item_marker.ordinal_value() {
201 expected_ordinal = Some(ordinal + 1);
202 }
203 }
204
205 // The bibliography anchor (`[[[id]]]`) is recognized in the principal
206 // text of any item of an explicitly-styled bibliography list, or of an
207 // unordered-list item when the style is inherited from the section.
208 // Pass that context down so the item's inline substitution can detect
209 // it.
210 let item_is_bibliography = own_style_bibliography
211 || (section_propagated_bibliography
212 && matches!(
213 this_item_marker,
214 ListItemMarker::Asterisks(_)
215 | ListItemMarker::Hyphen(_)
216 | ListItemMarker::Bullet(_)
217 ));
218
219 let Some(list_item_mi) = ListItem::parse(
220 &list_item_metadata,
221 parent_list_markers,
222 item_is_bibliography,
223 parser,
224 warnings,
225 ) else {
226 break;
227 };
228
229 // The item is now committed, so its preceding metadata's warnings
230 // are real and can be surfaced.
231 warnings.append(&mut list_item_metadata_warnings);
232
233 items.push(Block::ListItem(list_item_mi.item));
234 next_item_source = list_item_mi.after;
235 }
236
237 if items.is_empty() {
238 return None;
239 }
240
241 let first_marker = first_marker?;
242 let type_ = match first_marker {
243 ListItemMarker::Asterisks(_) => ListType::Unordered,
244 ListItemMarker::Hyphen(_) => ListType::Unordered,
245 ListItemMarker::Bullet(_) => ListType::Unordered,
246 ListItemMarker::Dots(_) => ListType::Ordered,
247 ListItemMarker::AlphaListCapital(_) => ListType::Ordered,
248 ListItemMarker::AlphaListLower(_) => ListType::Ordered,
249 ListItemMarker::RomanNumeralLower(_) => ListType::Ordered,
250 ListItemMarker::RomanNumeralUpper(_) => ListType::Ordered,
251 ListItemMarker::ArabicNumeral(_) => ListType::Ordered,
252 ListItemMarker::Callout(_) => ListType::Callout,
253
254 ListItemMarker::DefinedTerm {
255 term: _,
256 marker: _,
257 source: _,
258 } => ListType::Description,
259 };
260
261 // A callout list annotates the callouts of a preceding verbatim block.
262 // For each item (by position): an explicit `<N>` marker that doesn't
263 // match the item's position is out of sequence, and an item position
264 // with no callout registered while substituting the block has no
265 // matching callout. Both mirror Asciidoctor's `parse_callout_list`
266 // warnings. The list is then closed so the next block's callouts start
267 // fresh.
268 if type_ == ListType::Callout {
269 for (index, item) in items.iter().enumerate() {
270 let position = (index + 1) as u32;
271
272 if let Some(marker_number) = item
273 .as_list_item()
274 .and_then(|li| li.list_item_marker().callout_number())
275 && marker_number != position
276 {
277 warnings.push(Warning {
278 source: item.span(),
279 warning: WarningType::CalloutListItemOutOfSequence(
280 position as usize,
281 marker_number as usize,
282 ),
283 origin: None,
284 });
285 }
286
287 if !parser.callout_defined(position) {
288 warnings.push(Warning {
289 source: item.span(),
290 warning: WarningType::NoCalloutFound(position as usize),
291 origin: None,
292 });
293 }
294 }
295 parser.close_callout_list();
296 }
297
298 // An unordered list is a checklist (i.e. task list) when at least one of
299 // its items has checkbox syntax. This mirrors Asciidoctor, which sets the
300 // `checklist` option on the list once any item carries a checkbox.
301 let is_checklist = type_ == ListType::Unordered
302 && items.iter().any(|item| {
303 item.as_list_item()
304 .is_some_and(|li| li.checkbox().is_some())
305 });
306
307 // An explicit `[bibliography]` style applies to any list type; the style
308 // inherited from a section applies only to unordered lists.
309 let is_bibliography = own_style_bibliography
310 || (section_propagated_bibliography && type_ == ListType::Unordered);
311
312 Some(MatchedItem {
313 item: Self {
314 type_,
315 items,
316 source: metadata
317 .source
318 .trim_remainder(next_item_source)
319 .trim_trailing_line_end()
320 .trim_trailing_whitespace(),
321 title_source: metadata.title_source,
322 title: metadata.title.clone(),
323 anchor: metadata.anchor,
324 anchor_reftext: metadata.anchor_reftext,
325 attrlist: metadata.attrlist.clone(),
326 is_checklist,
327 is_bibliography,
328 },
329 after: next_item_source,
330 })
331 }
332
333 /// Returns the type of this list.
334 pub fn type_(&self) -> ListType {
335 self.type_
336 }
337
338 /// Returns `true` if this list is a checklist (i.e. task list).
339 ///
340 /// An unordered list becomes a checklist when at least one of its items
341 /// uses checkbox syntax (`[ ]`, `[x]`, or `[*]`). See
342 /// [`ListItem::checkbox`].
343 ///
344 /// [`ListItem::checkbox`]: crate::blocks::ListItem::checkbox
345 pub fn is_checklist(&self) -> bool {
346 self.is_checklist
347 }
348
349 /// Returns `true` if this list carries the `bibliography` style.
350 ///
351 /// A list is a bibliography list when it is an unordered list that is
352 /// either explicitly marked `[bibliography]` or appears as a top-level
353 /// list within a section that carries the `bibliography` style (the
354 /// section implicitly adds the style to each of its unordered lists).
355 /// Each item of such a list may begin with a bibliography anchor
356 /// (`[[[id]]]`).
357 pub fn is_bibliography(&self) -> bool {
358 self.is_bibliography
359 }
360
361 /// Returns the style class for this list based on the marker length.
362 /// For ordered lists, the style is determined by the number of dots:
363 /// - 1 dot: arabic (1, 2, 3, ...)
364 /// - 2 dots: loweralpha (a, b, c, ...)
365 /// - 3 dots: lowerroman (i, ii, iii, ...)
366 /// - 4 dots: upperalpha (A, B, C, ...)
367 /// - 5 dots: upperroman (I, II, III, ...)
368 pub fn marker_style(&self) -> Option<&'static str> {
369 let first_marker = self.items.first()?.as_list_item()?.list_item_marker();
370
371 match first_marker {
372 ListItemMarker::Dots(span) => {
373 let marker_len = span.data().len();
374 match marker_len {
375 1 => Some("arabic"),
376 2 => Some("loweralpha"),
377 3 => Some("lowerroman"),
378 4 => Some("upperalpha"),
379 5 => Some("upperroman"),
380 _ => Some("arabic"),
381 }
382 }
383 ListItemMarker::ArabicNumeral(_) => Some("arabic"),
384 ListItemMarker::Callout(_) => Some("arabic"),
385 ListItemMarker::AlphaListLower(_) => Some("loweralpha"),
386 ListItemMarker::AlphaListCapital(_) => Some("upperalpha"),
387 ListItemMarker::RomanNumeralLower(_) => Some("lowerroman"),
388 ListItemMarker::RomanNumeralUpper(_) => Some("upperroman"),
389 _ => None,
390 }
391 }
392
393 /// Returns the starting ordinal a converter should emit as the `start`
394 /// attribute of an HTML `<ol>`, if any.
395 ///
396 /// An ordered list can begin at a value other than 1 in two ways (matching
397 /// Asciidoctor):
398 ///
399 /// * an explicit `[start=N]` attribute, which takes precedence; or
400 /// * the ordinal of an explicit first-item marker – for example `7.`
401 /// (arabic), `c.` (loweralpha, ⇒ 3), or `iv)` (lowerroman, ⇒ 4).
402 ///
403 /// The result is `None` whenever the start resolves to the default of 1 –
404 /// whether from implicit markers (e.g. `.`), an explicit ordinal-1 marker
405 /// (`1.`, `a.`, `i)`), or `[start=1]` – because a converter emits a bare
406 /// `<ol>` in that case. It is likewise `None` for a list that is not
407 /// ordered. So `start()` is `Some(n)` exactly when a converter must emit a
408 /// non-default `start="n"`, mirroring the `ordinal != 1` guard in this
409 /// crate's own reference renderer.
410 pub fn start(&self) -> Option<i64> {
411 if self.type_ != ListType::Ordered {
412 return None;
413 }
414
415 // An explicit `[start=N]` attribute takes precedence; otherwise derive
416 // the start from an explicit first-item marker.
417 let resolved = self
418 .attrlist
419 .as_ref()
420 .and_then(|attrlist| attrlist.named_attribute("start"))
421 .and_then(|attr| attr.value().trim().parse::<i64>().ok())
422 .or_else(|| {
423 self.items
424 .first()
425 .and_then(|item| item.as_list_item())
426 .and_then(|li| li.list_item_marker().ordinal_value())
427 .map(i64::from)
428 });
429
430 // A start of 1 is the default, which a converter renders as a bare
431 // `<ol>`, so it is reported as `None` rather than `Some(1)`.
432 resolved.filter(|&n| n != 1)
433 }
434}
435
436impl<'src> IsBlock<'src> for ListBlock<'src> {
437 fn content_model(&self) -> ContentModel {
438 ContentModel::Compound
439 }
440
441 fn raw_context(&self) -> CowStr<'src> {
442 "list".into()
443 }
444
445 fn child_blocks_mut(&mut self) -> &mut [Block<'src>] {
446 &mut self.items
447 }
448
449 fn title_source(&'src self) -> Option<Span<'src>> {
450 self.title_source
451 }
452
453 fn title(&self) -> Option<&str> {
454 self.title.as_ref().map(Content::rendered_str)
455 }
456
457 fn anchor(&'src self) -> Option<Span<'src>> {
458 self.anchor
459 }
460
461 fn anchor_reftext(&'src self) -> Option<Span<'src>> {
462 self.anchor_reftext
463 }
464
465 fn attrlist(&'src self) -> Option<&'src Attrlist<'src>> {
466 self.attrlist.as_ref()
467 }
468}
469
470impl<'src> HasSpan<'src> for ListBlock<'src> {
471 fn span(&self) -> Span<'src> {
472 self.source
473 }
474}
475
476impl std::fmt::Debug for ListBlock<'_> {
477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478 f.debug_struct("ListBlock")
479 .field("type_", &self.type_)
480 .field("items", &DebugSliceReference(&self.items))
481 .field("source", &self.source)
482 .field("title_source", &self.title_source)
483 .field("title", &self.title)
484 .field("anchor", &self.anchor)
485 .field("anchor_reftext", &self.anchor_reftext)
486 .field("attrlist", &self.attrlist)
487 .field("is_checklist", &self.is_checklist)
488 .field("is_bibliography", &self.is_bibliography)
489 .finish()
490 }
491}
492
493/// Represents the type of a list.
494#[derive(Clone, Copy, Eq, Hash, PartialEq)]
495pub enum ListType {
496 /// An unordered list is a list with items prefixed with symbol, such as a
497 /// disc (aka bullet).
498 Unordered,
499
500 /// An ordered list is a list with items prefixed with a number or other
501 /// sequential mark.
502 Ordered,
503
504 /// A description list is an association list that consists of one or more
505 /// terms (or sets of terms) that each have a description.
506 Description,
507
508 /// A callout list provides annotations for lines in a preceding verbatim
509 /// block. Its items are marked with `<1>`, `<2>`, … (or `<.>` for automatic
510 /// numbering).
511 Callout,
512}
513
514impl std::fmt::Debug for ListType {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 match self {
517 ListType::Unordered => write!(f, "ListType::Unordered"),
518 ListType::Ordered => write!(f, "ListType::Ordered"),
519 ListType::Description => write!(f, "ListType::Description"),
520 ListType::Callout => write!(f, "ListType::Callout"),
521 }
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 #![allow(clippy::indexing_slicing)]
528 #![allow(clippy::panic)]
529 #![allow(clippy::unwrap_used)]
530
531 use crate::{
532 blocks::{ContentModel, ListType, metadata::BlockMetadata},
533 span::MatchedItem,
534 tests::prelude::*,
535 warnings::Warning,
536 };
537
538 fn list_parse<'a>(source: &'a str) -> Option<MatchedItem<'a, crate::blocks::ListBlock<'a>>> {
539 let mut parser = crate::Parser::default();
540 let mut warnings: Vec<Warning<'a>> = vec![];
541
542 let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
543
544 let result = crate::blocks::list::ListBlock::parse(&metadata, &mut parser, &mut warnings);
545
546 assert!(warnings.is_empty());
547
548 result
549 }
550
551 /// Like [`list_parse`], but also returns the warnings produced. Used for
552 /// callout lists, which warn when an item has no matching callout in a
553 /// preceding verbatim block.
554 fn list_parse_with_warnings<'a>(
555 source: &'a str,
556 ) -> (
557 Option<MatchedItem<'a, crate::blocks::ListBlock<'a>>>,
558 Vec<Warning<'a>>,
559 ) {
560 let mut parser = crate::Parser::default();
561 let mut warnings: Vec<Warning<'a>> = vec![];
562
563 let metadata = BlockMetadata::parse(crate::Span::new(source), &mut parser).item;
564
565 let result = crate::blocks::list::ListBlock::parse(&metadata, &mut parser, &mut warnings);
566
567 (result, warnings)
568 }
569
570 #[test]
571 fn basic_case() {
572 assert!(list_parse("-xyz").is_none());
573 assert!(list_parse("-- x").is_none());
574
575 let list = list_parse("- blah").unwrap();
576
577 assert_eq!(
578 list.item,
579 ListBlock {
580 type_: ListType::Unordered,
581 items: &[Block::ListItem(ListItem {
582 marker: ListItemMarker::Hyphen(Span {
583 data: "-",
584 line: 1,
585 col: 1,
586 offset: 0,
587 },),
588 blocks: &[Block::Simple(SimpleBlock {
589 content: Content {
590 original: Span {
591 data: "blah",
592 line: 1,
593 col: 3,
594 offset: 2,
595 },
596 rendered: "blah",
597 },
598 source: Span {
599 data: "blah",
600 line: 1,
601 col: 3,
602 offset: 2,
603 },
604 style: SimpleBlockStyle::Paragraph,
605 title_source: None,
606 title: None,
607 caption: None,
608 number: None,
609 anchor: None,
610 anchor_reftext: None,
611 attrlist: None,
612 },),],
613 source: Span {
614 data: "- blah",
615 line: 1,
616 col: 1,
617 offset: 0,
618 },
619 anchor: None,
620 anchor_reftext: None,
621 attrlist: None,
622 },),],
623 source: Span {
624 data: "- blah",
625 line: 1,
626 col: 1,
627 offset: 0,
628 },
629 title_source: None,
630 title: None,
631 anchor: None,
632 anchor_reftext: None,
633 attrlist: None,
634 }
635 );
636
637 assert_eq!(list.item.type_(), ListType::Unordered);
638 assert_eq!(list.item.content_model(), ContentModel::Compound);
639 assert_eq!(list.item.raw_context().as_ref(), "list");
640
641 let mut list_blocks = list.item.child_blocks();
642
643 let list_item = list_blocks.next().unwrap();
644
645 assert_eq!(
646 list_item,
647 &Block::ListItem(ListItem {
648 marker: ListItemMarker::Hyphen(Span {
649 data: "-",
650 line: 1,
651 col: 1,
652 offset: 0,
653 },),
654 blocks: &[Block::Simple(SimpleBlock {
655 content: Content {
656 original: Span {
657 data: "blah",
658 line: 1,
659 col: 3,
660 offset: 2,
661 },
662 rendered: "blah",
663 },
664 source: Span {
665 data: "blah",
666 line: 1,
667 col: 3,
668 offset: 2,
669 },
670 style: SimpleBlockStyle::Paragraph,
671 title_source: None,
672 title: None,
673 caption: None,
674 number: None,
675 anchor: None,
676 anchor_reftext: None,
677 attrlist: None,
678 },),],
679 source: Span {
680 data: "- blah",
681 line: 1,
682 col: 1,
683 offset: 0,
684 },
685 anchor: None,
686 anchor_reftext: None,
687 attrlist: None,
688 })
689 );
690
691 assert_eq!(list_item.content_model(), ContentModel::Compound);
692 assert_eq!(list_item.raw_context().as_ref(), "list_item");
693
694 let mut li_blocks = list_item.child_blocks();
695
696 assert_eq!(
697 li_blocks.next().unwrap(),
698 &Block::Simple(SimpleBlock {
699 content: Content {
700 original: Span {
701 data: "blah",
702 line: 1,
703 col: 3,
704 offset: 2,
705 },
706 rendered: "blah",
707 },
708 source: Span {
709 data: "blah",
710 line: 1,
711 col: 3,
712 offset: 2,
713 },
714 style: SimpleBlockStyle::Paragraph,
715 title_source: None,
716 title: None,
717 caption: None,
718 number: None,
719 anchor: None,
720 anchor_reftext: None,
721 attrlist: None,
722 })
723 );
724 assert!(li_blocks.next().is_none());
725
726 assert!(list_item.title_source().is_none());
727 assert!(list_item.title().is_none());
728 assert!(list_item.anchor().is_none());
729 assert!(list_item.anchor_reftext().is_none());
730 assert!(list_item.attrlist().is_none());
731 assert_eq!(list_item.substitution_group(), SubstitutionGroup::Normal);
732 assert_eq!(
733 list_item.span(),
734 Span {
735 data: "- blah",
736 line: 1,
737 col: 1,
738 offset: 0,
739 }
740 );
741
742 assert!(list_blocks.next().is_none());
743
744 assert!(list.item.title_source().is_none());
745 assert!(list.item.title().is_none());
746 assert!(list.item.anchor().is_none());
747 assert!(list.item.anchor_reftext().is_none());
748 assert!(list.item.attrlist().is_none());
749
750 assert_eq!(
751 format!("{:#?}", list.item),
752 "ListBlock {\n type_: ListType::Unordered,\n items: &[\n Block::ListItem(\n 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 },\n ),\n ],\n source: Span {\n data: \"- blah\",\n line: 1,\n col: 1,\n offset: 0,\n },\n title_source: None,\n title: None,\n anchor: None,\n anchor_reftext: None,\n attrlist: None,\n is_checklist: false,\n is_bibliography: false,\n}"
753 );
754
755 assert_eq!(
756 list.after,
757 Span {
758 data: "",
759 line: 1,
760 col: 7,
761 offset: 6,
762 }
763 );
764 }
765
766 #[test]
767 fn list_type_impl_debug() {
768 assert_eq!(format!("{:#?}", ListType::Unordered), "ListType::Unordered");
769 assert_eq!(format!("{:#?}", ListType::Ordered), "ListType::Ordered");
770
771 assert_eq!(
772 format!("{:#?}", ListType::Description),
773 "ListType::Description"
774 );
775
776 assert_eq!(format!("{:#?}", ListType::Callout), "ListType::Callout");
777 }
778
779 #[test]
780 fn callout_list() {
781 // Parsed in isolation (no preceding verbatim block), so each item warns
782 // that it has no matching callout.
783 let (list, warnings) = list_parse_with_warnings("<1> First\n<2> Second\n");
784 let list = list.unwrap();
785
786 assert_eq!(list.item.type_(), ListType::Callout);
787 assert_eq!(list.item.marker_style(), Some("arabic"));
788
789 let items: Vec<_> = list.item.child_blocks().collect();
790 assert_eq!(items.len(), 2);
791
792 assert_eq!(
793 items[0].child_blocks().next().unwrap().rendered_content(),
794 Some("First")
795 );
796 assert_eq!(
797 items[1].child_blocks().next().unwrap().rendered_content(),
798 Some("Second")
799 );
800
801 let warning_types: Vec<_> = warnings.iter().map(|w| &w.warning).collect();
802 assert_eq!(
803 warning_types,
804 vec![
805 &WarningType::NoCalloutFound(1),
806 &WarningType::NoCalloutFound(2),
807 ]
808 );
809 }
810
811 #[test]
812 fn callout_list_auto_numbered() {
813 // `<.>` markers form a single callout list.
814 let (list, warnings) = list_parse_with_warnings("<.> First\n<.> Second\n<.> Third\n");
815 let list = list.unwrap();
816
817 assert_eq!(list.item.type_(), ListType::Callout);
818 assert_eq!(list.item.child_blocks().count(), 3);
819
820 // No preceding verbatim block defines these callouts.
821 assert_eq!(warnings.len(), 3);
822 }
823
824 #[test]
825 fn callout_list_marker_only_trailing_bracket_is_not_a_list() {
826 // `1>` (trailing bracket only) is not a callout list marker.
827 assert!(list_parse("1> Not a callout list item\n").is_none());
828 }
829
830 #[test]
831 fn attrlist_doesnt_exit() {
832 let list = list_parse("* Foo\n[loweralpha]\n. Boo\n* Blech").unwrap();
833
834 assert_eq!(
835 list.item,
836 ListBlock {
837 type_: ListType::Unordered,
838 items: &[
839 Block::ListItem(ListItem {
840 marker: ListItemMarker::Asterisks(Span {
841 data: "*",
842 line: 1,
843 col: 1,
844 offset: 0,
845 },),
846 blocks: &[
847 Block::Simple(SimpleBlock {
848 content: Content {
849 original: Span {
850 data: "Foo",
851 line: 1,
852 col: 3,
853 offset: 2,
854 },
855 rendered: "Foo",
856 },
857 source: Span {
858 data: "Foo",
859 line: 1,
860 col: 3,
861 offset: 2,
862 },
863 style: SimpleBlockStyle::Paragraph,
864 title_source: None,
865 title: None,
866 caption: None,
867 number: None,
868 anchor: None,
869 anchor_reftext: None,
870 attrlist: None,
871 },),
872 Block::List(ListBlock {
873 type_: ListType::Ordered,
874 items: &[Block::ListItem(ListItem {
875 marker: ListItemMarker::Dots(Span {
876 data: ".",
877 line: 3,
878 col: 1,
879 offset: 19,
880 },),
881 blocks: &[Block::Simple(SimpleBlock {
882 content: Content {
883 original: Span {
884 data: "Boo",
885 line: 3,
886 col: 3,
887 offset: 21,
888 },
889 rendered: "Boo",
890 },
891 source: Span {
892 data: "Boo",
893 line: 3,
894 col: 3,
895 offset: 21,
896 },
897 style: SimpleBlockStyle::Paragraph,
898 title_source: None,
899 title: None,
900 caption: None,
901 number: None,
902 anchor: None,
903 anchor_reftext: None,
904 attrlist: None,
905 },),],
906 source: Span {
907 data: ". Boo",
908 line: 3,
909 col: 1,
910 offset: 19,
911 },
912 anchor: None,
913 anchor_reftext: None,
914 attrlist: None,
915 },),],
916 source: Span {
917 data: "[loweralpha]\n. Boo",
918 line: 2,
919 col: 1,
920 offset: 6,
921 },
922 title_source: None,
923 title: None,
924 anchor: None,
925 anchor_reftext: None,
926 attrlist: Some(Attrlist {
927 attributes: &[ElementAttribute {
928 name: None,
929 value: "loweralpha",
930 shorthand_items: &["loweralpha"],
931 },],
932 anchor: None,
933 source: Span {
934 data: "loweralpha",
935 line: 2,
936 col: 2,
937 offset: 7,
938 },
939 },),
940 },),
941 ],
942 source: Span {
943 data: "* Foo\n[loweralpha]\n. Boo",
944 line: 1,
945 col: 1,
946 offset: 0,
947 },
948 anchor: None,
949 anchor_reftext: None,
950 attrlist: None,
951 },),
952 Block::ListItem(ListItem {
953 marker: ListItemMarker::Asterisks(Span {
954 data: "*",
955 line: 4,
956 col: 1,
957 offset: 25,
958 },),
959 blocks: &[Block::Simple(SimpleBlock {
960 content: Content {
961 original: Span {
962 data: "Blech",
963 line: 4,
964 col: 3,
965 offset: 27,
966 },
967 rendered: "Blech",
968 },
969 source: Span {
970 data: "Blech",
971 line: 4,
972 col: 3,
973 offset: 27,
974 },
975 style: SimpleBlockStyle::Paragraph,
976 title_source: None,
977 title: None,
978 caption: None,
979 number: None,
980 anchor: None,
981 anchor_reftext: None,
982 attrlist: None,
983 },),],
984 source: Span {
985 data: "* Blech",
986 line: 4,
987 col: 1,
988 offset: 25,
989 },
990 anchor: None,
991 anchor_reftext: None,
992 attrlist: None,
993 },),
994 ],
995 source: Span {
996 data: "* Foo\n[loweralpha]\n. Boo\n* Blech",
997 line: 1,
998 col: 1,
999 offset: 0,
1000 },
1001 title_source: None,
1002 title: None,
1003 anchor: None,
1004 anchor_reftext: None,
1005 attrlist: None,
1006 }
1007 );
1008
1009 assert_eq!(
1010 list.after,
1011 Span {
1012 data: "",
1013 line: 4,
1014 col: 8,
1015 offset: 32,
1016 }
1017 );
1018 }
1019
1020 #[test]
1021 fn metadata_merged_across_empty_lines_for_nested_list() {
1022 // Exercises the `if ext_anchor.is_none()` merge path in
1023 // ListItem::parse (circa line 283 of list_item.rs).
1024 let list = list_parse("* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo\n* Blech").unwrap();
1025
1026 assert_eq!(
1027 list.item,
1028 ListBlock {
1029 type_: ListType::Unordered,
1030 items: &[
1031 Block::ListItem(ListItem {
1032 marker: ListItemMarker::Asterisks(Span {
1033 data: "*",
1034 line: 1,
1035 col: 1,
1036 offset: 0,
1037 },),
1038 blocks: &[
1039 Block::Simple(SimpleBlock {
1040 content: Content {
1041 original: Span {
1042 data: "Foo",
1043 line: 1,
1044 col: 3,
1045 offset: 2,
1046 },
1047 rendered: "Foo",
1048 },
1049 source: Span {
1050 data: "Foo",
1051 line: 1,
1052 col: 3,
1053 offset: 2,
1054 },
1055 style: SimpleBlockStyle::Paragraph,
1056 title_source: None,
1057 title: None,
1058 caption: None,
1059 number: None,
1060 anchor: None,
1061 anchor_reftext: None,
1062 attrlist: None,
1063 },),
1064 Block::List(ListBlock {
1065 type_: ListType::Ordered,
1066 items: &[Block::ListItem(ListItem {
1067 marker: ListItemMarker::Dots(Span {
1068 data: ".",
1069 line: 5,
1070 col: 1,
1071 offset: 31,
1072 },),
1073 blocks: &[Block::Simple(SimpleBlock {
1074 content: Content {
1075 original: Span {
1076 data: "Boo",
1077 line: 5,
1078 col: 3,
1079 offset: 33,
1080 },
1081 rendered: "Boo",
1082 },
1083 source: Span {
1084 data: "Boo",
1085 line: 5,
1086 col: 3,
1087 offset: 33,
1088 },
1089 style: SimpleBlockStyle::Paragraph,
1090 title_source: None,
1091 title: None,
1092 caption: None,
1093 number: None,
1094 anchor: None,
1095 anchor_reftext: None,
1096 attrlist: None,
1097 },),],
1098 source: Span {
1099 data: ". Boo",
1100 line: 5,
1101 col: 1,
1102 offset: 31,
1103 },
1104 anchor: None,
1105 anchor_reftext: None,
1106 attrlist: None,
1107 },),],
1108 source: Span {
1109 data: "[loweralpha]\n\n[[anchor]]\n. Boo",
1110 line: 2,
1111 col: 1,
1112 offset: 6,
1113 },
1114 title_source: None,
1115 title: None,
1116 anchor: Some(Span {
1117 data: "anchor",
1118 line: 4,
1119 col: 3,
1120 offset: 22,
1121 },),
1122 anchor_reftext: None,
1123 attrlist: Some(Attrlist {
1124 attributes: &[ElementAttribute {
1125 name: None,
1126 value: "loweralpha",
1127 shorthand_items: &["loweralpha"],
1128 },],
1129 anchor: None,
1130 source: Span {
1131 data: "loweralpha",
1132 line: 2,
1133 col: 2,
1134 offset: 7,
1135 },
1136 },),
1137 },),
1138 ],
1139 source: Span {
1140 data: "* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo",
1141 line: 1,
1142 col: 1,
1143 offset: 0,
1144 },
1145 anchor: None,
1146 anchor_reftext: None,
1147 attrlist: None,
1148 },),
1149 Block::ListItem(ListItem {
1150 marker: ListItemMarker::Asterisks(Span {
1151 data: "*",
1152 line: 6,
1153 col: 1,
1154 offset: 37,
1155 },),
1156 blocks: &[Block::Simple(SimpleBlock {
1157 content: Content {
1158 original: Span {
1159 data: "Blech",
1160 line: 6,
1161 col: 3,
1162 offset: 39,
1163 },
1164 rendered: "Blech",
1165 },
1166 source: Span {
1167 data: "Blech",
1168 line: 6,
1169 col: 3,
1170 offset: 39,
1171 },
1172 style: SimpleBlockStyle::Paragraph,
1173 title_source: None,
1174 title: None,
1175 caption: None,
1176 number: None,
1177 anchor: None,
1178 anchor_reftext: None,
1179 attrlist: None,
1180 },),],
1181 source: Span {
1182 data: "* Blech",
1183 line: 6,
1184 col: 1,
1185 offset: 37,
1186 },
1187 anchor: None,
1188 anchor_reftext: None,
1189 attrlist: None,
1190 },),
1191 ],
1192 source: Span {
1193 data: "* Foo\n[loweralpha]\n\n[[anchor]]\n. Boo\n* Blech",
1194 line: 1,
1195 col: 1,
1196 offset: 0,
1197 },
1198 title_source: None,
1199 title: None,
1200 anchor: None,
1201 anchor_reftext: None,
1202 attrlist: None,
1203 }
1204 );
1205 }
1206
1207 #[test]
1208 fn parent_marker_after_metadata_separated_by_empty_lines() {
1209 // Exercises the parent_list_markers check in ListItem::parse
1210 // (circa line 308) where a list marker found after extending metadata
1211 // past empty lines matches a grandparent marker.
1212 //
1213 // Input: three nesting levels, then [[anchor]] + blank line + * marker.
1214 // The *** item should recognize * as a grandparent marker and break.
1215 let list =
1216 list_parse("* grandparent\n** parent\n*** nested\n[[anchor]]\n\n* back to grandparent")
1217 .unwrap();
1218
1219 // Outer list has two * items.
1220 assert_eq!(list.item.child_blocks().count(), 2);
1221 assert_eq!(list.item.type_(), ListType::Unordered);
1222
1223 let mut outer_items = list.item.child_blocks();
1224
1225 // First outer item should contain a nested ** list.
1226 let first_outer = outer_items.next().unwrap();
1227 let first_outer_blocks: Vec<_> = first_outer.child_blocks().collect();
1228 assert_eq!(first_outer_blocks.len(), 2); // SimpleBlock + ListBlock
1229
1230 // The nested ** list should have one item.
1231 let nested_list = &first_outer_blocks[1];
1232 assert_eq!(nested_list.child_blocks().count(), 1);
1233
1234 // That ** item should contain a nested *** list.
1235 let parent_item = nested_list.child_blocks().next().unwrap();
1236 let parent_blocks: Vec<_> = parent_item.child_blocks().collect();
1237 assert_eq!(parent_blocks.len(), 2); // SimpleBlock + ListBlock
1238
1239 // The *** list should have one item.
1240 let innermost_list = &parent_blocks[1];
1241 assert_eq!(innermost_list.child_blocks().count(), 1);
1242
1243 // The *** item should have only its principal text.
1244 let innermost_item = innermost_list.child_blocks().next().unwrap();
1245 assert_eq!(innermost_item.child_blocks().count(), 1);
1246
1247 // Second outer item is "back to grandparent".
1248 let second_outer = outer_items.next().unwrap();
1249 assert_eq!(second_outer.child_blocks().count(), 1);
1250 assert!(outer_items.next().is_none());
1251 }
1252
1253 #[test]
1254 fn block_metadata_on_a_subsequent_item_is_captured() {
1255 // A block anchor and attribute list written directly before a later
1256 // item (no intervening blank line) keep the item in this list and
1257 // attach to it, rather than being dropped or splitting the list.
1258 let list = list_parse("* one\n[[second]]\n[.special]\n* two").unwrap();
1259
1260 let items: Vec<_> = list.item.child_blocks().collect();
1261 assert_eq!(items.len(), 2);
1262
1263 // The metadata attaches to the second item.
1264 assert_eq!(items[1].anchor().unwrap().data(), "second");
1265 assert_eq!(items[1].attrlist().unwrap().roles(), vec!["special"]);
1266
1267 // The first item carries none of it.
1268 assert!(items[0].anchor().is_none());
1269 assert!(items[0].attrlist().is_none());
1270 }
1271
1272 #[test]
1273 fn blank_line_before_metadata_starts_a_new_list() {
1274 // A blank line followed by block metadata ends the list; that metadata
1275 // decorates a new, separate list rather than the next item (matching
1276 // Asciidoctor).
1277 let doc = crate::Parser::default().parse("* one\n\n[[second]]\n* two");
1278
1279 let lists: Vec<_> = doc
1280 .child_blocks()
1281 .filter(|b| b.raw_context().as_ref() == "list")
1282 .collect();
1283 assert_eq!(lists.len(), 2);
1284
1285 // Each list holds a single item, and the blank-separated anchor
1286 // attaches to the second list.
1287 assert_eq!(lists[0].child_blocks().count(), 1);
1288 assert_eq!(lists[1].child_blocks().count(), 1);
1289 assert_eq!(lists[1].anchor().unwrap().data(), "second");
1290 }
1291
1292 #[test]
1293 fn marker_style_single_dot() {
1294 let list = list_parse(". Item one\n. Item two\n").unwrap();
1295 assert_eq!(list.item.marker_style(), Some("arabic"));
1296 }
1297
1298 #[test]
1299 fn marker_style_double_dots() {
1300 let list = list_parse(".. Item a\n.. Item b\n").unwrap();
1301 assert_eq!(list.item.marker_style(), Some("loweralpha"));
1302 }
1303
1304 #[test]
1305 fn marker_style_triple_dots() {
1306 let list = list_parse("... Item i\n... Item ii\n").unwrap();
1307 assert_eq!(list.item.marker_style(), Some("lowerroman"));
1308 }
1309
1310 #[test]
1311 fn marker_style_four_dots() {
1312 let list = list_parse(".... Item A\n.... Item B\n").unwrap();
1313 assert_eq!(list.item.marker_style(), Some("upperalpha"));
1314 }
1315
1316 #[test]
1317 fn marker_style_five_dots() {
1318 let list = list_parse("..... Item I\n..... Item II\n").unwrap();
1319 assert_eq!(list.item.marker_style(), Some("upperroman"));
1320 }
1321
1322 #[test]
1323 fn marker_style_hyphen_returns_none() {
1324 let list = list_parse("- Item one\n- Item two\n").unwrap();
1325 assert_eq!(list.item.marker_style(), None);
1326 }
1327
1328 #[test]
1329 fn marker_style_asterisk_returns_none() {
1330 let list = list_parse("* Item one\n* Item two\n").unwrap();
1331 assert_eq!(list.item.marker_style(), None);
1332 }
1333
1334 #[test]
1335 fn marker_with_no_content() {
1336 // Exercises the `break` in `parse_inside_list` when
1337 // `ListItemMarker::parse` succeeds but `ListItem::parse`
1338 // returns `None` (marker present, no content after it).
1339 assert!(list_parse("- ").is_none());
1340 assert!(list_parse("* ").is_none());
1341 assert!(list_parse(". ").is_none());
1342 }
1343
1344 #[test]
1345 fn orphaned_title_after_continuation_is_discarded() {
1346 // Exercises the "If there's block metadata but no block, just discard
1347 // it and continue." path in ListItem::parse (circa line 368 of
1348 // list_item.rs). A `+` continuation followed by a block title (`.Title`)
1349 // and then an empty line means the title is orphaned (no block
1350 // immediately follows). The title metadata is discarded and the
1351 // subsequent paragraph is parsed as a continuation block.
1352 let list = list_parse("* item one\n+\n.Title\n\nsecond paragraph").unwrap();
1353
1354 // The list should have one item.
1355 let mut items = list.item.child_blocks();
1356 let item = items.next().unwrap();
1357 assert!(items.next().is_none());
1358
1359 // The item should have two blocks: the principal text and the
1360 // continuation paragraph. The orphaned `.Title` should be discarded.
1361 let blocks: Vec<_> = item.child_blocks().collect();
1362 assert_eq!(blocks.len(), 2);
1363
1364 // First block is the principal text.
1365 assert_eq!(
1366 blocks[0],
1367 &Block::Simple(SimpleBlock {
1368 content: Content {
1369 original: Span {
1370 data: "item one",
1371 line: 1,
1372 col: 3,
1373 offset: 2,
1374 },
1375 rendered: "item one",
1376 },
1377 source: Span {
1378 data: "item one",
1379 line: 1,
1380 col: 3,
1381 offset: 2,
1382 },
1383 style: SimpleBlockStyle::Paragraph,
1384 title_source: None,
1385 title: None,
1386 caption: None,
1387 number: None,
1388 anchor: None,
1389 anchor_reftext: None,
1390 attrlist: None,
1391 })
1392 );
1393
1394 // Second block is the continuation paragraph (no title attached).
1395 assert_eq!(
1396 blocks[1],
1397 &Block::Simple(SimpleBlock {
1398 content: Content {
1399 original: Span {
1400 data: "second paragraph",
1401 line: 5,
1402 col: 1,
1403 offset: 21,
1404 },
1405 rendered: "second paragraph",
1406 },
1407 source: Span {
1408 data: "second paragraph",
1409 line: 5,
1410 col: 1,
1411 offset: 21,
1412 },
1413 style: SimpleBlockStyle::Paragraph,
1414 title_source: None,
1415 title: None,
1416 caption: None,
1417 number: None,
1418 anchor: None,
1419 anchor_reftext: None,
1420 attrlist: None,
1421 })
1422 );
1423 }
1424
1425 #[test]
1426 fn block_list_enum_case() {
1427 let mut parser = crate::Parser::default();
1428
1429 let mi = crate::blocks::Block::parse(crate::Span::new("- blah"), &mut parser)
1430 .unwrap_if_no_warnings()
1431 .unwrap();
1432
1433 assert!(matches!(mi.item, crate::blocks::Block::List(_)));
1434
1435 assert_eq!(mi.item.content_model(), ContentModel::Compound);
1436 assert!(mi.item.rendered_content().is_none());
1437 assert_eq!(mi.item.raw_context().as_ref(), "list");
1438 assert_eq!(mi.item.child_blocks().count(), 1);
1439 assert!(mi.item.title_source().is_none());
1440 assert!(mi.item.title().is_none());
1441 assert!(mi.item.anchor().is_none());
1442 assert!(mi.item.anchor_reftext().is_none());
1443 assert!(mi.item.attrlist().is_none());
1444 assert_eq!(mi.item.substitution_group(), SubstitutionGroup::Normal);
1445
1446 assert_eq!(
1447 mi.item.span(),
1448 Span {
1449 data: "- blah",
1450 line: 1,
1451 col: 1,
1452 offset: 0,
1453 }
1454 );
1455
1456 let debug_str = format!("{:?}", mi.item);
1457 assert!(debug_str.starts_with("Block::List("));
1458 }
1459
1460 mod start {
1461 use super::list_parse;
1462 use crate::blocks::ListType;
1463
1464 #[test]
1465 fn unordered_list_has_no_start() {
1466 let mi = list_parse("* one\n* two").unwrap();
1467 assert_eq!(mi.item.type_(), ListType::Unordered);
1468 assert_eq!(mi.item.start(), None);
1469 }
1470
1471 #[test]
1472 fn implicit_ordered_marker_has_no_start() {
1473 // Implicit `.` markers with no `[start]` attribute default to 1,
1474 // reported as `None`.
1475 let mi = list_parse(". one\n. two").unwrap();
1476 assert_eq!(mi.item.type_(), ListType::Ordered);
1477 assert_eq!(mi.item.start(), None);
1478 }
1479
1480 #[test]
1481 fn explicit_arabic_first_marker_sets_start() {
1482 let mi = list_parse("7. one\n8. two").unwrap();
1483 assert_eq!(mi.item.type_(), ListType::Ordered);
1484 assert_eq!(mi.item.start(), Some(7));
1485 }
1486
1487 #[test]
1488 fn explicit_alpha_first_marker_sets_start() {
1489 // `c.` is the third letter, so the list starts at 3.
1490 let mi = list_parse("c. one\nd. two").unwrap();
1491 assert_eq!(mi.item.start(), Some(3));
1492 }
1493
1494 #[test]
1495 fn explicit_ordinal_one_marker_defaults_to_none() {
1496 // An explicit ordinal-1 marker resolves to the default start of 1,
1497 // which a converter renders as a bare `<ol>`, so `start()` is
1498 // `None` rather than `Some(1)`.
1499 assert_eq!(list_parse("1. one\n2. two").unwrap().item.start(), None);
1500 assert_eq!(list_parse("a. one\nb. two").unwrap().item.start(), None);
1501 }
1502
1503 #[test]
1504 fn start_attribute_takes_precedence() {
1505 let mi = list_parse("[start=5]\n. one\n. two").unwrap();
1506 assert_eq!(mi.item.type_(), ListType::Ordered);
1507 assert_eq!(mi.item.start(), Some(5));
1508 }
1509
1510 #[test]
1511 fn start_attribute_of_one_is_none() {
1512 // `[start=1]` is the default too, so it is also reported as `None`.
1513 let mi = list_parse("[start=1]\n. one\n. two").unwrap();
1514 assert_eq!(mi.item.start(), None);
1515 }
1516
1517 #[test]
1518 fn start_attribute_overrides_first_marker() {
1519 let mi = list_parse("[start=5]\n7. one\n8. two").unwrap();
1520 assert_eq!(mi.item.start(), Some(5));
1521 }
1522
1523 #[test]
1524 fn non_numeric_start_attribute_falls_back_to_marker() {
1525 let mi = list_parse("[start=abc]\n7. one\n8. two").unwrap();
1526 assert_eq!(mi.item.start(), Some(7));
1527 }
1528 }
1529}