Skip to main content

asciidoc_parser/blocks/
list_item_marker.rs

1use std::sync::LazyLock;
2
3use regex::Regex;
4
5use crate::{
6    HasSpan, Parser, Span,
7    content::{Content, SubstitutionStep},
8    document::RefType,
9    span::MatchedItem,
10    warnings::{Warning, WarningType},
11};
12
13/// A list item is signaled by one of several designated marker sequences.
14#[derive(Clone, Eq, PartialEq)]
15pub enum ListItemMarker<'src> {
16    /// Unordered list (hyphen).
17    Hyphen(Span<'src>),
18
19    /// Unordered list (asterisks).
20    Asterisks(Span<'src>),
21
22    /// Unordered list (Unicode bullet).
23    Bullet(Span<'src>),
24
25    /// Ordered list (dots).
26    Dots(Span<'src>),
27
28    /// Uppercase letter followed by dot (alpha list).
29    AlphaListCapital(Span<'src>),
30
31    /// Lowercase letter followed by dot (alpha list).
32    AlphaListLower(Span<'src>),
33
34    /// Lowercase Roman numeral followed by closing paren.
35    RomanNumeralLower(Span<'src>),
36
37    /// Uppercase Roman numeral followed by closing paren.
38    RomanNumeralUpper(Span<'src>),
39
40    /// Explicit Arabic numeral followed by dot (e.g., "7.").
41    ArabicNumeral(Span<'src>),
42
43    /// A callout list marker (`<1>` or `<.>`), used to annotate lines in a
44    /// preceding verbatim block.
45    Callout(Span<'src>),
46
47    /// A term to be defined.
48    DefinedTerm {
49        /// The name of the term being defined.
50        term: Content<'src>,
51
52        /// The marker (`::`, etc.) used to call out the definition.
53        marker: Span<'src>,
54
55        /// The source span for the entire term assembly.
56        source: Span<'src>,
57    },
58}
59
60impl<'src> ListItemMarker<'src> {
61    pub(crate) fn starts_with_marker(source: Span<'src>) -> bool {
62        // Discard leading whitespace before matching, mirroring `parse` (which
63        // does the same for every marker kind), so both marker regexes see the
64        // same input.
65        let source = source.discard_whitespace();
66        LIST_ITEM_MARKER.is_match(source.data()) || CALLOUT_LIST_MARKER.is_match(source.data())
67    }
68
69    pub(crate) fn parse(source: Span<'src>, parser: &Parser) -> Option<MatchedItem<'src, Self>> {
70        let source = source.discard_whitespace();
71
72        // A callout list marker (`<1>` or `<.>`) is not matched by
73        // `LIST_ITEM_MARKER`, so it is checked first.
74        if let Some(captures) = CALLOUT_LIST_MARKER.captures(source.data()) {
75            let marker = source.slice(0..captures[1].len());
76            let after = source.slice_from(captures[1].len()..).discard_whitespace();
77
78            return Some(MatchedItem {
79                item: Self::Callout(marker),
80                after,
81            });
82        }
83
84        if let Some(captures) = LIST_ITEM_MARKER.captures(source.data()) {
85            let marker = source.slice(0..captures[1].len());
86            let marker_str = marker.data();
87            let after = source.slice_from(captures[1].len()..).discard_whitespace();
88
89            let first_char = captures[1].chars().next();
90
91            let item = if marker_str == "-" {
92                Self::Hyphen(marker)
93            } else if marker_str.starts_with('*') {
94                Self::Asterisks(marker)
95            } else if marker_str == "•" {
96                Self::Bullet(marker)
97            } else if marker_str.starts_with('.') {
98                Self::Dots(marker)
99            } else if let Some(first_char) = first_char
100                && first_char.is_ascii_uppercase()
101                && marker_str.ends_with('.')
102            {
103                Self::AlphaListCapital(marker)
104            } else if let Some(first_char) = first_char
105                && first_char.is_ascii_lowercase()
106                && marker_str.ends_with('.')
107            {
108                Self::AlphaListLower(marker)
109            } else if marker_str.ends_with(')')
110                && marker_str
111                    .chars()
112                    .take(marker_str.len() - 1)
113                    .all(|c| "ivxlcdm".contains(c))
114            {
115                Self::RomanNumeralLower(marker)
116            } else if marker_str.ends_with(')')
117                && marker_str
118                    .chars()
119                    .take(marker_str.len() - 1)
120                    .all(|c| "IVXLCDM".contains(c))
121            {
122                Self::RomanNumeralUpper(marker)
123            } else if marker_str.ends_with('.')
124                && marker_str
125                    .chars()
126                    .take(marker_str.len() - 1)
127                    .all(|c| c.is_ascii_digit())
128            {
129                Self::ArabicNumeral(marker)
130            } else {
131                // Regex and if-else chain should be exhaustive. If not, treat as non-match.
132                return None;
133            };
134
135            Some(MatchedItem { item, after })
136        } else {
137            // Don't match description list markers in comment lines.
138            // Comment lines start with // but not /// (which is a valid term).
139            let source_data = source.data();
140            if source_data.starts_with("//") && !source_data.starts_with("///") {
141                return None;
142            }
143
144            let captures = DESCRIPTION_LIST_MARKER.captures(source_data)?;
145
146            // With multi-line mode enabled, ^ can match at any line start.
147            // We only accept matches that start at the beginning of the source.
148            let full_match = captures.get(0)?;
149            if full_match.start() != 0 {
150                return None;
151            }
152
153            let after = source.slice_from(full_match.end()..).discard_whitespace();
154
155            let source = source
156                .slice_to(..full_match.end())
157                .trim_trailing_whitespace();
158
159            let term_len = captures[1].len();
160            let term = source.slice(0..term_len);
161            let mut term: Content<'src> = term.into();
162
163            // Apply attribute substitution to the term so that attribute references
164            // like `{blank}` are resolved before determining if this is a valid
165            // definition list marker.
166            SubstitutionStep::AttributeReferences.apply(&mut term, parser, None);
167
168            let marker = source.slice_from(term_len..);
169
170            Some(MatchedItem {
171                item: Self::DefinedTerm {
172                    term,
173                    marker,
174                    source,
175                },
176                after,
177            })
178        }
179    }
180
181    /// Register any leading inline anchors found in a description list term.
182    ///
183    /// This should be called after parsing a `DefinedTerm` marker when the list
184    /// item is being kept (not just checked for existence). It detects anchors
185    /// like `[[id]]` or `[[id,reftext]]` at the start of the term text,
186    /// registers them in the catalog, and applies macros substitution to
187    /// render the anchor.
188    ///
189    /// This method is a no-op for non-`DefinedTerm` markers.
190    pub(crate) fn register_leading_anchors(
191        &mut self,
192        parser: &mut Parser,
193        warnings: &mut Vec<Warning<'src>>,
194    ) {
195        let Self::DefinedTerm {
196            term,
197            marker: _,
198            source: _,
199        } = self
200        else {
201            return;
202        };
203
204        // Check if term starts with `[[` indicating a potential inline anchor.
205        let term_text = term.rendered();
206        if !term_text.starts_with("[[") {
207            // Apply macros substitution even if no leading anchor.
208            SubstitutionStep::Macros.apply(term, parser, None);
209            return;
210        }
211
212        // NOTE: Code coverage for the remainder of this function will be
213        // missing until we complete MAJOR REFACTOR: Split parsing and
214        // inline substitution steps.
215        // (See https://github.com/asciidoc-rs/asciidoc-parser/issues/461.)
216
217        // Detect leading inline anchor pattern: [[id]] or [[id,reftext]]
218        if let Some(captures) = LEADING_INLINE_ANCHOR.captures(term_text) {
219            let id = &captures[1];
220
221            // If reftext is provided, use it. Otherwise, use the text after the anchor
222            // as the default reftext.
223            let reftext = captures.get(2).map(|m| m.as_str().to_string()).or_else(|| {
224                // Use the text after the anchor as default reftext.
225                captures.get(3).map(|m| m.as_str().trim().to_string())
226            });
227
228            // Register the anchor in the catalog.
229            if let Err(_duplicate_error) =
230                parser.register_ref(id, reftext.as_deref(), RefType::Anchor)
231            {
232                warnings.push(Warning {
233                    source: term.original(),
234                    warning: WarningType::DuplicateId(id.to_string()),
235                });
236            }
237        }
238
239        // Apply macros substitution to render the inline anchor as HTML.
240        SubstitutionStep::Macros.apply(term, parser, None);
241    }
242
243    /// Return a mutable reference to the term content of a description-list
244    /// marker, or `None` for other marker kinds.
245    pub(crate) fn term_mut(&mut self) -> Option<&mut Content<'src>> {
246        match self {
247            Self::DefinedTerm { term, .. } => Some(term),
248            _ => None,
249        }
250    }
251
252    /// Returns the explicit number of a `<N>` callout marker, or `None` for an
253    /// automatically-numbered (`<.>`) callout or any non-callout marker.
254    pub(crate) fn callout_number(&self) -> Option<u32> {
255        match self {
256            Self::Callout(span) => span
257                .data()
258                .trim_start_matches('<')
259                .trim_end_matches('>')
260                .parse::<u32>()
261                .ok(),
262            _ => None,
263        }
264    }
265
266    /// Test for equality, disregarding span offsets.
267    pub(crate) fn is_match_for(&self, other: &Self) -> bool {
268        match self {
269            Self::Hyphen(self_span) => match other {
270                Self::Hyphen(other_span) => self_span.data() == other_span.data(),
271                _ => false,
272            },
273
274            Self::Asterisks(self_span) => match other {
275                Self::Asterisks(other_span) => self_span.data() == other_span.data(),
276                _ => false,
277            },
278
279            Self::Bullet(self_span) => match other {
280                Self::Bullet(other_span) => self_span.data() == other_span.data(),
281                _ => false,
282            },
283
284            Self::Dots(self_span) => match other {
285                Self::Dots(other_span) => self_span.data() == other_span.data(),
286                _ => false,
287            },
288
289            Self::AlphaListCapital(_self_span) => {
290                matches!(other, Self::AlphaListCapital(_other_span))
291            }
292
293            Self::AlphaListLower(_self_span) => {
294                matches!(other, Self::AlphaListLower(_other_span))
295            }
296
297            Self::RomanNumeralLower(_self_span) => {
298                matches!(other, Self::RomanNumeralLower(_other_span))
299            }
300
301            Self::RomanNumeralUpper(_self_span) => {
302                matches!(other, Self::RomanNumeralUpper(_other_span))
303            }
304
305            Self::ArabicNumeral(_self_span) => {
306                matches!(other, Self::ArabicNumeral(_other_span))
307            }
308
309            Self::Callout(_self_span) => {
310                matches!(other, Self::Callout(_other_span))
311            }
312
313            Self::DefinedTerm {
314                term: _,
315                marker: self_marker,
316                source: _,
317            } => match other {
318                Self::DefinedTerm {
319                    term: _,
320                    marker: other_marker,
321                    source: _,
322                } => self_marker.data() == other_marker.data(),
323                _ => false,
324            },
325        }
326    }
327
328    /// Returns the ordinal value for explicit markers, or `None` for implicit
329    /// markers.
330    ///
331    /// Explicit markers like `x.`, `7.`, or `iv)` have specific sequence
332    /// values. Implicit markers like `.` or `*` don't have ordinal values.
333    pub(crate) fn ordinal_value(&self) -> Option<u32> {
334        match self {
335            Self::AlphaListLower(span) => {
336                // "x." -> 24 (1-indexed: a=1, b=2, ..., x=24)
337                let ch = span.data().chars().next()?;
338                Some((ch as u32) - ('a' as u32) + 1)
339            }
340
341            Self::AlphaListCapital(span) => {
342                // "X." -> 24 (1-indexed: A=1, B=2, ..., X=24)
343                let ch = span.data().chars().next()?;
344                Some((ch as u32) - ('A' as u32) + 1)
345            }
346
347            Self::ArabicNumeral(span) => {
348                // "7." -> 7
349                span.data().trim_end_matches('.').parse().ok()
350            }
351
352            Self::RomanNumeralLower(span) => {
353                // "xvii)" -> 17
354                parse_roman_numeral(span.data().trim_end_matches(')'))
355            }
356
357            Self::RomanNumeralUpper(span) => {
358                // "XVII)" -> 17
359                parse_roman_numeral(span.data().trim_end_matches(')'))
360            }
361
362            // Implicit markers (dots, asterisks, etc.) don't have ordinal values.
363            _ => None,
364        }
365    }
366
367    /// Converts an ordinal value back to the display form for this marker type.
368    ///
369    /// Used to generate warning messages about expected vs. actual sequence
370    /// values.
371    pub(crate) fn ordinal_to_marker_text(&self, ordinal: u32) -> Option<String> {
372        match self {
373            Self::AlphaListLower(_) => {
374                // 24 -> "x"
375                char::from_u32('a' as u32 + ordinal - 1).map(|c| c.to_string())
376            }
377
378            Self::AlphaListCapital(_) => {
379                // 24 -> "X"
380                char::from_u32('A' as u32 + ordinal - 1).map(|c| c.to_string())
381            }
382
383            Self::ArabicNumeral(_) => {
384                // 7 -> "7"
385                Some(ordinal.to_string())
386            }
387
388            Self::RomanNumeralLower(_) => {
389                // 17 -> "xvii"
390                Some(to_roman_numeral_lower(ordinal))
391            }
392
393            Self::RomanNumeralUpper(_) => {
394                // 17 -> "XVII"
395                Some(to_roman_numeral_upper(ordinal))
396            }
397
398            // Implicit markers don't have ordinal display forms.
399            _ => None,
400        }
401    }
402}
403
404impl<'src> HasSpan<'src> for ListItemMarker<'src> {
405    fn span(&self) -> Span<'src> {
406        match self {
407            Self::Hyphen(x) => *x,
408            Self::Asterisks(x) => *x,
409            Self::Bullet(x) => *x,
410            Self::Dots(x) => *x,
411            Self::AlphaListCapital(x) => *x,
412            Self::AlphaListLower(x) => *x,
413            Self::RomanNumeralLower(x) => *x,
414            Self::RomanNumeralUpper(x) => *x,
415            Self::ArabicNumeral(x) => *x,
416            Self::Callout(x) => *x,
417
418            Self::DefinedTerm {
419                term: _,
420                marker: _,
421                source,
422            } => *source,
423        }
424    }
425}
426
427impl std::fmt::Debug for ListItemMarker<'_> {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        match self {
430            Self::Hyphen(x) => f.debug_tuple("ListItemMarker::Hyphen").field(x).finish(),
431            Self::Asterisks(x) => f.debug_tuple("ListItemMarker::Asterisks").field(x).finish(),
432            Self::Bullet(x) => f.debug_tuple("ListItemMarker::Bullet").field(x).finish(),
433            Self::Dots(x) => f.debug_tuple("ListItemMarker::Dots").field(x).finish(),
434
435            Self::AlphaListCapital(x) => f
436                .debug_tuple("ListItemMarker::AlphaListCapital")
437                .field(x)
438                .finish(),
439
440            Self::AlphaListLower(x) => f
441                .debug_tuple("ListItemMarker::AlphaListLower")
442                .field(x)
443                .finish(),
444
445            Self::RomanNumeralLower(x) => f
446                .debug_tuple("ListItemMarker::RomanNumeralLower")
447                .field(x)
448                .finish(),
449
450            Self::RomanNumeralUpper(x) => f
451                .debug_tuple("ListItemMarker::RomanNumeralUpper")
452                .field(x)
453                .finish(),
454
455            Self::ArabicNumeral(x) => f
456                .debug_tuple("ListItemMarker::ArabicNumeral")
457                .field(x)
458                .finish(),
459
460            Self::Callout(x) => f.debug_tuple("ListItemMarker::Callout").field(x).finish(),
461
462            Self::DefinedTerm {
463                term,
464                marker,
465                source,
466            } => f
467                .debug_struct("ListItemMarker::DefinedTerm")
468                .field("term", term)
469                .field("marker", marker)
470                .field("source", source)
471                .finish(),
472        }
473    }
474}
475
476static LIST_ITEM_MARKER: LazyLock<Regex> = LazyLock::new(|| {
477    #[allow(clippy::unwrap_used)]
478    Regex::new(
479        r#"(?x)    
480            ^(                      # Capture group for list marker
481                -                       # Hyphen (unordered list)
482                |\*+                    # One or more asterisks (unordered list, up to 5 levels)
483                |\.+                    # One or more dots (ordered list, up to 5 levels)
484                |\u{2022}               # Bullet character • (unordered list)
485                |\d+\.                  # Digits followed by dot (numbered list)
486                |[a-zA-Z]\.             # Letter followed by dot (alpha list)
487                |[ivxlcdm]+\)           # Lowercase Roman numerals followed by )
488                |[IVXLCDM]+\)           # Uppercase Roman numerals followed by )
489            )
490            [\ \t]                  # Required whitespace after marker
491        "#,
492    )
493    .unwrap()
494});
495
496/// Matches a callout list item marker: `<` followed by a number or `.`, then
497/// `>`, then required whitespace. Mirrors Asciidoctor's `CalloutListRx`.
498static CALLOUT_LIST_MARKER: LazyLock<Regex> = LazyLock::new(|| {
499    #[allow(clippy::unwrap_used)]
500    Regex::new(
501        r#"(?x)
502            ^                       # Start of line
503            (                       # Capture group 1: the marker
504                <(?:\d+|\.)>            # `<` then digits or a dot, then `>`
505            )
506            [\ \t]                  # Required whitespace after marker
507        "#,
508    )
509    .unwrap()
510});
511
512static DESCRIPTION_LIST_MARKER: LazyLock<Regex> = LazyLock::new(|| {
513    #[allow(clippy::unwrap_used)]
514    Regex::new(
515        r#"(?xm)
516            ^                       # Start of line
517            (                       # Capture group 1: Term being defined
518                [^\ \t]                 # At least one non-whitespace character (start of term)
519                .*?                     # Any characters (rest of term, non-greedy)
520            )
521            (?::::?:?|;;)           # Delimiter: ::, :::, ::::, or ;;
522            (?:$|[\ \t])            # End of line or whitespace after marker
523        "#,
524    )
525    .unwrap()
526});
527
528/// Matches a leading inline anchor at the start of text.
529///
530/// Captures:
531/// - Group 1: The anchor ID
532/// - Group 2: Optional reftext (after comma)
533/// - Group 3: Text after the anchor (used as default reftext if group 2 is
534///   absent)
535static LEADING_INLINE_ANCHOR: LazyLock<Regex> = LazyLock::new(|| {
536    #[allow(clippy::unwrap_used)]
537    Regex::new(
538        r#"(?x)
539            ^                           # Start of string
540            \[\[                        # Opening [[
541            ([\p{Alphabetic}_:]         # (1) Anchor ID: first char must be letter, _, or :
542             [\p{Alphabetic}\p{Nd}_\-:.]*)  # Rest of ID: letters, digits, _, -, :, .
543            (?:,\s*([^\]]+))?           # (2) Optional reftext after comma
544            \]\]                        # Closing ]]
545            (.*)                        # (3) Text after the anchor
546        "#,
547    )
548    .unwrap()
549});
550
551/// Parses a lowercase Roman numeral string into its numeric value.
552fn parse_roman_numeral(s: &str) -> Option<u32> {
553    let mut result: u32 = 0;
554    let mut prev_value: u32 = 0;
555
556    for ch in s.chars().rev() {
557        let value = match ch {
558            'i' | 'I' => 1,
559            'v' | 'V' => 5,
560            'x' | 'X' => 10,
561            'l' | 'L' => 50,
562            'c' | 'C' => 100,
563            'd' | 'D' => 500,
564            'm' | 'M' => 1000,
565            _ => return None,
566        };
567
568        if value < prev_value {
569            result -= value;
570        } else {
571            result += value;
572        }
573        prev_value = value;
574    }
575
576    if result > 0 { Some(result) } else { None }
577}
578
579/// Converts a numeric value to a lowercase Roman numeral string.
580fn to_roman_numeral_lower(mut n: u32) -> String {
581    const NUMERALS: &[(u32, &str)] = &[
582        (1000, "m"),
583        (900, "cm"),
584        (500, "d"),
585        (400, "cd"),
586        (100, "c"),
587        (90, "xc"),
588        (50, "l"),
589        (40, "xl"),
590        (10, "x"),
591        (9, "ix"),
592        (5, "v"),
593        (4, "iv"),
594        (1, "i"),
595    ];
596
597    let mut result = String::new();
598    for &(value, numeral) in NUMERALS {
599        while n >= value {
600            result.push_str(numeral);
601            n -= value;
602        }
603    }
604    result
605}
606
607/// Converts a numeric value to an uppercase Roman numeral string.
608fn to_roman_numeral_upper(mut n: u32) -> String {
609    const NUMERALS: &[(u32, &str)] = &[
610        (1000, "M"),
611        (900, "CM"),
612        (500, "D"),
613        (400, "CD"),
614        (100, "C"),
615        (90, "XC"),
616        (50, "L"),
617        (40, "XL"),
618        (10, "X"),
619        (9, "IX"),
620        (5, "V"),
621        (4, "IV"),
622        (1, "I"),
623    ];
624
625    let mut result = String::new();
626    for &(value, numeral) in NUMERALS {
627        while n >= value {
628            result.push_str(numeral);
629            n -= value;
630        }
631    }
632    result
633}
634
635#[cfg(test)]
636mod tests {
637    #![allow(clippy::panic)]
638    #![allow(clippy::unwrap_used)]
639
640    use crate::{span::MatchedItem, tests::prelude::*};
641
642    fn lim_parse<'a>(
643        source: &'a str,
644    ) -> Option<MatchedItem<'a, crate::blocks::ListItemMarker<'a>>> {
645        let parser = Parser::default();
646        crate::blocks::ListItemMarker::parse(crate::Span::new(source), &parser)
647    }
648
649    #[test]
650    fn hyphen() {
651        assert!(lim_parse("-").is_none());
652        assert!(lim_parse("-- x").is_none());
653
654        let lim = lim_parse("- blah").unwrap();
655
656        assert_eq!(
657            lim.item,
658            ListItemMarker::Hyphen(Span {
659                data: "-",
660                line: 1,
661                col: 1,
662                offset: 0,
663            },)
664        );
665
666        assert_eq!(
667            lim.after,
668            Span {
669                data: "blah",
670                line: 1,
671                col: 3,
672                offset: 2,
673            }
674        );
675
676        assert_eq!(
677            lim.item.span(),
678            Span {
679                data: "-",
680                line: 1,
681                col: 1,
682                offset: 0,
683            }
684        );
685
686        assert_eq!(
687            format!("{lim:#?}", lim = lim.item),
688            "ListItemMarker::Hyphen(\n    Span {\n        data: \"-\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
689        );
690    }
691
692    #[test]
693    fn asterisks() {
694        assert!(lim_parse("*").is_none());
695        assert!(lim_parse("*- x").is_none());
696
697        let lim = lim_parse("* blah").unwrap();
698
699        assert_eq!(
700            lim.item,
701            ListItemMarker::Asterisks(Span {
702                data: "*",
703                line: 1,
704                col: 1,
705                offset: 0,
706            },)
707        );
708
709        assert_eq!(
710            lim.after,
711            Span {
712                data: "blah",
713                line: 1,
714                col: 3,
715                offset: 2,
716            }
717        );
718
719        assert_eq!(
720            lim.item.span(),
721            Span {
722                data: "*",
723                line: 1,
724                col: 1,
725                offset: 0,
726            }
727        );
728
729        assert_eq!(
730            format!("{lim:#?}", lim = lim.item),
731            "ListItemMarker::Asterisks(\n    Span {\n        data: \"*\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
732        );
733
734        let lim = lim_parse("***** blah").unwrap();
735
736        assert_eq!(
737            lim.item,
738            ListItemMarker::Asterisks(Span {
739                data: "*****",
740                line: 1,
741                col: 1,
742                offset: 0,
743            },)
744        );
745
746        assert_eq!(
747            lim.after,
748            Span {
749                data: "blah",
750                line: 1,
751                col: 7,
752                offset: 6,
753            }
754        );
755
756        assert_eq!(
757            lim.item.span(),
758            Span {
759                data: "*****",
760                line: 1,
761                col: 1,
762                offset: 0,
763            }
764        );
765
766        assert_eq!(
767            format!("{lim:#?}", lim = lim.item),
768            "ListItemMarker::Asterisks(\n    Span {\n        data: \"*****\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
769        );
770    }
771
772    #[test]
773    fn dots() {
774        assert!(lim_parse(".").is_none());
775        assert!(lim_parse(".- x").is_none());
776
777        let lim = lim_parse(". blah").unwrap();
778
779        assert_eq!(
780            lim.item,
781            ListItemMarker::Dots(Span {
782                data: ".",
783                line: 1,
784                col: 1,
785                offset: 0,
786            },)
787        );
788
789        assert_eq!(
790            lim.after,
791            Span {
792                data: "blah",
793                line: 1,
794                col: 3,
795                offset: 2,
796            }
797        );
798
799        assert_eq!(
800            lim.item.span(),
801            Span {
802                data: ".",
803                line: 1,
804                col: 1,
805                offset: 0,
806            }
807        );
808
809        assert_eq!(
810            format!("{lim:#?}", lim = lim.item),
811            "ListItemMarker::Dots(\n    Span {\n        data: \".\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
812        );
813
814        let lim = lim_parse("..... blah").unwrap();
815
816        assert_eq!(
817            lim.item,
818            ListItemMarker::Dots(Span {
819                data: ".....",
820                line: 1,
821                col: 1,
822                offset: 0,
823            },)
824        );
825
826        assert_eq!(
827            lim.after,
828            Span {
829                data: "blah",
830                line: 1,
831                col: 7,
832                offset: 6,
833            }
834        );
835
836        assert_eq!(
837            lim.item.span(),
838            Span {
839                data: ".....",
840                line: 1,
841                col: 1,
842                offset: 0,
843            }
844        );
845
846        assert_eq!(
847            format!("{lim:#?}", lim = lim.item),
848            "ListItemMarker::Dots(\n    Span {\n        data: \".....\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
849        );
850    }
851
852    #[test]
853    fn roman_numeral_lower() {
854        assert!(lim_parse("i").is_none());
855        assert!(lim_parse("i.").is_none());
856
857        let lim = lim_parse("i) blah").unwrap();
858
859        assert_eq!(
860            lim.item,
861            ListItemMarker::RomanNumeralLower(Span {
862                data: "i)",
863                line: 1,
864                col: 1,
865                offset: 0,
866            },)
867        );
868
869        assert_eq!(
870            lim.after,
871            Span {
872                data: "blah",
873                line: 1,
874                col: 4,
875                offset: 3,
876            }
877        );
878
879        assert_eq!(
880            lim.item.span(),
881            Span {
882                data: "i)",
883                line: 1,
884                col: 1,
885                offset: 0,
886            }
887        );
888
889        assert_eq!(
890            format!("{lim:#?}", lim = lim.item),
891            "ListItemMarker::RomanNumeralLower(\n    Span {\n        data: \"i)\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
892        );
893
894        let lim = lim_parse("xvii) blah").unwrap();
895
896        assert_eq!(
897            lim.item,
898            ListItemMarker::RomanNumeralLower(Span {
899                data: "xvii)",
900                line: 1,
901                col: 1,
902                offset: 0,
903            },)
904        );
905
906        assert_eq!(
907            lim.after,
908            Span {
909                data: "blah",
910                line: 1,
911                col: 7,
912                offset: 6,
913            }
914        );
915    }
916
917    #[test]
918    fn roman_numeral_upper() {
919        assert!(lim_parse("I").is_none());
920        assert!(lim_parse("I.").is_none());
921
922        let lim = lim_parse("I) blah").unwrap();
923
924        assert_eq!(
925            lim.item,
926            ListItemMarker::RomanNumeralUpper(Span {
927                data: "I)",
928                line: 1,
929                col: 1,
930                offset: 0,
931            },)
932        );
933
934        assert_eq!(
935            lim.after,
936            Span {
937                data: "blah",
938                line: 1,
939                col: 4,
940                offset: 3,
941            }
942        );
943
944        assert_eq!(
945            lim.item.span(),
946            Span {
947                data: "I)",
948                line: 1,
949                col: 1,
950                offset: 0,
951            }
952        );
953
954        assert_eq!(
955            format!("{lim:#?}", lim = lim.item),
956            "ListItemMarker::RomanNumeralUpper(\n    Span {\n        data: \"I)\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
957        );
958
959        let lim = lim_parse("XVII) blah").unwrap();
960
961        assert_eq!(
962            lim.item,
963            ListItemMarker::RomanNumeralUpper(Span {
964                data: "XVII)",
965                line: 1,
966                col: 1,
967                offset: 0,
968            },)
969        );
970
971        assert_eq!(
972            lim.after,
973            Span {
974                data: "blah",
975                line: 1,
976                col: 7,
977                offset: 6,
978            }
979        );
980    }
981
982    #[test]
983    fn alpha_list_lower() {
984        assert!(lim_parse("a").is_none());
985        assert!(lim_parse("a)").is_none());
986
987        let lim = lim_parse("a. blah").unwrap();
988
989        assert_eq!(
990            lim.item,
991            ListItemMarker::AlphaListLower(Span {
992                data: "a.",
993                line: 1,
994                col: 1,
995                offset: 0,
996            },)
997        );
998
999        assert_eq!(
1000            lim.after,
1001            Span {
1002                data: "blah",
1003                line: 1,
1004                col: 4,
1005                offset: 3,
1006            }
1007        );
1008
1009        assert_eq!(
1010            lim.item.span(),
1011            Span {
1012                data: "a.",
1013                line: 1,
1014                col: 1,
1015                offset: 0,
1016            }
1017        );
1018
1019        assert_eq!(
1020            format!("{lim:#?}", lim = lim.item),
1021            "ListItemMarker::AlphaListLower(\n    Span {\n        data: \"a.\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
1022        );
1023
1024        let lim = lim_parse("x. blah").unwrap();
1025
1026        assert_eq!(
1027            lim.item,
1028            ListItemMarker::AlphaListLower(Span {
1029                data: "x.",
1030                line: 1,
1031                col: 1,
1032                offset: 0,
1033            },)
1034        );
1035
1036        assert_eq!(
1037            lim.after,
1038            Span {
1039                data: "blah",
1040                line: 1,
1041                col: 4,
1042                offset: 3,
1043            }
1044        );
1045    }
1046
1047    #[test]
1048    fn callout() {
1049        // Not callout markers: no leading bracket, no trailing whitespace, or
1050        // a non-numeric/non-dot body.
1051        assert!(lim_parse("1> blah").is_none());
1052        assert!(lim_parse("<1>blah").is_none());
1053        assert!(lim_parse("<1>").is_none());
1054        assert!(lim_parse("<a> blah").is_none());
1055
1056        let lim = lim_parse("<1> blah").unwrap();
1057
1058        assert_eq!(
1059            lim.item,
1060            ListItemMarker::Callout(Span {
1061                data: "<1>",
1062                line: 1,
1063                col: 1,
1064                offset: 0,
1065            },)
1066        );
1067
1068        assert_eq!(
1069            lim.after,
1070            Span {
1071                data: "blah",
1072                line: 1,
1073                col: 5,
1074                offset: 4,
1075            }
1076        );
1077
1078        assert_eq!(
1079            lim.item.span(),
1080            Span {
1081                data: "<1>",
1082                line: 1,
1083                col: 1,
1084                offset: 0,
1085            }
1086        );
1087
1088        // Ordinal helpers do not apply to callout markers.
1089        assert!(lim.item.ordinal_value().is_none());
1090        assert!(lim.item.ordinal_to_marker_text(1).is_none());
1091
1092        // Callout markers of any number match each other (so a single list is
1093        // formed), but not markers of other kinds.
1094        let lim2 = lim_parse("<.> blah").unwrap();
1095        assert_eq!(
1096            lim2.item,
1097            ListItemMarker::Callout(Span {
1098                data: "<.>",
1099                line: 1,
1100                col: 1,
1101                offset: 0,
1102            },)
1103        );
1104        assert!(lim.item.is_match_for(&lim2.item));
1105        assert!(!lim.item.is_match_for(&lim_parse("- blah").unwrap().item));
1106
1107        // An explicit `<N>` marker reports its number; an automatic `<.>`
1108        // marker and any non-callout marker report `None`.
1109        assert_eq!(lim.item.callout_number(), Some(1));
1110        assert!(lim2.item.callout_number().is_none());
1111        assert!(lim_parse("- blah").unwrap().item.callout_number().is_none());
1112
1113        assert_eq!(
1114            format!("{:#?}", lim.item),
1115            "ListItemMarker::Callout(\n    Span {\n        data: \"<1>\",\n        line: 1,\n        col: 1,\n        offset: 0,\n    },\n)"
1116        );
1117
1118        assert!(crate::blocks::ListItemMarker::starts_with_marker(
1119            crate::Span::new("<1> blah")
1120        ));
1121        assert!(!crate::blocks::ListItemMarker::starts_with_marker(
1122            crate::Span::new("1> blah")
1123        ));
1124
1125        // Leading whitespace is discarded consistently for every marker kind,
1126        // matching `parse`.
1127        assert!(crate::blocks::ListItemMarker::starts_with_marker(
1128            crate::Span::new("  <1> blah")
1129        ));
1130        assert!(crate::blocks::ListItemMarker::starts_with_marker(
1131            crate::Span::new("  - blah")
1132        ));
1133    }
1134}