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
29#[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
42pub 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
71fn 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
84fn 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 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 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 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
173struct 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#[must_use]
225pub fn to_slack(text: &str) -> String {
226 render(text, &CHAT)
227}
228
229#[must_use]
231pub fn to_google_chat(text: &str) -> String {
232 render(text, &CHAT)
233}
234
235fn 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('&', "&").replace('<', "<").replace('>', ">"))
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#[must_use]
268pub fn to_teams(text: &str) -> String {
269 render(
270 text,
271 &Dialect {
272 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#[must_use]
285pub fn html_escape(t: &str) -> String {
286 t.replace('&', "&")
287 .replace('<', "<")
288 .replace('>', ">")
289 .replace('"', """)
290}
291
292#[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#[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#[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 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
378pub 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 (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 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 assert_eq!(fit(t, 15, "\n…", to_slack), ("*a & 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}