Skip to main content

dre_protocol/
markdown.rs

1//! The portable Markdown subset a message's text is written in, and its translations.
2//!
3//! Inline: `**bold**`, `*italic*` or `_italic_`, `` `code` ``, `[text](url)` and backslash
4//! escapes (`\*`). Lines: a line starting with `- ` or `* ` is a bullet; other lines are text,
5//! kept as written (line breaks matter in chat). There are no headings and no tables: the title
6//! is separate.
7//!
8//! Core escapes every value a template interpolates ([`escape`]), so a value containing `*` or
9//! `_` stays literal. Each destination translates with one of the `to_*` functions.
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Inline {
13    Text(String),
14    Bold(Vec<Inline>),
15    Italic(Vec<Inline>),
16    Code(String),
17    Link { text: Vec<Inline>, url: String },
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Line {
22    Text(Vec<Inline>),
23    Bullet(Vec<Inline>),
24    Blank,
25}
26
27const ESCAPABLE: &[char] = &['\\', '*', '_', '`', '[', ']'];
28
29/// `s` with every character that means something in the subset backslash-escaped.
30#[must_use]
31pub fn escape(s: &str) -> String {
32    let mut out = String::with_capacity(s.len());
33    for c in s.chars() {
34        if ESCAPABLE.contains(&c) {
35            out.push('\\');
36        }
37        out.push(c);
38    }
39    out
40}
41
42/// Parse `text` line by line.
43pub fn parse(text: &str) -> Vec<Line> {
44    text.lines()
45        .map(|l| {
46            let t = l.trim_start();
47            if t.is_empty() {
48                Line::Blank
49            } else if let Some(rest) = t.strip_prefix("- ").or_else(|| t.strip_prefix("* ")) {
50                Line::Bullet(inlines(rest.trim_start()))
51            } else {
52                Line::Text(inlines(l.trim_end()))
53            }
54        })
55        .collect()
56}
57
58fn inlines(s: &str) -> Vec<Inline> {
59    let chars: Vec<char> = s.chars().collect();
60    parse_span(&chars)
61}
62
63fn push_text(out: &mut Vec<Inline>, c: char) {
64    if let Some(Inline::Text(t)) = out.last_mut() {
65        t.push(c);
66    } else {
67        out.push(Inline::Text(c.to_string()));
68    }
69}
70
71/// The index of the next unescaped `pat` at or after `from`.
72fn find(chars: &[char], from: usize, pat: &[char]) -> Option<usize> {
73    let mut i = from;
74    while let Some(rest) = chars.get(i..).filter(|r| r.len() >= pat.len()) {
75        match rest {
76            ['\\', ..] => i += 2,
77            _ if rest.starts_with(pat) => return Some(i),
78            _ => i += 1,
79        }
80    }
81    None
82}
83
84/// The character `\` escapes at the start of `chars`, if any.
85fn escaped(chars: &[char]) -> Option<char> {
86    match chars {
87        ['\\', n, ..] if n.is_ascii_punctuation() => Some(*n),
88        _ => None,
89    }
90}
91
92fn unescape(chars: &[char]) -> String {
93    let mut out = String::new();
94    let mut rest = chars;
95    while let Some((&c, tail)) = rest.split_first() {
96        if let Some(n) = escaped(rest) {
97            out.push(n);
98            rest = tail.get(1..).unwrap_or_default();
99        } else {
100            out.push(c);
101            rest = tail;
102        }
103    }
104    out
105}
106
107fn parse_span(chars: &[char]) -> Vec<Inline> {
108    let mut out = Vec::new();
109    let mut i = 0;
110    let word = |j: usize| chars.get(j).is_some_and(|c| c.is_alphanumeric());
111    // The sub-slices below are in range: `find` only returns an index whose pattern fits
112    // before the end, and never one before `from`.
113    while let Some(&c) = chars.get(i) {
114        if let Some(n) = chars.get(i..).and_then(escaped) {
115            push_text(&mut out, n);
116            i += 2;
117            continue;
118        }
119        // Values printed inside a code span were escaped too: unescape them.
120        if c == '`'
121            && let Some(end) = find(chars, i + 1, &['`'])
122        {
123            out.push(Inline::Code(unescape(&chars[i + 1..end])));
124            i = end + 1;
125            continue;
126        }
127        if c == '*'
128            && chars.get(i + 1) == Some(&'*')
129            && let Some(end) = find(chars, i + 2, &['*', '*'])
130            && end > i + 2
131        {
132            out.push(Inline::Bold(parse_span(&chars[i + 2..end])));
133            i = end + 2;
134            continue;
135        }
136        // `_` only opens and closes at word boundaries, so snake_case stays text.
137        if (c == '*' || (c == '_' && !word(i.wrapping_sub(1))))
138            && chars.get(i + 1).is_some_and(|n| !n.is_whitespace())
139        {
140            let mut from = i + 1;
141            let close = loop {
142                match find(chars, from, &[c]) {
143                    Some(e) if c == '_' && word(e + 1) => from = e + 1,
144                    Some(e) if c == '*' && chars.get(e + 1) == Some(&'*') => from = e + 2,
145                    other => break other,
146                }
147            };
148            if let Some(end) = close
149                && end > i + 1
150            {
151                out.push(Inline::Italic(parse_span(&chars[i + 1..end])));
152                i = end + 1;
153                continue;
154            }
155        }
156        if c == '['
157            && let Some(mid) = find(chars, i + 1, &[']', '('])
158            && let Some(end) = find(chars, mid + 2, &[')'])
159        {
160            out.push(Inline::Link {
161                text: parse_span(&chars[i + 1..mid]),
162                url: unescape(&chars[mid + 2..end]),
163            });
164            i = end + 1;
165            continue;
166        }
167        push_text(&mut out, c);
168        i += 1;
169    }
170    out
171}
172
173/// How one dialect writes each piece.
174struct Dialect {
175    text: fn(&str) -> String,
176    bold: (&'static str, &'static str),
177    italic: (&'static str, &'static str),
178    code: fn(&str) -> String,
179    link: fn(&str, &str) -> String,
180    bullet: &'static str,
181}
182
183fn span(items: &[Inline], d: &Dialect, out: &mut String) {
184    for it in items {
185        match it {
186            Inline::Text(t) => out.push_str(&(d.text)(t)),
187            Inline::Bold(x) => {
188                out.push_str(d.bold.0);
189                span(x, d, out);
190                out.push_str(d.bold.1);
191            }
192            Inline::Italic(x) => {
193                out.push_str(d.italic.0);
194                span(x, d, out);
195                out.push_str(d.italic.1);
196            }
197            Inline::Code(c) => out.push_str(&(d.code)(c)),
198            Inline::Link { text, url } => {
199                let mut t = String::new();
200                span(text, d, &mut t);
201                out.push_str(&(d.link)(&t, url));
202            }
203        }
204    }
205}
206
207fn render(text: &str, d: &Dialect) -> String {
208    let lines: Vec<String> = parse(text)
209        .iter()
210        .map(|l| {
211            let mut out = String::new();
212            if let Line::Bullet(_) = l {
213                out.push_str(d.bullet);
214            }
215            out.push_str(&render_line(l, d));
216            out
217        })
218        .collect();
219    lines.join("\n")
220}
221
222/// Slack's `mrkdwn`, which has no escapes: `&`, `<` and `>` become entities, and literal `*`, `~`
223/// and `` ` `` become look-alikes so they can't start formatting.
224#[must_use]
225pub fn to_slack(text: &str) -> String {
226    render(text, &CHAT)
227}
228
229/// Google Chat's text formatting (the same marks as Slack's).
230#[must_use]
231pub fn to_google_chat(text: &str) -> String {
232    render(text, &CHAT)
233}
234
235/// Literal text where `*`, `_` (at a word's edge), `~` and `` ` `` would start formatting: each
236/// becomes a look-alike, since chat services have no escapes.
237fn defused(t: &str) -> String {
238    let chars: Vec<char> = t.chars().collect();
239    let word = |j: Option<usize>| j.and_then(|j| chars.get(j)).is_some_and(|c| c.is_alphanumeric());
240    let mut out = String::with_capacity(t.len());
241    for (i, &c) in chars.iter().enumerate() {
242        match c {
243            '*' => out.push('\u{2217}'),
244            '~' => out.push('\u{223c}'),
245            '`' => out.push('\u{02cb}'),
246            '_' if !(word(i.checked_sub(1)) && word(Some(i + 1))) => out.push('\u{02cd}'),
247            c => out.push(c),
248        }
249    }
250    out
251}
252
253fn chat_text(t: &str) -> String {
254    defused(&t.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;"))
255}
256
257const CHAT: Dialect = Dialect {
258    text: chat_text,
259    bold: ("*", "*"),
260    italic: ("_", "_"),
261    code: |c| format!("`{}`", c.replace('`', "\u{02cb}")),
262    link: |t, u| format!("<{}|{t}>", u.replace('|', "%7C").replace('>', "%3E")),
263    bullet: "• ",
264};
265
266/// The Markdown Microsoft Teams renders in an Adaptive Card text block.
267#[must_use]
268pub fn to_teams(text: &str) -> String {
269    render(
270        text,
271        &Dialect {
272            // Teams' card Markdown has no reliable escapes either: look-alikes, as for chat.
273            text: defused,
274            bold: ("**", "**"),
275            italic: ("_", "_"),
276            code: |c| format!("`{}`", c.replace('`', "\u{02cb}")),
277            link: |t, u| format!("[{t}]({})", u.replace(')', "%29")),
278            bullet: "- ",
279        },
280    )
281}
282
283/// `t` safe as HTML text or an attribute value.
284#[must_use]
285pub fn html_escape(t: &str) -> String {
286    t.replace('&', "&amp;")
287        .replace('<', "&lt;")
288        .replace('>', "&gt;")
289        .replace('"', "&quot;")
290}
291
292/// An HTML fragment: lines become paragraphs (consecutive lines joined by `<br>`), bullets a
293/// list.
294#[must_use]
295pub fn to_html(text: &str) -> String {
296    let d = Dialect {
297        text: html_escape,
298        bold: ("<strong>", "</strong>"),
299        italic: ("<em>", "</em>"),
300        code: |c| format!("<code>{}</code>", html_escape(c)),
301        link: |t, u| format!("<a href=\"{}\">{t}</a>", html_escape(u)),
302        bullet: "",
303    };
304    let parsed = parse(text);
305    let mut out = String::new();
306    let mut i = 0;
307    while let Some(line) = parsed.get(i) {
308        match line {
309            Line::Blank => i += 1,
310            Line::Bullet(_) => {
311                out.push_str("<ul>\n");
312                while let Some(l @ Line::Bullet(_)) = parsed.get(i) {
313                    out.push_str(&format!("<li>{}</li>\n", render_line(l, &d)));
314                    i += 1;
315                }
316                out.push_str("</ul>\n");
317            }
318            Line::Text(_) => {
319                let mut lines = Vec::new();
320                while let Some(l @ Line::Text(_)) = parsed.get(i) {
321                    lines.push(render_line(l, &d));
322                    i += 1;
323                }
324                out.push_str(&format!("<p>{}</p>\n", lines.join("<br>\n")));
325            }
326        }
327    }
328    out
329}
330
331fn render_line(l: &Line, d: &Dialect) -> String {
332    let mut out = String::new();
333    if let Line::Text(x) | Line::Bullet(x) = l {
334        span(x, d, &mut out);
335    }
336    out
337}
338
339/// Plain text: marks removed, links as `text (url)`, bullets as `- `.
340#[must_use]
341pub fn to_plain(text: &str) -> String {
342    render(
343        text,
344        &Dialect {
345            text: |t| t.to_string(),
346            bold: ("", ""),
347            italic: ("", ""),
348            code: |c| c.to_string(),
349            link: |t, u| {
350                if t == u {
351                    u.to_string()
352                } else {
353                    format!("{t} ({u})")
354                }
355            },
356            bullet: "- ",
357        },
358    )
359}
360
361/// `text` cut to at most `max` characters including `marker`, at a line break when one is close
362/// enough; `None` when it already fits.
363#[must_use]
364pub fn truncate(text: &str, max: usize, marker: &str) -> Option<String> {
365    if text.chars().count() <= max {
366        return None;
367    }
368    let room = max.saturating_sub(marker.chars().count());
369    let cut: String = text.chars().take(room).collect();
370    // Prefer the last line break in the second half of what's kept.
371    let kept = match cut.rfind('\n') {
372        Some(i) if i >= cut.len() / 2 => &cut[..i],
373        _ => cut.as_str(),
374    };
375    Some(format!("{}{marker}", kept.trim_end()))
376}
377
378/// `text` (portable Markdown) translated with `translate`, fitting in `max` characters: when the
379/// translation is too long, whole lines are dropped from the end of the source before
380/// translating, so a cut never lands inside the dialect's markup, and `marker` (already in the
381/// dialect) is appended. The second value says whether it was cut.
382pub fn fit(text: &str, max: usize, marker: &str, translate: impl Fn(&str) -> String) -> (String, bool) {
383    let full = translate(text);
384    if full.chars().count() <= max {
385        return (full, false);
386    }
387    let room = max.saturating_sub(marker.chars().count());
388    let lines: Vec<&str> = text.lines().collect();
389    for n in (1..lines.len()).rev() {
390        let t = translate(&lines[..n].join("\n"));
391        if t.chars().count() <= room {
392            return (format!("{}{marker}", t.trim_end()), true);
393        }
394    }
395    // Even the first line is too long: cut the translation itself.
396    (truncate(&full, max, marker).unwrap_or(full), true)
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn translates_the_subset() {
405        let src =
406            "Revenue **€12,340** (_+4.1%_)\n- [Report](https://x.test/a_b) `code`\n- acme\\_corp has 2\\*3";
407        assert_eq!(
408            to_slack(src),
409            "Revenue *€12,340* (_+4.1%_)\n• <https://x.test/a_b|Report> `code`\n• acme_corp has 2\u{2217}3"
410        );
411        assert_eq!(
412            to_plain(src),
413            "Revenue €12,340 (+4.1%)\n- Report (https://x.test/a_b) code\n- acme_corp has 2*3"
414        );
415        assert_eq!(
416            to_html(src),
417            "<p>Revenue <strong>€12,340</strong> (<em>+4.1%</em>)</p>\n<ul>\n<li><a href=\"https://x.test/a_b\">Report</a> <code>code</code></li>\n<li>acme_corp has 2*3</li>\n</ul>\n"
418        );
419        assert_eq!(
420            to_teams(src),
421            "Revenue **€12,340** (_+4.1%_)\n- [Report](https://x.test/a_b) `code`\n- acme_corp has 2\u{2217}3"
422        );
423    }
424
425    #[test]
426    fn escaped_values_stay_literal_in_code_and_at_word_edges() {
427        // A template's `{{ v }}` arrives escaped: `a\_b` inside a code span, `\_pending\_` as text.
428        assert_eq!(to_plain("id `a\\_b`"), "id a_b");
429        assert_eq!(
430            to_slack("state \\_pending\\_ ok"),
431            "state \u{02cd}pending\u{02cd} ok"
432        );
433        assert_eq!(to_slack("snake_case"), "snake_case");
434    }
435
436    #[test]
437    fn snake_case_and_lone_marks_stay_text() {
438        assert_eq!(to_plain("total_rows is 3 * 4"), "total_rows is 3 * 4");
439        assert_eq!(escape("a_b*[c]"), "a\\_b\\*\\[c\\]");
440    }
441
442    #[test]
443    fn fit_drops_whole_source_lines() {
444        let t = "**a & b**\n**c & d**\n**e & f**";
445        assert_eq!(fit(t, 100, "…", to_slack), (to_slack(t), false));
446        // `*a &amp; b*` is 11 characters; two lines would be 23 with the newline.
447        assert_eq!(fit(t, 15, "\n…", to_slack), ("*a &amp; b*\n…".to_string(), true));
448    }
449
450    #[test]
451    fn truncates_at_a_line_break() {
452        let t = "line one\nline two\nline three";
453        assert_eq!(truncate(t, 100, "…"), None);
454        assert_eq!(truncate(t, 20, "\n…").unwrap(), "line one\nline two\n…");
455    }
456}