1use crate::doc::{Block, Container, Document, Flag, Leaf, Link, ListInfo, OBJ, SOFT, Style};
17use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, LinkType, Options, Parser, Tag};
18use std::collections::HashMap;
19use std::sync::Arc;
20
21fn options() -> Options {
22 Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS
23}
24
25fn is_break(c: char) -> bool {
26 c == '\n' || c == SOFT
27}
28
29enum Ctx {
34 Paragraph,
35 Heading,
36 Quote,
37 List,
38 Item,
39 Code,
40 Html,
41 Emphasis,
42 Strong,
43 Strike,
44 Link,
45 Image,
46 Other,
47}
48
49fn html_flag(tag: &str) -> Option<(Flag, bool)> {
51 match tag {
52 "<strong>" => Some((Flag::Bold, false)),
53 "</strong>" => Some((Flag::Bold, true)),
54 "<em>" => Some((Flag::Italic, false)),
55 "</em>" => Some((Flag::Italic, true)),
56 "<del>" => Some((Flag::Strike, false)),
57 "</del>" => Some((Flag::Strike, true)),
58 _ => None,
59 }
60}
61
62fn flag_bit(flag: Flag) -> u8 {
63 match flag {
64 Flag::Bold => 1,
65 Flag::Italic => 2,
66 Flag::Strike => 4,
67 Flag::Code => 8,
68 }
69}
70
71fn flag_tag(flag: Flag, closing: bool) -> &'static str {
72 match (flag, closing) {
73 (Flag::Bold, false) => "<strong>",
74 (Flag::Bold, true) => "</strong>",
75 (Flag::Italic, false) => "<em>",
76 (Flag::Italic, true) => "</em>",
77 (Flag::Strike, false) => "<del>",
78 (Flag::Strike, true) => "</del>",
79 (Flag::Code, false) => "<code>",
80 (Flag::Code, true) => "</code>",
81 }
82}
83
84#[derive(Default)]
85struct Builder {
86 blocks: Vec<Block>,
87 current: Option<Block>,
88 stack: Vec<Ctx>,
89 containers: Vec<Container>,
90 lists: Vec<(ListInfo, usize)>,
91 starts: Vec<usize>,
92 bold: u32,
93 italic: u32,
94 strike: u32,
95 html_open: Vec<(Flag, usize)>,
97 html_bits: Vec<u8>,
99 links: Vec<(Arc<Link>, usize)>,
101 image: Option<(Arc<Link>, usize)>,
103}
104
105impl Builder {
106 fn html_has(&self, flag: Flag) -> bool {
107 self.html_open.iter().any(|(open, _)| *open == flag)
108 }
109
110 fn style(&self, code: bool) -> Style {
111 Style {
112 bold: self.bold > 0 || self.html_has(Flag::Bold),
113 italic: self.italic > 0 || self.html_has(Flag::Italic),
114 strike: self.strike > 0 || self.html_has(Flag::Strike),
115 code,
116 link: self.links.last().map(|(link, _)| link.clone()),
117 image: self.image.as_ref().map(|(link, _)| link.clone()),
118 html: false,
119 }
120 }
121
122 fn len(&self) -> usize {
123 self.current.as_ref().map(Block::len).unwrap_or(0)
124 }
125
126 fn ensure(&mut self) -> &mut Block {
129 if self.current.is_none() {
130 self.current = Some(Block::new(self.containers.clone(), Leaf::Paragraph));
131 self.html_bits.clear();
132 }
133 self.current.as_mut().expect("current block")
134 }
135
136 fn push_styled(&mut self, text: &str, style: Style) {
137 let bits = self
138 .html_open
139 .iter()
140 .fold(0u8, |bits, (flag, _)| bits | flag_bit(*flag));
141 let block = self.ensure();
142 let mut count = 0;
143 for c in text.chars() {
144 block.push(c, style.clone());
145 count += 1;
146 }
147 self.html_bits.extend(std::iter::repeat(bits).take(count));
148 }
149
150 fn push_text(&mut self, text: &str, code: bool) {
151 if let Some(block) = self.current.as_mut() {
152 if block.leaf.is_raw() {
153 for c in text.chars() {
154 if c != '\r' {
155 block.push(c, Style::default());
156 }
157 }
158 return;
159 }
160 }
161 let style = self.style(code);
162 if self.image.is_some() {
163 let text: String = text
164 .chars()
165 .map(|c| if is_break(c) { ' ' } else { c })
166 .collect();
167 self.push_styled(&text, style);
168 } else {
169 let text: String = text
170 .chars()
171 .map(|c| if c == '\n' { SOFT } else { c })
172 .collect();
173 self.push_styled(&text, style);
174 }
175 }
176
177 fn push_break(&mut self, c: char) {
178 if self.image.is_some() {
179 self.push_styled(" ", self.style(false));
180 } else {
181 let style = self.style(false);
182 self.push_styled(&c.to_string(), style);
183 }
184 }
185
186 fn task(&mut self, checked: bool) {
188 let Some(depth) = self
189 .containers
190 .iter()
191 .rposition(|c| matches!(c, Container::Item { .. }))
192 else {
193 return;
194 };
195 if let Container::Item { task, .. } = &mut self.containers[depth] {
196 *task = Some(checked);
197 }
198 if let Some(block) = self.current.as_mut() {
199 if let Some(Container::Item { task, .. }) = block.containers.get_mut(depth) {
200 *task = Some(checked);
201 }
202 }
203 }
204
205 fn inline_html(&mut self, tag: &str) {
206 if self.image.is_some() {
207 return;
208 }
209 match html_flag(tag) {
210 Some((flag, false)) => {
211 let start = self.len();
212 self.ensure();
213 self.html_open.push((flag, start));
214 }
215 Some((flag, true)) if self.html_has(flag) => {
216 if let Some(index) = self.html_open.iter().rposition(|(open, _)| *open == flag) {
217 self.html_open.remove(index);
218 }
219 }
220 _ => {
221 let raw: String = tag
222 .chars()
223 .map(|c| if c == '\n' { SOFT } else { c })
224 .collect();
225 let style = Style {
226 html: true,
227 link: self.links.last().map(|(link, _)| link.clone()),
228 ..Style::default()
229 };
230 self.push_styled(&raw, style);
231 }
232 }
233 }
234
235 fn flush(&mut self) {
236 let Some(mut block) = self.current.take() else {
237 return;
238 };
239
240 for (flag, start) in std::mem::take(&mut self.html_open).into_iter().rev() {
242 let bit = flag_bit(flag);
243 for j in start..block.len() {
244 if self.html_bits.get(j).is_some_and(|bits| bits & bit != 0) {
245 self.html_bits[j] &= !bit;
246 block.styles[j].set(flag, false);
247 }
248 }
249 let tag = flag_tag(flag, false);
250 let count = tag.chars().count();
251 let raw = Style {
252 html: true,
253 ..Style::default()
254 };
255 block.chars.splice(start..start, tag.chars());
256 block
257 .styles
258 .splice(start..start, std::iter::repeat(raw).take(count));
259 self.html_bits
260 .splice(start..start, std::iter::repeat(0).take(count));
261 }
262 self.html_bits.clear();
263
264 match block.leaf {
265 Leaf::Code => {
266 if block.chars.last() == Some(&'\n') {
267 block.chars.pop();
268 block.styles.pop();
269 }
270 }
271 Leaf::Html => {
272 while block.chars.last() == Some(&'\n') {
273 block.chars.pop();
274 block.styles.pop();
275 }
276 }
277 _ => {}
278 }
279 self.blocks.push(block);
280 }
281
282 fn begin(&mut self, leaf: Leaf) {
283 self.flush();
284 self.current = Some(Block::new(self.containers.clone(), leaf));
285 self.html_bits.clear();
286 }
287
288 fn start(&mut self, tag: Tag<'_>) {
289 if self.image.is_some() {
290 self.stack.push(Ctx::Other);
292 return;
293 }
294 match tag {
295 Tag::Paragraph => {
296 if matches!(self.containers.last(), Some(Container::Item { .. })) {
297 if let Some((list, _)) = self.lists.last_mut() {
298 list.tight = false;
299 }
300 }
301 self.begin(Leaf::Paragraph);
302 self.stack.push(Ctx::Paragraph);
303 }
304 Tag::Heading { level, .. } => {
305 let level = match level {
306 HeadingLevel::H1 => 1,
307 HeadingLevel::H2 => 2,
308 HeadingLevel::H3 => 3,
309 HeadingLevel::H4 => 4,
310 HeadingLevel::H5 => 5,
311 HeadingLevel::H6 => 6,
312 };
313 self.begin(Leaf::Heading(level));
314 self.stack.push(Ctx::Heading);
315 }
316 Tag::BlockQuote(_) => {
317 self.flush();
318 self.containers.push(Container::quote());
319 self.starts.push(self.blocks.len());
320 self.stack.push(Ctx::Quote);
321 }
322 Tag::CodeBlock(kind) => {
323 self.begin(Leaf::Code);
324 if let CodeBlockKind::Fenced(info) = kind {
325 let info = info.trim();
326 if !info.is_empty() {
327 if let Some(block) = self.current.as_mut() {
328 block.info = Some(Arc::from(info));
329 }
330 }
331 }
332 self.stack.push(Ctx::Code);
333 }
334 Tag::HtmlBlock => {
335 self.begin(Leaf::Html);
336 self.stack.push(Ctx::Html);
337 }
338 Tag::List(start) => {
339 self.flush();
340 let list = ListInfo::new(start.is_some(), start.unwrap_or(1));
341 self.lists.push((list, self.blocks.len()));
342 self.stack.push(Ctx::List);
343 }
344 Tag::Item => {
345 self.flush();
346 let list = self
347 .lists
348 .last()
349 .map(|(list, _)| *list)
350 .unwrap_or(ListInfo::new(false, 1));
351 self.containers.push(Container::item(list));
352 self.starts.push(self.blocks.len());
353 self.stack.push(Ctx::Item);
354 }
355 Tag::Emphasis => {
356 self.italic += 1;
357 self.stack.push(Ctx::Emphasis);
358 }
359 Tag::Strong => {
360 self.bold += 1;
361 self.stack.push(Ctx::Strong);
362 }
363 Tag::Strikethrough => {
364 self.strike += 1;
365 self.stack.push(Ctx::Strike);
366 }
367 Tag::Link {
368 link_type,
369 dest_url,
370 title,
371 ..
372 } => {
373 let url = if link_type == LinkType::Email {
374 format!("mailto:{dest_url}")
375 } else {
376 dest_url.to_string()
377 };
378 let start = self.len();
379 self.ensure();
380 self.links
381 .push((Link::new(url, Some(title.to_string())), start));
382 self.stack.push(Ctx::Link);
383 }
384 Tag::Image {
385 dest_url, title, ..
386 } => {
387 let start = self.len();
388 self.ensure();
389 self.image = Some((
390 Link::new(dest_url.to_string(), Some(title.to_string())),
391 start,
392 ));
393 self.stack.push(Ctx::Image);
394 }
395 _ => self.stack.push(Ctx::Other),
396 }
397 }
398
399 fn end(&mut self) {
400 match self.stack.pop() {
401 Some(Ctx::Paragraph) | Some(Ctx::Heading) | Some(Ctx::Code) | Some(Ctx::Html) => {
402 self.flush()
403 }
404 Some(Ctx::Quote) | Some(Ctx::Item) => {
405 self.flush();
406 let start = self.starts.pop().unwrap_or(0);
407 if self.blocks.len() == start {
408 self.blocks
410 .push(Block::new(self.containers.clone(), Leaf::Paragraph));
411 }
412 self.containers.pop();
413 }
414 Some(Ctx::List) => {
415 self.flush();
416 if let Some((list, first)) = self.lists.pop() {
417 if !list.tight {
418 for block in &mut self.blocks[first..] {
419 for container in &mut block.containers {
420 if let Container::Item { list: info, .. } = container {
421 if info.id == list.id {
422 info.tight = false;
423 }
424 }
425 }
426 }
427 }
428 }
429 }
430 Some(Ctx::Emphasis) => self.italic = self.italic.saturating_sub(1),
431 Some(Ctx::Strong) => self.bold = self.bold.saturating_sub(1),
432 Some(Ctx::Strike) => self.strike = self.strike.saturating_sub(1),
433 Some(Ctx::Link) => {
434 if let Some((link, start)) = self.links.last().cloned() {
435 if self.len() == start {
436 let style = self.style(false);
438 self.push_styled(
439 &OBJ.to_string(),
440 Style {
441 link: Some(link),
442 ..style
443 },
444 );
445 }
446 }
447 self.links.pop();
448 }
449 Some(Ctx::Image) => {
450 if let Some((_, start)) = self.image.clone() {
451 if self.len() == start {
452 let style = self.style(false);
453 self.push_styled(&OBJ.to_string(), style);
454 }
455 }
456 self.image = None;
457 }
458 Some(Ctx::Other) | None => {}
459 }
460 }
461}
462
463pub fn parse(source: &str) -> Document {
465 let mut builder = Builder::default();
466
467 for event in Parser::new_ext(source, options()) {
468 match event {
469 Event::Start(tag) => builder.start(tag),
470 Event::End(_) => builder.end(),
471 Event::Text(text) => builder.push_text(&text, false),
472 Event::Code(text) => {
473 if builder.image.is_some() {
474 builder.push_text(&text, false);
475 } else {
476 builder.push_text(&text, true);
477 }
478 }
479 Event::Html(text) => {
480 if !matches!(builder.current.as_ref().map(|b| b.leaf), Some(Leaf::Html)) {
481 builder.begin(Leaf::Html);
482 }
483 builder.push_text(&text, false);
484 }
485 Event::InlineHtml(text) => builder.inline_html(&text),
486 Event::SoftBreak => builder.push_break(SOFT),
487 Event::HardBreak => builder.push_break('\n'),
488 Event::TaskListMarker(checked) => builder.task(checked),
489 Event::Rule => {
490 builder.flush();
491 builder
492 .blocks
493 .push(Block::new(builder.containers.clone(), Leaf::Rule));
494 }
495 _ => {}
496 }
497 }
498
499 builder.flush();
500
501 if builder.blocks.is_empty() {
502 builder.blocks.push(Block::new(Vec::new(), Leaf::Paragraph));
503 }
504
505 Document {
506 blocks: builder.blocks,
507 }
508}
509
510const MARKS: [Flag; 3] = [Flag::Bold, Flag::Italic, Flag::Strike];
515
516fn mark_token(flag: Flag, use_html: bool, closing: bool) -> &'static str {
517 if use_html {
518 flag_tag(flag, closing)
519 } else {
520 match flag {
521 Flag::Bold => "**",
522 Flag::Italic => "*",
523 Flag::Strike => "~~",
524 Flag::Code => "`",
525 }
526 }
527}
528
529fn esc_text(c: char, line_start: bool, heading: bool) -> String {
530 if heading && c == '#' {
531 return "\\#".into();
532 }
533 if "\\`*_[]<>~&|!".contains(c) {
534 return format!("\\{c}");
535 }
536 if line_start && "#>+-=".contains(c) {
537 return format!("\\{c}");
538 }
539 c.to_string()
540}
541
542fn esc_dest(url: &str) -> String {
543 let plain = !url.is_empty()
545 && !url
546 .chars()
547 .any(|c| c.is_whitespace() || c.is_control() || "<>()\\&".contains(c));
548 if plain {
549 return url.to_string();
550 }
551 let mut out = String::from("<");
552 for c in url.chars() {
553 match c {
554 '<' | '>' | '\\' | '&' => {
555 out.push('\\');
556 out.push(c);
557 }
558 '\n' | '\r' => out.push(' '),
559 c => out.push(c),
560 }
561 }
562 out.push('>');
563 out
564}
565
566fn esc_title(title: &str) -> String {
567 let mut out = String::from("\"");
568 for c in title.chars() {
569 if matches!(c, '"' | '\\' | '&') {
570 out.push('\\');
571 }
572 out.push(c);
573 }
574 out.push('"');
575 out
576}
577
578fn code_span(text: &str) -> String {
579 let mut longest = 0;
580 let mut run = 0;
581 for c in text.chars() {
582 run = if c == '`' { run + 1 } else { 0 };
583 longest = longest.max(run);
584 }
585 let ticks = "`".repeat(longest + 1);
586 let pad = text.starts_with('`')
587 || text.ends_with('`')
588 || (text.starts_with(' ') && text.ends_with(' ') && !text.trim_matches(' ').is_empty());
589 if pad {
590 format!("{ticks} {text} {ticks}")
591 } else {
592 format!("{ticks}{text}{ticks}")
593 }
594}
595
596fn is_autolink(url: &str) -> bool {
598 let Some((scheme, rest)) = url.split_once(':') else {
599 return false;
600 };
601 let mut chars = scheme.chars();
602 let first_ok = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
603 let len_ok = (2..=32).contains(&scheme.len());
604 let scheme_ok = scheme
605 .chars()
606 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
607 let rest_ok = rest
608 .chars()
609 .all(|c| !c.is_whitespace() && !c.is_control() && c != '<' && c != '>');
610 first_ok && len_ok && scheme_ok && rest_ok
611}
612
613fn is_email(text: &str) -> bool {
614 let Some((local, domain)) = text.split_once('@') else {
615 return false;
616 };
617 let local_ok = !local.is_empty()
618 && local
619 .chars()
620 .all(|c| c.is_ascii_alphanumeric() || ".!#$%&'*+/=?^_`{|}~-".contains(c));
621 let domain_ok = !domain.is_empty()
622 && domain.split('.').all(|label| {
623 !label.is_empty()
624 && label.len() <= 63
625 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
626 && !label.starts_with('-')
627 && !label.ends_with('-')
628 });
629 local_ok && domain_ok
630}
631
632fn image_md(alt: &[char], target: &Link) -> String {
633 let mut text = String::new();
634 for c in alt {
635 match *c {
636 OBJ => {}
637 SOFT | '\n' => text.push(' '),
638 c => text.push_str(&esc_text(c, false, false)),
639 }
640 }
641 let title = target
642 .title
643 .as_deref()
644 .map(|title| format!(" {}", esc_title(title)))
645 .unwrap_or_default();
646 format!("", esc_dest(&target.url))
647}
648
649pub(crate) fn image_markdown(alt: &str, url: &str) -> String {
651 let alt: Vec<char> = alt.chars().collect();
652 image_md(
653 &alt,
654 &Link {
655 url: url.to_string(),
656 title: None,
657 },
658 )
659}
660
661struct Inline<'a> {
662 chars: &'a [char],
663 styles: &'a [Style],
664 heading: bool,
665 use_html: bool,
666 para_start: bool,
667 para_end: bool,
668 out: String,
669 open: Vec<Flag>,
670 line_start: bool,
671}
672
673impl<'a> Inline<'a> {
674 fn has(&self, k: usize, flag: Flag) -> bool {
675 self.styles[k].get(flag)
676 }
677
678 fn wanted(&self, k: usize) -> Vec<Flag> {
679 MARKS
680 .into_iter()
681 .filter(|flag| self.has(k, *flag))
682 .collect()
683 }
684
685 fn mark_end(&self, flag: Flag, mut k: usize) -> usize {
686 while k < self.chars.len() && self.has(k, flag) {
687 k += 1;
688 }
689 k
690 }
691
692 fn close_to(&mut self, keep: &[Flag]) {
694 while self.open.iter().any(|flag| !keep.contains(flag)) {
695 if let Some(flag) = self.open.pop() {
696 self.out.push_str(mark_token(flag, self.use_html, true));
697 }
698 }
699 }
700
701 fn open_at(&mut self, k: usize, marks: &[Flag]) {
703 let mut new: Vec<(Flag, usize)> = marks
704 .iter()
705 .filter(|flag| !self.open.contains(flag))
706 .map(|flag| (*flag, self.mark_end(*flag, k)))
707 .collect();
708 new.sort_by(|a, b| b.1.cmp(&a.1));
709 for (flag, _) in new {
710 self.open.push(flag);
711 self.out.push_str(mark_token(flag, self.use_html, false));
712 }
713 }
714
715 fn emit_text(&mut self, text: &[char], at_end: bool) {
716 let mut text: Vec<char> = text.to_vec();
717 let mut tail = String::new();
718 if at_end {
719 while let Some(c) = text.last().copied() {
720 if c == ' ' || c == '\t' {
721 tail.insert_str(0, if c == ' ' { " " } else { "	" });
722 text.pop();
723 } else {
724 break;
725 }
726 }
727 }
728
729 let mut k = 0;
730 while k < text.len() {
731 let c = text[k];
732 if c == OBJ {
733 k += 1;
734 continue;
735 }
736 if self.line_start && (c == ' ' || c == '\t') {
737 self.out.push_str(if c == ' ' { " " } else { "	" });
738 k += 1;
739 continue;
740 }
741 if self.line_start && c.is_ascii_digit() {
742 let mut m = k;
743 while m < text.len() && text[m].is_ascii_digit() {
744 m += 1;
745 }
746 if m < text.len() && (text[m] == '.' || text[m] == ')') {
747 self.out.extend(&text[k..m]);
748 self.out.push('\\');
749 self.out.push(text[m]);
750 k = m + 1;
751 self.line_start = false;
752 continue;
753 }
754 }
755 if c == '\u{a0}' {
756 self.out.push_str(" ");
757 } else {
758 let escaped = esc_text(c, self.line_start, self.heading);
759 self.out.push_str(&escaped);
760 }
761 self.line_start = false;
762 k += 1;
763 }
764
765 if !tail.is_empty() {
766 self.out.push_str(&tail);
767 self.line_start = false;
768 }
769 }
770
771 fn run(mut self) -> String {
772 let n = self.chars.len();
773 let mut i = 0;
774
775 while i < n {
776 let c = self.chars[i];
777 let style = self.styles[i].clone();
778
779 if is_break(c) {
780 if style.html && c == SOFT {
781 self.out.push('\n');
783 self.line_start = true;
784 i += 1;
785 continue;
786 }
787 let trailing = self.chars[i + 1..]
788 .iter()
789 .all(|ch| *ch == '\n' || *ch == OBJ);
790 if c == '\n' && ((i == 0 && self.para_start) || (self.para_end && trailing)) {
791 i += 1;
793 continue;
794 }
795 let prev = if i > 0 { Some(self.chars[i - 1]) } else { None };
796 let next = self.chars.get(i + 1).copied();
797 let edge = (prev.is_none() && self.para_start)
798 || (next.is_none() && self.para_end)
799 || prev.is_some_and(is_break)
800 || next.is_some_and(is_break);
801 if c == SOFT && (self.heading || edge) {
802 self.out.push_str(" ");
803 self.line_start = false;
804 } else if self.heading {
805 self.out.push('\n');
806 self.line_start = true;
807 } else {
808 self.out.push_str(if c == '\n' { "\\\n" } else { "\n" });
809 self.line_start = true;
810 }
811 i += 1;
812 continue;
813 }
814
815 if let Some(link) = style.link.clone() {
816 let mut j = i;
817 while j < n && self.styles[j].link.as_ref() == Some(&link) {
818 j += 1;
819 }
820 let after = if j < n { self.wanted(j) } else { Vec::new() };
823 let outside: Vec<Flag> = self
824 .wanted(i)
825 .into_iter()
826 .filter(|flag| {
827 (self.open.contains(flag) || after.contains(flag))
828 && (i..j).all(|k| self.has(k, *flag))
829 })
830 .collect();
831 self.close_to(&outside);
832 self.open_at(i, &outside);
833
834 let text: String = self.chars[i..j].iter().collect();
835 let plain_style = Style {
836 bold: outside.contains(&Flag::Bold),
837 italic: outside.contains(&Flag::Italic),
838 strike: outside.contains(&Flag::Strike),
839 link: Some(link.clone()),
840 ..Style::default()
841 };
842 let plain = self.styles[i..j].iter().all(|st| *st == plain_style);
843 if plain && link.title.is_none() && link.url == text && is_autolink(&link.url) {
844 self.out.push_str(&format!("<{}>", link.url));
845 } else if plain
846 && link.title.is_none()
847 && link.url == format!("mailto:{text}")
848 && is_email(&text)
849 {
850 self.out.push_str(&format!("<{text}>"));
851 } else {
852 let inner: Vec<Style> = self.styles[i..j]
853 .iter()
854 .map(|st| {
855 let mut st = st.clone();
856 st.link = None;
857 for flag in &outside {
858 st.set(*flag, false);
859 }
860 st
861 })
862 .collect();
863 let body = Inline {
864 chars: &self.chars[i..j],
865 styles: &inner,
866 heading: self.heading,
867 use_html: self.use_html,
868 para_start: false,
869 para_end: false,
870 out: String::new(),
871 open: Vec::new(),
872 line_start: true,
873 }
874 .run();
875 let title = link
876 .title
877 .as_deref()
878 .map(|title| format!(" {}", esc_title(title)))
879 .unwrap_or_default();
880 self.out
881 .push_str(&format!("[{body}]({}{title})", esc_dest(&link.url)));
882 }
883 self.line_start = false;
884 i = j;
885 continue;
886 }
887
888 let wanted = self.wanted(i);
889 self.close_to(&wanted);
890 self.open_at(i, &wanted);
891
892 let mut j = i;
893 if let Some(image) = style.image.clone() {
894 while j < n && self.styles[j] == style {
895 j += 1;
896 }
897 self.out.push_str(&image_md(&self.chars[i..j], &image));
898 self.line_start = false;
899 } else if style.html {
900 while j < n && self.styles[j] == style && self.chars[j] != SOFT {
901 j += 1;
902 }
903 self.out.extend(&self.chars[i..j]);
904 self.line_start = false;
905 } else if style.code {
906 while j < n && self.styles[j] == style && !is_break(self.chars[j]) {
907 j += 1;
908 }
909 let text: String = self.chars[i..j].iter().collect();
910 self.out.push_str(&code_span(&text));
911 self.line_start = false;
912 } else {
913 while j < n && self.styles[j] == style && !is_break(self.chars[j]) {
914 j += 1;
915 }
916 let rest = &self.chars[j..];
917 let at_end = rest.iter().all(|ch| *ch == OBJ)
918 || rest.first().is_some_and(|ch| is_break(*ch));
919 let chars = self.chars;
920 self.emit_text(&chars[i..j], at_end);
921 }
922 i = j;
923 }
924
925 self.close_to(&[]);
926 self.out
927 }
928}
929
930fn inline_md(chars: &[char], styles: &[Style], heading: bool, use_html: bool) -> String {
931 Inline {
932 chars,
933 styles,
934 heading,
935 use_html,
936 para_start: true,
937 para_end: true,
938 out: String::new(),
939 open: Vec::new(),
940 line_start: true,
941 }
942 .run()
943}
944
945fn inline_checked(block: &Block, heading: bool, wrap: impl Fn(&str) -> String) -> String {
947 let mut text = String::new();
948 for use_html in [false, true] {
949 text = inline_md(&block.chars, &block.styles, heading, use_html);
950 if block.chars.is_empty() {
951 return text;
952 }
953 let back = parse(&wrap(&text));
954 if back.blocks.len() == 1
955 && back.blocks[0].chars == block.chars
956 && back.blocks[0].styles == block.styles
957 {
958 return text;
959 }
960 }
961 text
962}
963
964fn strip_edges(block: &Block) -> Block {
970 let mut block = block.clone();
971 while block.chars.first() == Some(&'\n') {
972 block.chars.remove(0);
973 block.styles.remove(0);
974 }
975 while block.chars.last() == Some(&'\n') {
976 block.chars.pop();
977 block.styles.pop();
978 }
979 block
980}
981
982fn expressible(blocks: &[Block]) -> Vec<Block> {
985 let mut out = Vec::new();
986 for (index, block) in blocks.iter().enumerate() {
987 let empty = match block.leaf {
988 Leaf::Paragraph => strip_edges(block).chars.is_empty(),
989 Leaf::Html => block.chars.is_empty(),
990 _ => false,
991 };
992 if empty {
993 let Some(inner) = block.containers.last() else {
994 continue;
995 };
996 let shared = blocks
997 .iter()
998 .enumerate()
999 .any(|(other, b)| other != index && b.containers.iter().any(|c| c.same(inner)));
1000 if shared {
1001 continue;
1002 }
1003 out.push(Block::new(block.containers.clone(), Leaf::Paragraph));
1004 } else {
1005 out.push(block.clone());
1006 }
1007 }
1008 if out.is_empty() {
1009 out.push(Block::new(Vec::new(), Leaf::Paragraph));
1010 }
1011 out
1012}
1013
1014const HTML_BLOCK_TAGS: &[&str] = &[
1015 "address",
1016 "article",
1017 "aside",
1018 "base",
1019 "basefont",
1020 "blockquote",
1021 "body",
1022 "caption",
1023 "center",
1024 "col",
1025 "colgroup",
1026 "dd",
1027 "details",
1028 "dialog",
1029 "dir",
1030 "div",
1031 "dl",
1032 "dt",
1033 "fieldset",
1034 "figcaption",
1035 "figure",
1036 "footer",
1037 "form",
1038 "frame",
1039 "frameset",
1040 "h1",
1041 "h2",
1042 "h3",
1043 "h4",
1044 "h5",
1045 "h6",
1046 "head",
1047 "header",
1048 "hr",
1049 "html",
1050 "iframe",
1051 "legend",
1052 "li",
1053 "link",
1054 "main",
1055 "menu",
1056 "menuitem",
1057 "nav",
1058 "noframes",
1059 "ol",
1060 "optgroup",
1061 "option",
1062 "p",
1063 "param",
1064 "search",
1065 "section",
1066 "summary",
1067 "table",
1068 "tbody",
1069 "td",
1070 "tfoot",
1071 "th",
1072 "thead",
1073 "title",
1074 "tr",
1075 "track",
1076 "ul",
1077];
1078
1079fn html_interrupts(first_line: &str) -> bool {
1081 let s = first_line.trim_start_matches(' ');
1082 let lower = s.to_ascii_lowercase();
1083 for tag in ["<script", "<pre", "<style", "<textarea"] {
1084 if let Some(rest) = lower.strip_prefix(tag) {
1085 if rest.is_empty() || rest.starts_with(|c: char| c.is_whitespace() || c == '>') {
1086 return true;
1087 }
1088 }
1089 }
1090 if s.starts_with("<!--") || s.starts_with("<?") || s.starts_with("<![CDATA[") {
1091 return true;
1092 }
1093 if s.starts_with("<!") && s[2..].starts_with(|c: char| c.is_ascii_alphabetic()) {
1094 return true;
1095 }
1096 let name_start = if s.starts_with("</") {
1097 2
1098 } else if s.starts_with('<') {
1099 1
1100 } else {
1101 return false;
1102 };
1103 let name: String = s[name_start..]
1104 .chars()
1105 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
1106 .collect();
1107 if name.is_empty() {
1108 return false;
1109 }
1110 let rest = &s[name_start + name.len()..];
1111 let ends_ok = rest.is_empty()
1112 || rest.starts_with(|c: char| c.is_whitespace())
1113 || rest.starts_with('>')
1114 || rest.starts_with("/>");
1115 ends_ok && HTML_BLOCK_TAGS.contains(&name.to_ascii_lowercase().as_str())
1116}
1117
1118fn is_setext(block: &Block) -> bool {
1120 matches!(block.leaf, Leaf::Heading(1 | 2))
1121 && strip_edges(block).chars.iter().any(|c| is_break(*c))
1122}
1123
1124fn can_interrupt(a: &Block, b: &Block, common: usize) -> bool {
1126 if b.containers.len() > common
1127 && b.containers[common].is_quote()
1128 && a.containers.get(common).is_some_and(Container::is_quote)
1129 {
1130 return false; }
1132 if a.leaf == Leaf::Html {
1133 return common < a.containers.len();
1135 }
1136 if a.leaf != Leaf::Paragraph || strip_edges(a).chars.is_empty() {
1137 return true;
1138 }
1139 if b.containers.len() > common {
1140 let container = b.containers[common];
1141 let Container::Item { list, .. } = container else {
1142 return true;
1143 };
1144 let first_of_list = !a.containers.get(common).is_some_and(
1145 |other| matches!(other, Container::Item { list: l, .. } if l.id == list.id),
1146 );
1147 if first_of_list {
1148 if list.ordered && list.start != 1 {
1149 return false;
1150 }
1151 let empty_item = b.leaf == Leaf::Paragraph
1152 && strip_edges(b).chars.is_empty()
1153 && b.containers.len() == common + 1;
1154 if empty_item {
1155 return false;
1156 }
1157 }
1158 return true;
1159 }
1160 if is_setext(b) {
1161 return false; }
1163 match b.leaf {
1164 Leaf::Heading(_) | Leaf::Code | Leaf::Rule => true,
1165 Leaf::Html => {
1166 let text = b.text();
1167 html_interrupts(text.split('\n').next().unwrap_or(""))
1168 }
1169 Leaf::Paragraph | Leaf::Line => false,
1170 }
1171}
1172
1173fn needs_blank(a: &Block, b: &Block, common: usize) -> bool {
1174 let mut shared: Option<ListInfo> = None;
1176 for d in 0..a.containers.len().min(b.containers.len()) {
1177 match (a.containers[d], b.containers[d]) {
1178 (
1179 Container::Item {
1180 list: la, id: ia, ..
1181 },
1182 Container::Item {
1183 list: lb, id: ib, ..
1184 },
1185 ) if la.id == lb.id => {
1186 shared = Some(la);
1187 if ia != ib {
1188 break;
1189 }
1190 }
1191 (ca, cb) if ca.same(&cb) => {}
1192 _ => break,
1193 }
1194 }
1195 !(shared.is_some_and(|list| list.tight) && can_interrupt(a, b, common))
1196}
1197
1198fn block_lines(block: &Block) -> Option<String> {
1199 match block.leaf {
1200 Leaf::Rule => Some("___".into()), Leaf::Html | Leaf::Line => Some(block.text()),
1202 Leaf::Paragraph => {
1203 let block = strip_edges(block);
1204 if block.chars.is_empty() {
1205 None
1206 } else {
1207 Some(inline_checked(&block, false, |text| text.to_string()))
1208 }
1209 }
1210 Leaf::Heading(level) => {
1211 let level = level.clamp(1, 6) as usize;
1212 let block = strip_edges(block);
1213 if is_setext(&block) {
1214 let under = format!("\n{}", if level == 1 { "===" } else { "---" });
1215 let text = inline_checked(&block, false, |text| format!("{text}{under}"));
1216 return Some(format!("{text}{under}"));
1217 }
1218 let mut atx = block.clone();
1219 for c in &mut atx.chars {
1220 if *c == '\n' {
1221 *c = ' ';
1222 }
1223 }
1224 let hashes = "#".repeat(level);
1225 let text = inline_checked(&atx, true, |text| format!("{hashes} {text}"));
1226 if text.is_empty() {
1227 Some(hashes)
1228 } else {
1229 Some(format!("{hashes} {text}"))
1230 }
1231 }
1232 Leaf::Code => {
1233 let text = block.text();
1234 let info = block.info.as_deref().unwrap_or("");
1235 let fence_char = if info.contains('`') { '~' } else { '`' };
1236 let mut longest = 0;
1237 let mut run = 0;
1238 for c in text.chars() {
1239 run = if c == fence_char { run + 1 } else { 0 };
1240 longest = longest.max(run);
1241 }
1242 let fence: String = std::iter::repeat(fence_char)
1243 .take((longest + 1).max(3))
1244 .collect();
1245 if text.is_empty() {
1246 Some(format!("{fence}{info}\n{fence}"))
1247 } else {
1248 Some(format!("{fence}{info}\n{text}\n{fence}"))
1249 }
1250 }
1251 }
1252}
1253
1254fn first_prefix(containers: &[Container], common: usize, markers: &HashMap<u64, String>) -> String {
1255 let mut prefix = String::new();
1256 for (d, container) in containers.iter().enumerate() {
1257 match container {
1258 Container::Quote { .. } => prefix.push_str("> "),
1259 Container::Item { id, .. } => {
1260 let marker = markers.get(id).map(String::as_str).unwrap_or("- ");
1261 if d >= common {
1262 prefix.push_str(marker);
1263 } else {
1264 prefix.push_str(&" ".repeat(marker.len()));
1265 }
1266 }
1267 }
1268 }
1269 prefix
1270}
1271
1272fn cont_prefix(containers: &[Container], markers: &HashMap<u64, String>) -> String {
1273 let mut prefix = String::new();
1274 for container in containers {
1275 match container {
1276 Container::Quote { .. } => prefix.push_str("> "),
1277 Container::Item { id, .. } => {
1278 let width = markers.get(id).map(String::len).unwrap_or(2);
1279 prefix.push_str(&" ".repeat(width));
1280 }
1281 }
1282 }
1283 prefix
1284}
1285
1286fn task_marker(block: &Block, common: usize) -> Option<&'static str> {
1289 let depth = block.containers.len().checked_sub(1)?;
1290 if depth < common || block.leaf != Leaf::Paragraph || strip_edges(block).chars.is_empty() {
1291 return None;
1292 }
1293 match block.containers[depth].task()? {
1294 true => Some("[x] "),
1295 false => Some("[ ] "),
1296 }
1297}
1298
1299pub fn serialize(doc: &Document) -> String {
1301 let blocks = expressible(&doc.blocks);
1302 let mut lines: Vec<String> = Vec::new();
1303 let mut markers: HashMap<u64, String> = HashMap::new();
1304 let mut list_marker: HashMap<u64, char> = HashMap::new();
1305 let mut list_counter: HashMap<u64, u64> = HashMap::new();
1306 let mut previous: Option<&Block> = None;
1307
1308 for block in &blocks {
1309 let containers = &block.containers;
1310 let mut common = 0;
1311 if let Some(prev) = previous {
1312 while common < containers.len().min(prev.containers.len())
1313 && containers[common].same(&prev.containers[common])
1314 {
1315 common += 1;
1316 }
1317 }
1318
1319 for d in common..containers.len() {
1321 let Container::Item { list, id, .. } = containers[d] else {
1322 continue;
1323 };
1324 if markers.contains_key(&id) {
1325 continue;
1326 }
1327 if !list_marker.contains_key(&list.id) {
1328 let other = previous.and_then(|prev| {
1331 let sibling = prev.containers.get(d)?;
1332 let Container::Item { list: other, .. } = sibling else {
1333 return None;
1334 };
1335 let same_parent = (0..d).all(|k| prev.containers[k].same(&containers[k]));
1336 (other.id != list.id && same_parent)
1337 .then(|| list_marker.get(&other.id).copied())
1338 .flatten()
1339 });
1340 let marker = if list.ordered {
1341 if other == Some('.') { ')' } else { '.' }
1342 } else if other == Some('-') {
1343 '*'
1344 } else {
1345 '-'
1346 };
1347 list_marker.insert(list.id, marker);
1348 list_counter.insert(list.id, list.start.min(999_999_999));
1349 }
1350 let marker = list_marker[&list.id];
1351 let text = if list.ordered {
1352 let number = list_counter[&list.id];
1353 list_counter.insert(list.id, (number + 1).min(999_999_999));
1354 format!("{number}{marker} ")
1355 } else {
1356 format!("{marker} ")
1357 };
1358 markers.insert(id, text);
1359 }
1360
1361 if let Some(prev) = previous {
1362 if needs_blank(prev, block, common) {
1363 lines.push(
1364 cont_prefix(&containers[..common], &markers)
1365 .trim_end()
1366 .to_string(),
1367 );
1368 }
1369 }
1370
1371 let mut first = first_prefix(containers, common, &markers);
1372 if let Some(task) = task_marker(block, common) {
1373 first.push_str(task);
1374 }
1375 let cont = cont_prefix(containers, &markers);
1376 match block_lines(block) {
1377 None => lines.push(first.trim_end().to_string()),
1378 Some(body) => {
1379 for (k, line) in body.split('\n').enumerate() {
1380 let prefix = if k == 0 { &first } else { &cont };
1381 if line.is_empty() {
1382 lines.push(prefix.trim_end().to_string());
1383 } else {
1384 lines.push(format!("{prefix}{line}"));
1385 }
1386 }
1387 }
1388 }
1389
1390 previous = Some(block);
1391 }
1392
1393 let mut out = lines.join("\n");
1394 out.push('\n');
1395 out
1396}
1397
1398pub fn normalize(markdown: &str) -> String {
1400 serialize(&parse(markdown))
1401}
1402
1403#[cfg(test)]
1408mod tests {
1409 use super::*;
1410
1411 fn html(markdown: &str) -> String {
1412 let mut out = String::new();
1413 pulldown_cmark::html::push_html(&mut out, Parser::new_ext(markdown, options()));
1414 out
1415 }
1416
1417 #[derive(serde::Deserialize)]
1418 struct Example {
1419 markdown: String,
1420 example: u32,
1421 section: String,
1422 }
1423
1424 const NESTED_EMPHASIS: &[u32] = &[
1428 368, 372, 388, 406, 407, 408, 416, 417, 418, 424, 425, 426, 431, 460, 462, 463, 464, 465,
1429 467,
1430 ];
1431
1432 const FORMAT_ORDER: &[u32] = &[168];
1436
1437 fn examples() -> Vec<Example> {
1438 serde_json::from_str(include_str!("../tests/commonmark-0.31.2.json")).unwrap()
1439 }
1440
1441 #[test]
1444 fn commonmark_spec_round_trip() {
1445 let examples = examples();
1446 assert_eq!(examples.len(), 652);
1447
1448 let mut unexpected = Vec::new();
1449 let mut known = 0;
1450 for example in &examples {
1451 let written = normalize(&example.markdown);
1452 let expected = html(&example.markdown);
1453 let actual = html(&written);
1454 if expected != actual {
1455 let is_known = NESTED_EMPHASIS.contains(&example.example)
1456 || FORMAT_ORDER.contains(&example.example);
1457 if is_known {
1458 known += 1;
1459 } else {
1460 unexpected.push(format!(
1461 "example {} ({})\n--- markdown\n{}--- written\n{}--- expected html\n{}--- actual html\n{}",
1462 example.example, example.section, example.markdown, written, expected, actual
1463 ));
1464 }
1465 }
1466 }
1467
1468 println!(
1469 "round trip: {} of 652 identical, {known} known differences",
1470 652 - known - unexpected.len()
1471 );
1472 if !unexpected.is_empty() {
1473 panic!(
1474 "{} unexpected differences:\n\n{}",
1475 unexpected.len(),
1476 unexpected.join("\n\n")
1477 );
1478 }
1479 }
1480
1481 #[test]
1483 fn commonmark_spec_stable() {
1484 for example in &examples() {
1485 let once = normalize(&example.markdown);
1486 let twice = normalize(&once);
1487 assert_eq!(once, twice, "example {} is not stable", example.example);
1488 }
1489 }
1490
1491 #[test]
1492 fn task_lists() {
1493 for source in [
1494 "- [ ] asdf\n",
1495 "- [ ] open\n- [x] done\n",
1496 "1. [x] first\n2. [ ] second\n",
1497 "- [ ] parent\n - [x] child\n - plain child\n",
1498 "- [x] item\n\n with a second paragraph\n",
1499 "> - [ ] in a quote\n",
1500 "- [ ] **bold** and [link](https://x.org)\n",
1501 ] {
1502 assert_eq!(normalize(source), source, "task list changed");
1503 }
1504
1505 let doc = parse("- [ ] open\n- [X] done\n* [x] other list\n");
1506 let tasks: Vec<Option<bool>> = doc
1507 .blocks
1508 .iter()
1509 .map(|block| block.containers.last().and_then(Container::task))
1510 .collect();
1511 assert_eq!(tasks, vec![Some(false), Some(true), Some(true)]);
1512 assert_eq!(doc.blocks[0].text(), "open");
1513 assert_eq!(normalize("- [X] done\n"), "- [x] done\n");
1514
1515 assert_eq!(
1516 normalize("- \\[ \\] not a task\n"),
1517 "- \\[ \\] not a task\n"
1518 );
1519 let doc = parse("- \\[ \\] not a task\n");
1520 assert_eq!(doc.blocks[0].containers[0].task(), None);
1521 assert_eq!(doc.blocks[0].text(), "[ ] not a task");
1522 }
1523
1524 #[test]
1525 fn image_markdown_round_trips() {
1526 let text = image_markdown("a [b]", "assets/my image.png");
1527 assert_eq!(text, "![a \\[b\\]](<assets/my image.png>)");
1528 let doc = parse(&text);
1529 let style = &doc.blocks[0].styles[0];
1530 assert_eq!(style.image.as_ref().unwrap().url, "assets/my image.png");
1531 assert_eq!(doc.blocks[0].text(), "a [b]");
1532 }
1533
1534 struct Rng(u64);
1537
1538 impl Rng {
1539 fn next(&mut self) -> u64 {
1540 self.0 ^= self.0 << 13;
1541 self.0 ^= self.0 >> 7;
1542 self.0 ^= self.0 << 17;
1543 self.0
1544 }
1545 fn below(&mut self, n: u64) -> u64 {
1546 self.next() % n
1547 }
1548 fn chance(&mut self, p: f64) -> bool {
1549 (self.next() % 10_000) as f64 / 10_000.0 < p
1550 }
1551 fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
1552 &items[self.below(items.len() as u64) as usize]
1553 }
1554 }
1555
1556 const PIECES: &[&str] = &[
1557 "a", "b", "c", " ", "x", "y", "z", "1", "2", "3", "*", "_", "[", "]", "#", "<", ">", "!",
1558 "-", "+", "=", ".", ")", "`", "\\", "&", "~", "|", " ", "\t", "\u{2028}", "\n", "\u{a0}",
1559 "[ ] ", "[x] ",
1560 ];
1561
1562 fn random_inline(rng: &mut Rng, len: usize) -> (Vec<char>, Vec<Style>) {
1563 let links = [
1564 Link::new("https://x.org/a", None),
1565 Link::new("/u", Some("T \"q\"".into())),
1566 Link::new("mailto:a@b.co", None),
1567 ];
1568 let mut chars = Vec::new();
1569 let mut styles = Vec::new();
1570 let mut style = Style::default();
1571 while chars.len() < len {
1572 if rng.chance(0.25) {
1573 style = Style {
1574 bold: rng.chance(0.3),
1575 italic: rng.chance(0.3),
1576 strike: rng.chance(0.2),
1577 code: rng.chance(0.15),
1578 link: rng.chance(0.15).then(|| rng.pick(&links).clone()),
1579 ..Style::default()
1580 };
1581 }
1582 for mut c in rng.pick(PIECES).chars() {
1583 if style.code && (is_break(c) || c == '\t') {
1584 c = 'q';
1585 }
1586 chars.push(c);
1587 styles.push(style.clone());
1588 }
1589 }
1590 (chars, styles)
1591 }
1592
1593 fn random_blocks(rng: &mut Rng, path: Vec<Container>, depth: usize, out: &mut Vec<Block>) {
1594 for _ in 0..1 + rng.below(3) {
1595 let roll = rng.below(100);
1596 if depth < 3 && roll < 20 {
1597 let mut inner = path.clone();
1598 inner.push(Container::quote());
1599 random_blocks(rng, inner, depth + 1, out);
1600 } else if depth < 3 && roll < 40 {
1601 let mut list = ListInfo::new(rng.chance(0.5), *rng.pick(&[1, 1, 3, 10]));
1602 list.tight = rng.chance(0.6);
1603 for _ in 0..1 + rng.below(3) {
1604 let task = if rng.chance(0.3) {
1605 Some(rng.chance(0.5))
1606 } else {
1607 None
1608 };
1609 let mut inner = path.clone();
1610 inner.push(Container::task_item(list, task));
1611 random_blocks(rng, inner, depth + 1, out);
1612 }
1613 } else {
1614 let leaf = *rng.pick(&[
1615 Leaf::Paragraph,
1616 Leaf::Paragraph,
1617 Leaf::Paragraph,
1618 Leaf::Heading(1),
1619 Leaf::Heading(2),
1620 Leaf::Heading(3),
1621 Leaf::Code,
1622 Leaf::Rule,
1623 Leaf::Html,
1624 ]);
1625 let mut block = Block::new(path.clone(), leaf);
1626 match leaf {
1627 Leaf::Rule => {}
1628 Leaf::Code => {
1629 for _ in 0..rng.below(13) {
1630 block.push(
1631 *rng.pick(&['x', ' ', '\n', '`', '~', '\t']),
1632 Style::default(),
1633 );
1634 }
1635 block.info = (*rng.pick(&[None, Some("rust"), Some("a b")])).map(Arc::from);
1636 }
1637 Leaf::Html => {
1638 let text = format!("<div>\n{}\n</div>", rng.pick(&["x", "*y*", "z"]));
1639 for c in text.chars() {
1640 block.push(c, Style::default());
1641 }
1642 }
1643 _ => {
1644 let len = 1 + rng.below(14) as usize;
1645 let (chars, styles) = random_inline(rng, len);
1646 block.chars = chars;
1647 block.styles = styles;
1648 }
1649 }
1650 out.push(block);
1651 }
1652 }
1653 }
1654
1655 fn expected(blocks: &[Block]) -> Vec<Block> {
1657 let mut out: Vec<Block> = Vec::new();
1658 for block in expressible(blocks) {
1659 let mut block = if block.leaf.is_text() {
1660 strip_edges(&block)
1661 } else {
1662 block
1663 };
1664 if matches!(block.leaf, Leaf::Heading(3..=6)) {
1665 for c in &mut block.chars {
1666 if *c == '\n' {
1667 *c = ' ';
1668 }
1669 }
1670 }
1671 out.push(block);
1672 }
1673 for index in 0..out.len() {
1674 for depth in 0..out[index].containers.len() {
1675 let Container::Item { task: Some(_), .. } = out[index].containers[depth] else {
1676 continue;
1677 };
1678 let container = out[index].containers[depth];
1679 let first = (0..out.len())
1680 .find(|k| {
1681 out[*k]
1682 .containers
1683 .get(depth)
1684 .is_some_and(|c| c.same(&container))
1685 })
1686 .unwrap();
1687 let ok = out[first].leaf == Leaf::Paragraph
1688 && out[first].containers.len() == depth + 1
1689 && !out[first].chars.is_empty();
1690 if !ok {
1691 if let Container::Item { task, .. } = &mut out[index].containers[depth] {
1692 *task = None;
1693 }
1694 }
1695 }
1696 }
1697 out
1698 }
1699
1700 type Canon = Vec<(
1701 Vec<(char, usize, usize, bool, u64, Option<bool>)>,
1702 String,
1703 String,
1704 Vec<Style>,
1705 Option<String>,
1706 )>;
1707
1708 fn canon(blocks: &[Block]) -> Canon {
1709 let mut ids: HashMap<(char, u64), usize> = HashMap::new();
1710 let mut id = |kind: char, value: u64| {
1711 let len = ids.len();
1712 *ids.entry((kind, value)).or_insert(len)
1713 };
1714 blocks
1715 .iter()
1716 .map(|block| {
1717 let containers: Vec<(char, usize, usize, bool, u64, Option<bool>)> = block
1718 .containers
1719 .iter()
1720 .map(|c| match c {
1721 Container::Quote { id: q } => ('q', id('q', *q), 0, false, 0, None),
1722 Container::Item {
1723 list,
1724 id: item,
1725 task,
1726 } => (
1727 'i',
1728 id('l', list.id),
1729 id('i', *item),
1730 list.ordered,
1731 if list.ordered { list.start } else { 1 },
1732 *task,
1733 ),
1734 })
1735 .collect();
1736 let styles: Vec<Style> = block
1737 .chars
1738 .iter()
1739 .zip(&block.styles)
1740 .map(|(c, s)| {
1741 if is_break(*c) {
1742 Style::default()
1743 } else {
1744 s.clone()
1745 }
1746 })
1747 .collect();
1748 (
1749 containers,
1750 format!("{:?}", block.leaf),
1751 block.text(),
1752 styles,
1753 block.info.as_deref().map(str::to_string),
1754 )
1755 })
1756 .collect()
1757 }
1758
1759 #[test]
1760 fn random_documents_round_trip() {
1761 let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
1762 let mut failures = Vec::new();
1763 for _ in 0..3000 {
1764 let mut blocks = Vec::new();
1765 random_blocks(&mut rng, Vec::new(), 0, &mut blocks);
1766 let doc = Document { blocks };
1767 let written = serialize(&doc);
1768 let back = parse(&written);
1769 if canon(&back.blocks) != canon(&expected(&doc.blocks)) {
1770 failures.push(format!("not preserved:\n{written}"));
1771 continue;
1772 }
1773 let again = serialize(&back);
1774 if serialize(&parse(&again)) != again {
1775 failures.push(format!("not stable:\n{again}"));
1776 }
1777 }
1778 if !failures.is_empty() {
1779 panic!("{} of 3000 failed, first:\n{}", failures.len(), failures[0]);
1780 }
1781 }
1782}