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, Run, 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
29impl From<&Doc> for String {
30    fn from(doc: &Doc) -> Self {
31        serialize(doc)
32    }
33}
34
35/// [`serialize`] with the app's own marks — see [`crate::Marks`].
36pub fn serialize_with(doc: &Doc, marks: &Marks) -> String {
37    let layout = Layout::of(&doc.blocks);
38    write_range(&doc.blocks, &layout, 0..doc.blocks.len(), marks)
39}
40
41/// Where each block stands when written.
42///
43/// A blank paragraph is not written: markdown has no spelling for one, and
44/// blank lines there separate blocks rather than being one. The empty body of
45/// an alert is the exception. Every other block's indent is clamped to one
46/// level under the written block before it, and its place in a blockquote is
47/// read off the written blocks alone.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub(crate) struct Layout {
50    /// One per block: `None` for a block that is not written.
51    pub(crate) at: Vec<Option<Place>>,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub(crate) struct Place {
56    pub(crate) indent: u8,
57    pub(crate) run: Option<Run>,
58}
59
60impl Layout {
61    pub(crate) fn of(blocks: &[Block]) -> Self {
62        let mut at = Vec::with_capacity(blocks.len());
63        let mut previous: Option<u8> = None;
64        let mut runs = crate::doc::Runs::default();
65        for block in blocks {
66            if block.blank() {
67                at.push(None);
68                continue;
69            }
70            let indent = previous.map_or(0, |previous| block.indent.min(previous + 1));
71            previous = Some(indent);
72            at.push(Some(Place {
73                indent,
74                run: runs.next(block.quote, indent),
75            }));
76        }
77        Self { at }
78    }
79
80    /// The layout of `blocks`, which are the blocks this was laid out for
81    /// with `removed` replaced by `added` blocks. Laid out again from the
82    /// top-level block before the change to the first top-level block after it
83    /// that stands as it did.
84    pub(crate) fn respliced(
85        &self,
86        blocks: &[Block],
87        removed: std::ops::Range<usize>,
88        added: usize,
89    ) -> Self {
90        let start = (0..removed.start.min(self.at.len()))
91            .rev()
92            .find(|&ix| self.top(ix))
93            .unwrap_or(0);
94        let delta = added as isize - removed.len() as isize;
95        let changed_end = removed.start + added;
96        let mut at = self.at[..start].to_vec();
97        let mut previous: Option<u8> = None;
98        let mut runs = crate::doc::Runs::default();
99        for (ix, block) in blocks.iter().enumerate().skip(start) {
100            let place = (!block.blank()).then(|| {
101                let indent = previous.map_or(0, |previous| block.indent.min(previous + 1));
102                previous = Some(indent);
103                Place {
104                    indent,
105                    run: runs.next(block.quote, indent),
106                }
107            });
108            let was = (ix as isize - delta) as usize;
109            let top = place
110                .is_some_and(|place| place.indent == 0 && place.run.is_none_or(|run| run.first));
111            if ix >= changed_end && top && self.at.get(was) == Some(&place) {
112                at.extend_from_slice(&self.at[was..]);
113                return Self { at };
114            }
115            at.push(place);
116        }
117        Self { at }
118    }
119
120    /// Whether block `ix` is written at the top level: unindented, and opening
121    /// its blockquote if it is in one. Markdown read from such a block's start
122    /// reads the same as it does in place.
123    pub(crate) fn top(&self, ix: usize) -> bool {
124        self.at[ix].is_some_and(|place| place.indent == 0 && place.run.is_none_or(|run| run.first))
125    }
126}
127
128/// Blocks `range` of `blocks` as markdown, each followed by the gap to the
129/// next written block. The gap after the range's last written block is the
130/// one to the first written block past it, or the document's tail where
131/// none is.
132///
133/// A block that is not written writes nothing and takes no gap.
134pub(crate) fn write_range(
135    blocks: &[Block],
136    layout: &Layout,
137    range: std::ops::Range<usize>,
138    marks: &Marks,
139) -> String {
140    let mut out = String::new();
141    for ix in range {
142        write_one(&mut out, blocks, layout, ix, marks);
143    }
144    out
145}
146
147/// Block `ix` and the gap after it, as [`write_range`] writes them.
148pub(crate) fn write_one(
149    out: &mut String,
150    blocks: &[Block],
151    layout: &Layout,
152    ix: usize,
153    marks: &Marks,
154) {
155    write_one_as(out, &blocks[ix], blocks, layout, ix, marks);
156}
157
158/// [`write_one`] with `block`'s texts in place of block `ix`'s. Everything
159/// else, the gap included, is decided by `blocks`.
160pub(crate) fn write_one_as(
161    out: &mut String,
162    block: &Block,
163    blocks: &[Block],
164    layout: &Layout,
165    ix: usize,
166    marks: &Marks,
167) {
168    let Some(place) = layout.at[ix] else { return };
169    write_text(out, block, place, marks);
170    write_gap_after(out, blocks, layout, ix);
171}
172
173/// The gap [`write_one`] writes after block `ix`, alone.
174pub(crate) fn write_gap_after(out: &mut String, blocks: &[Block], layout: &Layout, ix: usize) {
175    let Some(place) = layout.at[ix] else { return };
176    let next = (ix + 1..blocks.len()).find_map(|next| layout.at[next].map(|at| (next, at)));
177    match next {
178        Some((next, at)) => write_gap(out, &blocks[ix].kind, place, &blocks[next].kind, at),
179        None => {
180            // GFM reads `[ ]` as a task marker only when whitespace follows
181            // it, and an empty task has no text to supply it. Every block but
182            // the last is followed by the newline of the block after; the last
183            // is followed by nothing.
184            if matches!(&blocks[ix].kind, BlockKind::Task { text, .. } if text.is_empty()) {
185                out.push(' ');
186            }
187        }
188    }
189}
190
191/// A blockquote's `>` stands at its level, and the block is written after it
192/// as if the level were the margin: the prefix, and the indent within it.
193fn quote_prefix(place: Place) -> (String, u8) {
194    match place.run {
195        Some(run) => (
196            format!("{}> ", INDENT.repeat(run.level as usize)),
197            place.indent - run.level,
198        ),
199        None => (String::new(), place.indent),
200    }
201}
202
203fn write_text(out: &mut String, block: &Block, place: Place, marks: &Marks) {
204    let (prefix, inner) = quote_prefix(place);
205    let mut written = String::new();
206    write_block(&mut written, &block.kind, inner, marks);
207    if let Some(alert) = block.quote.and_then(|quote| quote.alert)
208        && place.run.is_some_and(|run| run.first)
209    {
210        // A marker line stands alone when nothing at all is under it.
211        let body = std::mem::take(&mut written);
212        written.push_str(alert.marker());
213        if !body.is_empty() {
214            written.push('\n');
215            written.push_str(&body);
216        }
217    }
218    for (ix, line) in written.split('\n').enumerate() {
219        if ix > 0 {
220            out.push('\n');
221        }
222        if line.is_empty() {
223            out.push_str(prefix.trim_end());
224        } else {
225            out.push_str(&prefix);
226            out.push_str(line);
227        }
228    }
229}
230
231/// The newlines between a block written at `before` and the next, at `after`.
232fn write_gap(
233    out: &mut String,
234    previous: &BlockKind,
235    before: Place,
236    next: &BlockKind,
237    after: Place,
238) {
239    out.push('\n');
240    // A quote's edge takes a blank line, which is what ends one blockquote
241    // before the next block or the next blockquote. Only an empty marker
242    // cannot have one after it.
243    let edge =
244        after.run.is_some_and(|run| run.first) || (after.run.is_none() && before.run.is_some());
245    if !tight_after(previous, next, after.indent > before.indent)
246        || (edge && !is_empty_marker(previous))
247    {
248        if after.run.is_some_and(|run| !run.first) {
249            out.push_str(quote_prefix(after).0.trim_end());
250        }
251        out.push('\n');
252    }
253}
254
255/// Which list a marker block belongs to. Two markers of different kinds are two
256/// different lists even when they are adjacent.
257fn marker_kind(kind: &BlockKind) -> Option<u8> {
258    match kind {
259        BlockKind::Bullet(_) => Some(0),
260        BlockKind::Ordered { .. } => Some(1),
261        BlockKind::Task { .. } => Some(2),
262        _ => None,
263    }
264}
265
266fn is_marker(kind: &BlockKind) -> bool {
267    marker_kind(kind).is_some()
268}
269
270/// Whether a blank line between these two blocks would be wrong.
271///
272/// Items of one list stay tight: a blank line between them makes the list loose,
273/// and `- a\n- b` should come back out as it went in. A blank line does go
274/// between two *different* lists, which is legal — they were already separate —
275/// and is the difference between a readable document and a wall.
276///
277/// An *empty* marker is a special case on both sides, and in opposite
278/// directions. Nothing may separate it from what follows: CommonMark lets a
279/// list item begin with at most one blank line, so a blank line there ends the
280/// list and the item's child becomes a top-level indented code block. But a
281/// blank line must come *before* it, because an empty list item cannot
282/// interrupt a paragraph — written tight against the item above, it is read as
283/// a lazy continuation of that item's text instead of as a list of its own.
284///
285/// Everywhere else the blank line is required too: without it a child block is
286/// read as a lazy continuation of its item.
287///
288/// `nested` says the next block opens a level deeper, which makes it a *new*
289/// list whatever its marker — a checklist under a bullet is as much its own list
290/// as a bullet under a bullet, so all that is left to ask is whether it can
291/// interrupt the paragraph above it, which it has to do to be seen at all. A
292/// bullet may; an ordered list may only when it starts at 1. So a nested `2.`
293/// written tight is read as more text in the item above, and needs the blank
294/// line that ends that paragraph.
295fn tight_after(previous: &BlockKind, next: &BlockKind, nested: bool) -> bool {
296    if is_empty_marker(previous) {
297        return true;
298    }
299    if is_empty_marker(next) {
300        return false;
301    }
302    if nested {
303        return is_marker(previous)
304            && is_marker(next)
305            && !matches!(next, BlockKind::Ordered { number, .. } if *number != 1);
306    }
307    marker_kind(previous).is_some() && marker_kind(previous) == marker_kind(next)
308}
309
310fn is_empty_marker(kind: &BlockKind) -> bool {
311    is_marker(kind)
312        && Block::new(kind.clone())
313            .text_at(Part::Body)
314            .is_some_and(Text::is_empty)
315}
316
317fn write_block(out: &mut String, kind: &BlockKind, indent: u8, marks: &Marks) {
318    let pad = INDENT.repeat(indent as usize);
319
320    match kind {
321        BlockKind::Paragraph(text) => write_lines(out, &pad, &pad, &inline(text, marks)),
322        BlockKind::Heading { level, text } => {
323            let hashes = "#".repeat((*level).clamp(1, 6) as usize);
324            let body = inline_in(text, marks, Lead::Heading);
325            write_lines(out, &format!("{pad}{hashes} "), &pad, &body);
326        }
327        // A bullet with no text would be written as a line holding nothing but
328        // a dash — and a line of dashes directly under a paragraph is a setext
329        // heading underline, not a list item. `+` is the bullet marker that
330        // cannot be read as one.
331        BlockKind::Bullet(text) => {
332            let marker = if text.is_empty() { "+ " } else { "- " };
333            write_marked(out, &pad, marker, text, marks)
334        }
335        BlockKind::Ordered { number, text } => {
336            write_marked(out, &pad, &format!("{number}. "), text, marks)
337        }
338        BlockKind::Task { checked, text } => {
339            let marker = if *checked { "- [x] " } else { "- [ ] " };
340            write_marked(out, &pad, marker, text, marks);
341        }
342        BlockKind::Code {
343            language,
344            code,
345            height,
346        } => {
347            let fence = "`".repeat(fence_width(&code.text));
348            out.push_str(&pad);
349            out.push_str(&fence);
350            out.push_str(language.as_deref().unwrap_or(""));
351            if let Some(height) = height {
352                out.push(' ');
353                out.push_str(&height.to_string());
354            }
355            for line in code.text.split('\n') {
356                out.push('\n');
357                out.push_str(&pad);
358                out.push_str(line);
359            }
360            out.push('\n');
361            out.push_str(&pad);
362            out.push_str(&fence);
363        }
364        BlockKind::Image { url, alt, width } => {
365            out.push_str(&pad);
366            out.push_str("![");
367            escape_inline(out, &alt.text, marks, Lead::Line);
368            // After the escaping, and bare: every `|` a caption holds is
369            // written `\|` to keep two body lines from reconstituting into a
370            // table, so an unescaped one is the delimiter and nothing else.
371            if let Some(width) = width {
372                out.push('|');
373                out.push_str(&width.to_string());
374            }
375            out.push_str("](");
376            write_destination(out, url);
377            out.push(')');
378        }
379        // The angles are what makes a line with a link on it into a card, and
380        // they are core CommonMark — every other reader still shows a link
381        // here. The other two forms have no shorthand and say their name.
382        BlockKind::Bookmark { url, form } => {
383            out.push_str(&pad);
384            match form.title() {
385                None => {
386                    out.push('<');
387                    out.push_str(url);
388                    out.push('>');
389                }
390                Some(title) => {
391                    out.push('[');
392                    out.push_str(url);
393                    out.push_str("](");
394                    write_destination(out, url);
395                    out.push_str(&format!(" \"{title}\")"));
396                }
397            }
398        }
399        BlockKind::Table {
400            align,
401            header,
402            rows,
403        } => write_table(out, &pad, align, header, rows, marks),
404        BlockKind::Rule => {
405            out.push_str(&pad);
406            out.push_str("---");
407        }
408    }
409}
410
411/// A list item: the marker on the first line, its content column on the rest.
412fn write_marked(out: &mut String, pad: &str, marker: &str, text: &Text, marks: &Marks) {
413    // An empty item has nothing for the marker's space to hold apart from it,
414    // so the space is trailing whitespace no one typed.
415    let opener = if text.is_empty() {
416        marker.trim_end()
417    } else {
418        marker
419    };
420    let first = format!("{pad}{opener}");
421    let rest = format!("{pad}{}", " ".repeat(marker.chars().count()));
422    write_lines(out, &first, &rest, &inline(text, marks));
423}
424
425fn write_lines(out: &mut String, first: &str, rest: &str, body: &str) {
426    for (ix, line) in body.split('\n').enumerate() {
427        if ix > 0 {
428            out.push('\n');
429        }
430        out.push_str(if ix == 0 { first } else { rest });
431        out.push_str(line);
432    }
433}
434
435/// Long enough to survive any run of backticks the code itself contains.
436fn fence_width(code: &str) -> usize {
437    let mut longest = 0;
438    let mut run = 0;
439    for c in code.chars() {
440        run = if c == '`' { run + 1 } else { 0 };
441        longest = longest.max(run);
442    }
443    (longest + 1).max(3)
444}
445
446fn write_table(
447    out: &mut String,
448    pad: &str,
449    align: &[Align],
450    header: &[Text],
451    rows: &[Vec<Text>],
452    marks: &Marks,
453) {
454    let columns = align.len().max(header.len());
455    let row_of = |cells: &[Text]| {
456        let mut line = String::from("|");
457        for ix in 0..columns {
458            line.push(' ');
459            if let Some(cell) = cells.get(ix) {
460                // `escape_span` already escapes the pipes.
461                line.push_str(&inline(cell, marks));
462            }
463            line.push_str(" |");
464        }
465        line
466    };
467
468    out.push_str(pad);
469    out.push_str(&row_of(header));
470    out.push('\n');
471    out.push_str(pad);
472    out.push('|');
473    for ix in 0..columns {
474        out.push_str(match align.get(ix).copied().unwrap_or_default() {
475            Align::Left => " --- |",
476            Align::Center => " :-: |",
477            Align::Right => " ---: |",
478        });
479    }
480    for row in rows {
481        out.push('\n');
482        out.push_str(pad);
483        out.push_str(&row_of(row));
484    }
485}
486
487/// Render inline content with its marks. Marks are stored outermost first, so
488/// opening them in order and closing them in reverse reproduces the nesting —
489/// which is what keeps `**_x_**` and `_**x**_` distinct.
490fn inline(text: &Text, marks: &Marks) -> String {
491    inline_in(text, marks, Lead::Line)
492}
493
494/// What the first line of inline text follows.
495#[derive(Clone, Copy)]
496enum Lead {
497    /// The start of a line, or a container marker that reads block syntax
498    /// after it.
499    Line,
500    /// An ATX heading's `# `, after which only a run of hashes means anything.
501    Heading,
502}
503
504fn inline_in(text: &Text, marks: &Marks, lead: Lead) -> String {
505    let mut out = String::new();
506    let mut open: Vec<usize> = Vec::new();
507    let mut started = vec![false; text.marks.len()];
508    // The delimiter a span opened with, so it closes with the same one.
509    let mut delimiters = vec!['_'; text.marks.len()];
510    let mut cursor = 0usize;
511
512    let mut boundaries: Vec<usize> = text
513        .marks
514        .iter()
515        .flat_map(|m| [m.range.start, m.range.end])
516        .chain([0, text.text.len()])
517        .collect();
518    boundaries.sort_unstable();
519    boundaries.dedup();
520
521    for point in boundaries {
522        if point < cursor {
523            continue;
524        }
525        escape_inline(&mut out, &text.text[cursor..point], marks, lead);
526        cursor = point;
527
528        while let Some(&top) = open.last() {
529            if text.marks[top].range.end <= point {
530                close_mark(&mut out, &text.marks[top].mark, delimiters[top], marks);
531                open.pop();
532            } else {
533                break;
534            }
535        }
536
537        for (ix, span) in text.marks.iter().enumerate() {
538            if started[ix] || span.range.start != point {
539                continue;
540            }
541            started[ix] = true;
542            // Code spans are literal to their closing backtick: nothing inside
543            // is markup, so they are emitted whole rather than opened.
544            if span.mark == Mark::Code {
545                let body = &text.text[span.range.clone()];
546                let ticks = "`".repeat(fence_width_inline(body));
547                out.push_str(&ticks);
548                out.push_str(body);
549                out.push_str(&ticks);
550                cursor = cursor.max(span.range.end);
551                continue;
552            }
553            // The shorthand is its angles, emitted whole for the same reason a
554            // code span is: the text between them *is* the URL, so there is
555            // nothing inside for another mark to open against. Anything the
556            // angles cannot hold falls through to the explicit spelling, which
557            // is why a mention never has to stop being one.
558            if let Mark::Mention { url, .. } = &span.mark
559                && crate::parse::is_shorthand(text, ix)
560            {
561                out.push('<');
562                out.push_str(url);
563                out.push('>');
564                cursor = cursor.max(span.range.end);
565                continue;
566            }
567            // A link whose text is the URL it points at is written bare, which
568            // is what the linkifier reads back — so a URL in a sentence
569            // survives byte for byte instead of growing brackets it never had.
570            // Only when no other mark touches it: like a code span this is
571            // emitted whole, and a boundary inside it would have nowhere to
572            // land.
573            if let Mark::Link(url) = &span.mark
574                && text.text.get(span.range.clone()) == Some(url.as_str())
575                && crate::parse::is_url(url)
576                && text.alone(ix)
577            {
578                out.push_str(url);
579                cursor = cursor.max(span.range.end);
580                continue;
581            }
582            let italic = italic_delimiter(&out, text, &span.range);
583            delimiters[ix] = italic;
584            open_mark(&mut out, &span.mark, italic, marks);
585            // A mark over nothing — an image with no alt text — closes here.
586            // Leaving it on the stack would stretch it to the next boundary.
587            if span.range.is_empty() {
588                close_mark(&mut out, &span.mark, italic, marks);
589            } else {
590                open.push(ix);
591            }
592        }
593    }
594
595    escape_inline(
596        &mut out,
597        &text.text[cursor.min(text.text.len())..],
598        marks,
599        lead,
600    );
601    while let Some(ix) = open.pop() {
602        close_mark(&mut out, &text.marks[ix].mark, delimiters[ix], marks);
603    }
604    out
605}
606
607fn fence_width_inline(body: &str) -> usize {
608    let mut longest = 0;
609    let mut run = 0;
610    for c in body.chars() {
611        run = if c == '`' { run + 1 } else { 0 };
612        longest = longest.max(run);
613    }
614    longest + 1
615}
616
617/// Which delimiter spells italic for this span.
618///
619/// `_` is preferred because it nests unambiguously inside `**` — `***x***` is
620/// read as emphasis wrapping strong, so writing bold-outside-italic with stars
621/// would come back inside out. But `_` cannot open or close against a letter,
622/// so an emphasis that starts or ends mid-word has to use `*` instead.
623///
624/// What counts as "against a letter" is the *output*, not the source text: a
625/// mark opening right after a code span is preceded by a backtick, which is
626/// punctuation, even though the character before it in the text is a letter.
627/// Deciding from `written` is what keeps `` `a`**_x_** `` from being spelled
628/// `***`, which reads back inside out.
629fn italic_delimiter(written: &str, text: &Text, range: &std::ops::Range<usize>) -> char {
630    let intraword = written
631        .chars()
632        .next_back()
633        .is_some_and(char::is_alphanumeric)
634        || text.text[range.end..]
635            .chars()
636            .next()
637            .is_some_and(char::is_alphanumeric);
638    if intraword { '*' } else { '_' }
639}
640
641fn open_mark(out: &mut String, mark: &Mark, italic: char, marks: &Marks) {
642    match mark {
643        Mark::Bold => out.push_str("**"),
644        Mark::Italic => out.push(italic),
645        Mark::Strike => out.push_str("~~"),
646        Mark::Link(_) | Mark::Mention { .. } => out.push('['),
647        Mark::Image(_) => out.push_str("!["),
648        // A name no registry spells writes nothing and reads back as the text
649        // it wrapped, which is the only degradation that cannot corrupt a file.
650        Mark::Custom(name) => out.push_str(marks.delimiter(name).unwrap_or("")),
651        Mark::Code => {}
652    }
653}
654
655fn close_mark(out: &mut String, mark: &Mark, italic: char, marks: &Marks) {
656    match mark {
657        Mark::Bold => out.push_str("**"),
658        Mark::Italic => out.push(italic),
659        Mark::Strike => out.push_str("~~"),
660        Mark::Link(url) | Mark::Image(url) => {
661            out.push_str("](");
662            write_destination(out, url);
663            out.push(')');
664        }
665        // The title names the form. It is the only slot CommonMark leaves for
666        // it, and the shorthand having been ruled out is what got us here.
667        Mark::Mention { url, form } => {
668            out.push_str("](");
669            write_destination(out, url);
670            out.push_str(" \"");
671            out.push_str(&form.title().unwrap_or_else(|| "chip".to_owned()));
672            out.push_str("\")");
673        }
674        Mark::Custom(name) => out.push_str(marks.delimiter(name).unwrap_or("")),
675        Mark::Code => {}
676    }
677}
678
679/// A link destination, in whichever of CommonMark's two spellings reads back
680/// as the URL it was handed. Bare wherever that works, because every reader
681/// shows it and it is what a URL was written as; in angles for a destination
682/// bare would swallow or cut short — the space in `/My Notes/a.png` ends a
683/// bare destination, and the rest of it becomes text.
684fn write_destination(out: &mut String, url: &str) {
685    if bare_destination(url) {
686        return out.push_str(url);
687    }
688    out.push('<');
689    for c in url.chars() {
690        match c {
691            '<' | '>' | '\\' => {
692                out.push('\\');
693                out.push(c);
694            }
695            // The one thing neither spelling can hold. Percent-encoding is
696            // what a URL says instead, and leaving it raw would end the
697            // destination the same way the space did.
698            c if c.is_ascii_control() => out.push_str(&format!("%{:02X}", c as u8)),
699            c => out.push(c),
700        }
701    }
702    out.push('>');
703}
704
705/// Whether `url` survives being written without its angles: no whitespace, no
706/// backslash to be read as an escape, and parentheses balanced — an unmatched
707/// `)` is where the destination ends.
708fn bare_destination(url: &str) -> bool {
709    if url.starts_with('<') {
710        return false;
711    }
712    let mut depth = 0i32;
713    for c in url.chars() {
714        match c {
715            '(' => depth += 1,
716            ')' if depth == 0 => return false,
717            ')' => depth -= 1,
718            '\\' => return false,
719            c if c.is_whitespace() || c.is_ascii_control() => return false,
720            _ => {}
721        }
722    }
723    depth == 0
724}
725
726/// Escape only what would otherwise re-parse as syntax.
727///
728/// Called with slices between mark boundaries, so "line start" means the start
729/// of a line in the *output*, not in the slice.
730fn escape_inline(out: &mut String, s: &str, marks: &Marks, lead: Lead) {
731    let mut line_start = out.is_empty() || out.ends_with('\n');
732    let mut lead = if out.is_empty() { lead } else { Lead::Line };
733    for (ix, line) in s.split('\n').enumerate() {
734        if ix > 0 {
735            out.push('\n');
736            line_start = true;
737            lead = Lead::Line;
738        }
739        let body = if line_start {
740            escape_block_marker(out, line, lead)
741        } else {
742            line
743        };
744        escape_span(out, body, marks);
745        line_start = false;
746    }
747}
748
749/// Escape a leading run that would open a block, returning what is left of the
750/// line. Only ever fires at a line start — mid-line these characters are
751/// ordinary text, and escaping them there is what turns `#123` into `\#123`.
752fn escape_block_marker<'a>(out: &mut String, line: &'a str, lead: Lead) -> &'a str {
753    // A position's sentinel is gone once the text is written, so what follows
754    // it is what starts the line.
755    let placed = line.len()
756        - line
757            .trim_start_matches(|c| sentinel_index(c).is_some())
758            .len();
759    if placed > 0 {
760        out.push_str(&line[..placed]);
761        return escape_block_marker(out, &line[placed..], lead);
762    }
763    let after_space = |rest: &str| rest.starts_with([' ', '\t']) || rest.is_empty();
764
765    let hashes = line.len() - line.trim_start_matches('#').len();
766    if hashes > 0 && after_space(&line[hashes..]) {
767        out.push('\\');
768        out.push_str(&line[..hashes]);
769        return &line[hashes..];
770    }
771    if let Lead::Heading = lead {
772        return line;
773    }
774
775    if let Some(rest) = line.strip_prefix('>') {
776        out.push_str("\\>");
777        return rest;
778    }
779
780    // `*` is escaped by `escape_span` wherever it appears, so only `-` and `+`
781    // need catching here.
782    if (line.starts_with('-') || line.starts_with('+')) && after_space(&line[1..]) {
783        out.push('\\');
784        out.push_str(&line[..1]);
785        return &line[1..];
786    }
787
788    let digits = line.len() - line.trim_start_matches(|c: char| c.is_ascii_digit()).len();
789    if digits > 0 {
790        let after = &line[digits..];
791        if (after.starts_with('.') || after.starts_with(')')) && after_space(&after[1..]) {
792            out.push_str(&line[..digits]);
793            out.push('\\');
794            out.push_str(&after[..1]);
795            return &after[1..];
796        }
797    }
798
799    // A run of `-` or `=` alone is a thematic break or a setext underline.
800    let trimmed = line.trim_end();
801    let mut bare = trimmed
802        .chars()
803        .filter(|c| sentinel_index(*c).is_none())
804        .peekable();
805    if bare.peek().is_some() && bare.all(|c| c == '=' || c == '-') {
806        out.push('\\');
807        out.push_str(&line[..1]);
808        return &line[1..];
809    }
810
811    line
812}
813
814/// Per-character escaping within one line.
815fn escape_span(out: &mut String, s: &str, marks: &Marks) {
816    let mut skip = 0usize;
817    for (ix, c) in s.char_indices() {
818        if ix < skip {
819            continue;
820        }
821        let rest = &s[ix + c.len_utf8()..];
822        // A registered delimiter standing in the text is text, and has to come
823        // back as text: every character of it takes a backslash, or the next
824        // read finds a mark nobody wrote. Longest first, so `===` is not
825        // escaped as `==` and a stray `=`.
826        if let Some(entry) = marks
827            .sorted()
828            .into_iter()
829            .find(|entry| s[ix..].starts_with(entry.delimiter.as_ref()))
830        {
831            for c in entry.delimiter.chars() {
832                out.push('\\');
833                out.push(c);
834            }
835            skip = ix + entry.delimiter.len();
836            continue;
837        }
838        match c {
839            // Every tilde, not just a doubled one: GFM strikes on `~x~` as
840            // well, so escaping only the first of a pair leaves the survivors
841            // to find each other. Pipes are here because two consecutive body
842            // lines that happen to look like a header and a delimiter row will
843            // otherwise reconstitute themselves into a table.
844            '\\' | '*' | '`' | '[' | ']' | '~' | '|' => {
845                out.push('\\');
846                out.push(c);
847            }
848            // Intraword underscores are not emphasis in CommonMark, and
849            // escaping them would mangle every snake_case identifier.
850            '_' => {
851                let before = s[..ix].chars().next_back();
852                let inside_word = before.is_some_and(char::is_alphanumeric)
853                    && rest.chars().next().is_some_and(char::is_alphanumeric);
854                if !inside_word {
855                    out.push('\\');
856                }
857                out.push('_');
858            }
859            // `<` matters for autolinks and raw tags, not for `1 < 2`.
860            '<' if rest
861                .chars()
862                .next()
863                .is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | '!' | '?')) =>
864            {
865                out.push_str("\\<")
866            }
867            '&' if rest
868                .chars()
869                .next()
870                .is_some_and(|c| c.is_alphanumeric() || c == '#') =>
871            {
872                out.push_str("\\&")
873            }
874            _ => out.push(c),
875        }
876    }
877}
878
879/// The private-use codepoints the position mapping rides on, one per
880/// position: plane 15, clear of the ones [`crate::parse`] lifts marks to.
881const SENTINELS: std::ops::RangeInclusive<u32> = 0xF0000..=0xFFFFD;
882
883/// The sentinel standing for position `ix`, or `None` past the last one.
884pub(crate) fn sentinel(ix: usize) -> Option<char> {
885    let code = SENTINELS.start().checked_add(u32::try_from(ix).ok()?)?;
886    SENTINELS
887        .contains(&code)
888        .then(|| char::from_u32(code))
889        .flatten()
890}
891
892/// Which position a sentinel stands for.
893pub(crate) fn sentinel_index(c: char) -> Option<usize> {
894    SENTINELS
895        .contains(&(c as u32))
896        .then(|| (c as u32 - SENTINELS.start()) as usize)
897}
898
899/// Whether any text in `doc` already holds a sentinel.
900fn carries_sentinel(doc: &Doc) -> bool {
901    doc.blocks.iter().any(|block| {
902        block.parts().into_iter().any(|part| {
903            block
904                .text_at(part)
905                .is_some_and(|text| text.text.chars().any(|c| sentinel_index(c).is_some()))
906        })
907    })
908}
909
910/// The document as markdown, and where `at` landed in it.
911///
912/// Exact through markers, escapes and marks because it *is* the serializer: a
913/// sentinel goes in at the caret, the document is written, and where the
914/// sentinel came out is the answer. The string comes back without it.
915///
916/// The offset is the end of the output for a caret this cannot place — a
917/// document already carrying a sentinel, or a part that no longer exists.
918pub fn serialize_at(doc: &Doc, at: Cursor, marks: &Marks) -> (String, usize) {
919    let (source, offsets) = serialize_at_many(doc, &[at], marks);
920    let end = source.len();
921    (source, offsets[0].unwrap_or(end))
922}
923
924/// [`serialize_at`] for many positions in one pass: where each of `at`
925/// landed, in the same order, `None` for one this cannot place.
926pub fn serialize_at_many(doc: &Doc, at: &[Cursor], marks: &Marks) -> (String, Vec<Option<usize>>) {
927    let mut doc = doc.clone();
928    let mut placed = vec![false; at.len()];
929    if !carries_sentinel(&doc) {
930        // Back to front, so each insert leaves the offsets still to come where
931        // they were.
932        let mut order: Vec<usize> = (0..at.len()).collect();
933        order.sort_by_key(|&ix| std::cmp::Reverse(at[ix]));
934        for ix in order {
935            let Some(mark) = sentinel(ix) else { continue };
936            let Some(text) = doc
937                .blocks
938                .get_mut(at[ix].block)
939                .and_then(|block| block.text_at_mut(at[ix].part))
940            else {
941                continue;
942            };
943            let offset = at[ix].offset.min(text.text.len());
944            if text.text.is_char_boundary(offset) {
945                text.insert(offset, mark.encode_utf8(&mut [0; 4]));
946                placed[ix] = true;
947            }
948        }
949    }
950    // Normalized *after* the sentinels go in, so the string this returns is
951    // the one the offsets index into — a trailing space is only trailing
952    // while nothing sits after it.
953    doc.normalize_with(marks);
954    let written = serialize_with(&doc, marks);
955    if !placed.contains(&true) {
956        return (written, vec![None; at.len()]);
957    }
958    let mut source = String::with_capacity(written.len());
959    let mut offsets = vec![None; at.len()];
960    for c in written.chars() {
961        match sentinel_index(c) {
962            Some(ix) => {
963                if placed.get(ix) == Some(&true) {
964                    offsets[ix] = Some(source.len());
965                }
966            }
967            None => source.push(c),
968        }
969    }
970    (source, offsets)
971}