robit_chatbot/
markdown.rs1use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
19
20use crate::adapter::MarkdownFeatures;
21
22pub fn prepare_markdown_for_platform(text: &str, features: &MarkdownFeatures) -> String {
27 let mut opts = Options::empty();
28 if features.strikethrough {
29 opts.insert(Options::ENABLE_STRIKETHROUGH);
30 }
31 opts.insert(Options::ENABLE_TABLES);
32 opts.insert(Options::ENABLE_TASKLISTS);
33
34 let parser = Parser::new_ext(text, opts);
35 let mut output = String::with_capacity(text.len());
36 let mut ctx = RenderCtx::new(features);
37
38 for event in parser {
39 match event {
40 Event::Text(t) => {
42 output.push_str(&t);
43 }
44 Event::Code(c) => {
45 if features.inline_code {
46 output.push('`');
47 output.push_str(&c);
48 output.push('`');
49 } else {
50 output.push_str(&c);
51 }
52 }
53 Event::SoftBreak => output.push('\n'),
54 Event::HardBreak => output.push_str(" \n"),
55
56 Event::Start(Tag::Strong) if features.bold => output.push_str("**"),
58 Event::End(TagEnd::Strong) if features.bold => output.push_str("**"),
59 Event::Start(Tag::Emphasis) if features.italic => output.push('*'),
60 Event::End(TagEnd::Emphasis) if features.italic => output.push('*'),
61 Event::Start(Tag::Strikethrough) if features.strikethrough => output.push_str("~~"),
62 Event::End(TagEnd::Strikethrough) if features.strikethrough => output.push_str("~~"),
63
64 Event::Start(Tag::Heading { level, .. }) if features.headings => {
66 let hashes = "#".repeat(level as usize);
67 output.push_str(&hashes);
68 output.push(' ');
69 }
70 Event::End(TagEnd::Heading(_)) if features.headings => {
71 output.push_str("\n\n");
72 }
73 Event::Start(Tag::Heading { .. }) => output.push_str("**"),
75 Event::End(TagEnd::Heading(_)) => output.push_str("**\n\n"),
76
77 Event::Start(Tag::Paragraph) => {}
79 Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
80
81 Event::Start(Tag::CodeBlock(_)) => {
83 ctx.in_code_block = true;
84 output.push_str("\n```\n");
85 }
86 Event::End(TagEnd::CodeBlock) if ctx.in_code_block => {
87 ctx.in_code_block = false;
88 if output.ends_with('\n') {
91 output.push_str("```\n\n");
92 } else {
93 output.push_str("\n```\n\n");
94 }
95 }
96
97 Event::Start(Tag::List(None)) => ctx.list_stack.push(ListKind::Unordered),
99 Event::Start(Tag::List(Some(start))) => {
100 ctx.list_stack.push(ListKind::Ordered(start));
101 }
102 Event::End(TagEnd::List(_)) => {
103 ctx.list_stack.pop();
104 if ctx.list_stack.is_empty() {
105 output.push('\n');
106 }
107 }
108 Event::Start(Tag::Item) => {
109 ctx.indent(&mut output);
110 match ctx.list_stack.last() {
111 Some(ListKind::Ordered(n)) => {
112 output.push_str(&format!("{}. ", n));
113 }
114 _ => output.push_str("- "),
115 }
116 }
117 Event::End(TagEnd::Item) => {
118 if !output.ends_with('\n') {
119 output.push('\n');
120 }
121 }
122
123 Event::Start(Tag::BlockQuote(_)) if features.blockquotes => {
131 ctx.in_blockquote = true;
132 }
133 Event::End(TagEnd::BlockQuote(_)) if ctx.blockquote_was_open() => {
134 ctx.in_blockquote = false;
135 }
136 Event::Start(Tag::BlockQuote(_)) => {
137 output.push_str("> ");
139 }
140 Event::End(TagEnd::BlockQuote(_)) => {
141 output.push('\n');
142 }
143
144 Event::Start(Tag::Link { dest_url, .. }) if features.links => {
146 ctx.link_url = Some(dest_url.into_string());
147 output.push('[');
148 }
149 Event::End(TagEnd::Link) if ctx.link_url.is_some() => {
150 if let Some(url) = ctx.link_url.take() {
151 output.push_str(&format!("]({})", url));
152 }
153 }
154 Event::Start(Tag::Link { .. }) => {}
156 Event::End(TagEnd::Link) => {}
157
158 Event::Start(Tag::Image { dest_url, .. }) => {
160 ctx.image_url = Some(dest_url.into_string());
161 output.push_str("[Image: ");
162 }
163 Event::End(TagEnd::Image) => {
164 ctx.image_url = None;
165 output.push(']');
166 }
167
168 Event::Rule => {
170 output.push('\n');
171 }
172
173 Event::Start(Tag::Table(_)) => {
175 ctx.in_table = true;
176 ctx.table_rows.clear();
177 ctx.current_row.clear();
178 }
179 Event::End(TagEnd::Table) => {
180 ctx.in_table = false;
181 output.push_str(&render_table(&ctx.table_rows));
182 ctx.table_rows.clear();
183 }
184 Event::Start(Tag::TableHead) => {}
185 Event::End(TagEnd::TableHead) => {
186 ctx.table_rows.push(std::mem::take(&mut ctx.current_row));
187 }
188 Event::Start(Tag::TableRow) => {}
189 Event::End(TagEnd::TableRow) => {
190 ctx.table_rows.push(std::mem::take(&mut ctx.current_row));
191 }
192 Event::Start(Tag::TableCell) => {
193 ctx.cell_buf.clear();
194 ctx.in_cell = true;
195 }
196 Event::End(TagEnd::TableCell) => {
197 ctx.in_cell = false;
198 ctx.current_row.push(ctx.cell_buf.trim().to_string());
199 }
200
201 _ => {}
206 }
207
208 if ctx.in_cell {
210 if let Some(stripped) = strip_last_text(&mut output) {
211 ctx.cell_buf.push_str(&stripped);
212 }
213 }
214 }
215
216 while output.contains("\n\n\n") {
218 output = output.replace("\n\n\n", "\n\n");
219 }
220 output.trim_end().to_string() + "\n"
221}
222
223#[derive(Debug, Clone, Copy)]
224enum ListKind {
225 Unordered,
226 Ordered(u64),
227}
228
229struct RenderCtx<'a> {
230 #[allow(dead_code)]
231 features: &'a MarkdownFeatures,
232 in_code_block: bool,
233 in_blockquote: bool,
234 list_stack: Vec<ListKind>,
235 link_url: Option<String>,
236 image_url: Option<String>,
237 in_table: bool,
239 in_cell: bool,
240 cell_buf: String,
241 current_row: Vec<String>,
242 table_rows: Vec<Vec<String>>,
243}
244
245impl<'a> RenderCtx<'a> {
246 fn new(features: &'a MarkdownFeatures) -> Self {
247 Self {
248 features,
249 in_code_block: false,
250 in_blockquote: false,
251 list_stack: Vec::new(),
252 link_url: None,
253 image_url: None,
254 in_table: false,
255 in_cell: false,
256 cell_buf: String::new(),
257 current_row: Vec::new(),
258 table_rows: Vec::new(),
259 }
260 }
261
262 fn indent(&self, out: &mut String) {
263 for _ in 0..self.list_stack.len().saturating_sub(1) {
265 out.push_str(" ");
266 }
267 }
268
269 fn blockquote_was_open(&self) -> bool {
270 self.in_blockquote
271 }
272}
273
274fn render_table(rows: &[Vec<String>]) -> String {
276 if rows.is_empty() {
277 return String::new();
278 }
279 let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
280 let mut widths = vec![0usize; cols];
281 for row in rows {
282 for (i, cell) in row.iter().enumerate() {
283 widths[i] = widths[i].max(cell.chars().count());
284 }
285 }
286 let mut out = String::new();
287 for (ri, row) in rows.iter().enumerate() {
288 for (i, cell) in row.iter().enumerate() {
289 let w = widths.get(i).copied().unwrap_or(0);
290 let pad = w.saturating_sub(cell.chars().count());
291 out.push_str(cell);
292 out.push_str(&" ".repeat(pad));
293 if i + 1 < cols {
294 out.push_str(" | ");
295 }
296 }
297 out.push('\n');
298 if ri == 0 {
299 for (i, w) in widths.iter().enumerate() {
301 out.push_str(&"-".repeat(*w));
302 if i + 1 < cols {
303 out.push_str("-+-");
304 }
305 }
306 out.push('\n');
307 }
308 }
309 out.push('\n');
310 out
311}
312
313fn strip_last_text(output: &mut String) -> Option<String> {
316 let end = output.len();
319 if end == 0 {
320 return None;
321 }
322 let bytes = output.as_bytes();
323 let mut start = end;
324 while start > 0 && bytes[start - 1] != b'\n' {
325 start -= 1;
326 }
327 if start == end {
328 return None;
329 }
330 let text = output[start..end].to_string();
331 output.truncate(start);
332 Some(text)
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 fn qq() -> MarkdownFeatures {
340 MarkdownFeatures::qq()
341 }
342
343 #[test]
344 fn strips_bold_and_italic_for_qq() {
345 let out = prepare_markdown_for_platform("**bold** and *italic*", &qq());
347 assert!(!out.contains("**bold**"));
348 assert!(!out.contains("*italic*"));
349 assert!(out.contains("bold and italic"));
350 }
351
352 #[test]
353 fn passes_through_code_blocks_for_qq() {
354 let md = "```rust\nfn main() {}\n```\n";
356 let out = prepare_markdown_for_platform(md, &qq());
357 assert!(out.contains("```\nfn main() {}\n```"));
358 }
359
360 #[test]
361 fn strips_inline_code_for_qq() {
362 let out = prepare_markdown_for_platform("use `cargo` to build", &qq());
364 assert!(!out.contains("`cargo`"));
365 assert!(out.contains("cargo"));
366 }
367
368 #[test]
369 fn strips_links_for_qq() {
370 let out = prepare_markdown_for_platform("[site](https://example.com)", &qq());
372 assert!(out.contains("site") || out.contains("https://example.com"));
374 }
375
376 #[test]
377 fn converts_image_to_alt_fallback() {
378 let out = prepare_markdown_for_platform("", &qq());
379 assert!(out.contains("[Image: logo]"));
380 assert!(!out.contains("https://x.com/a.png"));
381 }
382
383 #[test]
384 fn strips_html_tags() {
385 let out = prepare_markdown_for_platform("<b>hi</b>", &qq());
386 assert!(!out.contains("<b>"));
387 assert!(out.contains("hi"));
388 }
389
390 #[test]
391 fn converts_table_to_aligned_text() {
392 let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
393 let out = prepare_markdown_for_platform(md, &qq());
394 assert!(!out.contains("|---|"));
396 assert!(out.contains("a"));
397 assert!(out.contains("1"));
398 }
399
400 #[test]
401 fn preserves_code_block_contents_with_dashes() {
402 let md = "```\n---\nx\n```\n";
404 let out = prepare_markdown_for_platform(md, &qq());
405 assert!(out.contains("---\nx"));
406 }
407
408 #[test]
409 fn handles_empty_input() {
410 let out = prepare_markdown_for_platform("", &qq());
411 assert!(out.trim().is_empty());
412 }
413
414 #[test]
415 fn handles_unicode_for_qq() {
416 let out = prepare_markdown_for_platform("**你好** 世界", &qq());
418 assert!(out.contains("你好"));
419 assert!(out.contains("世界"));
420 }
421
422 #[test]
423 fn unsupported_headings_become_bold() {
424 let mut f = MarkdownFeatures::default(); f.bold = true;
426 let out = prepare_markdown_for_platform("# Title", &f);
427 assert!(out.contains("**Title**"));
428 }
429}