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}