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