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/// A code span holding `text` exactly as given: CommonMark reads it back as `text`, every line
127/// ending in it read as one space.
128///
129/// Nothing is escaped inside a code span — a backslash there is printed (CommonMark §6.1), so
130/// the text is written as it is and the span is fenced instead:
131///
132/// - the fence is one backtick longer than the longest run of backticks in the text, so no run
133///   inside can close it;
134/// - one space pads each side when the text starts or ends with a backtick (it would join the
135///   fence) or both starts and ends with a space (a reader strips one from each side) — the
136///   reader strips the padding again;
137/// - a line ending becomes a space, so a blank line cannot end the paragraph mid-span;
138/// - inside a table cell `|` is escaped — a GFM table splits its cells before it reads code
139///   spans, and reads `\|` back as `|` inside them.
140///
141/// Empty text has no code span and gives an empty string.
142///
143/// ```
144/// use unparser_shared::markdown::code_span;
145///
146/// assert_eq!(code_span(r"C:\dir", false), r"`C:\dir`");
147/// assert_eq!(code_span("a`b", false), "``a`b``");
148/// assert_eq!(code_span("`x`", false), "`` `x` ``");
149/// assert_eq!(code_span(" a ", false), "`  a  `");
150/// assert_eq!(code_span("a | b", true), r"`a \| b`");
151/// assert_eq!(code_span("", false), "");
152/// ```
153pub fn code_span(text: &str, in_table_cell: bool) -> String {
154    if text.is_empty() {
155        return String::new();
156    }
157    let text = text.replace("\r\n", " ").replace(['\r', '\n'], " ");
158    let longest_run = text.split(|c| c != '`').map(str::len).max().unwrap_or(0);
159    let fence = "`".repeat(longest_run + 1);
160    let pad = text.starts_with('`')
161        || text.ends_with('`')
162        || (text.starts_with(' ')
163            && text.ends_with(' ')
164            && !text.trim_start_matches(' ').is_empty());
165    let pad = if pad { " " } else { "" };
166    let body = if in_table_cell {
167        text.replace('|', "\\|")
168    } else {
169        text
170    };
171    format!("{fence}{pad}{body}{pad}{fence}")
172}
173
174/// An inline link: `[label](destination)`, or `[label](destination "title")`.
175///
176/// `label` is inline Markdown the writer has already rendered — its text escaped and styled
177/// the writer's own way (`**bold**`, `<u>…</u>`, a code span). What this adds is what keeps
178/// that Markdown *inside the link*, which no display setting may turn off:
179///
180/// - an unescaped `[` or `]` is escaped — an unbalanced `]` ends the link text early, and a
181///   `[x](y)` inside it would be a second link, which CommonMark does not nest;
182/// - a line ending becomes a space, so a blank line cannot end the paragraph mid-link;
183/// - a backslash at the very end is doubled, since it would otherwise escape the closing `]`;
184/// - inside a table cell, an unescaped `|` is escaped, code spans included — a GFM table
185///   splits its cells before it reads anything else.
186///
187/// Code spans are left as they are (apart from that `|`): a code span binds more tightly than
188/// the link's brackets, so a `]` inside one does not end the link, and a backslash inside one
189/// would be printed. Backslash escapes the label already carries (`\*`, `\[`) are kept as
190/// they are. The label is not expected to hold a link, an image, an autolink or raw HTML with
191/// a bracket in it.
192///
193/// The destination is written by [`link_destination`]. The title, when there is one and it is
194/// not empty, is written in double quotes: `"` is escaped, a backslash that would escape the
195/// character after it is doubled, an `&` that would start a character reference is escaped,
196/// a line ending becomes a space, and inside a table cell `|` is escaped.
197///
198/// ```
199/// use unparser_shared::markdown::link;
200///
201/// assert_eq!(link("see [3]", "https://example.com", None, false), r"[see \[3\]](https://example.com)");
202/// assert_eq!(link("**bold** `a]b`", "u", None, false), "[**bold** `a]b`](u)");
203/// assert_eq!(link("x", "u", Some(r#"say "hi""#), false), r#"[x](u "say \"hi\"")"#);
204/// assert_eq!(link("a | b", "u", None, true), r"[a \| b](u)");
205/// ```
206pub fn link(label: &str, destination: &str, title: Option<&str>, in_table_cell: bool) -> String {
207    let mut out = String::with_capacity(label.len() + destination.len() + 4);
208    out.push('[');
209    push_link_label(&mut out, label, in_table_cell);
210    out.push_str("](");
211    out.push_str(&link_destination(destination, in_table_cell));
212    if let Some(title) = title.filter(|t| !t.is_empty()) {
213        out.push_str(" \"");
214        let mut chars = title.char_indices().peekable();
215        while let Some((i, c)) = chars.next() {
216            match c {
217                '\\' if chars.peek().is_none_or(|&(_, n)| n.is_ascii_punctuation()) => {
218                    out.push_str("\\\\")
219                }
220                '"' => out.push_str("\\\""),
221                '&' if starts_character_reference(&title[i + 1..]) => out.push_str("\\&"),
222                '|' if in_table_cell => out.push_str("\\|"),
223                '\n' | '\r' => out.push(' '),
224                _ => out.push(c),
225            }
226        }
227        out.push('"');
228    }
229    out.push(')');
230    out
231}
232
233/// Append `label` as link text — see [`link`] for what is escaped and what is kept.
234fn push_link_label(out: &mut String, label: &str, in_table_cell: bool) {
235    let chars: Vec<char> = label.chars().collect();
236    let mut i = 0;
237    while i < chars.len() {
238        let c = chars[i];
239        match c {
240            '\\' => match chars.get(i + 1) {
241                // An escape the label already carries: one unit, kept.
242                Some(&n) if n.is_ascii_punctuation() => {
243                    out.push('\\');
244                    out.push(n);
245                    i += 2;
246                    continue;
247                }
248                None => out.push_str("\\\\"),
249                Some(_) => out.push('\\'),
250            },
251            '`' => {
252                let run = chars[i..].iter().take_while(|&&b| b == '`').count();
253                if let Some(close) = closing_backticks(&chars, i + run, run) {
254                    // A code span, written as it is: no escapes inside, but a GFM cell
255                    // still splits on its `|`.
256                    for (k, &s) in chars[i..close + run].iter().enumerate() {
257                        match s {
258                            '\n' | '\r' => out.push(' '),
259                            '|' if in_table_cell && (k == 0 || chars[i + k - 1] != '\\') => {
260                                out.push_str("\\|")
261                            }
262                            _ => out.push(s),
263                        }
264                    }
265                    i = close + run;
266                } else {
267                    // No closing run of the same length: the backticks are literal.
268                    out.extend(std::iter::repeat_n('`', run));
269                    i += run;
270                }
271                continue;
272            }
273            '[' | ']' => {
274                out.push('\\');
275                out.push(c);
276            }
277            '|' if in_table_cell => out.push_str("\\|"),
278            '\n' | '\r' => out.push(' '),
279            _ => out.push(c),
280        }
281        i += 1;
282    }
283}
284
285/// Where the backtick run of exactly `len` that closes a code span opened before `from`
286/// starts, if there is one.
287fn closing_backticks(chars: &[char], from: usize, len: usize) -> Option<usize> {
288    let mut j = from;
289    while j < chars.len() {
290        if chars[j] == '`' {
291            let run = chars[j..].iter().take_while(|&&b| b == '`').count();
292            if run == len {
293                return Some(j);
294            }
295            j += run;
296        } else {
297            j += 1;
298        }
299    }
300    None
301}
302
303/// A link or image destination, written so that CommonMark reads back exactly `url` (§6.3).
304///
305/// The bare form is used when it can carry the URL: no spaces, no ASCII control characters,
306/// no `<` or `>`, and parentheses that balance — an unbalanced `)` would end the destination
307/// early, a `(` would leave it open. Anything else is written in the pointy-bracket form
308/// `<...>`, which takes spaces and any parentheses. A line ending cannot stand in either form;
309/// it is percent-encoded (`%0A`, `%0D`), the one place the URL is changed rather than
310/// escaped. In both forms a backslash that would otherwise escape the character after it — an
311/// ASCII punctuation character, or the end of the destination — is doubled; other backslashes
312/// (`C:\dir`) are literal and stay as they are. An `&` that would start a character reference
313/// is escaped, so `?a=1&amp;b` stays as written. Inside a table cell `|` is escaped, since a
314/// GFM table splits its cells before it reads links. An empty URL is `<>`.
315///
316/// A link that is not in a table cell passes `in_table_cell: false`.
317///
318/// ```
319/// use unparser_shared::markdown::link_destination;
320///
321/// assert_eq!(link_destination("https://example.com/a(b)", false), "https://example.com/a(b)");
322/// assert_eq!(link_destination("my folder/file.png", false), "<my folder/file.png>");
323/// assert_eq!(link_destination("notes).txt", false), "<notes).txt>");
324/// assert_eq!(link_destination("a<b>c", false), r"<a\<b\>c>");
325/// ```
326pub fn link_destination(url: &str, in_table_cell: bool) -> String {
327    let bare = !url.is_empty()
328        && !url
329            .chars()
330            .any(|c| c == ' ' || c == '<' || c == '>' || c.is_ascii_control())
331        && parentheses_balance(url);
332    let mut out = String::with_capacity(url.len() + 2);
333    if !bare {
334        out.push('<');
335    }
336    let mut chars = url.char_indices().peekable();
337    while let Some((i, c)) = chars.next() {
338        match c {
339            // Judged on what is written next: a line ending goes out as `%`.
340            '\\' if chars
341                .peek()
342                .is_none_or(|&(_, n)| n.is_ascii_punctuation() || n == '\n' || n == '\r') =>
343            {
344                out.push_str("\\\\")
345            }
346            '&' if starts_character_reference(&url[i + 1..]) => out.push_str("\\&"),
347            '<' | '>' => {
348                out.push('\\');
349                out.push(c);
350            }
351            '|' if in_table_cell => out.push_str("\\|"),
352            '\n' => out.push_str("%0A"),
353            '\r' => out.push_str("%0D"),
354            _ => out.push(c),
355        }
356    }
357    if !bare {
358        out.push('>');
359    }
360    out
361}
362
363/// Whether the text after an `&` makes it a character reference (§6.2) — `name;`, `#123;` or
364/// `#x1F;` — which CommonMark would replace by the character it names.
365/// `text` with whatever would open a block at the start of one of its lines escaped, so that
366/// prose written into a paragraph or a list item reads back as that prose.
367///
368/// A writer puts its own block markers in front of the text — `# ` for a heading it means,
369/// `1. ` for a list it means. Text the document supplies is not supposed to add any, but a
370/// paragraph whose text starts `1. `, `* `, `# ` or `> ` is, written as is, a list item, a
371/// heading or a quote (CommonMark §4–5). At the start of each line, after at most three
372/// spaces, this escapes the first character of:
373///
374/// - an ATX heading opener: one to six `#` followed by a space, a tab or the line's end;
375/// - a bullet list marker: `-`, `+` or `*` followed by a space, a tab or the line's end;
376/// - an ordered list marker: one to nine digits and `.` or `)`, followed likewise (the
377///   delimiter is escaped: `1\.`);
378/// - a block quote marker: `>`;
379/// - a code fence: three or more `` ` `` or `~`;
380/// - a thematic break or setext heading underline: a line of `-`, `*`, `_` (spaces between
381///   allowed) or of `=` alone.
382///
383/// A line indented four spaces or more is left alone — a paragraph continues through it, and
384/// a writer has already removed indentation from a paragraph's first line. A character that
385/// is already escaped (`\*`) opens nothing and is left as it is. Not for table cells, where
386/// no block can start.
387///
388/// ```
389/// use unparser_shared::markdown::escape_block_starts;
390///
391/// assert_eq!(escape_block_starts("1. A sentence that starts with a number"), r"1\. A sentence that starts with a number");
392/// assert_eq!(escape_block_starts("# not a heading"), r"\# not a heading");
393/// assert_eq!(escape_block_starts("* note\n> quoted"), "\\* note\n\\> quoted");
394/// assert_eq!(escape_block_starts("#hashtag, 2024. 10. 14"), "#hashtag, 2024. 10. 14");
395/// ```
396pub fn escape_block_starts(text: &str) -> String {
397    let mut out = String::with_capacity(text.len() + 4);
398    for (n, line) in text.split('\n').enumerate() {
399        if n > 0 {
400            out.push('\n');
401        }
402        let indent = line.len() - line.trim_start_matches(' ').len();
403        match block_marker_at(&line[indent.min(line.len())..]).filter(|_| indent <= 3) {
404            Some(at) => {
405                let at = indent + at;
406                out.push_str(&line[..at]);
407                out.push('\\');
408                out.push_str(&line[at..]);
409            }
410            None => out.push_str(line),
411        }
412    }
413    out
414}
415
416/// `text` with what a Markdown reader would take for HTML escaped: a `<` that can open a tag,
417/// a comment, a declaration, a processing instruction or an autolink, and a `&` that starts
418/// a character reference.
419///
420/// Text written into Markdown is read for inline HTML (CommonMark §6.6–6.7): `Vec<String>`
421/// shows as `Vec` with an unknown tag swallowed, `a <b>bold</b>` turns bold, `AT&amp;T`
422/// shows as `AT&T`. A `<` is escaped unless the character after it cannot start any of those
423/// — a space, a digit, `=`, … — and a `<` that ends `text` is escaped too, since the next
424/// piece written after it may start with a letter. A `&` is escaped only where a character
425/// reference starts (`&name;`, `&#…;`), so `R&D` and `a & b` are left alone.
426///
427/// `text` may already be escaped for Markdown (`\\` for a backslash); apply this after that.
428///
429/// ```
430/// use unparser_shared::markdown::escape_html_syntax;
431///
432/// assert_eq!(escape_html_syntax("Vec<String>"), r"Vec\<String>");
433/// assert_eq!(escape_html_syntax("a <b>bold</b>"), r"a \<b>bold\</b>");
434/// assert_eq!(escape_html_syntax("AT&amp;T, R&D, 1 < 2"), r"AT\&amp;T, R&D, 1 < 2");
435/// ```
436pub fn escape_html_syntax(text: &str) -> String {
437    let mut out = String::with_capacity(text.len() + 2);
438    for (i, c) in text.char_indices() {
439        let rest = &text[i + c.len_utf8()..];
440        let opens = match c {
441            '<' => rest
442                .chars()
443                .next()
444                .is_none_or(|n| n.is_ascii_alphabetic() || matches!(n, '/' | '!' | '?')),
445            '&' => starts_character_reference(rest),
446            _ => false,
447        };
448        if opens {
449            out.push('\\');
450        }
451        out.push(c);
452    }
453    out
454}
455
456/// Whether the `delimiter` (`*` or `_`) characters of `text` can make emphasis among
457/// themselves: a run of them that can open (CommonMark §6.2: left-flanking, and for `_` not
458/// inside a word) followed somewhere later by a run that can close.
459///
460/// `text` is a complete inline context — a table cell, a link label — so when no run can
461/// pair, every delimiter in it is read as itself and none needs escaping: the significance
462/// stars of `0.039***`, a lone `*8 ml`. Without a later closer an opener opens nothing.
463/// The answer errs toward `true`: the rule of three (§6.2, rules 9–10) is not applied, so a
464/// pair it would forbid still counts as one.
465///
466/// ```
467/// use unparser_shared::markdown::emphasis_can_pair;
468///
469/// assert!(!emphasis_can_pair("0.039***", '*'));
470/// assert!(!emphasis_can_pair("*8 ml", '*'));
471/// assert!(emphasis_can_pair("x*y*z", '*'));
472/// assert!(emphasis_can_pair("a _foo_ b", '_'));
473/// assert!(!emphasis_can_pair("snake_case_name", '_'));
474/// ```
475pub fn emphasis_can_pair(text: &str, delimiter: char) -> bool {
476    let chars: Vec<char> = text.chars().collect();
477    let mut opener_seen = false;
478    let mut i = 0;
479    while i < chars.len() {
480        if chars[i] != delimiter {
481            i += 1;
482            continue;
483        }
484        let start = i;
485        while i < chars.len() && chars[i] == delimiter {
486            i += 1;
487        }
488        let before = start.checked_sub(1).map(|b| chars[b]);
489        let after = chars.get(i).copied();
490        let space = |c: Option<char>| c.is_none_or(char::is_whitespace);
491        let punct =
492            |c: Option<char>| c.is_some_and(|c| is_flanking_neutral(c) && !c.is_whitespace());
493        let left = !space(after) && (!punct(after) || space(before) || punct(before));
494        let right = !space(before) && (!punct(before) || space(after) || punct(after));
495        let (opens, closes) = if delimiter == '_' {
496            (
497                left && (!right || punct(before)),
498                right && (!left || punct(after)),
499            )
500        } else {
501            (left, right)
502        };
503        if closes && opener_seen {
504            return true;
505        }
506        opener_seen |= opens;
507    }
508    false
509}
510
511/// Where in `line` (already past its indentation) the character to escape stands, when the
512/// line opens a block (see [`escape_block_starts`]).
513fn block_marker_at(line: &str) -> Option<usize> {
514    let bytes = line.as_bytes();
515    let first = *bytes.first()?;
516    // What follows a marker for it to be one: a space, a tab, or nothing.
517    let ends_marker = |i: usize| matches!(bytes.get(i), None | Some(b' ' | b'\t' | b'\r'));
518    let content = line.trim_end_matches([' ', '\t', '\r']);
519    let only = |c: u8, spaced: bool| {
520        content
521            .bytes()
522            .all(|b| b == c || (spaced && (b == b' ' || b == b'\t')))
523    };
524    match first {
525        b'#' => {
526            let hashes = bytes.iter().take_while(|&&b| b == b'#').count();
527            (hashes <= 6 && ends_marker(hashes)).then_some(0)
528        }
529        b'>' => Some(0),
530        b'-' | b'+' | b'*' if ends_marker(1) => Some(0),
531        // A thematic break (three or more) or a setext underline (`-`, any number).
532        b'-' if only(b'-', true) => Some(0),
533        b'*' | b'_'
534            if only(first, true) && content.bytes().filter(|&b| b == first).count() >= 3 =>
535        {
536            Some(0)
537        }
538        b'=' if only(b'=', false) => Some(0),
539        b'`' | b'~' if bytes.iter().take_while(|&&b| b == first).count() >= 3 => Some(0),
540        b'0'..=b'9' => {
541            let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
542            (digits <= 9
543                && matches!(bytes.get(digits), Some(b'.' | b')'))
544                && ends_marker(digits + 1))
545            .then_some(digits)
546        }
547        _ => None,
548    }
549}
550
551fn starts_character_reference(rest: &str) -> bool {
552    let Some(end) = rest.find(';') else {
553        return false;
554    };
555    let body = &rest[..end];
556    if let Some(number) = body.strip_prefix('#') {
557        return match number.strip_prefix(['x', 'X']) {
558            Some(hex) => (1..=6).contains(&hex.len()) && hex.chars().all(|c| c.is_ascii_hexdigit()),
559            None => (1..=7).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit()),
560        };
561    }
562    body.starts_with(|c: char| c.is_ascii_alphabetic())
563        && (2..=32).contains(&body.len())
564        && body.chars().all(|c| c.is_ascii_alphanumeric())
565}
566
567/// Whether every `)` closes an earlier `(` and every `(` is closed. A backslash in the URL
568/// does not shield a parenthesis: [`link_destination`] writes it as a literal backslash.
569fn parentheses_balance(url: &str) -> bool {
570    let mut depth = 0usize;
571    for c in url.chars() {
572        match c {
573            '(' => depth += 1,
574            ')' => match depth.checked_sub(1) {
575                Some(d) => depth = d,
576                None => return false,
577            },
578            _ => {}
579        }
580    }
581    depth == 0
582}
583
584/// Whitespace or punctuation — what a delimiter may touch on its outside when its inside is
585/// punctuation. CommonMark's punctuation is the Unicode P and S categories; a character that
586/// is neither alphanumeric, whitespace nor a control is one of them, save for combining
587/// marks, which do not stand at a run's edge.
588fn is_flanking_neutral(c: char) -> bool {
589    c.is_whitespace() || !(c.is_alphanumeric() || c.is_control())
590}
591
592/// A unit is neutral when the character it writes is: an escape writes punctuation.
593fn unit_is_neutral(unit: &str) -> bool {
594    unit.chars().last().is_some_and(is_flanking_neutral)
595}
596
597/// The byte ranges of `text[start..end]`'s units: one character, or a backslash and the ASCII
598/// punctuation it escapes.
599fn units(text: &str, start: usize, end: usize) -> impl Iterator<Item = (usize, usize)> + '_ {
600    let mut chars = text[start..end].char_indices().peekable();
601    std::iter::from_fn(move || {
602        let (i, c) = chars.next()?;
603        let mut j = i + c.len_utf8();
604        if c == '\\' {
605            if let Some(&(k, escaped)) = chars.peek() {
606                if escaped.is_ascii_punctuation() {
607                    j = k + escaped.len_utf8();
608                    chars.next();
609                }
610            }
611        }
612        Some((start + i, start + j))
613    })
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    /// `text` with `*` around its emphasis span.
621    fn wrap(text: &str, before: Option<char>, after: Option<char>) -> String {
622        emphasis_span(text, before, after).map_or(text.to_string(), |r| {
623            format!(
624                "{}*{}*{}",
625                &text[..r.start],
626                &text[r.clone()],
627                &text[r.end..]
628            )
629        })
630    }
631
632    #[test]
633    fn whitespace_stays_outside() {
634        assert_eq!(wrap("  word  ", Some('a'), Some('b')), "  *word*  ");
635        assert_eq!(emphasis_span("   ", None, None), None);
636        assert_eq!(emphasis_span("", None, None), None);
637    }
638
639    #[test]
640    fn leading_punctuation_against_a_word_moves_out_with_its_space() {
641        assert_eq!(wrap(", s", Some('2'), Some(' ')), ", *s*");
642    }
643
644    #[test]
645    fn trailing_punctuation_against_a_word_moves_out() {
646        assert_eq!(wrap("word.", Some(' '), Some('x')), "*word*.");
647        assert_eq!(wrap("word, ", Some(' '), Some('x')), "*word,* ");
648    }
649
650    #[test]
651    fn punctuation_between_words_alone_has_nothing_to_wrap() {
652        assert_eq!(emphasis_span(",", Some('8'), Some('a')), None);
653        assert_eq!(emphasis_span(", ", Some('8'), None), None);
654    }
655
656    #[test]
657    fn punctuation_against_space_punctuation_or_an_edge_stays_inside() {
658        assert_eq!(wrap("i.e.,", Some(' '), Some(' ')), "*i.e.,*");
659        assert_eq!(wrap("(h)", None, None), "*(h)*");
660        assert_eq!(wrap("(h)", Some('('), Some(')')), "*(h)*");
661        assert_eq!(
662            wrap("Continued pretraining.", None, Some(' ')),
663            "*Continued pretraining.*"
664        );
665    }
666
667    #[test]
668    fn a_word_on_either_side_moves_both_ends() {
669        assert_eq!(wrap("(h)", Some('Q'), Some('x')), "(*h*)");
670    }
671
672    #[test]
673    fn an_escape_is_never_split() {
674        assert_eq!(wrap(r"\*x\_", Some('a'), Some('b')), r"\**x*\_");
675    }
676
677    #[test]
678    fn a_backslash_before_a_letter_is_not_an_escape() {
679        assert_eq!(wrap(r"\a", Some('b'), None), r"\*a*");
680    }
681
682    #[test]
683    fn non_ascii_punctuation_and_letters_count() {
684        assert_eq!(wrap("“word”", Some('x'), Some('y')), "“*word*”");
685        assert_eq!(wrap("단어", Some('가'), Some('나')), "*단어*");
686    }
687
688    /// Every block a line of prose can open, escaped at its first character -- and what
689    /// opens none left as it is.
690    #[test]
691    fn block_starts_are_escaped() {
692        for (line, escaped) in [
693            ("# a", r"\# a"),
694            ("###### a", r"\###### a"),
695            ("#", r"\#"),
696            ("- a", r"\- a"),
697            ("+ a", r"\+ a"),
698            ("* a", r"\* a"),
699            ("-", r"\-"),
700            ("1. a", r"1\. a"),
701            ("1) a", r"1\) a"),
702            ("2024. 10. 14", r"2024\. 10. 14"),
703            ("123456789. a", r"123456789\. a"),
704            ("> a", r"\> a"),
705            (">a", r"\>a"),
706            ("```", r"\```"),
707            ("~~~ rust", r"\~~~ rust"),
708            ("---", r"\---"),
709            ("* * *", r"\* * *"),
710            ("___", r"\___"),
711            ("===", r"\==="),
712            ("   # a", r"   \# a"),
713        ] {
714            assert_eq!(escape_block_starts(line), escaped, "{line:?}");
715        }
716        for line in [
717            "#a",
718            "####### a",
719            "-a",
720            "*a*",
721            "1.5 litres",
722            "1234567890. a",
723            "a. b",
724            "__init__",
725            "**",
726            "= a",
727            "``",
728            "    # a",
729            r"\* a",
730            "",
731        ] {
732            assert_eq!(escape_block_starts(line), line, "{line:?}");
733        }
734    }
735
736    /// A delimiter pairs only with a later one that can close; a lone opener or closer, or
737    /// underscores inside words, make no emphasis.
738    #[test]
739    fn emphasis_pairs_only_opener_then_closer() {
740        for (text, delimiter, pairs) in [
741            ("0.039***", '*', false),
742            ("*8 ml", '*', false),
743            ("p < .05*", '*', false),
744            ("a * b * c", '*', false),
745            ("x*y*z", '*', true),
746            ("*bold* text", '*', true),
747            ("**strong**", '*', true),
748            ("a _foo_ b", '_', true),
749            ("snake_case_name", '_', false),
750            ("_init", '_', false),
751            ("(*) and (*)", '*', true),
752        ] {
753            assert_eq!(emphasis_can_pair(text, delimiter), pairs, "{text:?}");
754        }
755    }
756
757    /// What can open inline HTML or a character reference is escaped; what cannot is left.
758    #[test]
759    fn html_syntax_is_escaped_where_it_can_open() {
760        for (text, escaped) in [
761            ("Vec<String>", r"Vec\<String>"),
762            ("</div>", r"\</div>"),
763            ("<!-- c -->", r"\<!-- c -->"),
764            ("<?php", r"\<?php"),
765            ("<https://example.com>", r"\<https://example.com>"),
766            ("ends with <", r"ends with \<"),
767            ("&amp; &#38; &#x26;", r"\&amp; \&#38; \&#x26;"),
768        ] {
769            assert_eq!(escape_html_syntax(text), escaped, "{text:?}");
770        }
771        for text in [
772            "1 < 2",
773            "a <= b",
774            "<3",
775            "<< b",
776            "R&D",
777            "a & b",
778            "&notareference",
779            "& ;",
780        ] {
781            assert_eq!(escape_html_syntax(text), text, "{text:?}");
782        }
783    }
784
785    /// Every line is a line start, not only the first: a paragraph with a hard break.
786    #[test]
787    fn every_line_of_the_text_is_checked() {
788        assert_eq!(
789            escape_block_starts("first  \n# second\nthird"),
790            "first  \n\\# second\nthird"
791        );
792    }
793}