Skip to main content

unparser_shared/
markdown.rs

1//! CommonMark syntax facts every Markdown writer in the family needs.
2//!
3//! Each library renders its own document model, and how it styles a run is its own business.
4//! *Where* an emphasis delimiter may stand is not — it is a property of CommonMark, and a
5//! writer that gets it wrong prints its asterisks as text. This module answers that one
6//! question so the three writers cannot drift on it.
7//!
8//! `std`-only, like the rest of the crate root.
9
10use std::ops::Range;
11
12/// The part of a styled run that emphasis delimiters (`*`, `**`, `~~`) can wrap, given the
13/// characters the run lands between, or `None` when no part can.
14///
15/// `before` is the last character already written, `after` the first one the next piece will
16/// write (`None` at either end of the inline content). The text outside the returned range is
17/// written as-is, outside the delimiters.
18///
19/// CommonMark reads a delimiter as emphasis only when it *flanks* the text (§6.2): it may not
20/// touch whitespace on its inside, and when its inside is punctuation its outside must be
21/// whitespace, punctuation, or the end of the line. So:
22///
23/// - leading and trailing whitespace always stays outside — `**word **` is not emphasis;
24/// - leading or trailing punctuation stays outside too, with any whitespace behind it, when
25///   the run touches a letter or digit on that side — an italic `", s"` after `32` written as
26///   `32*, s*` shows its asterisks, written as `32, *s*` it does not;
27/// - a run that is nothing but whitespace, or nothing but punctuation between words, has
28///   nothing to wrap.
29///
30/// Punctuation that does not touch a word stays inside (`*i.e.,*`, `*(h)*`), so a run is not
31/// reshaped unless it has to be.
32///
33/// `text` may already be escaped: a backslash escape (`\*`) is one unit and is never split
34/// across a delimiter. Only escapes of ASCII punctuation are units, as in CommonMark.
35///
36/// ```
37/// use unparser_shared::markdown::emphasis_span;
38///
39/// let wrap = |text: &str, before, after| {
40///     emphasis_span(text, before, after).map_or(text.to_string(), |r| {
41///         format!("{}*{}*{}", &text[..r.start], &text[r.clone()], &text[r.end..])
42///     })
43/// };
44/// assert_eq!(wrap(", s", Some('2'), Some(' ')), ", *s*");
45/// assert_eq!(wrap("word ", None, Some('x')), "*word* ");
46/// assert_eq!(wrap(",", Some('8'), Some('a')), ",");
47/// assert_eq!(wrap("(h)", Some(' '), None), "*(h)*");
48/// ```
49pub fn emphasis_span(
50    text: &str,
51    before: Option<char>,
52    after: Option<char>,
53) -> Option<Range<usize>> {
54    let mut start = text.len() - text.trim_start().len();
55    let mut end = text.trim_end().len();
56    if start >= end {
57        return None;
58    }
59    let touches_a_word = |c: Option<char>| c.is_some_and(|c| !is_flanking_neutral(c));
60    if start == 0 && touches_a_word(before) {
61        start = units(text, start, end)
62            .find(|&(i, j)| !unit_is_neutral(&text[i..j]))
63            .map_or(end, |(i, _)| i);
64    }
65    if end == text.len() && touches_a_word(after) {
66        end = units(text, start, end)
67            .filter(|&(i, j)| !unit_is_neutral(&text[i..j]))
68            .last()
69            .map_or(start, |(_, j)| j);
70    }
71    (start < end).then_some(start..end)
72}
73
74/// Whitespace or punctuation — what a delimiter may touch on its outside when its inside is
75/// punctuation. CommonMark's punctuation is the Unicode P and S categories; a character that
76/// is neither alphanumeric, whitespace nor a control is one of them, save for combining
77/// marks, which do not stand at a run's edge.
78fn is_flanking_neutral(c: char) -> bool {
79    c.is_whitespace() || !(c.is_alphanumeric() || c.is_control())
80}
81
82/// A unit is neutral when the character it writes is: an escape writes punctuation.
83fn unit_is_neutral(unit: &str) -> bool {
84    unit.chars().last().is_some_and(is_flanking_neutral)
85}
86
87/// The byte ranges of `text[start..end]`'s units: one character, or a backslash and the ASCII
88/// punctuation it escapes.
89fn units(text: &str, start: usize, end: usize) -> impl Iterator<Item = (usize, usize)> + '_ {
90    let mut chars = text[start..end].char_indices().peekable();
91    std::iter::from_fn(move || {
92        let (i, c) = chars.next()?;
93        let mut j = i + c.len_utf8();
94        if c == '\\' {
95            if let Some(&(k, escaped)) = chars.peek() {
96                if escaped.is_ascii_punctuation() {
97                    j = k + escaped.len_utf8();
98                    chars.next();
99                }
100            }
101        }
102        Some((start + i, start + j))
103    })
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    /// `text` with `*` around its emphasis span.
111    fn wrap(text: &str, before: Option<char>, after: Option<char>) -> String {
112        emphasis_span(text, before, after).map_or(text.to_string(), |r| {
113            format!(
114                "{}*{}*{}",
115                &text[..r.start],
116                &text[r.clone()],
117                &text[r.end..]
118            )
119        })
120    }
121
122    #[test]
123    fn whitespace_stays_outside() {
124        assert_eq!(wrap("  word  ", Some('a'), Some('b')), "  *word*  ");
125        assert_eq!(emphasis_span("   ", None, None), None);
126        assert_eq!(emphasis_span("", None, None), None);
127    }
128
129    #[test]
130    fn leading_punctuation_against_a_word_moves_out_with_its_space() {
131        assert_eq!(wrap(", s", Some('2'), Some(' ')), ", *s*");
132    }
133
134    #[test]
135    fn trailing_punctuation_against_a_word_moves_out() {
136        assert_eq!(wrap("word.", Some(' '), Some('x')), "*word*.");
137        assert_eq!(wrap("word, ", Some(' '), Some('x')), "*word,* ");
138    }
139
140    #[test]
141    fn punctuation_between_words_alone_has_nothing_to_wrap() {
142        assert_eq!(emphasis_span(",", Some('8'), Some('a')), None);
143        assert_eq!(emphasis_span(", ", Some('8'), None), None);
144    }
145
146    #[test]
147    fn punctuation_against_space_punctuation_or_an_edge_stays_inside() {
148        assert_eq!(wrap("i.e.,", Some(' '), Some(' ')), "*i.e.,*");
149        assert_eq!(wrap("(h)", None, None), "*(h)*");
150        assert_eq!(wrap("(h)", Some('('), Some(')')), "*(h)*");
151        assert_eq!(
152            wrap("Continued pretraining.", None, Some(' ')),
153            "*Continued pretraining.*"
154        );
155    }
156
157    #[test]
158    fn a_word_on_either_side_moves_both_ends() {
159        assert_eq!(wrap("(h)", Some('Q'), Some('x')), "(*h*)");
160    }
161
162    #[test]
163    fn an_escape_is_never_split() {
164        assert_eq!(wrap(r"\*x\_", Some('a'), Some('b')), r"\**x*\_");
165    }
166
167    #[test]
168    fn a_backslash_before_a_letter_is_not_an_escape() {
169        assert_eq!(wrap(r"\a", Some('b'), None), r"\*a*");
170    }
171
172    #[test]
173    fn non_ascii_punctuation_and_letters_count() {
174        assert_eq!(wrap("“word”", Some('x'), Some('y')), "“*word*”");
175        assert_eq!(wrap("단어", Some('가'), Some('나')), "*단어*");
176    }
177}