Skip to main content

common/parser_tools/
djot_escape.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Turn arbitrary plain text into Djot that renders it back verbatim.
5//!
6//! The inverse of [`djot_to_plain_text`](super::djot_to_plain_text), and the counterpart
7//! it had been missing. The Djot exporter has always needed this — it cannot write a
8//! paragraph containing `*` without the re-parse reading emphasis that the writer never
9//! typed — but the two halves lived privately inside `export_djot_uc`, so anything *else*
10//! holding plain text destined to become Djot had to reinvent them.
11//!
12//! That reinvention is the failure this module exists to prevent. A host app promoting a
13//! stored plain-text field to a Djot one (a comment body, say) has to escape the values
14//! already on disk, and a second, slightly-different escaper would disagree with the
15//! exporter about exactly the awkward strings — a paragraph opening `- ` , a title with
16//! `[brackets]`, prose about `snake_case` — while agreeing on everything easy enough to
17//! notice in review.
18//!
19//! Two levels, because Djot has two:
20//!
21//! * [`escape_djot_inline`] neutralises the characters that can start *inline* markup
22//!   anywhere in a line.
23//! * [`guard_djot_block_start`] neutralises the markers that mean something only at the
24//!   **start of a line** — a leading `#` is a heading, a leading `- ` a list item, and no
25//!   amount of inline escaping reaches them.
26//!
27//! [`plain_text_to_djot`] composes both over every line, which is what a caller
28//! converting a whole stored string wants.
29
30/// Backslash-escape every character that can trigger Djot *inline* markup, so arbitrary
31/// text survives a re-parse verbatim.
32///
33/// jotdown turns `\x` into an `Escape` event followed by the literal character, so
34/// **over-escaping is always round-trip-safe** — which is why this takes the whole
35/// punctuation set rather than trying to be clever about which occurrences are actually
36/// syntactic. Being clever there means tracking Djot's inline state machine, and being
37/// wrong about it silently rewrites the writer's text.
38///
39/// Block-start markers (`#`, `>`, `-`, …) are *not* covered here — they are only
40/// meaningful at the start of a line, and escaping them mid-sentence would litter
41/// ordinary prose with backslashes. Use [`guard_djot_block_start`] for those.
42pub fn escape_djot_inline(s: &str) -> String {
43    let mut result = String::with_capacity(s.len());
44    for c in s.chars() {
45        match c {
46            '\\' | '*' | '_' | '`' | '~' | '^' | '[' | ']' | '(' | ')' | '{' | '}' | '|' | '<' => {
47                result.push('\\');
48                result.push(c);
49            }
50            _ => result.push(c),
51        }
52    }
53    result
54}
55
56/// Neutralise a line's leading characters so they are not parsed as a block-construct
57/// marker.
58///
59/// Covers the block-only markers (`#`, `>`, `-`, `+`, `:`) and the ordered-list forms
60/// `<digits>.` and `<digits>)`. Inline specials are [`escape_djot_inline`]'s job.
61///
62/// For the ordered-list case the **delimiter** is escaped rather than the digit: a
63/// backslash before a digit is a literal backslash in Djot, so escaping `1` in `1.` would
64/// add a visible `\` and still leave the list marker intact — wrong twice over.
65pub fn guard_djot_block_start(s: &str) -> String {
66    let Some(first) = s.chars().next() else {
67        return s.to_string();
68    };
69    if matches!(first, '#' | '>' | '-' | '+' | ':') {
70        return format!("\\{s}");
71    }
72    if first.is_ascii_digit() {
73        let rest = s.trim_start_matches(|c: char| c.is_ascii_digit());
74        if rest.starts_with('.') || rest.starts_with(')') {
75            let digits_len = s.len() - rest.len();
76            return format!("{}\\{}", &s[..digits_len], &s[digits_len..]);
77        }
78    }
79    s.to_string()
80}
81
82/// Convert a whole plain-text string into Djot that parses back to exactly that text.
83///
84/// Escapes inline markup everywhere and guards each line's own start, since a line
85/// beginning `- ` is a list item wherever it sits in the string, not only in the first.
86///
87/// # Each line becomes its own paragraph, and that is forced, not chosen
88///
89/// A single newline *inside* a Djot paragraph is a soft break, and
90/// [`djot_to_plain_text`](super::djot_to_plain_text) collapses it to a space — so
91/// emitting the lines as one paragraph loses every line ending. Blocks, meanwhile, are
92/// joined by exactly one `\n` when read back. One paragraph per line is therefore the
93/// only shape whose round trip is the identity, and it is also what the text it will
94/// meet already means: a `.docx`/`.odt` comment body is assembled by joining its
95/// paragraphs with `\n`, so each newline in such a string *is* a paragraph boundary.
96///
97/// # The contract, stated exactly
98///
99/// `djot_to_plain_text(plain_text_to_djot(s)) == s` for every `s` that contains **no
100/// blank line** — i.e. no two consecutive newlines, and no leading or trailing one.
101///
102/// That restriction is not a gap left open; it is the shape of the target. `djot_to_plain_text`
103/// never emits two consecutive newlines, because blocks are joined by exactly one — so no
104/// string containing a blank line is in the image of the parse, and none can be recovered by
105/// any encoding. Blank lines in the input collapse, which for the paragraph-joined text this
106/// serves is a no-op. Use [`needs_djot_escaping`] to find values a conversion would alter.
107pub fn plain_text_to_djot(s: &str) -> String {
108    let mut out = String::with_capacity(s.len());
109    let mut first = true;
110    for line in s.split('\n') {
111        if line.is_empty() {
112            continue;
113        }
114        if !first {
115            out.push_str("\n\n");
116        }
117        first = false;
118        out.push_str(&guard_djot_block_start(&escape_djot_inline(line)));
119    }
120    out
121}
122
123/// Whether [`plain_text_to_djot`] would rewrite `s` at all.
124///
125/// For a caller migrating a stored field from plain text to Djot: a value this returns
126/// `false` for is *already* legal Djot meaning exactly itself, so it can be left
127/// byte-identical on disk and stays readable by an older build. Only the values this
128/// returns `true` for force a rewrite — which is the distinction a format-version floor
129/// should be gated on, rather than stamping every project that merely *has* comments.
130///
131/// Note this asks whether the **stored bytes** change, not whether meaning survives. For
132/// that, see [`djot_round_trip_is_lossy`] — the two are independent, and a migration
133/// generally wants both.
134pub fn needs_djot_escaping(s: &str) -> bool {
135    plain_text_to_djot(s) != s
136}
137
138/// Whether converting `s` to Djot and reading it back would **lose text**.
139///
140/// Distinct from [`needs_djot_escaping`], and not derivable from it: the escape is a pure
141/// string transform, while this runs the real parse. Two shapes are outside the image of
142/// any Djot parse, so no encoding can recover them and this reports both:
143///
144/// * a **blank line** — blocks are joined by exactly one `\n` on the way back, so two
145///   consecutive newlines never come out;
146/// * **trailing whitespace** on a line — Djot strips it.
147///
148/// A migration should report the values this flags rather than rewrite them silently: the
149/// text is the writer's, and quietly dropping a blank line out of someone's remark is the
150/// same class of failure as quietly moving their comment.
151pub fn djot_round_trip_is_lossy(s: &str) -> bool {
152    use crate::parser_tools::djot_options::DjotImportOptions;
153    crate::parser_tools::content_parser::djot_to_plain_text(
154        &plain_text_to_djot(s),
155        &DjotImportOptions::default(),
156    ) != s
157}
158
159#[cfg(test)]
160mod tests {
161    use super::super::content_parser::djot_to_plain_text;
162    use super::*;
163    use crate::parser_tools::djot_options::DjotImportOptions;
164
165    /// The contract, over the strings that actually break naive escaping.
166    #[test]
167    fn escaped_plain_text_parses_back_to_itself() {
168        for original in [
169            "plain prose, nothing special",
170            "a *starred* word",
171            "snake_case and more_snake_case",
172            "code `backticks` here",
173            "brackets [like this] and (parens)",
174            "a title: The Lighthouse [Revised]",
175            "# not a heading",
176            "- not a list item",
177            "1. not an ordered list",
178            "12) also not an ordered list",
179            "> not a quote",
180            "+ not a list",
181            ": not a definition",
182            "a backslash \\ alone",
183            "tilde ~sub~ and caret ^sup^",
184            "braces {attr} and a pipe | here",
185            "an angle <bracket>",
186            "line one\nline two",
187            "- leading marker\nand a second line",
188            "1. first\n2. second\n3. third",
189            "unicode — em dash, ellipsis …, quotes “ ”",
190        ] {
191            let djot = plain_text_to_djot(original);
192            let round_tripped = djot_to_plain_text(&djot, &DjotImportOptions::default());
193            assert_eq!(
194                round_tripped, *original,
195                "escaping {original:?} produced {djot:?}, which parsed back as \
196                 {round_tripped:?} — the escape is not round-trip safe"
197            );
198        }
199    }
200
201    /// Text with nothing syntactic must be left byte-identical, or migrating a stored
202    /// field would rewrite every ordinary value for no reason.
203    #[test]
204    fn ordinary_prose_is_left_untouched() {
205        for plain in [
206            "Just an ordinary remark.",
207            "Two sentences. Both ordinary!",
208            "A question? Yes.",
209            "",
210        ] {
211            assert_eq!(plain_text_to_djot(plain), plain);
212            assert!(!needs_djot_escaping(plain), "{plain:?} needs no escaping");
213        }
214    }
215
216    #[test]
217    fn text_with_markup_characters_is_reported_as_needing_escaping() {
218        for plain in ["a *star*", "# heading-ish", "1. listish", "under_score"] {
219            assert!(needs_djot_escaping(plain), "{plain:?} must need escaping");
220        }
221    }
222
223    /// The documented restriction, asserted rather than left implicit: a blank line
224    /// cannot survive, because `djot_to_plain_text` joins blocks with exactly one `\n`
225    /// and so never emits two in a row. A caller that needs to know beforehand has
226    /// [`needs_djot_escaping`].
227    #[test]
228    fn a_blank_line_collapses_because_no_djot_can_produce_one() {
229        let round_tripped =
230            djot_to_plain_text(&plain_text_to_djot("a\n\nb"), &DjotImportOptions::default());
231        assert_eq!(round_tripped, "a\nb");
232    }
233
234    /// The two predicates answer different questions and neither implies the other —
235    /// which is exactly why both exist. `"a\n\nb"` escapes to itself byte-for-byte (the
236    /// blank line is dropped and the paragraph join puts it back), so a pure string
237    /// comparison sees no change while the round trip genuinely loses a line.
238    #[test]
239    fn lossiness_is_not_detectable_by_string_comparison_alone() {
240        assert!(
241            !needs_djot_escaping("a\n\nb"),
242            "the escape happens to reproduce the input byte-for-byte here"
243        );
244        assert!(
245            djot_round_trip_is_lossy("a\n\nb"),
246            "…but the round trip still loses the blank line, and a migration must be able \
247             to see that"
248        );
249    }
250
251    /// Djot strips trailing whitespace, so it is outside the image of any parse too.
252    #[test]
253    fn trailing_whitespace_is_reported_as_lossy() {
254        assert!(djot_round_trip_is_lossy("trailing spaces are content   "));
255        assert!(!djot_round_trip_is_lossy("no trailing space"));
256    }
257
258    /// Multi-line text is the shape a `.docx`/`.odt` comment body actually arrives in —
259    /// its paragraphs joined with `\n` by the scanners. It must survive exactly.
260    #[test]
261    fn a_multi_paragraph_comment_body_round_trips() {
262        let body = "First paragraph of the note.\nA second one, with *emphasis* typed literally.";
263        let djot = plain_text_to_djot(body);
264        assert_eq!(
265            djot_to_plain_text(&djot, &DjotImportOptions::default()),
266            body
267        );
268    }
269
270    /// The ordered-list guard must escape the delimiter, never the digit — a backslash
271    /// before a digit is a literal backslash in Djot.
272    #[test]
273    fn an_ordered_list_guard_escapes_the_delimiter_not_the_digit() {
274        assert_eq!(guard_djot_block_start("1. text"), "1\\. text");
275        assert_eq!(guard_djot_block_start("42) text"), "42\\) text");
276    }
277}