slack_markdown_converter/
lib.rs1use regex::{Captures, Regex};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum TableRenderMode {
18 CodeBlock,
20 Bulleted,
22}
23
24#[derive(Debug, Clone)]
25pub struct SlackMarkdownConverter {
26 pub table_mode: TableRenderMode,
27 pub max_table_col_width: usize,
29}
30
31impl Default for SlackMarkdownConverter {
32 fn default() -> Self {
33 Self {
34 table_mode: TableRenderMode::CodeBlock,
35 max_table_col_width: 32,
36 }
37 }
38}
39
40impl SlackMarkdownConverter {
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn with_table_mode(mut self, mode: TableRenderMode) -> Self {
46 self.table_mode = mode;
47 self
48 }
49
50 pub fn with_max_table_col_width(mut self, width: usize) -> Self {
51 self.max_table_col_width = width.max(4);
52 self
53 }
54
55 pub fn convert(&self, input: &str) -> String {
56 if input.is_empty() {
57 return String::new();
58 }
59 let had_trailing_newline = input.ends_with('\n');
60 let normalized = input.replace("\r\n", "\n").replace('\r', "\n");
61 let blocks = split_blocks_preserving_fenced_code(&normalized);
62
63 let mut out_blocks = Vec::new();
64
65 for block in blocks {
66 if block.trim().is_empty() {
67 continue;
68 }
69
70 if is_fenced_code_block(&block) {
71 out_blocks.push(normalize_fenced_code_block(&block));
72 continue;
73 }
74
75 if let Some(table) = MarkdownTable::parse(&block) {
76 let rendered = match self.table_mode {
77 TableRenderMode::CodeBlock => {
78 table.to_slack_code_block_with_max_width(self.max_table_col_width)
79 }
80 TableRenderMode::Bulleted => table.to_bulleted_text(),
81 };
82 out_blocks.push(rendered);
83 continue;
84 }
85
86 out_blocks.push(self.convert_text_block(&block));
87 }
88
89 let result = normalize_spacing(&out_blocks.join("\n\n"));
90 if had_trailing_newline && !result.ends_with('\n') {
91 format!("{}\n", result)
92 } else {
93 result
94 }
95 }
96
97 fn convert_text_block(&self, block: &str) -> String {
98 let protected = protect_inline_code(block);
99
100 let mut lines = Vec::new();
101 for line in protected.lines() {
102 lines.push(convert_line(line));
103 }
104
105 let s = lines.join("\n");
106 convert_inline_markdown(&s)
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct MarkdownTable {
112 pub headers: Vec<String>,
113 pub alignments: Vec<ColumnAlignment>,
114 pub rows: Vec<Vec<String>>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum ColumnAlignment {
119 Left,
120 Center,
121 Right,
122}
123
124impl MarkdownTable {
125 pub fn parse(block: &str) -> Option<Self> {
126 let lines: Vec<&str> = block
127 .lines()
128 .map(str::trim)
129 .filter(|line| !line.is_empty())
130 .collect();
131
132 if lines.len() < 2 {
133 return None;
134 }
135
136 let header = parse_table_row(lines[0])?;
137 let separator = parse_table_row(lines[1])?;
138
139 if header.is_empty() || separator.len() != header.len() {
140 return None;
141 }
142
143 let mut alignments = Vec::with_capacity(separator.len());
144 for cell in &separator {
145 let alignment = parse_alignment_cell(cell)?;
146 alignments.push(alignment);
147 }
148
149 let mut rows = Vec::new();
150 for line in &lines[2..] {
151 let row = parse_table_row(line)?;
152 if row.len() != header.len() {
153 return None;
154 }
155 rows.push(row);
156 }
157
158 Some(Self {
159 headers: header
160 .into_iter()
161 .map(|c| convert_inline_markdown_for_table_cell(&c))
162 .collect(),
163 alignments,
164 rows: rows
165 .into_iter()
166 .map(|row| {
167 row.into_iter()
168 .map(|c| convert_inline_markdown_for_table_cell(&c))
169 .collect()
170 })
171 .collect(),
172 })
173 }
174
175 pub fn to_slack_code_block_with_max_width(&self, max_col_width: usize) -> String {
176 let cols = self.headers.len();
177
178 let truncated_headers: Vec<String> = self
179 .headers
180 .iter()
181 .map(|s| truncate_for_table(s, max_col_width))
182 .collect();
183
184 let truncated_rows: Vec<Vec<String>> = self
185 .rows
186 .iter()
187 .map(|row| {
188 row.iter()
189 .map(|cell| truncate_for_table(cell, max_col_width))
190 .collect()
191 })
192 .collect();
193
194 let mut widths = vec![0usize; cols];
195
196 for (i, h) in truncated_headers.iter().enumerate() {
197 widths[i] = widths[i].max(display_width(h)).min(max_col_width);
198 }
199
200 for row in &truncated_rows {
201 for (i, cell) in row.iter().enumerate() {
202 widths[i] = widths[i].max(display_width(cell)).min(max_col_width);
203 }
204 }
205
206 let header_line = format_table_row(&truncated_headers, &widths, &self.alignments);
207 let separator = widths
208 .iter()
209 .map(|w| "-".repeat(*w))
210 .collect::<Vec<_>>()
211 .join("-+-");
212
213 let mut out = String::new();
214 out.push_str("```");
215 out.push('\n');
216 out.push_str(&header_line);
217 out.push('\n');
218 out.push_str(&separator);
219
220 for row in &truncated_rows {
221 out.push('\n');
222 out.push_str(&format_table_row(row, &widths, &self.alignments));
223 }
224
225 out.push('\n');
226 out.push_str("```");
227 out
228 }
229
230 pub fn to_slack_code_block_with_column_widths(&self, max_widths: &[usize]) -> String {
231 let cols = self.headers.len();
232 assert_eq!(cols, max_widths.len());
233
234 let truncated_headers: Vec<String> = self
235 .headers
236 .iter()
237 .enumerate()
238 .map(|(i, s)| truncate_for_table(s, max_widths[i]))
239 .collect();
240
241 let truncated_rows: Vec<Vec<String>> = self
242 .rows
243 .iter()
244 .map(|row| {
245 row.iter()
246 .enumerate()
247 .map(|(i, cell)| truncate_for_table(cell, max_widths[i]))
248 .collect()
249 })
250 .collect();
251
252 let mut widths = vec![0usize; cols];
253
254 for (i, h) in truncated_headers.iter().enumerate() {
255 widths[i] = display_width(h).min(max_widths[i]);
256 }
257
258 for row in &truncated_rows {
259 for (i, cell) in row.iter().enumerate() {
260 widths[i] = widths[i].max(display_width(cell)).min(max_widths[i]);
261 }
262 }
263
264 let header_line = format_table_row(&truncated_headers, &widths, &self.alignments);
265 let separator = widths
266 .iter()
267 .map(|w| "-".repeat(*w))
268 .collect::<Vec<_>>()
269 .join("-+-");
270
271 let mut out = String::new();
272 out.push_str("```");
273 out.push('\n');
274 out.push_str(&header_line);
275 out.push('\n');
276 out.push_str(&separator);
277
278 for row in &truncated_rows {
279 out.push('\n');
280 out.push_str(&format_table_row(row, &widths, &self.alignments));
281 }
282
283 out.push('\n');
284 out.push_str("```");
285 out
286 }
287
288 pub fn to_bulleted_text(&self) -> String {
289 let mut out = String::new();
290
291 for row in &self.rows {
292 out.push_str("• ");
293 for (i, cell) in row.iter().enumerate() {
294 if i > 0 {
295 out.push_str(" | ");
296 }
297 out.push_str(&format!("*{}:* {}", self.headers[i], cell));
298 }
299 out.push('\n');
300 }
301
302 out.trim_end().to_string()
303 }
304}
305
306fn split_blocks_preserving_fenced_code(input: &str) -> Vec<String> {
307 let mut blocks = Vec::new();
308 let mut current = Vec::new();
309 let mut in_fence = false;
310
311 for line in input.split('\n') {
312 let trimmed = line.trim_start();
313
314 if trimmed.starts_with("```") {
315 in_fence = !in_fence;
316 current.push(line.to_string());
317 continue;
318 }
319
320 if !in_fence && line.trim().is_empty() {
321 if !current.is_empty() {
322 blocks.push(current.join("\n"));
323 current.clear();
324 }
325 } else {
326 current.push(line.to_string());
327 }
328 }
329
330 if !current.is_empty() {
331 blocks.push(current.join("\n"));
332 }
333
334 blocks
335}
336
337fn is_fenced_code_block(block: &str) -> bool {
338 let trimmed = block.trim();
339 if !trimmed.starts_with("```") || !trimmed.ends_with("```") {
340 return false;
341 }
342
343 trimmed.lines().count() >= 2
344}
345
346fn normalize_fenced_code_block(block: &str) -> String {
347 let trimmed = block.trim();
348 let mut lines: Vec<&str> = trimmed.lines().collect();
349 if lines.len() < 2 {
350 return block.to_string();
351 }
352
353 let first = lines[0];
354 if !first.starts_with("```") {
355 return block.to_string();
356 }
357
358 let last = lines.last().unwrap();
359 if last.trim() == "```" {
360 lines.pop();
361 }
362
363 let mut out = String::new();
364 out.push_str(first);
365 out.push('\n');
366 if lines.len() > 1 {
367 out.push_str(&lines[1..].join("\n"));
368 out.push('\n');
369 }
370 out.push_str("```");
371 out
372}
373
374fn convert_line(line: &str) -> String {
375 if line.trim().is_empty() {
376 return String::new();
377 }
378
379 if let Some(h) = convert_heading_line(line) {
380 return h;
381 }
382
383 if is_horizontal_rule(line) {
384 return "---".to_string();
385 }
386
387 if let Some(list) = convert_list_line(line) {
388 return list;
389 }
390
391 line.to_string()
392}
393
394fn convert_heading_line(line: &str) -> Option<String> {
395 let trimmed = line.trim_start();
396 let hashes = trimmed.chars().take_while(|c| *c == '#').count();
397
398 if !(1..=6).contains(&hashes) {
399 return None;
400 }
401
402 let rest = trimmed[hashes..].trim();
403 if rest.is_empty() {
404 return None;
405 }
406
407 let stripped = rest
408 .strip_prefix("**")
409 .and_then(|s| s.strip_suffix("**"))
410 .or_else(|| rest.strip_prefix("__").and_then(|s| s.strip_suffix("__")))
411 .unwrap_or(rest);
412
413 Some(format!("**{}**", stripped))
414}
415
416fn is_horizontal_rule(line: &str) -> bool {
417 let trimmed = line.trim();
418 if trimmed.len() < 3 {
419 return false;
420 }
421
422 let chars: Vec<char> = trimmed.chars().filter(|c| !c.is_whitespace()).collect();
423 if chars.len() < 3 {
424 return false;
425 }
426
427 let first = chars[0];
428 if first != '-' && first != '*' && first != '_' {
429 return false;
430 }
431
432 chars.iter().all(|c| *c == first)
433}
434
435fn convert_list_line(line: &str) -> Option<String> {
436 let indent = line.chars().take_while(|c| c.is_whitespace()).count();
437 let trimmed = line.trim_start();
438 let indent_prefix = " ".repeat(indent);
439
440 for marker in ["- [ ] ", "* [ ] ", "+ [ ] "] {
441 if let Some(rest) = trimmed.strip_prefix(marker) {
442 return Some(format!("{indent_prefix}• ☐ {rest}"));
443 }
444 }
445
446 for marker in ["- [x] ", "- [X] ", "* [x] ", "* [X] ", "+ [x] ", "+ [X] "] {
447 if let Some(rest) = trimmed.strip_prefix(marker) {
448 return Some(format!("{indent_prefix}• ☑ {rest}"));
449 }
450 }
451
452 for marker in ["- ", "* ", "+ "] {
453 if let Some(rest) = trimmed.strip_prefix(marker) {
454 return Some(format!("{indent_prefix}• {rest}"));
455 }
456 }
457
458 if let Some((num, rest)) = parse_ordered_list_marker(trimmed) {
459 return Some(format!("{indent_prefix}{num}. {rest}"));
460 }
461
462 None
463}
464
465fn parse_ordered_list_marker(s: &str) -> Option<(usize, &str)> {
466 let bytes = s.as_bytes();
467 let mut i = 0usize;
468
469 while i < bytes.len() && bytes[i].is_ascii_digit() {
470 i += 1;
471 }
472
473 if i == 0 || i >= bytes.len() {
474 return None;
475 }
476
477 let marker = bytes[i] as char;
478 if marker != '.' && marker != ')' {
479 return None;
480 }
481
482 let n = s[..i].parse::<usize>().ok()?;
483 let rest = s[i + 1..].trim_start();
484 if rest.is_empty() {
485 return None;
486 }
487
488 Some((n, rest))
489}
490
491fn protect_inline_code(input: &str) -> String {
492 let re = Regex::new(r"`([^`\n]+)`").unwrap();
493 re.replace_all(input, |caps: &Captures| {
494 let payload = caps[1]
495 .replace('\x01', "")
496 .replace('\x02', "")
497 .replace('\x03', "")
498 .replace('\x04', "");
499 format!("\x01{}\x02", payload)
500 })
501 .to_string()
502}
503
504fn restore_inline_code(input: &str) -> String {
505 let re = Regex::new(r"\x01(.*?)\x02").unwrap();
506 re.replace_all(input, |caps: &Captures| format!("`{}`", &caps[1]))
507 .to_string()
508}
509
510fn protect_bold_segments(input: &str) -> String {
511 let re1 = Regex::new(r"\*\*(.+?)\*\*").unwrap();
512 let s = re1.replace_all(input, "\x03$1\x04").to_string();
513
514 let re2 = Regex::new(r"__(.+?)__").unwrap();
515 re2.replace_all(&s, "\x03$1\x04").to_string()
516}
517
518fn restore_bold_segments(input: &str) -> String {
519 let re = Regex::new(r"\x03(.*?)\x04").unwrap();
520 re.replace_all(input, "*$1*").to_string()
521}
522
523fn convert_inline_markdown(input: &str) -> String {
524 let mut s = protect_inline_code(input);
525 s = protect_bold_segments(&s);
526
527 s = convert_links(&s);
528 s = convert_strike(&s);
529 s = convert_italic_underscores(&s);
530 s = convert_italic_asterisks(&s);
531 s = restore_bold_segments(&s);
532 s = convert_html_breaks(&s);
533 s = restore_inline_code(&s);
534
535 s
536}
537
538fn convert_links(input: &str) -> String {
539 let re = Regex::new(r"\[([^\]]+)\]\((https?://[^)\s]+)\)").unwrap();
540 re.replace_all(input, "<$2|$1>").to_string()
541}
542
543fn convert_strike(input: &str) -> String {
544 let re = Regex::new(r"~~(.+?)~~").unwrap();
545 re.replace_all(input, "~$1~").to_string()
546}
547
548fn convert_italic_underscores(input: &str) -> String {
549 input.to_string()
550}
551
552fn convert_italic_asterisks(input: &str) -> String {
553 let re = Regex::new(
554 r"(?P<pre>^|[\s\(\[\{>])\*(?P<body>[^*\n][^*\n]*?)\*(?P<post>$|[\s\.\,\!\?\:\;\)\]\}<])",
555 )
556 .unwrap();
557 re.replace_all(input, "${pre}_$body_${post}").to_string()
558}
559
560fn convert_html_breaks(input: &str) -> String {
561 let re = Regex::new(r"(?i)<br\s*/?>").unwrap();
562 re.replace_all(input, " / ").to_string()
563}
564
565fn normalize_spacing(input: &str) -> String {
566 let had_trailing_newline = input.ends_with('\n');
567
568 let mut out = String::new();
569 let mut blank_count = 0usize;
570
571 for line in input.lines() {
572 if line.trim().is_empty() {
573 blank_count += 1;
574 if blank_count <= 1 && !out.ends_with('\n') {
575 out.push('\n');
576 }
577 } else {
578 blank_count = 0;
579 if !out.is_empty() && !out.ends_with('\n') {
580 out.push('\n');
581 }
582 out.push_str(line);
583 }
584 }
585
586 let trimmed_leading = out.trim_start_matches('\n').to_string();
587 if had_trailing_newline && !trimmed_leading.ends_with('\n') {
588 format!("{trimmed_leading}\n")
589 } else {
590 trimmed_leading
591 }
592}
593
594fn parse_table_row(line: &str) -> Option<Vec<String>> {
595 let trimmed = line.trim();
596 if !trimmed.contains('|') {
597 return None;
598 }
599
600 let core = trimmed
601 .strip_prefix('|')
602 .unwrap_or(trimmed)
603 .strip_suffix('|')
604 .unwrap_or(trimmed);
605
606 let cols = split_table_cells(core)
607 .into_iter()
608 .map(|s| s.trim().to_string())
609 .collect::<Vec<_>>();
610
611 if cols.len() < 2 {
612 return None;
613 }
614
615 Some(cols)
616}
617
618fn split_table_cells(s: &str) -> Vec<String> {
619 let mut cells = Vec::new();
620 let mut current = String::new();
621 let mut chars = s.chars().peekable();
622 let mut in_backticks = false;
623
624 while let Some(ch) = chars.next() {
625 match ch {
626 '\\' => {
627 if let Some(next) = chars.next() {
628 current.push(next);
629 } else {
630 current.push('\\');
631 }
632 }
633 '`' => {
634 in_backticks = !in_backticks;
635 current.push(ch);
636 }
637 '|' if !in_backticks => {
638 cells.push(current);
639 current = String::new();
640 }
641 _ => current.push(ch),
642 }
643 }
644
645 cells.push(current);
646 cells
647}
648
649fn parse_alignment_cell(cell: &str) -> Option<ColumnAlignment> {
650 let s = cell.trim();
651 if s.is_empty() {
652 return None;
653 }
654
655 if !s.chars().all(|c| c == '-' || c == ':') {
656 return None;
657 }
658
659 let dash_count = s.chars().filter(|c| *c == '-').count();
660 if dash_count < 3 {
661 return None;
662 }
663
664 let starts = s.starts_with(':');
665 let ends = s.ends_with(':');
666
667 match (starts, ends) {
668 (true, true) => Some(ColumnAlignment::Center),
669 (false, true) => Some(ColumnAlignment::Right),
670 _ => Some(ColumnAlignment::Left),
671 }
672}
673
674fn format_table_row(row: &[String], widths: &[usize], alignments: &[ColumnAlignment]) -> String {
675 row.iter()
676 .enumerate()
677 .map(|(i, cell)| align_cell(cell, widths[i], alignments[i]))
678 .collect::<Vec<_>>()
679 .join(" | ")
680}
681
682fn align_cell(cell: &str, width: usize, alignment: ColumnAlignment) -> String {
683 let len = display_width(cell);
684 let pad = width.saturating_sub(len);
685
686 match alignment {
687 ColumnAlignment::Left => format!("{cell}{}", " ".repeat(pad)),
688 ColumnAlignment::Right => format!("{}{cell}", " ".repeat(pad)),
689 ColumnAlignment::Center => {
690 let left = pad / 2;
691 let right = pad - left;
692 format!("{}{}{}", " ".repeat(left), cell, " ".repeat(right))
693 }
694 }
695}
696
697fn display_width(s: &str) -> usize {
698 s.chars().count()
699}
700
701fn truncate_for_table(s: &str, max_width: usize) -> String {
702 let text = s.trim();
703
704 if max_width <= 1 {
705 return "…".to_string();
706 }
707
708 let chars: Vec<char> = text.chars().collect();
709 if chars.len() <= max_width {
710 return text.to_string();
711 }
712
713 let keep = max_width - 1;
714 let mut out: String = chars.into_iter().take(keep).collect();
715 out.push('…');
716 out
717}
718
719fn convert_inline_markdown_for_table_cell(cell: &str) -> String {
720 convert_inline_markdown(cell)
721}