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, and how text the document supplies — a picture's
5//! description, a link's target — has to be written so that it stays inside the construct it
6//! belongs to, are not: they are properties of CommonMark, and a writer that gets them wrong
7//! prints its syntax as text. This module answers those questions once so the writers cannot
8//! drift on them.
9//!
10//! `std`-only, like the rest of the crate root.
11
12use std::ops::Range;
13
14/// The part of a styled run that emphasis delimiters (`*`, `**`, `~~`) can wrap, given the
15/// characters the run lands between, or `None` when no part can.
16///
17/// `before` is the last character already written, `after` the first one the next piece will
18/// write (`None` at either end of the inline content). The text outside the returned range is
19/// written as-is, outside the delimiters.
20///
21/// CommonMark reads a delimiter as emphasis only when it *flanks* the text (§6.2): it may not
22/// touch whitespace on its inside, and when its inside is punctuation its outside must be
23/// whitespace, punctuation, or the end of the line. So:
24///
25/// - leading and trailing whitespace always stays outside — `**word **` is not emphasis;
26/// - leading or trailing punctuation stays outside too, with any whitespace behind it, when
27///   the run touches a letter or digit on that side — an italic `", s"` after `32` written as
28///   `32*, s*` shows its asterisks, written as `32, *s*` it does not;
29/// - a run that is nothing but whitespace, or nothing but punctuation between words, has
30///   nothing to wrap.
31///
32/// Punctuation that does not touch a word stays inside (`*i.e.,*`, `*(h)*`), so a run is not
33/// reshaped unless it has to be.
34///
35/// `text` may already be escaped: a backslash escape (`\*`) is one unit and is never split
36/// across a delimiter. Only escapes of ASCII punctuation are units, as in CommonMark.
37///
38/// ```
39/// use unparser_shared::markdown::emphasis_span;
40///
41/// let wrap = |text: &str, before, after| {
42///     emphasis_span(text, before, after).map_or(text.to_string(), |r| {
43///         format!("{}*{}*{}", &text[..r.start], &text[r.clone()], &text[r.end..])
44///     })
45/// };
46/// assert_eq!(wrap(", s", Some('2'), Some(' ')), ", *s*");
47/// assert_eq!(wrap("word ", None, Some('x')), "*word* ");
48/// assert_eq!(wrap(",", Some('8'), Some('a')), ",");
49/// assert_eq!(wrap("(h)", Some(' '), None), "*(h)*");
50/// ```
51pub fn emphasis_span(
52    text: &str,
53    before: Option<char>,
54    after: Option<char>,
55) -> Option<Range<usize>> {
56    let mut start = text.len() - text.trim_start().len();
57    let mut end = text.trim_end().len();
58    if start >= end {
59        return None;
60    }
61    let touches_a_word = |c: Option<char>| c.is_some_and(|c| !is_flanking_neutral(c));
62    if start == 0 && touches_a_word(before) {
63        start = units(text, start, end)
64            .find(|&(i, j)| !unit_is_neutral(&text[i..j]))
65            .map_or(end, |(i, _)| i);
66    }
67    if end == text.len() && touches_a_word(after) {
68        end = units(text, start, end)
69            .filter(|&(i, j)| !unit_is_neutral(&text[i..j]))
70            .last()
71            .map_or(start, |(_, j)| j);
72    }
73    (start < end).then_some(start..end)
74}
75
76/// A picture as an inline image: `![alt](destination)`.
77///
78/// The alt text is data from the document — whatever a person typed into a description box:
79/// several lines, blank lines, brackets. Written verbatim, a blank line ends the paragraph and
80/// the image reads back as literal text plus a stray paragraph, and an unbalanced `]` ends the
81/// link text early. So the alt text is flattened to one line — every run of whitespace becomes
82/// one space, the ends trimmed — and the characters that are syntax inside link text are
83/// escaped: `\`, `[`, `]`, `` ` `` (a code span would swallow the closing bracket), `<` (raw
84/// HTML or an autolink), and an `&` that would start a character reference (`&amp;` would read
85/// back as `&`). Inside a table cell `|` is escaped too, since it would end the cell. `*` and
86/// `_` are left alone: unmatched they are literal, matched they only style the text, and
87/// neither can end the image.
88///
89/// The destination is written by [`link_destination`].
90///
91/// ```
92/// use unparser_shared::markdown::image;
93///
94/// assert_eq!(
95///     image("A view\n\nof the harbour", "img/1.png", false),
96///     "![A view of the harbour](img/1.png)"
97/// );
98/// assert_eq!(image("a [draft]", "my dir/a.png", false), r"![a \[draft\]](<my dir/a.png>)");
99/// assert_eq!(image("x | y", "a.png", true), r"![x \| y](a.png)");
100/// ```
101pub fn image(alt: &str, destination: &str, in_table_cell: bool) -> String {
102    let mut out = String::with_capacity(alt.len() + destination.len() + 5);
103    out.push_str("![");
104    for (n, word) in alt.split_whitespace().enumerate() {
105        if n > 0 {
106            out.push(' ');
107        }
108        for (i, c) in word.char_indices() {
109            match c {
110                '\\' | '[' | ']' | '`' | '<' => {
111                    out.push('\\');
112                    out.push(c);
113                }
114                '&' if starts_character_reference(&word[i + 1..]) => out.push_str("\\&"),
115                '|' if in_table_cell => out.push_str("\\|"),
116                _ => out.push(c),
117            }
118        }
119    }
120    out.push_str("](");
121    out.push_str(&link_destination(destination, in_table_cell));
122    out.push(')');
123    out
124}
125
126/// An inline link: `[label](destination)`, or `[label](destination "title")`.
127///
128/// `label` is inline Markdown the writer has already rendered — its text escaped and styled
129/// the writer's own way (`**bold**`, `<u>…</u>`, a code span). What this adds is what keeps
130/// that Markdown *inside the link*, which no display setting may turn off:
131///
132/// - an unescaped `[` or `]` is escaped — an unbalanced `]` ends the link text early, and a
133///   `[x](y)` inside it would be a second link, which CommonMark does not nest;
134/// - a line ending becomes a space, so a blank line cannot end the paragraph mid-link;
135/// - a backslash at the very end is doubled, since it would otherwise escape the closing `]`;
136/// - inside a table cell, an unescaped `|` is escaped, code spans included — a GFM table
137///   splits its cells before it reads anything else.
138///
139/// Code spans are left as they are (apart from that `|`): a code span binds more tightly than
140/// the link's brackets, so a `]` inside one does not end the link, and a backslash inside one
141/// would be printed. Backslash escapes the label already carries (`\*`, `\[`) are kept as
142/// they are. The label is not expected to hold a link, an image, an autolink or raw HTML with
143/// a bracket in it.
144///
145/// The destination is written by [`link_destination`]. The title, when there is one and it is
146/// not empty, is written in double quotes: `"` is escaped, a backslash that would escape the
147/// character after it is doubled, an `&` that would start a character reference is escaped,
148/// a line ending becomes a space, and inside a table cell `|` is escaped.
149///
150/// ```
151/// use unparser_shared::markdown::link;
152///
153/// assert_eq!(link("see [3]", "https://example.com", None, false), r"[see \[3\]](https://example.com)");
154/// assert_eq!(link("**bold** `a]b`", "u", None, false), "[**bold** `a]b`](u)");
155/// assert_eq!(link("x", "u", Some(r#"say "hi""#), false), r#"[x](u "say \"hi\"")"#);
156/// assert_eq!(link("a | b", "u", None, true), r"[a \| b](u)");
157/// ```
158pub fn link(label: &str, destination: &str, title: Option<&str>, in_table_cell: bool) -> String {
159    let mut out = String::with_capacity(label.len() + destination.len() + 4);
160    out.push('[');
161    push_link_label(&mut out, label, in_table_cell);
162    out.push_str("](");
163    out.push_str(&link_destination(destination, in_table_cell));
164    if let Some(title) = title.filter(|t| !t.is_empty()) {
165        out.push_str(" \"");
166        let mut chars = title.char_indices().peekable();
167        while let Some((i, c)) = chars.next() {
168            match c {
169                '\\' if chars.peek().is_none_or(|&(_, n)| n.is_ascii_punctuation()) => {
170                    out.push_str("\\\\")
171                }
172                '"' => out.push_str("\\\""),
173                '&' if starts_character_reference(&title[i + 1..]) => out.push_str("\\&"),
174                '|' if in_table_cell => out.push_str("\\|"),
175                '\n' | '\r' => out.push(' '),
176                _ => out.push(c),
177            }
178        }
179        out.push('"');
180    }
181    out.push(')');
182    out
183}
184
185/// Append `label` as link text — see [`link`] for what is escaped and what is kept.
186fn push_link_label(out: &mut String, label: &str, in_table_cell: bool) {
187    let chars: Vec<char> = label.chars().collect();
188    let mut i = 0;
189    while i < chars.len() {
190        let c = chars[i];
191        match c {
192            '\\' => match chars.get(i + 1) {
193                // An escape the label already carries: one unit, kept.
194                Some(&n) if n.is_ascii_punctuation() => {
195                    out.push('\\');
196                    out.push(n);
197                    i += 2;
198                    continue;
199                }
200                None => out.push_str("\\\\"),
201                Some(_) => out.push('\\'),
202            },
203            '`' => {
204                let run = chars[i..].iter().take_while(|&&b| b == '`').count();
205                if let Some(close) = closing_backticks(&chars, i + run, run) {
206                    // A code span, written as it is: no escapes inside, but a GFM cell
207                    // still splits on its `|`.
208                    for (k, &s) in chars[i..close + run].iter().enumerate() {
209                        match s {
210                            '\n' | '\r' => out.push(' '),
211                            '|' if in_table_cell && (k == 0 || chars[i + k - 1] != '\\') => {
212                                out.push_str("\\|")
213                            }
214                            _ => out.push(s),
215                        }
216                    }
217                    i = close + run;
218                } else {
219                    // No closing run of the same length: the backticks are literal.
220                    out.extend(std::iter::repeat_n('`', run));
221                    i += run;
222                }
223                continue;
224            }
225            '[' | ']' => {
226                out.push('\\');
227                out.push(c);
228            }
229            '|' if in_table_cell => out.push_str("\\|"),
230            '\n' | '\r' => out.push(' '),
231            _ => out.push(c),
232        }
233        i += 1;
234    }
235}
236
237/// Where the backtick run of exactly `len` that closes a code span opened before `from`
238/// starts, if there is one.
239fn closing_backticks(chars: &[char], from: usize, len: usize) -> Option<usize> {
240    let mut j = from;
241    while j < chars.len() {
242        if chars[j] == '`' {
243            let run = chars[j..].iter().take_while(|&&b| b == '`').count();
244            if run == len {
245                return Some(j);
246            }
247            j += run;
248        } else {
249            j += 1;
250        }
251    }
252    None
253}
254
255/// A link or image destination, written so that CommonMark reads back exactly `url` (§6.3).
256///
257/// The bare form is used when it can carry the URL: no spaces, no ASCII control characters,
258/// no `<` or `>`, and parentheses that balance — an unbalanced `)` would end the destination
259/// early, a `(` would leave it open. Anything else is written in the pointy-bracket form
260/// `<...>`, which takes spaces and any parentheses. A line ending cannot stand in either form;
261/// it is percent-encoded (`%0A`, `%0D`), the one place the URL is changed rather than
262/// escaped. In both forms a backslash that would otherwise escape the character after it — an
263/// ASCII punctuation character, or the end of the destination — is doubled; other backslashes
264/// (`C:\dir`) are literal and stay as they are. An `&` that would start a character reference
265/// is escaped, so `?a=1&amp;b` stays as written. Inside a table cell `|` is escaped, since a
266/// GFM table splits its cells before it reads links. An empty URL is `<>`.
267///
268/// A link that is not in a table cell passes `in_table_cell: false`.
269///
270/// ```
271/// use unparser_shared::markdown::link_destination;
272///
273/// assert_eq!(link_destination("https://example.com/a(b)", false), "https://example.com/a(b)");
274/// assert_eq!(link_destination("my folder/file.png", false), "<my folder/file.png>");
275/// assert_eq!(link_destination("notes).txt", false), "<notes).txt>");
276/// assert_eq!(link_destination("a<b>c", false), r"<a\<b\>c>");
277/// ```
278pub fn link_destination(url: &str, in_table_cell: bool) -> String {
279    let bare = !url.is_empty()
280        && !url
281            .chars()
282            .any(|c| c == ' ' || c == '<' || c == '>' || c.is_ascii_control())
283        && parentheses_balance(url);
284    let mut out = String::with_capacity(url.len() + 2);
285    if !bare {
286        out.push('<');
287    }
288    let mut chars = url.char_indices().peekable();
289    while let Some((i, c)) = chars.next() {
290        match c {
291            // Judged on what is written next: a line ending goes out as `%`.
292            '\\' if chars
293                .peek()
294                .is_none_or(|&(_, n)| n.is_ascii_punctuation() || n == '\n' || n == '\r') =>
295            {
296                out.push_str("\\\\")
297            }
298            '&' if starts_character_reference(&url[i + 1..]) => out.push_str("\\&"),
299            '<' | '>' => {
300                out.push('\\');
301                out.push(c);
302            }
303            '|' if in_table_cell => out.push_str("\\|"),
304            '\n' => out.push_str("%0A"),
305            '\r' => out.push_str("%0D"),
306            _ => out.push(c),
307        }
308    }
309    if !bare {
310        out.push('>');
311    }
312    out
313}
314
315/// Whether the text after an `&` makes it a character reference (§6.2) — `name;`, `#123;` or
316/// `#x1F;` — which CommonMark would replace by the character it names.
317fn starts_character_reference(rest: &str) -> bool {
318    let Some(end) = rest.find(';') else {
319        return false;
320    };
321    let body = &rest[..end];
322    if let Some(number) = body.strip_prefix('#') {
323        return match number.strip_prefix(['x', 'X']) {
324            Some(hex) => (1..=6).contains(&hex.len()) && hex.chars().all(|c| c.is_ascii_hexdigit()),
325            None => (1..=7).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit()),
326        };
327    }
328    body.starts_with(|c: char| c.is_ascii_alphabetic())
329        && (2..=32).contains(&body.len())
330        && body.chars().all(|c| c.is_ascii_alphanumeric())
331}
332
333/// Whether every `)` closes an earlier `(` and every `(` is closed. A backslash in the URL
334/// does not shield a parenthesis: [`link_destination`] writes it as a literal backslash.
335fn parentheses_balance(url: &str) -> bool {
336    let mut depth = 0usize;
337    for c in url.chars() {
338        match c {
339            '(' => depth += 1,
340            ')' => match depth.checked_sub(1) {
341                Some(d) => depth = d,
342                None => return false,
343            },
344            _ => {}
345        }
346    }
347    depth == 0
348}
349
350/// Whitespace or punctuation — what a delimiter may touch on its outside when its inside is
351/// punctuation. CommonMark's punctuation is the Unicode P and S categories; a character that
352/// is neither alphanumeric, whitespace nor a control is one of them, save for combining
353/// marks, which do not stand at a run's edge.
354fn is_flanking_neutral(c: char) -> bool {
355    c.is_whitespace() || !(c.is_alphanumeric() || c.is_control())
356}
357
358/// A unit is neutral when the character it writes is: an escape writes punctuation.
359fn unit_is_neutral(unit: &str) -> bool {
360    unit.chars().last().is_some_and(is_flanking_neutral)
361}
362
363/// The byte ranges of `text[start..end]`'s units: one character, or a backslash and the ASCII
364/// punctuation it escapes.
365fn units(text: &str, start: usize, end: usize) -> impl Iterator<Item = (usize, usize)> + '_ {
366    let mut chars = text[start..end].char_indices().peekable();
367    std::iter::from_fn(move || {
368        let (i, c) = chars.next()?;
369        let mut j = i + c.len_utf8();
370        if c == '\\' {
371            if let Some(&(k, escaped)) = chars.peek() {
372                if escaped.is_ascii_punctuation() {
373                    j = k + escaped.len_utf8();
374                    chars.next();
375                }
376            }
377        }
378        Some((start + i, start + j))
379    })
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    /// `text` with `*` around its emphasis span.
387    fn wrap(text: &str, before: Option<char>, after: Option<char>) -> String {
388        emphasis_span(text, before, after).map_or(text.to_string(), |r| {
389            format!(
390                "{}*{}*{}",
391                &text[..r.start],
392                &text[r.clone()],
393                &text[r.end..]
394            )
395        })
396    }
397
398    #[test]
399    fn whitespace_stays_outside() {
400        assert_eq!(wrap("  word  ", Some('a'), Some('b')), "  *word*  ");
401        assert_eq!(emphasis_span("   ", None, None), None);
402        assert_eq!(emphasis_span("", None, None), None);
403    }
404
405    #[test]
406    fn leading_punctuation_against_a_word_moves_out_with_its_space() {
407        assert_eq!(wrap(", s", Some('2'), Some(' ')), ", *s*");
408    }
409
410    #[test]
411    fn trailing_punctuation_against_a_word_moves_out() {
412        assert_eq!(wrap("word.", Some(' '), Some('x')), "*word*.");
413        assert_eq!(wrap("word, ", Some(' '), Some('x')), "*word,* ");
414    }
415
416    #[test]
417    fn punctuation_between_words_alone_has_nothing_to_wrap() {
418        assert_eq!(emphasis_span(",", Some('8'), Some('a')), None);
419        assert_eq!(emphasis_span(", ", Some('8'), None), None);
420    }
421
422    #[test]
423    fn punctuation_against_space_punctuation_or_an_edge_stays_inside() {
424        assert_eq!(wrap("i.e.,", Some(' '), Some(' ')), "*i.e.,*");
425        assert_eq!(wrap("(h)", None, None), "*(h)*");
426        assert_eq!(wrap("(h)", Some('('), Some(')')), "*(h)*");
427        assert_eq!(
428            wrap("Continued pretraining.", None, Some(' ')),
429            "*Continued pretraining.*"
430        );
431    }
432
433    #[test]
434    fn a_word_on_either_side_moves_both_ends() {
435        assert_eq!(wrap("(h)", Some('Q'), Some('x')), "(*h*)");
436    }
437
438    #[test]
439    fn an_escape_is_never_split() {
440        assert_eq!(wrap(r"\*x\_", Some('a'), Some('b')), r"\**x*\_");
441    }
442
443    #[test]
444    fn a_backslash_before_a_letter_is_not_an_escape() {
445        assert_eq!(wrap(r"\a", Some('b'), None), r"\*a*");
446    }
447
448    #[test]
449    fn non_ascii_punctuation_and_letters_count() {
450        assert_eq!(wrap("“word”", Some('x'), Some('y')), "“*word*”");
451        assert_eq!(wrap("단어", Some('가'), Some('나')), "*단어*");
452    }
453}