1use pulldown_cmark::{Alignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
2
3pub fn format(input: &str) -> String {
12 if input.trim().is_empty() {
13 return String::new();
14 }
15
16 let mut state = FormatterState::new();
17 let events: Vec<Event<'_>> = Parser::new_ext(input, mk_options()).collect();
18
19 let lookahead: Vec<bool> = (0..events.len())
21 .map(|i| matches!(events.get(i + 1), Some(Event::Start(Tag::List(None)))))
22 .collect();
23
24 for (event, next_is_ul) in events.into_iter().zip(lookahead) {
25 state.next_is_unordered_list = next_is_ul;
26 state.process(event);
27 }
28
29 state.finish()
30}
31
32fn mk_options() -> Options {
33 Options::ENABLE_TABLES
34 | Options::ENABLE_FOOTNOTES
35 | Options::ENABLE_STRIKETHROUGH
36 | Options::ENABLE_TASKLISTS
37 | Options::ENABLE_HEADING_ATTRIBUTES
38}
39
40struct FormatterState {
41 out: String,
42 needs_blank: bool,
44
45 list_depth: usize,
47 list_starts: Vec<Option<u64>>,
49 in_tight_item: bool,
51
52 bq_depth: usize,
54
55 inline: String,
57
58 in_code_block: bool,
60 code_block_indent: String,
61
62 list_item_widths: Vec<usize>,
65
66 link_stack: Vec<(String, String)>,
68
69 next_is_unordered_list: bool,
73
74 table_alignments: Vec<Alignment>,
76 table_head_cells: Vec<String>,
77 table_data_rows: Vec<Vec<String>>,
78 current_row_cells: Vec<String>,
79 in_table_head: bool,
80}
81
82impl FormatterState {
83 fn new() -> Self {
84 Self {
85 out: String::new(),
86 needs_blank: false,
87 list_depth: 0,
88 list_starts: Vec::new(),
89 in_tight_item: false,
90 bq_depth: 0,
91 inline: String::new(),
92 in_code_block: false,
93 code_block_indent: String::new(),
94 list_item_widths: Vec::new(),
95 link_stack: Vec::new(),
96 next_is_unordered_list: false,
97 table_alignments: Vec::new(),
98 table_head_cells: Vec::new(),
99 table_data_rows: Vec::new(),
100 current_row_cells: Vec::new(),
101 in_table_head: false,
102 }
103 }
104
105 fn process(&mut self, event: Event<'_>) {
106 match event {
107 Event::Start(tag) => self.on_start(tag),
108 Event::End(tag) => self.on_end(tag),
109 Event::Text(t) => self.on_text(&t),
110 Event::Code(c) => self.emit_inline_code(&c),
111 Event::Html(h) => {
112 self.out.push_str(&h);
113 }
114 Event::InlineHtml(h) => {
115 self.inline.push_str(&h);
116 }
117 Event::SoftBreak => {
118 self.inline.push('\n');
119 }
120 Event::HardBreak => {
121 self.inline.push_str("\\\n");
124 }
125 Event::Rule => {
126 self.emit_blank_if_needed();
127 self.write_bq_prefix();
128 self.out.push_str("---\n");
129 self.needs_blank = true;
130 }
131 Event::FootnoteReference(label) => {
132 self.inline.push_str(&format!("[^{}]", label));
133 }
134 Event::TaskListMarker(checked) => {
135 if checked {
136 self.inline.push_str("[x] ");
137 } else {
138 self.inline.push_str("[ ] ");
139 }
140 }
141 _ => {}
142 }
143 }
144
145 fn on_start(&mut self, tag: Tag<'_>) {
146 match tag {
147 Tag::Paragraph => {
148 if self.list_depth == 0 {
151 self.emit_blank_if_needed();
152 }
153 self.in_tight_item = false;
154 }
155 Tag::Heading { .. } => {
156 self.emit_blank_if_needed();
157 }
159 Tag::CodeBlock(kind) => {
160 self.emit_blank_if_needed();
161 let lang = match kind {
162 CodeBlockKind::Fenced(lang) => lang.into_string().replace('\\', "\\\\"),
163 CodeBlockKind::Indented => String::new(),
164 };
165 let fence_indent = self.list_continuation_prefix();
166 let content_indent = if self.in_tight_item {
170 let marker_width = self.list_item_widths.last().copied().unwrap_or(0);
171 " ".repeat(marker_width + fence_indent.len())
172 } else {
173 fence_indent.clone()
174 };
175 let was_tight = self.in_tight_item;
176 self.in_tight_item = false;
177 self.code_block_indent = content_indent;
178 if !was_tight {
183 self.write_bq_prefix();
184 }
185 self.out.push_str(&fence_indent);
186 self.out.push_str("```");
187 self.out.push_str(&lang);
188 self.out.push('\n');
189 self.in_code_block = true;
190 }
191 Tag::List(start) => {
192 self.list_item_widths.push(0);
193 if self.list_depth == 0 {
194 self.emit_blank_if_needed();
195 } else {
196 self.needs_blank = false;
199 if self.in_tight_item && !self.inline.is_empty() {
202 let text = std::mem::take(&mut self.inline);
203 let prefix = " ".repeat(self.list_depth);
204 self.flush_inline_text(&text, &prefix);
205 self.in_tight_item = false;
206 } else if self.in_tight_item {
207 self.out.push('\n');
211 self.in_tight_item = false;
212 }
213 }
214 self.list_depth += 1;
215 self.list_starts.push(start.map(|_| 1u64));
217 }
218 Tag::Item => {
219 if self.list_depth > 0 {
222 self.emit_blank_if_needed();
223 }
224 self.in_tight_item = true;
225 let indent = " ".repeat(self.list_depth.saturating_sub(1));
226 let marker = match self.list_starts.last_mut() {
227 Some(Some(n)) => {
228 let s = format!("{}{}. ", indent, n);
229 *n += 1;
230 s
231 }
232 _ => format!("{}- ", indent),
233 };
234 if let Some(w) = self.list_item_widths.last_mut() {
235 *w = marker.len();
236 }
237 self.write_bq_prefix();
238 self.out.push_str(&marker);
239 }
240 Tag::Emphasis => self.inline.push('*'),
241 Tag::Strong => self.inline.push_str("**"),
242 Tag::Strikethrough => self.inline.push_str("~~"),
243 Tag::Link {
244 dest_url, title, ..
245 } => {
246 self.link_stack
247 .push((dest_url.into_string(), title.into_string()));
248 self.inline.push('[');
249 }
250 Tag::Image {
251 dest_url, title, ..
252 } => {
253 self.link_stack
254 .push((dest_url.into_string(), title.into_string()));
255 self.inline.push_str("![");
256 }
257 Tag::HtmlBlock => {
258 self.emit_blank_if_needed();
259 }
260 Tag::BlockQuote(_) => {
261 self.emit_blank_if_needed();
262 self.bq_depth += 1;
263 }
264 Tag::FootnoteDefinition(label) => {
265 self.emit_blank_if_needed();
266 self.write_bq_prefix();
268 self.out.push_str(&format!("[^{}]: ", label));
269 }
270 Tag::Table(alignments) => {
271 self.emit_blank_if_needed();
272 self.table_alignments = alignments.to_vec();
273 self.table_head_cells = Vec::new();
274 self.table_data_rows = Vec::new();
275 self.current_row_cells = Vec::new();
276 self.in_table_head = false;
277 }
278 Tag::TableHead => {
279 self.in_table_head = true;
280 }
281 Tag::TableRow => {
282 self.current_row_cells = Vec::new();
283 }
284 Tag::TableCell => {
285 }
287 _ => {}
288 }
289 }
290
291 fn on_end(&mut self, tag: TagEnd) {
292 match tag {
293 TagEnd::Paragraph => {
294 let text = std::mem::take(&mut self.inline);
295 if self.list_depth == 0 {
296 self.write_bq_prefix();
297 }
298 let prefix = " ".repeat(self.list_depth);
299 self.flush_inline_text(&text, &prefix);
300 self.needs_blank = true;
301 self.in_tight_item = false;
302 }
303 TagEnd::Heading(level) => {
304 let text = std::mem::take(&mut self.inline);
305 let hashes = "#".repeat(level as usize);
306 self.write_bq_prefix();
307 let heading_raw = text.replace("\\\n", " ").replace('\n', " ");
313 let heading_text = heading_raw.trim();
314 self.out.push_str(&format!("{} {}\n", hashes, heading_text));
315 self.needs_blank = true;
316 }
317 TagEnd::CodeBlock => {
318 if !self.out.ends_with('\n') {
321 self.out.push('\n');
322 }
323 self.write_bq_prefix();
324 self.out.push_str(&self.code_block_indent.clone());
325 self.out.push_str("```\n");
326 self.in_code_block = false;
327 self.code_block_indent = String::new();
328 self.needs_blank = true;
329 }
330 TagEnd::List(_) => {
331 self.list_depth -= 1;
332 self.list_starts.pop();
333 self.list_item_widths.pop();
334 if self.list_depth == 0 {
335 if self.next_is_unordered_list {
336 self.needs_blank = false;
340 self.out.push_str("\n<!---->\n");
341 self.needs_blank = true;
342 } else {
343 self.needs_blank = true;
344 }
345 }
346 }
347 TagEnd::Item
348 if self.in_tight_item => {
350 let text = std::mem::take(&mut self.inline);
351 if text.is_empty() {
352 self.out.push('\n');
354 } else {
355 let prefix = " ".repeat(self.list_depth);
356 self.flush_inline_text(&text, &prefix);
357 }
358 self.in_tight_item = false;
359 }
360 TagEnd::Emphasis => self.inline.push('*'),
361 TagEnd::Strong => self.inline.push_str("**"),
362 TagEnd::Strikethrough => self.inline.push_str("~~"),
363 TagEnd::Link => {
364 if let Some((dest, title)) = self.link_stack.pop() {
365 if title.is_empty() {
366 self.inline.push_str(&format!("]({})", dest));
367 } else {
368 self.inline.push_str(&format!("]({} \"{}\")", dest, title));
369 }
370 }
371 }
372 TagEnd::Image => {
373 if let Some((dest, title)) = self.link_stack.pop() {
374 if title.is_empty() {
375 self.inline.push_str(&format!("]({})", dest));
376 } else {
377 self.inline.push_str(&format!("]({} \"{}\")", dest, title));
378 }
379 }
380 }
381 TagEnd::HtmlBlock => {
382 if !self.out.ends_with('\n') {
383 self.out.push('\n');
384 }
385 self.needs_blank = true;
386 }
387 TagEnd::BlockQuote(_) => {
388 self.bq_depth -= 1;
389 self.needs_blank = true;
390 }
391 TagEnd::FootnoteDefinition => {
392 let text = std::mem::take(&mut self.inline);
393 self.flush_inline_text(&text, "");
394 self.needs_blank = true;
395 }
396 TagEnd::TableCell => {
397 let cell = std::mem::take(&mut self.inline);
398 self.current_row_cells.push(cell);
399 }
400 TagEnd::TableHead => {
401 if self.table_head_cells.is_empty() {
404 self.table_head_cells = std::mem::take(&mut self.current_row_cells);
405 }
406 self.in_table_head = false;
407 }
408 TagEnd::TableRow => {
409 let row = std::mem::take(&mut self.current_row_cells);
410 if self.in_table_head {
411 self.table_head_cells = row;
412 } else {
413 self.table_data_rows.push(row);
414 }
415 }
416 TagEnd::Table => {
417 let head = std::mem::take(&mut self.table_head_cells);
418 let rows = std::mem::take(&mut self.table_data_rows);
419 let aligns = std::mem::take(&mut self.table_alignments);
420
421 self.write_bq_prefix();
423 self.out.push_str("| ");
424 self.out.push_str(&head.join(" | "));
425 self.out.push_str(" |\n");
426
427 self.write_bq_prefix();
429 self.out.push_str("| ");
430 let seps: Vec<&str> = aligns
431 .iter()
432 .map(|a| match a {
433 Alignment::Left => ":---",
434 Alignment::Right => "---:",
435 Alignment::Center => ":---:",
436 Alignment::None => "---",
437 })
438 .collect();
439 self.out.push_str(&seps.join(" | "));
440 self.out.push_str(" |\n");
441
442 for row in rows {
444 self.write_bq_prefix();
445 self.out.push_str("| ");
446 self.out.push_str(&row.join(" | "));
447 self.out.push_str(" |\n");
448 }
449
450 self.needs_blank = true;
451 }
452 _ => {}
453 }
454 }
455
456 fn on_text(&mut self, text: &str) {
457 if self.in_code_block {
458 let bq = "> ".repeat(self.bq_depth);
465 if bq.is_empty() && self.code_block_indent.is_empty() {
466 self.out.push_str(text);
467 } else {
468 for line in text.split_inclusive('\n') {
469 self.out.push_str(&bq);
470 self.out.push_str(&self.code_block_indent);
471 self.out.push_str(line);
472 }
473 }
474 } else {
475 let text = &*text.replace("\r\n", "\n").replace('\r', "\n");
494 let prev_inline_char = self.inline.chars().next_back();
495 let chars: Vec<char> = text.chars().collect();
496 let mut s = String::with_capacity(text.len() + 4);
497 for (i, &ch) in chars.iter().enumerate() {
498 match ch {
499 '\\' => s.push_str("\\\\"),
500 '`' => s.push_str("\\`"),
501 '_' | '~' => {
502 let prev = if i > 0 {
503 Some(chars[i - 1])
504 } else {
505 prev_inline_char
506 };
507 let next = chars.get(i + 1).copied();
508 if prev.is_some_and(char::is_alphanumeric)
510 && next.is_some_and(char::is_alphanumeric)
511 {
512 s.push(ch);
513 } else {
514 s.push('\\');
515 s.push(ch);
516 }
517 }
518 _ => s.push(ch),
519 }
520 }
521 self.inline.push_str(&s);
522 }
523 }
524
525 fn emit_inline_code(&mut self, code: &str) {
526 let max_run = code.chars().fold((0usize, 0usize), |(max, cur), ch| {
528 if ch == '`' {
529 (max.max(cur + 1), cur + 1)
530 } else {
531 (max, 0)
532 }
533 });
534 let delim = "`".repeat(max_run.0 + 1);
535 let needs_space = code.starts_with('`') || code.ends_with('`');
536 self.inline.push_str(&delim);
537 if needs_space {
538 self.inline.push(' ');
539 }
540 self.inline.push_str(code);
541 if needs_space {
542 self.inline.push(' ');
543 }
544 self.inline.push_str(&delim);
545 }
546
547 fn list_continuation_prefix(&self) -> String {
551 " ".repeat(self.list_item_widths.last().copied().unwrap_or(0))
552 }
553
554 fn emit_blank_if_needed(&mut self) {
555 if self.needs_blank && !self.out.is_empty() {
556 if self.bq_depth > 0 {
557 self.out.push_str(&">".repeat(self.bq_depth));
560 }
561 self.out.push('\n');
562 }
563 self.needs_blank = false;
564 }
565
566 fn write_bq_prefix(&mut self) {
567 self.out.push_str(&"> ".repeat(self.bq_depth));
568 }
569
570 fn flush_inline_text(&mut self, text: &str, continuation_prefix: &str) {
574 let text = {
581 let s = text.trim_end_matches(|c: char| c != '\n' && c.is_whitespace());
582 s.strip_suffix("\\\n").unwrap_or(text)
583 };
584 let bq = "> ".repeat(self.bq_depth);
585 let mut lines = text.split('\n').peekable();
586
587 if let Some(first) = lines.next() {
588 if self.bq_depth > 0 && (self.out.ends_with('\n') || self.out.is_empty()) {
589 self.out.push_str(&bq);
590 }
591 if needs_line_escape(first, false) {
592 self.out.push_str(&escape_line(first));
593 } else {
594 self.out.push_str(first);
595 }
596 self.out.push('\n');
597 }
598
599 while let Some(line) = lines.next() {
600 if lines.peek().is_none() && line.is_empty() {
601 break;
603 }
604 self.out.push_str(continuation_prefix);
605 self.out.push_str(&bq);
606 if needs_line_escape(line, true) {
607 self.out.push_str(&escape_line(line));
608 } else {
609 self.out.push_str(line);
610 }
611 self.out.push('\n');
612 }
613 }
614
615 fn finish(mut self) -> String {
616 let s = std::mem::take(&mut self.out);
617 let mut result: Vec<&str> = Vec::new();
618 let mut prev_blank = false;
619 for line in s.lines() {
620 let line = line.trim_end();
621 if line.is_empty() {
622 if !prev_blank {
623 result.push(line);
624 }
625 prev_blank = true;
626 } else {
627 result.push(line);
628 prev_blank = false;
629 }
630 }
631 let start = result
635 .iter()
636 .position(|l| !l.is_empty())
637 .unwrap_or(result.len());
638 let joined = result[start..].join("\n");
639 let trimmed = joined.trim_end_matches('\n');
640 if trimmed.is_empty() {
641 return String::new();
642 }
643 format!("{}\n", trimmed)
644 }
645}
646
647fn escape_line(line: &str) -> String {
660 let digits_len = line.chars().take_while(|c| c.is_ascii_digit()).count();
661 if digits_len > 0 {
662 format!("{}\\{}", &line[..digits_len], &line[digits_len..])
664 } else {
665 format!("\\{line}")
666 }
667}
668
669fn needs_line_escape(line: &str, is_continuation: bool) -> bool {
679 let line = line.trim_end();
683 if line.is_empty() {
684 return false;
685 }
686
687 if line.starts_with('>') {
689 return true;
690 }
691
692 if let Some(rest) = line.strip_prefix(['*', '-', '+'])
695 && (rest.is_empty() || rest.starts_with([' ', '\t']))
696 {
697 return true;
698 }
699
700 let first = line.chars().next().unwrap();
704 if matches!(first, '-' | '*' | '_') {
705 let all_valid = line.chars().all(|c| c == first || c == ' ' || c == '\t');
706 let count = line.chars().filter(|&c| c == first).count();
707 if all_valid && count >= 3 {
708 return true;
709 }
710 }
711
712 let after_hashes = line.trim_start_matches('#');
713 if after_hashes.len() < line.len()
714 && (after_hashes.is_empty() || after_hashes.starts_with([' ', '\t']))
715 {
716 return true;
717 }
718
719 let digits: String = line.chars().take_while(|c| c.is_ascii_digit()).collect();
723 if !digits.is_empty() {
724 let rest = &line[digits.len()..];
725 if let Some(after_marker) = rest.strip_prefix(['.', ')'])
726 && (after_marker.is_empty() || after_marker.starts_with([' ', '\t']))
727 && (!is_continuation || digits == "1")
728 {
729 return true;
730 }
731 }
732
733 if is_continuation {
739 let trimmed = line.trim_end_matches([' ', '\t']);
740 if !trimmed.is_empty()
741 && (trimmed.chars().all(|c| c == '=') || trimmed.chars().all(|c| c == '-'))
742 {
743 return true;
744 }
745 }
746
747 if line.starts_with("<!--") || line.starts_with("<?") || line.starts_with("<![CDATA[") {
753 return true;
754 }
755 if let Some(rest) = line.strip_prefix("<!")
756 && rest.starts_with(|c: char| c.is_ascii_uppercase())
757 {
758 return true;
759 }
760 let lower: String = line
762 .chars()
763 .take(12)
764 .collect::<String>()
765 .to_ascii_lowercase();
766 for tag in &["<script", "<pre", "<style", "<textarea"] {
767 if let Some(rest) = lower.strip_prefix(tag)
768 && (rest.is_empty() || rest.starts_with([' ', '\t', '>']))
769 {
770 return true;
771 }
772 }
773
774 false
775}
776
777#[cfg(test)]
778mod tests {
779 use super::*;
780
781 fn assert_formats_to(input: &str, expected: &str) {
784 let got = format(input);
785 assert_eq!(
786 got, expected,
787 "format(input) did not match expected.\nInput:\n{input}\nExpected:\n{expected}\nGot:\n{got}"
788 );
789 assert_eq!(
790 format(expected),
791 expected,
792 "format(expected) != expected — already-canonical content must be unchanged.\nExpected:\n{expected}"
793 );
794 }
795
796 #[test]
797 fn test_empty_input() {
798 assert_eq!(format(""), "");
799 assert_eq!(format(" "), "");
800 assert_eq!(format("\n\n"), "");
801 }
802
803 #[test]
804 fn test_simple_paragraph() {
805 assert_eq!(format("Hello, world."), "Hello, world.\n");
806 }
807
808 #[test]
809 fn test_atx_heading() {
810 assert_eq!(format("# Heading 1"), "# Heading 1\n");
811 assert_eq!(format("## Heading 2"), "## Heading 2\n");
812 assert_eq!(format("###### Heading 6"), "###### Heading 6\n");
813 }
814
815 #[test]
816 fn test_heading_and_paragraph() {
817 let input = "# Title\n\nSome text.";
818 let output = format(input);
819 assert_eq!(output, "# Title\n\nSome text.\n");
820 }
821
822 #[test]
823 fn test_multiple_paragraphs() {
824 let input = "First paragraph.\n\nSecond paragraph.";
825 let output = format(input);
826 assert_eq!(output, "First paragraph.\n\nSecond paragraph.\n");
827 }
828
829 #[test]
830 fn test_fenced_code_block() {
831 let input = "```rust\nlet x = 1;\n```";
832 let output = format(input);
833 assert_eq!(output, "```rust\nlet x = 1;\n```\n");
834 }
835
836 #[test]
837 fn test_code_block_no_lang() {
838 let input = "```\ncode here\n```";
839 let output = format(input);
840 assert_eq!(output, "```\ncode here\n```\n");
841 }
842
843 #[test]
844 fn test_unordered_list() {
845 let input = "- Item 1\n- Item 2\n- Item 3";
846 let output = format(input);
847 assert_eq!(output, "- Item 1\n- Item 2\n- Item 3\n");
848 }
849
850 #[test]
851 fn test_ordered_list() {
852 let input = "1. First\n2. Second\n3. Third";
853 let output = format(input);
854 assert_eq!(output, "1. First\n2. Second\n3. Third\n");
855 }
856
857 #[test]
858 fn test_ordered_list_all_ones_renumbered() {
859 assert_formats_to(
861 "1. First\n1. Second\n1. Third",
862 "1. First\n2. Second\n3. Third\n",
863 );
864 }
865
866 #[test]
867 fn test_ordered_list_non_one_start_renumbered() {
868 assert_formats_to(
870 "3. First\n5. Second\n9. Third",
871 "1. First\n2. Second\n3. Third\n",
872 );
873 }
874
875 #[test]
876 fn test_bold_italic_inline() {
877 assert_eq!(format("**bold** and *italic*"), "**bold** and *italic*\n");
878 }
879
880 #[test]
881 fn test_inline_code() {
882 assert_eq!(format("Use `foo()` here."), "Use `foo()` here.\n");
883 }
884
885 #[test]
886 fn test_link() {
887 let input = "[text](https://example.com)";
888 let output = format(input);
889 assert_eq!(output, "[text](https://example.com)\n");
890 }
891
892 #[test]
893 fn test_image() {
894 let input = "";
895 let output = format(input);
896 assert_eq!(output, "\n");
897 }
898
899 #[test]
900 fn test_blank_line_between_heading_and_code() {
901 let input = "# Heading\n\n```\ncode\n```";
902 let output = format(input);
903 assert_eq!(output, "# Heading\n\n```\ncode\n```\n");
904 }
905
906 #[test]
907 fn test_blank_line_between_list_and_paragraph() {
908 let input = "- item\n\nAfter list.";
909 let output = format(input);
910 assert_eq!(output, "- item\n\nAfter list.\n");
911 }
912
913 #[test]
914 fn test_nested_list() {
915 let input = "- Item 1\n - Nested\n- Item 2";
916 let output = format(input);
917 assert_eq!(output, "- Item 1\n - Nested\n- Item 2\n");
918 }
919
920 #[test]
921 fn test_strikethrough() {
922 assert_eq!(format("~~struck~~"), "~~struck~~\n");
923 }
924
925 #[test]
929 fn test_setext_headings_to_atx() {
930 assert_formats_to("Heading 1\n=========", "# Heading 1\n");
931 assert_formats_to("Heading 2\n---------", "## Heading 2\n");
932 }
933
934 #[test]
937 fn test_setext_heading_hard_break_not_leaked() {
938 assert_formats_to("\\\r¡\r=", "# ¡\n");
939 }
940
941 #[test]
943 fn test_closed_atx_stripped() {
944 assert_formats_to("## Heading ##", "## Heading\n");
945 assert_formats_to("# Title #", "# Title\n");
946 }
947
948 #[test]
950 fn test_multiple_spaces_after_hash_collapsed() {
951 assert_formats_to("# Heading", "# Heading\n");
952 assert_formats_to("## Wide", "## Wide\n");
953 }
954
955 #[test]
957 fn test_multiple_blank_lines_collapsed() {
958 assert_formats_to("First.\n\n\n\nSecond.", "First.\n\nSecond.\n");
959 }
960
961 #[test]
963 fn test_list_markers_to_dash() {
964 assert_formats_to("* Item 1\n* Item 2", "- Item 1\n- Item 2\n");
965 assert_formats_to("+ Item 1\n+ Item 2", "- Item 1\n- Item 2\n");
966 }
967
968 #[test]
970 fn test_emphasis_to_asterisk() {
971 assert_formats_to("_italic_", "*italic*\n");
972 assert_formats_to("__bold__", "**bold**\n");
973 }
974
975 #[test]
977 fn test_tilde_fence_to_backtick() {
978 assert_formats_to("~~~rust\ncode\n~~~", "```rust\ncode\n```\n");
979 assert_formats_to("~~~\ncode\n~~~", "```\ncode\n```\n");
980 }
981
982 #[test]
984 fn test_all_hr_styles_to_dashes() {
985 assert_formats_to("***", "---\n");
986 assert_formats_to("___", "---\n");
987 assert_formats_to("* * *", "---\n");
988 assert_formats_to("- - -", "---\n");
989 assert_formats_to("_ _ _", "---\n");
990 }
991
992 #[test]
996 fn test_hard_line_break_becomes_backslash() {
997 assert_formats_to("foo \nbar", "foo\\\nbar\n");
998 }
999
1000 #[test]
1002 fn test_simple_table() {
1003 let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n";
1004 let output = format(input);
1005 assert_eq!(output, "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |\n");
1006 }
1007
1008 #[test]
1009 fn test_table_no_leading_pipes() {
1010 assert_formats_to(
1012 "A | B\n--- | ---\n1 | 2\n",
1013 "| A | B |\n| --- | --- |\n| 1 | 2 |\n",
1014 );
1015 }
1016
1017 #[test]
1018 fn test_table_idempotent() {
1019 let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
1022 let once = format(input);
1023 let twice = format(&once);
1024 assert_eq!(once, twice);
1025 }
1026
1027 #[test]
1028 fn test_table_with_inline_formatting() {
1029 let input = "| **bold** | `code` |\n| --- | --- |\n| *em* | plain |\n";
1030 let output = format(input);
1031 assert_eq!(
1032 output,
1033 "| **bold** | `code` |\n| --- | --- |\n| *em* | plain |\n"
1034 );
1035 }
1036
1037 #[test]
1038 fn test_table_followed_by_paragraph() {
1039 let input = "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nSome text.\n";
1040 let output = format(input);
1041 assert_eq!(
1042 output,
1043 "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nSome text.\n"
1044 );
1045 }
1046
1047 #[test]
1050 fn test_escaped_list_marker_in_paragraph() {
1051 let once = format("\\*");
1053 let twice = format(&once);
1054 assert_eq!(once, twice, "idempotency: escaped asterisk");
1055 let once = format("\\-");
1057 let twice = format(&once);
1058 assert_eq!(once, twice, "idempotency: escaped dash");
1059 }
1060
1061 #[test]
1062 fn test_setext_heading_with_leading_vt() {
1063 let once = format("\u{b}¡\r=");
1066 let twice = format(&once);
1067 assert_eq!(once, twice, "idempotency: setext heading with leading VT");
1068 }
1069
1070 #[test]
1071 fn test_escaped_heading_in_paragraph() {
1072 let once = format("\\# not a heading");
1074 let twice = format(&once);
1075 assert_eq!(once, twice, "idempotency: escaped hash");
1076 }
1077
1078 #[test]
1081 fn test_ordered_list_with_code_block() {
1082 let canonical = "1. **Enable rule:**\n\n ```toml\n enabled = false\n ```\n\n2. **Another item:**\n\n ```toml\n line_length = 100\n ```\n";
1083 assert_formats_to(
1085 "1. **Enable rule:**\n\n ```toml\n enabled = false\n ```\n\n1. **Another item:**\n\n ```toml\n line_length = 100\n ```\n",
1086 canonical,
1087 );
1088 }
1089
1090 #[test]
1091 fn test_unordered_list_with_code_block() {
1092 let canonical = "- **Item:**\n\n ```toml\n enabled = false\n ```\n";
1093 assert_formats_to(canonical, canonical);
1094 }
1095
1096 #[test]
1097 fn test_tight_list_item_code_block_only() {
1098 let canonical = "- ```\n ¡\n ```\n";
1103 assert_formats_to(canonical, canonical);
1104 }
1105
1106 #[test]
1107 fn test_setext_underline_in_paragraph_continuation() {
1108 let once = format("a\r\t=");
1112 let twice = format(&once);
1113 assert_eq!(
1114 once, twice,
1115 "idempotency: setext-underline-like continuation"
1116 );
1117 let once = format("a\r\t--");
1119 let twice = format(&once);
1120 assert_eq!(once, twice, "idempotency: setext h2 continuation");
1121 }
1122
1123 #[test]
1124 fn test_backtick_in_text_escaped() {
1125 let once = format("\\`\r`");
1128 let twice = format(&once);
1129 assert_eq!(once, twice, "idempotency: lone backticks in text");
1130 }
1131
1132 #[test]
1133 fn test_empty_list_items_idempotent() {
1134 let once = format("*\r*\t");
1139 let twice = format(&once);
1140 assert_eq!(once, twice, "idempotency: empty tight list items");
1141 }
1142
1143 #[test]
1144 fn test_html_block_with_cr_content_idempotent() {
1145 let once = format("<?>\r\\");
1151 let twice = format(&once);
1152 assert_eq!(once, twice, "idempotency: HTML block with CR content");
1153 }
1154
1155 #[test]
1156 fn test_list_marker_with_trailing_unicode_whitespace_idempotent() {
1157 assert_formats_to("*\u{85}\u{b}", "\\*\n");
1163 }
1164
1165 #[test]
1166 fn test_code_fence_info_backslash_idempotent() {
1167 let once = format("```\\\r!");
1172 let twice = format(&once);
1173 assert_eq!(
1174 once, twice,
1175 "idempotency: code fence info string with backslash"
1176 );
1177 }
1178}