use text_document::{
DjotExportOptions, DjotImportOptions, FindOptions, ReplaceFormatPolicy, ReplaceOptions,
ReplaceRange, TextDocument,
};
fn doc_with(djot: &str) -> TextDocument {
let doc = TextDocument::new();
doc.set_djot_with_options(djot, DjotImportOptions::default())
.and_then(|op| op.wait())
.expect("set_djot");
doc
}
fn djot(doc: &TextDocument) -> String {
doc.to_djot_with_options(DjotExportOptions::default())
.expect("to_djot")
.trim()
.to_string()
}
fn opts() -> ReplaceOptions {
ReplaceOptions::new(FindOptions::default())
}
#[test]
fn a_reviewed_rename_skips_the_unticked_and_keeps_the_case() {
let doc = doc_with("Aurélien woke. AURÉLIEN shouted. Aurélien slept. aurélien dreamt.");
let excluded = [2usize];
let count = doc
.find_and_replace("aurélien", &opts(), |matched, i| {
if excluded.contains(&i) {
return None;
}
Some(
if matched
.chars()
.all(|c| c.is_uppercase() || !c.is_alphabetic())
{
"AURÉLIAN".to_string()
} else if matched.chars().next().is_some_and(char::is_uppercase) {
"Aurélian".to_string()
} else {
"aurélian".to_string()
},
)
})
.expect("find_and_replace");
assert_eq!(count, 3, "four matches, one unticked");
assert_eq!(
djot(&doc),
"Aurélian woke. AURÉLIAN shouted. Aurélien slept. aurélian dreamt.",
"each occurrence keeps the case it was found in, and the unticked one is untouched"
);
}
#[test]
fn the_whole_batch_is_one_undo() {
let source = "Elena went. Elena stayed. Elena returned.";
let doc = doc_with(source);
let count = doc
.find_and_replace("Elena", &opts(), |_, _| Some("Marta".to_string()))
.unwrap();
assert_eq!(count, 3);
assert_eq!(djot(&doc), "Marta went. Marta stayed. Marta returned.");
doc.undo().expect("undo");
assert_eq!(
djot(&doc),
source,
"ONE undo must restore all three, exactly — not one of them, and not none"
);
doc.redo().expect("redo");
assert_eq!(djot(&doc), "Marta went. Marta stayed. Marta returned.");
}
#[test]
fn edits_of_different_lengths_do_not_shift_one_another() {
let doc = doc_with("x A x A x A x");
let count = doc
.find_and_replace("A", &opts(), |_, i| {
Some(match i {
0 => "LONGER-REPLACEMENT".to_string(),
1 => "B".to_string(),
_ => "MEDIUM".to_string(),
})
})
.unwrap();
assert_eq!(count, 3);
assert_eq!(djot(&doc), "x LONGER-REPLACEMENT x B x MEDIUM x");
}
#[test]
fn a_second_rename_after_a_length_changing_one_still_lands_correctly() {
let doc = doc_with("Elena walked.\n\nThe road was long.\n\nElena rested.");
doc.find_and_replace("Elena", &opts(), |_, _| {
Some("Marguerite-Anne".to_string()) })
.unwrap();
let count = doc
.find_and_replace("road", &opts(), |_, _| Some("path".to_string()))
.unwrap();
assert_eq!(count, 1);
assert_eq!(
djot(&doc),
"Marguerite-Anne walked.\n\nThe path was long.\n\nMarguerite-Anne rested.",
"the second rename must land on `road`, not on text shifted by the first"
);
}
#[test]
fn the_formatting_under_a_renamed_word_survives() {
let doc = doc_with("She called *Aurélien* into the trees.");
doc.find_and_replace(
"Aurélien",
&opts().with_format_policy(ReplaceFormatPolicy::PreserveIfFullyCovered),
|_, _| Some("Aurélian".to_string()),
)
.unwrap();
assert_eq!(
djot(&doc),
"She called *Aurélian* into the trees.",
"the emphasis must survive the rename"
);
}
#[test]
fn a_match_inside_a_links_url_is_not_rewritten() {
let doc = doc_with("Read [the note](https://example.test/note) about the note.");
let count = doc
.find_and_replace("note", &opts(), |_, _| Some("letter".to_string()))
.unwrap();
let out = djot(&doc);
assert_eq!(
count, 2,
"only the two occurrences in the PROSE (the link's label, and the last word)"
);
assert!(
out.contains("https://example.test/note"),
"the link's destination is markup and must survive untouched: {out:?}"
);
assert_eq!(
out,
"Read [the letter](https://example.test/note) about the letter."
);
}
#[test]
fn deciding_against_every_occurrence_is_a_no_op() {
let source = "Elena went. Elena stayed.";
let doc = doc_with(source);
let count = doc.find_and_replace("Elena", &opts(), |_, _| None).unwrap();
assert_eq!(count, 0);
assert_eq!(djot(&doc), source);
}
#[test]
fn overlapping_ranges_are_refused_not_silently_merged() {
let doc = doc_with("abcdef");
let count = doc
.replace_ranges(
&[
ReplaceRange {
position: 0,
length: 3,
replacement: "X".to_string(),
},
ReplaceRange {
position: 2,
length: 3,
replacement: "Y".to_string(),
},
],
&opts(),
)
.unwrap();
assert_eq!(count, 1, "only the earlier range is applied");
assert_eq!(djot(&doc), "Xdef");
}
#[test]
fn a_range_addresses_the_same_offsets_find_all_reports() {
let doc = doc_with("one two three");
let hits = doc.find_all("two", &FindOptions::default()).unwrap();
assert_eq!(hits.len(), 1);
doc.replace_ranges(
&[ReplaceRange {
position: hits[0].position,
length: hits[0].length,
replacement: "TWO".to_string(),
}],
&opts(),
)
.unwrap();
assert_eq!(djot(&doc), "one TWO three");
}