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