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