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