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}