pub fn escape_djot_inline(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' | '*' | '_' | '`' | '~' | '^' | '[' | ']' | '(' | ')' | '{' | '}' | '|' | '<' => {
result.push('\\');
result.push(c);
}
_ => result.push(c),
}
}
result
}
pub fn guard_djot_block_start(s: &str) -> String {
let Some(first) = s.chars().next() else {
return s.to_string();
};
if matches!(first, '#' | '>' | '-' | '+' | ':') {
return format!("\\{s}");
}
if first.is_ascii_digit() {
let rest = s.trim_start_matches(|c: char| c.is_ascii_digit());
if rest.starts_with('.') || rest.starts_with(')') {
let digits_len = s.len() - rest.len();
return format!("{}\\{}", &s[..digits_len], &s[digits_len..]);
}
}
s.to_string()
}
pub fn plain_text_to_djot(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut first = true;
for line in s.split('\n') {
if line.is_empty() {
continue;
}
if !first {
out.push_str("\n\n");
}
first = false;
out.push_str(&guard_djot_block_start(&escape_djot_inline(line)));
}
out
}
pub fn needs_djot_escaping(s: &str) -> bool {
plain_text_to_djot(s) != s
}
pub fn djot_round_trip_is_lossy(s: &str) -> bool {
use crate::parser_tools::djot_options::DjotImportOptions;
crate::parser_tools::content_parser::djot_to_plain_text(
&plain_text_to_djot(s),
&DjotImportOptions::default(),
) != s
}
#[cfg(test)]
mod tests {
use super::super::content_parser::djot_to_plain_text;
use super::*;
use crate::parser_tools::djot_options::DjotImportOptions;
#[test]
fn escaped_plain_text_parses_back_to_itself() {
for original in [
"plain prose, nothing special",
"a *starred* word",
"snake_case and more_snake_case",
"code `backticks` here",
"brackets [like this] and (parens)",
"a title: The Lighthouse [Revised]",
"# not a heading",
"- not a list item",
"1. not an ordered list",
"12) also not an ordered list",
"> not a quote",
"+ not a list",
": not a definition",
"a backslash \\ alone",
"tilde ~sub~ and caret ^sup^",
"braces {attr} and a pipe | here",
"an angle <bracket>",
"line one\nline two",
"- leading marker\nand a second line",
"1. first\n2. second\n3. third",
"unicode — em dash, ellipsis …, quotes “ ”",
] {
let djot = plain_text_to_djot(original);
let round_tripped = djot_to_plain_text(&djot, &DjotImportOptions::default());
assert_eq!(
round_tripped, *original,
"escaping {original:?} produced {djot:?}, which parsed back as \
{round_tripped:?} — the escape is not round-trip safe"
);
}
}
#[test]
fn ordinary_prose_is_left_untouched() {
for plain in [
"Just an ordinary remark.",
"Two sentences. Both ordinary!",
"A question? Yes.",
"",
] {
assert_eq!(plain_text_to_djot(plain), plain);
assert!(!needs_djot_escaping(plain), "{plain:?} needs no escaping");
}
}
#[test]
fn text_with_markup_characters_is_reported_as_needing_escaping() {
for plain in ["a *star*", "# heading-ish", "1. listish", "under_score"] {
assert!(needs_djot_escaping(plain), "{plain:?} must need escaping");
}
}
#[test]
fn a_blank_line_collapses_because_no_djot_can_produce_one() {
let round_tripped =
djot_to_plain_text(&plain_text_to_djot("a\n\nb"), &DjotImportOptions::default());
assert_eq!(round_tripped, "a\nb");
}
#[test]
fn lossiness_is_not_detectable_by_string_comparison_alone() {
assert!(
!needs_djot_escaping("a\n\nb"),
"the escape happens to reproduce the input byte-for-byte here"
);
assert!(
djot_round_trip_is_lossy("a\n\nb"),
"…but the round trip still loses the blank line, and a migration must be able \
to see that"
);
}
#[test]
fn trailing_whitespace_is_reported_as_lossy() {
assert!(djot_round_trip_is_lossy("trailing spaces are content "));
assert!(!djot_round_trip_is_lossy("no trailing space"));
}
#[test]
fn a_multi_paragraph_comment_body_round_trips() {
let body = "First paragraph of the note.\nA second one, with *emphasis* typed literally.";
let djot = plain_text_to_djot(body);
assert_eq!(
djot_to_plain_text(&djot, &DjotImportOptions::default()),
body
);
}
#[test]
fn an_ordered_list_guard_escapes_the_delimiter_not_the_digit() {
assert_eq!(guard_djot_block_start("1. text"), "1\\. text");
assert_eq!(guard_djot_block_start("42) text"), "42\\) text");
}
}