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