Skip to main content

markdown/
serialize.rs

1//! [`Doc`] → markdown.
2//!
3//! The guarantee is a **fixed point**: `parse(serialize(parse(s)))` equals
4//! `parse(s)` for any input. An edit/save cycle can therefore never drift,
5//! which is the property an editor actually needs — stronger than pretty
6//! output and weaker (honestly so) than byte-identical round tripping, which a
7//! flat model cannot promise for arbitrarily nested CommonMark.
8//!
9//! Escaping is deliberately narrow. Over-escaping is its own bug: escaping `#`
10//! everywhere turns a `#123` reference into `\#123`, which no reader matches.
11//! So `#`, `>`, `-` and friends are escaped only at the start of a line, where
12//! they would actually mean something.
13
14use crate::doc::{Align, Block, BlockKind, Doc, Mark, Part, Text};
15
16/// Four spaces per level: enough to sit inside any list marker's content
17/// column (`- ` is 2, `10. ` is 4), and never enough to become an indented
18/// code block, because a block at depth N+1 always follows its marker at N.
19const INDENT: &str = "    ";
20
21pub fn serialize(doc: &Doc) -> String {
22    let mut out = String::new();
23    let mut previous: Option<(&BlockKind, u8)> = None;
24
25    for block in &doc.blocks {
26        let indent = match previous {
27            Some((_, prev)) => block.indent.min(prev + 1),
28            None => 0,
29        };
30
31        if let Some((prev_kind, prev_indent)) = previous {
32            out.push('\n');
33            if !tight_after(prev_kind, &block.kind, indent > prev_indent) {
34                out.push('\n');
35            }
36        }
37
38        write_block(&mut out, &block.kind, indent);
39        previous = Some((&block.kind, indent));
40    }
41
42    // GFM reads `[ ]` as a task marker only when whitespace follows it, and an
43    // empty task has no text to supply it. Every block but the last is followed
44    // by the newline of the block after; the last is followed by nothing.
45    if doc
46        .blocks
47        .last()
48        .is_some_and(|block| matches!(&block.kind, BlockKind::Task { text, .. } if text.is_empty()))
49    {
50        out.push(' ');
51    }
52
53    out
54}
55
56/// Which list a marker block belongs to. Two markers of different kinds are two
57/// different lists even when they are adjacent.
58fn marker_kind(kind: &BlockKind) -> Option<u8> {
59    match kind {
60        BlockKind::Bullet(_) => Some(0),
61        BlockKind::Ordered { .. } => Some(1),
62        BlockKind::Task { .. } => Some(2),
63        _ => None,
64    }
65}
66
67fn is_marker(kind: &BlockKind) -> bool {
68    marker_kind(kind).is_some()
69}
70
71/// Whether a blank line between these two blocks would be wrong.
72///
73/// Items of one list stay tight: a blank line between them makes the list loose,
74/// and `- a\n- b` should come back out as it went in. A blank line does go
75/// between two *different* lists, which is legal — they were already separate —
76/// and is the difference between a readable document and a wall.
77///
78/// An *empty* marker is a special case on both sides, and in opposite
79/// directions. Nothing may separate it from what follows: CommonMark lets a
80/// list item begin with at most one blank line, so a blank line there ends the
81/// list and the item's child becomes a top-level indented code block. But a
82/// blank line must come *before* it, because an empty list item cannot
83/// interrupt a paragraph — written tight against the item above, it is read as
84/// a lazy continuation of that item's text instead of as a list of its own.
85///
86/// Everywhere else the blank line is required too: without it a child block is
87/// read as a lazy continuation of its item.
88///
89/// `nested` says the next block opens a level deeper, which makes it a *new*
90/// list whatever its marker — a checklist under a bullet is as much its own list
91/// as a bullet under a bullet, so all that is left to ask is whether it can
92/// interrupt the paragraph above it, which it has to do to be seen at all. A
93/// bullet may; an ordered list may only when it starts at 1. So a nested `2.`
94/// written tight is read as more text in the item above, and needs the blank
95/// line that ends that paragraph.
96fn tight_after(previous: &BlockKind, next: &BlockKind, nested: bool) -> bool {
97    if is_empty_marker(previous) {
98        return true;
99    }
100    if is_empty_marker(next) {
101        return false;
102    }
103    if nested {
104        return is_marker(previous)
105            && is_marker(next)
106            && !matches!(next, BlockKind::Ordered { number, .. } if *number != 1);
107    }
108    marker_kind(previous).is_some() && marker_kind(previous) == marker_kind(next)
109}
110
111fn is_empty_marker(kind: &BlockKind) -> bool {
112    is_marker(kind)
113        && Block::new(kind.clone())
114            .text_at(Part::Body)
115            .is_some_and(Text::is_empty)
116}
117
118fn write_block(out: &mut String, kind: &BlockKind, indent: u8) {
119    let pad = INDENT.repeat(indent as usize);
120
121    match kind {
122        BlockKind::Paragraph(text) => write_lines(out, &pad, &pad, &inline(text)),
123        BlockKind::Heading { level, text } => {
124            let hashes = "#".repeat((*level).clamp(1, 6) as usize);
125            write_lines(out, &format!("{pad}{hashes} "), &pad, &inline(text));
126        }
127        // A bullet with no text would be written as a line holding nothing but
128        // a dash — and a line of dashes directly under a paragraph is a setext
129        // heading underline, not a list item. `+` is the bullet marker that
130        // cannot be read as one.
131        BlockKind::Bullet(text) => {
132            let marker = if text.is_empty() { "+ " } else { "- " };
133            write_marked(out, &pad, marker, text)
134        }
135        BlockKind::Ordered { number, text } => {
136            write_marked(out, &pad, &format!("{number}. "), text)
137        }
138        BlockKind::Task { checked, text } => {
139            let marker = if *checked { "- [x] " } else { "- [ ] " };
140            write_marked(out, &pad, marker, text);
141        }
142        BlockKind::Quote(text) => {
143            let prefix = format!("{pad}> ");
144            write_lines(out, &prefix, &prefix, &inline(text));
145        }
146        BlockKind::Code { language, code } => {
147            let fence = "`".repeat(fence_width(&code.text));
148            out.push_str(&pad);
149            out.push_str(&fence);
150            out.push_str(language.as_deref().unwrap_or(""));
151            for line in code.text.split('\n') {
152                out.push('\n');
153                out.push_str(&pad);
154                out.push_str(line);
155            }
156            out.push('\n');
157            out.push_str(&pad);
158            out.push_str(&fence);
159        }
160        BlockKind::Image { url, alt, width } => {
161            out.push_str(&pad);
162            out.push_str("![");
163            escape_inline(out, &alt.text);
164            // After the escaping, and bare: every `|` a caption holds is
165            // written `\|` to keep two body lines from reconstituting into a
166            // table, so an unescaped one is the delimiter and nothing else.
167            if let Some(width) = width {
168                out.push('|');
169                out.push_str(&width.to_string());
170            }
171            out.push_str("](");
172            out.push_str(url);
173            out.push(')');
174        }
175        // The angles are what makes a line with a link on it into a card, and
176        // they are core CommonMark — every other reader still shows a link
177        // here. The other two forms have no shorthand and say their name.
178        BlockKind::Bookmark { url, form } => {
179            out.push_str(&pad);
180            match form.title() {
181                None => {
182                    out.push('<');
183                    out.push_str(url);
184                    out.push('>');
185                }
186                Some(title) => {
187                    out.push('[');
188                    out.push_str(url);
189                    out.push_str("](");
190                    out.push_str(url);
191                    out.push_str(&format!(" \"{title}\")"));
192                }
193            }
194        }
195        BlockKind::Table {
196            align,
197            header,
198            rows,
199        } => write_table(out, &pad, align, header, rows),
200        BlockKind::Rule => {
201            out.push_str(&pad);
202            out.push_str("---");
203        }
204    }
205}
206
207/// A list item: the marker on the first line, its content column on the rest.
208fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text) {
209    // An empty item has nothing for the marker's space to hold apart from it,
210    // so the space is trailing whitespace no one typed.
211    let opener = if text.is_empty() {
212        marker.trim_end()
213    } else {
214        marker
215    };
216    let first = format!("{pad}{opener}");
217    let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
218    write_lines(out, &first, &rest, &inline(text));
219}
220
221fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
222    for (ix, line) in body.split('\n').enumerate() {
223        if ix > 0 {
224            out.push('\n');
225        }
226        out.push_str(if ix == 0 { first } else { rest });
227        out.push_str(line);
228    }
229}
230
231/// Long enough to survive any run of backticks the code itself contains.
232fn fence_width(code: &str) -> usize {
233    let mut longest = 0;
234    let mut run = 0;
235    for c in code.chars() {
236        run = if c == '`' { run + 1 } else { 0 };
237        longest = longest.max(run);
238    }
239    (longest + 1).max(3)
240}
241
242fn write_table(out: &mut String, pad: &str, align: &[Align], header: &[Text], rows: &[Vec<Text>]) {
243    let columns = align.len().max(header.len());
244    let row_of = |cells: &[Text]| {
245        let mut line = String::from("|");
246        for ix in 0..columns {
247            line.push(' ');
248            if let Some(cell) = cells.get(ix) {
249                // `escape_span` already escapes the pipes.
250                line.push_str(&inline(cell));
251            }
252            line.push_str(" |");
253        }
254        line
255    };
256
257    out.push_str(pad);
258    out.push_str(&row_of(header));
259    out.push('\n');
260    out.push_str(pad);
261    out.push('|');
262    for ix in 0..columns {
263        out.push_str(match align.get(ix).copied().unwrap_or_default() {
264            Align::Left => " --- |",
265            Align::Center => " :-: |",
266            Align::Right => " ---: |",
267        });
268    }
269    for row in rows {
270        out.push('\n');
271        out.push_str(pad);
272        out.push_str(&row_of(row));
273    }
274}
275
276/// Render inline content with its marks. Marks are stored outermost first, so
277/// opening them in order and closing them in reverse reproduces the nesting —
278/// which is what keeps `**_x_**` and `_**x**_` distinct.
279fn inline(text: &Text) -> String {
280    let mut out = String::new();
281    let mut open: Vec<usize> = Vec::new();
282    let mut started = vec![false; text.marks.len()];
283    // The delimiter a span opened with, so it closes with the same one.
284    let mut delimiters = vec!['_'; text.marks.len()];
285    let mut cursor = 0usize;
286
287    let mut boundaries: Vec<usize> = text
288        .marks
289        .iter()
290        .flat_map(|m| [m.range.start, m.range.end])
291        .chain([0, text.text.len()])
292        .collect();
293    boundaries.sort_unstable();
294    boundaries.dedup();
295
296    for point in boundaries {
297        if point < cursor {
298            continue;
299        }
300        escape_inline(&mut out, &text.text[cursor..point]);
301        cursor = point;
302
303        while let Some(&top) = open.last() {
304            if text.marks[top].range.end <= point {
305                close_mark(&mut out, &text.marks[top].mark, delimiters[top]);
306                open.pop();
307            } else {
308                break;
309            }
310        }
311
312        for (ix, span) in text.marks.iter().enumerate() {
313            if started[ix] || span.range.start != point {
314                continue;
315            }
316            started[ix] = true;
317            // Code spans are literal to their closing backtick: nothing inside
318            // is markup, so they are emitted whole rather than opened.
319            if span.mark == Mark::Code {
320                let body = &text.text[span.range.clone()];
321                let ticks = "`".repeat(fence_width_inline(body));
322                out.push_str(&ticks);
323                out.push_str(body);
324                out.push_str(&ticks);
325                cursor = cursor.max(span.range.end);
326                continue;
327            }
328            // The shorthand is its angles, emitted whole for the same reason a
329            // code span is: the text between them *is* the URL, so there is
330            // nothing inside for another mark to open against. Anything the
331            // angles cannot hold falls through to the explicit spelling, which
332            // is why a mention never has to stop being one.
333            if let Mark::Mention { url, .. } = &span.mark
334                && crate::parse::is_shorthand(text, ix)
335            {
336                out.push('<');
337                out.push_str(url);
338                out.push('>');
339                cursor = cursor.max(span.range.end);
340                continue;
341            }
342            // A link whose text is the URL it points at is written bare, which
343            // is what the linkifier reads back — so a URL in a sentence
344            // survives byte for byte instead of growing brackets it never had.
345            // Only when no other mark touches it: like a code span this is
346            // emitted whole, and a boundary inside it would have nowhere to
347            // land.
348            if let Mark::Link(url) = &span.mark
349                && text.text.get(span.range.clone()) == Some(url.as_str())
350                && crate::parse::is_url(url)
351                && text.alone(ix)
352            {
353                out.push_str(url);
354                cursor = cursor.max(span.range.end);
355                continue;
356            }
357            let italic = italic_delimiter(&out, text, &span.range);
358            delimiters[ix] = italic;
359            open_mark(&mut out, &span.mark, italic);
360            // A mark over nothing — an image with no alt text — closes here.
361            // Leaving it on the stack would stretch it to the next boundary.
362            if span.range.is_empty() {
363                close_mark(&mut out, &span.mark, italic);
364            } else {
365                open.push(ix);
366            }
367        }
368    }
369
370    escape_inline(&mut out, &text.text[cursor.min(text.text.len())..]);
371    while let Some(ix) = open.pop() {
372        close_mark(&mut out, &text.marks[ix].mark, delimiters[ix]);
373    }
374    out
375}
376
377fn fence_width_inline(body: &str) -> usize {
378    let mut longest = 0;
379    let mut run = 0;
380    for c in body.chars() {
381        run = if c == '`' { run + 1 } else { 0 };
382        longest = longest.max(run);
383    }
384    longest + 1
385}
386
387/// Which delimiter spells italic for this span.
388///
389/// `_` is preferred because it nests unambiguously inside `**` — `***x***` is
390/// read as emphasis wrapping strong, so writing bold-outside-italic with stars
391/// would come back inside out. But `_` cannot open or close against a letter,
392/// so an emphasis that starts or ends mid-word has to use `*` instead.
393///
394/// What counts as "against a letter" is the *output*, not the source text: a
395/// mark opening right after a code span is preceded by a backtick, which is
396/// punctuation, even though the character before it in the text is a letter.
397/// Deciding from `written` is what keeps `` `a`**_x_** `` from being spelled
398/// `***`, which reads back inside out.
399fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
400    let intraword = written
401        .chars()
402        .next_back()
403        .is_some_and(char::is_alphanumeric)
404        || text.text[range.end..]
405            .chars()
406            .next()
407            .is_some_and(char::is_alphanumeric);
408    if intraword { '*' } else { '_' }
409}
410
411fn open_mark(out: &mut String, mark: &Mark, italic: char) {
412    match mark {
413        Mark::Bold => out.push_str("**"),
414        Mark::Italic => out.push(italic),
415        Mark::Strike => out.push_str("~~"),
416        Mark::Link(_) | Mark::Mention { .. } => out.push('['),
417        Mark::Image(_) => out.push_str("!["),
418        Mark::Code => {}
419    }
420}
421
422fn close_mark(out: &mut String, mark: &Mark, italic: char) {
423    match mark {
424        Mark::Bold => out.push_str("**"),
425        Mark::Italic => out.push(italic),
426        Mark::Strike => out.push_str("~~"),
427        Mark::Link(url) | Mark::Image(url) => {
428            out.push_str("](");
429            out.push_str(url);
430            out.push(')');
431        }
432        // The title names the form. It is the only slot CommonMark leaves for
433        // it, and the shorthand having been ruled out is what got us here.
434        Mark::Mention { url, form } => {
435            out.push_str("](");
436            out.push_str(url);
437            out.push_str(" \"");
438            out.push_str(form.title().unwrap_or("chip"));
439            out.push_str("\")");
440        }
441        Mark::Code => {}
442    }
443}
444
445/// Escape only what would otherwise re-parse as syntax.
446///
447/// Called with slices between mark boundaries, so "line start" means the start
448/// of a line in the *output*, not in the slice.
449fn escape_inline(out: &mut String, s: &str) {
450    let mut line_start = out.is_empty() || out.ends_with('\n');
451    for (ix, line) in s.split('\n').enumerate() {
452        if ix > 0 {
453            out.push('\n');
454            line_start = true;
455        }
456        let body = if line_start {
457            escape_block_marker(out, line)
458        } else {
459            line
460        };
461        escape_span(out, body);
462        line_start = false;
463    }
464}
465
466/// Escape a leading run that would open a block, returning what is left of the
467/// line. Only ever fires at a line start — mid-line these characters are
468/// ordinary text, and escaping them there is what turns `#123` into `\#123`.
469fn escape_block_marker<'a>(out: &mut String, line: &'a str) -> &'a str {
470    let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
471
472    let hashes = line.len() - line.trim_start_matches('#').len();
473    if hashes > 0 && after_space(&line[hashes..]) {
474        out.push('\\');
475        out.push_str(&line[..hashes]);
476        return &line[hashes..];
477    }
478
479    if let Some(rest) = line.strip_prefix('>') {
480        out.push_str("\\>");
481        return rest;
482    }
483
484    // `*` is escaped by `escape_span` wherever it appears, so only `-` and `+`
485    // need catching here.
486    if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
487        out.push('\\');
488        out.push_str(&line[..1]);
489        return &line[1..];
490    }
491
492    let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
493    if digits > 0 {
494        let after = &line[digits..];
495        if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
496            out.push_str(&line[..digits]);
497            out.push('\\');
498            out.push_str(&after[..1]);
499            return &after[1..];
500        }
501    }
502
503    // A run of `-` or `=` alone is a thematic break or a setext underline.
504    let trimmed = line.trim_end();
505    if !trimmed.is_empty() && trimmed.chars().all(|c| c == '=' || c == '-') {
506        out.push('\\');
507        out.push_str(&line[..1]);
508        return &line[1..];
509    }
510
511    line
512}
513
514/// Per-character escaping within one line.
515fn escape_span(out: &mut String, s: &str) {
516    for (ix, c) in s.char_indices() {
517        let rest = &s[ix + c.len_utf8()..];
518        match c {
519            // Every tilde, not just a doubled one: GFM strikes on `~x~` as
520            // well, so escaping only the first of a pair leaves the survivors
521            // to find each other. Pipes are here because two consecutive body
522            // lines that happen to look like a header and a delimiter row will
523            // otherwise reconstitute themselves into a table.
524            '\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
525                out.push('\\');
526                out.push(c);
527            }
528            // Intraword underscores are not emphasis in CommonMark, and
529            // escaping them would mangle every snake_case identifier.
530            '_' => {
531                let before = s[..ix].chars().next_back();
532                let inside_word = before.is_some_and(char::is_alphanumeric)
533                    && rest.chars().next().is_some_and(char::is_alphanumeric);
534                if !inside_word {
535                    out.push('\\');
536                }
537                out.push('_');
538            }
539            // `<` matters for autolinks and raw tags, not for `1 < 2`.
540            '<' if rest
541                .chars()
542                .next()
543                .is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
544            {
545                out.push_str("\\<")
546            }
547            '&' if rest
548                .chars()
549                .next()
550                .is_some_and(|c| c.is_alphanumeric() || c == '#') =>
551            {
552                out.push_str("\\&")
553            }
554            _ => out.push(c),
555        }
556    }
557}