1use once_cell::sync::Lazy;
11use regex::Regex;
12use std::collections::HashMap;
13use std::rc::Rc;
14
15static RE_STRIP_TAGS: Lazy<Regex> =
19 Lazy::new(|| Regex::new(r"<[^>]*>").expect("valid regex literal"));
20static RE_NEWLINES: Lazy<Regex> = Lazy::new(|| Regex::new(r"\n{2,}").expect("valid regex literal"));
21static RE_CODE_BLOCK: Lazy<Regex> =
22 Lazy::new(|| Regex::new(r"(?s)```(.+?)```").expect("valid regex literal"));
23static RE_INLINE_CODE: Lazy<Regex> =
24 Lazy::new(|| Regex::new(r"`([^`]+?)`").expect("valid regex literal"));
25static RE_HEADER: Lazy<Regex> =
26 Lazy::new(|| Regex::new(r"(?m)^#+\s*(.+)").expect("valid regex literal"));
27
28pub fn escape_html(s: &str) -> String {
33 s.replace('&', "&")
34 .replace('<', "<")
35 .replace('>', ">")
36}
37
38pub fn strip_html_tags(s: &str) -> String {
40 RE_STRIP_TAGS.replace_all(s, "").to_string()
41}
42
43pub fn replace_with_placeholders(
46 s: &str,
47 pattern: &str,
48 placeholder: &str,
49) -> (String, HashMap<String, String>) {
50 let re = Regex::new(pattern).expect("valid regex literal");
51 let mut placeholders = HashMap::new();
52 let mut counter: usize = 0;
53
54 let result = re
55 .replace_all(s, |caps: ®ex::Captures<'_>| {
56 let full = caps
57 .get(0)
58 .expect("capture group present after successful match")
59 .as_str()
60 .to_string();
61 let ph = format!("\x00{placeholder}{counter}\x00");
65 counter += 1;
66 placeholders.insert(ph.clone(), full);
67 ph
68 })
69 .to_string();
70
71 (result, placeholders)
72}
73
74pub fn restore_from_placeholders(s: &str, placeholders: &HashMap<String, String>) -> String {
76 let mut result = s.to_string();
77 for (ph, original) in placeholders {
78 result = result.replace(ph, original);
79 }
80 result
81}
82
83#[derive(Clone, Debug)]
90struct ParseResult {
91 consumed: String,
92 left: String,
93}
94
95static OPEN_TAGS: &[(&str, &str)] = &[("*", "<i>"), ("**", "<b>"), ("_", "<i>"), ("__", "<b>")];
97
98static CLOSE_TAGS: &[(&str, &str)] =
100 &[("*", "</i>"), ("**", "</b>"), ("_", "</i>"), ("__", "</b>")];
101
102fn open_tag(token: &str) -> &'static str {
103 OPEN_TAGS
104 .iter()
105 .find(|(k, _)| *k == token)
106 .map(|(_, v)| *v)
107 .unwrap_or("")
108}
109
110fn close_tag(token: &str) -> &'static str {
111 CLOSE_TAGS
112 .iter()
113 .find(|(k, _)| *k == token)
114 .map(|(_, v)| *v)
115 .unwrap_or("")
116}
117
118type Parser = Rc<dyn Fn(&str) -> Vec<ParseResult>>;
120
121fn parse_open(token: &'static str) -> Parser {
124 Rc::new(move |input: &str| {
125 if let Some(rest) = input.strip_prefix(token) {
126 vec![ParseResult {
127 consumed: open_tag(token).to_string(),
128 left: rest.to_string(),
129 }]
130 } else {
131 vec![]
132 }
133 })
134}
135
136fn parse_close(token: &'static str) -> Parser {
139 Rc::new(move |input: &str| {
140 if let Some(rest) = input.strip_prefix(token) {
141 vec![ParseResult {
142 consumed: close_tag(token).to_string(),
143 left: rest.to_string(),
144 }]
145 } else {
146 vec![]
147 }
148 })
149}
150
151fn parse_not_markdown() -> Parser {
153 Rc::new(|input: &str| {
154 for (i, ch) in input.char_indices() {
155 if ch == '*' || ch == '_' {
156 return vec![ParseResult {
157 consumed: input[..i].to_string(),
158 left: input[i..].to_string(),
159 }];
160 }
161 }
162 if !input.is_empty() {
163 vec![ParseResult {
164 consumed: input.to_string(),
165 left: String::new(),
166 }]
167 } else {
168 vec![]
169 }
170 })
171}
172
173fn parse_or(parsers: Vec<Parser>) -> Parser {
180 Rc::new(move |input: &str| {
181 for p in &parsers {
182 if let Some(first) = p(input).into_iter().next() {
183 return vec![first];
184 }
185 }
186 vec![]
187 })
188}
189
190fn parse_and(parsers: Vec<Parser>) -> Parser {
196 Rc::new(move |input: &str| {
197 let mut current = ParseResult {
198 consumed: String::new(),
199 left: input.to_string(),
200 };
201
202 for p in &parsers {
203 let Some(parsed) = p(¤t.left)
204 .into_iter()
205 .find(|x| !x.consumed.is_empty())
206 else {
207 return vec![];
208 };
209 current = ParseResult {
210 consumed: format!("{}{}", current.consumed, parsed.consumed),
211 left: parsed.left,
212 };
213 }
214 vec![current]
215 })
216}
217
218fn parse_some(parser: Parser) -> Parser {
220 Rc::new(move |input: &str| recursive(input, &parser, 0))
221}
222
223fn recursive(input: &str, parser: &Parser, depth: usize) -> Vec<ParseResult> {
224 const MAX_RECURSION_DEPTH: usize = 4096;
230 if depth >= MAX_RECURSION_DEPTH {
231 return vec![ParseResult {
232 consumed: String::new(),
233 left: input.to_string(),
234 }];
235 }
236
237 let Some(item) = parser(input).into_iter().find(|x| !x.consumed.is_empty()) else {
238 if depth == 0 {
242 return vec![];
243 }
244 return vec![ParseResult {
245 consumed: String::new(),
246 left: input.to_string(),
247 }];
248 };
249
250 let children = recursive(&item.left, parser, depth + 1);
252 if children.is_empty() {
253 return vec![item];
254 }
255 children
256 .into_iter()
257 .map(|child| ParseResult {
258 consumed: format!("{}{}", item.consumed, child.consumed),
259 left: child.left,
260 })
261 .collect()
262}
263
264fn markdown_parser() -> Parser {
266 let text = parse_not_markdown();
268
269 let italic_no_bold = parse_or(vec![
274 parse_and(vec![
275 parse_open("*"),
276 parse_not_markdown(),
277 parse_close("*"),
278 ]),
279 parse_and(vec![
280 parse_open("_"),
281 parse_not_markdown(),
282 parse_close("_"),
283 ]),
284 ]);
285
286 let bold = parse_or(vec![
291 parse_and(vec![
292 parse_open("**"),
293 parse_some(parse_or(vec![parse_not_markdown(), italic_no_bold.clone()])),
294 parse_close("**"),
295 ]),
296 parse_and(vec![
297 parse_open("__"),
298 parse_some(parse_or(vec![parse_not_markdown(), italic_no_bold])),
299 parse_close("__"),
300 ]),
301 ]);
302
303 let italic = parse_or(vec![
308 parse_and(vec![
309 parse_open("*"),
310 parse_some(parse_or(vec![parse_not_markdown(), bold.clone()])),
311 parse_close("*"),
312 ]),
313 parse_and(vec![
314 parse_open("_"),
315 parse_some(parse_or(vec![parse_not_markdown(), bold.clone()])),
316 parse_close("_"),
317 ]),
318 ]);
319
320 parse_some(parse_or(vec![bold, italic, text]))
323}
324
325pub const MAX_MARKDOWN_HTML_INPUT: usize = 64 * 1024;
339
340pub fn markdown_to_html(md: &str) -> String {
344 if md.len() > MAX_MARKDOWN_HTML_INPUT {
345 return escape_html(md);
346 }
347
348 let md_without_code = escape_html(md);
349
350 let (md_without_code, code_placeholders) =
352 replace_with_placeholders(&md_without_code, r"(?s)```.*?```", "c0debl0ck");
353 let (md_without_code, inline_placeholders) =
354 replace_with_placeholders(&md_without_code, r"`[^`]+`", "inl1ne");
355
356 let segments = RE_NEWLINES.split(&md_without_code);
358 let processed: Vec<String> = segments
359 .map(|segment| {
360 let parser = markdown_parser();
361 let docs = parser(segment);
362 if !docs.is_empty() {
363 format!("{}{}", docs[0].consumed, docs[0].left)
364 } else {
365 segment.to_string()
366 }
367 })
368 .collect();
369 let md_without_code = processed.join("\n\n");
370
371 let mut result = restore_from_placeholders(&md_without_code, &code_placeholders);
373 result = restore_from_placeholders(&result, &inline_placeholders);
374
375 result = RE_CODE_BLOCK
377 .replace_all(&result, |caps: ®ex::Captures<'_>| {
378 let inner = caps
379 .get(1)
380 .expect("capture group present after successful match")
381 .as_str()
382 .trim();
383 format!("<pre>{inner}</pre>")
384 })
385 .to_string();
386
387 result = RE_INLINE_CODE
389 .replace_all(&result, "<code>$1</code>")
390 .to_string();
391
392 result = RE_HEADER.replace_all(&result, "<b>$1</b>").to_string();
394
395 result
396}
397
398#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_escape_html() {
408 assert_eq!(escape_html("a & b < c > d"), "a & b < c > d");
409 assert_eq!(escape_html("plain"), "plain");
410 }
411
412 #[test]
413 fn test_strip_html_tags() {
414 assert_eq!(strip_html_tags("<b>hello</b>"), "hello");
415 assert_eq!(strip_html_tags("no tags"), "no tags");
416 assert_eq!(
417 strip_html_tags("<b>bold</b> and <i>italic</i>"),
418 "bold and italic"
419 );
420 }
421
422 #[test]
423 fn test_replace_and_restore_placeholders() {
424 let input = "some ```code``` here";
425 let (modified, phs) = replace_with_placeholders(input, r"(?s)```.*?```", "c0de");
426 assert!(modified.contains("c0de"));
427 let restored = restore_from_placeholders(&modified, &phs);
428 assert_eq!(restored, input);
429 }
430
431 #[test]
432 fn test_markdown_to_html_italic() {
433 let result = markdown_to_html("hello *world*");
434 assert!(result.contains("<i>world</i>"));
435 assert!(result.contains("hello"));
436 }
437
438 #[test]
439 fn test_markdown_to_html_bold() {
440 let result = markdown_to_html("hello **world**");
441 assert!(result.contains("<b>world</b>"));
442 }
443
444 #[test]
445 fn test_markdown_to_html_bold_underscore() {
446 let result = markdown_to_html("hello __world__");
447 assert!(result.contains("<b>world</b>"));
448 }
449
450 #[test]
451 fn test_markdown_to_html_italic_underscore() {
452 let result = markdown_to_html("hello _world_");
453 assert!(result.contains("<i>world</i>"));
454 }
455
456 #[test]
457 fn test_markdown_to_html_code_block() {
458 let result = markdown_to_html("```\ncode\n```");
459 assert!(result.contains("<pre>code</pre>"));
460 }
461
462 #[test]
463 fn test_markdown_to_html_inline_code() {
464 let result = markdown_to_html("use `foo` here");
465 assert!(result.contains("<code>foo</code>"));
466 }
467
468 #[test]
469 fn test_markdown_to_html_header() {
470 let result = markdown_to_html("# Title");
471 assert!(result.contains("<b>Title</b>"));
472 }
473
474 #[test]
475 fn test_markdown_to_html_header_h3() {
476 let result = markdown_to_html("### Subtitle");
477 assert!(result.contains("<b>Subtitle</b>"));
478 }
479
480 #[test]
481 fn test_markdown_to_html_plain_text_unchanged() {
482 let result = markdown_to_html("just plain text");
483 assert_eq!(result, "just plain text");
484 }
485
486 #[test]
487 fn test_markdown_to_html_html_chars_escaped() {
488 let result = markdown_to_html("a < b & c > d");
489 assert!(result.contains("<"));
490 assert!(result.contains(">"));
491 assert!(result.contains("&"));
492 }
493
494 #[test]
495 fn test_markdown_to_html_mixed() {
496 let result = markdown_to_html("**bold** and *italic* and `code`");
497 assert!(result.contains("<b>bold</b>"));
498 assert!(result.contains("<i>italic</i>"));
499 assert!(result.contains("<code>code</code>"));
500 }
501
502 #[test]
503 fn test_parser_not_markdown() {
504 let p = parse_not_markdown();
505 let results = p("hello*world");
506 assert_eq!(results.len(), 1);
507 assert_eq!(results[0].consumed, "hello");
508 assert_eq!(results[0].left, "*world");
509 }
510
511 #[test]
512 fn test_parser_not_markdown_no_special() {
513 let p = parse_not_markdown();
514 let results = p("hello world");
515 assert_eq!(results.len(), 1);
516 assert_eq!(results[0].consumed, "hello world");
517 assert_eq!(results[0].left, "");
518 }
519
520 #[test]
521 fn test_parser_open_close() {
522 let p = parse_open("**");
523 let results = p("**bold**");
524 assert_eq!(results.len(), 1);
525 assert_eq!(results[0].consumed, "<b>");
526 assert_eq!(results[0].left, "bold**");
527
528 let p = parse_close("**");
529 let results = p("**rest");
530 assert_eq!(results.len(), 1);
531 assert_eq!(results[0].consumed, "</b>");
532 assert_eq!(results[0].left, "rest");
533 }
534
535 #[test]
536 fn test_parser_and() {
537 let p = parse_and(vec![
538 parse_open("*"),
539 parse_not_markdown(),
540 parse_close("*"),
541 ]);
542 let results = p("*hello*");
543 assert!(!results.is_empty());
544 assert_eq!(results[0].consumed, "<i>hello</i>");
545 }
546}