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