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