1#[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
29pub 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
41pub 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
70fn 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 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 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
166struct 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
215pub fn to_slack(text: &str) -> String {
218 render(text, &CHAT)
219}
220
221pub fn to_google_chat(text: &str) -> String {
223 render(text, &CHAT)
224}
225
226fn 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('&', "&").replace('<', "<").replace('>', ">"))
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
257pub fn to_teams(text: &str) -> String {
259 render(
260 text,
261 &Dialect {
262 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
273pub fn html_escape(t: &str) -> String {
275 t.replace('&', "&")
276 .replace('<', "<")
277 .replace('>', ">")
278 .replace('"', """)
279}
280
281pub 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
327pub 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
348pub 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 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
364pub 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 (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 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 assert_eq!(fit(t, 15, "\n…", to_slack), ("*a & 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}