use text_document::{
DjotExportOptions, DjotImportOptions, FindOptions, ReplaceFormatPolicy, ReplaceOptions,
TextDocument,
matching::{FoldLocale, preserve_case},
};
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 loose() -> FindOptions {
FindOptions::default()
}
#[test]
fn a_plain_ascii_query_finds_accented_prose() {
let doc = doc_with("Aurélien traversa la forêt. Le café était froid.");
for (query, expected) in [
("aurelien", "Aurélien"),
("foret", "forêt"),
("cafe", "café"),
("AURELIEN", "Aurélien"),
] {
let hits = doc.find_all(query, &loose()).expect("find_all");
assert_eq!(hits.len(), 1, "{query:?} must find something");
assert_eq!(
hits[0].matched_text, expected,
"{query:?} must report the ACCENTED text it actually matched"
);
}
}
#[test]
fn the_diacritic_toggle_reaches_the_matcher() {
let doc = doc_with("Le café était froid.");
let strict = FindOptions {
diacritic_sensitive: true,
..FindOptions::default()
};
assert!(
doc.find_all("cafe", &strict).unwrap().is_empty(),
"diacritic_sensitive must REFUSE the unaccented query — if this finds `café`, the \
flag never left the DTO"
);
assert_eq!(doc.find_all("café", &strict).unwrap().len(), 1);
assert_eq!(doc.find_all("cafe", &loose()).unwrap().len(), 1);
}
#[test]
fn full_case_folding_finds_the_sharp_s() {
let doc = doc_with("Sie wohnte in der Bahnhofstraße.");
let strict_diacritics = FindOptions {
diacritic_sensitive: true,
..FindOptions::default()
};
let hits = doc.find_all("strasse", &strict_diacritics).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].matched_text, "straße",
"`ss` matched the `ß` itself"
);
}
#[test]
fn a_query_cannot_replace_half_of_a_folded_letter() {
let doc = doc_with("Bahnhofstraße");
let hits = doc.find_all("s", &loose()).unwrap();
assert_eq!(
hits.len(),
1,
"only the real `s` of `Bahnhofs`, never half the ß"
);
assert_eq!(hits[0].position, 7);
let count = doc
.replace_text("s", "S", true, &ReplaceOptions::new(loose()))
.unwrap();
assert_eq!(count, 1);
assert_eq!(
djot(&doc),
"BahnhofStraße",
"the ß must come through the replace intact"
);
}
#[test]
fn whole_word_sees_through_the_arabic_article() {
let doc = doc_with("قرأت الكتاب ثم كتبت.");
let whole = FindOptions {
whole_word: true,
..FindOptions::default()
};
let hits = doc.find_all("كتاب", &whole).unwrap();
assert_eq!(hits.len(), 1, "the article must not hide the noun");
assert_eq!(hits[0].matched_text, "كتاب");
}
#[test]
fn one_rename_across_a_french_scene_and_a_turkish_scene() {
let french = doc_with("Ilse était là. ILSE cria.");
let turkish = doc_with("İlse oradaydı. İLSE bağırdı. Ilse başka bir kelime.");
let fr_opts = FindOptions {
language: "fr-FR".to_string(),
..FindOptions::default()
};
let tr_opts = FindOptions {
language: "tr-TR".to_string(),
..FindOptions::default()
};
let rename = |doc: &TextDocument, opts: &FindOptions, locale: FoldLocale| {
doc.find_and_replace(
"ilse",
&ReplaceOptions::new(opts.clone())
.with_format_policy(ReplaceFormatPolicy::PreserveIfFullyCovered),
|matched, _| Some(preserve_case(matched, "irene", locale)),
)
.expect("find_and_replace")
};
assert_eq!(rename(&french, &fr_opts, FoldLocale::Root), 2);
assert_eq!(djot(&french), "Irene était là. IRENE cria.");
assert_eq!(
rename(&turkish, &tr_opts, FoldLocale::Turkic),
2,
"`İlse` and `İLSE` are the word — `Ilse` is a different one"
);
assert_eq!(
djot(&turkish),
"İrene oradaydı. İRENE bağırdı. Ilse başka bir kelime.",
"Turkish uppercases `i` to `İ`, and the dotless `Ilse` must be left alone"
);
let untailored = doc_with("İlse oradaydı. İLSE bağırdı. Ilse başka bir kelime.");
assert_eq!(rename(&untailored, &loose(), FoldLocale::Root), 3);
}
#[test]
fn the_language_decides_whether_two_letters_are_the_same_letter() {
let doc = doc_with("KISA bir yol.");
let turkish = FindOptions {
language: "tr".to_string(),
..FindOptions::default()
};
assert_eq!(doc.find_all("kısa", &turkish).unwrap().len(), 1);
assert!(
doc.find_all("kisa", &turkish).unwrap().is_empty(),
"in a Turkish scene, `kisa` is not `kısa`"
);
assert_eq!(doc.find_all("kisa", &loose()).unwrap().len(), 1);
}
#[test]
fn a_malformed_language_tag_still_searches() {
let doc = doc_with("Le café était froid.");
for tag in ["", "---", "klingon", "tr-", "!!!"] {
let opts = FindOptions {
language: tag.to_string(),
..FindOptions::default()
};
assert_eq!(
doc.find_all("cafe", &opts).unwrap().len(),
1,
"tag {tag:?} must fall back to an untailored fold, not break the search"
);
}
}
#[test]
fn the_regex_path_honours_the_diacritic_fold() {
let doc = doc_with("Aurélien et Aurelie.");
let folded = FindOptions {
use_regex: true,
..FindOptions::default()
};
let hits = doc.find_all(r"aurel\w+", &folded).unwrap();
assert_eq!(hits.len(), 2, "the pattern runs against the FOLDED text");
assert_eq!(
hits[0].matched_text, "Aurélien",
"and the offsets still address the SOURCE, accents and all"
);
assert_eq!(hits[1].matched_text, "Aurelie");
let strict = FindOptions {
use_regex: true,
diacritic_sensitive: true,
..FindOptions::default()
};
let hits = doc.find_all(r"aurel\w+", &strict).unwrap();
assert_eq!(
hits.len(),
1,
"diacritic-sensitive: only the unaccented one"
);
assert_eq!(hits[0].matched_text, "Aurelie");
let cased = FindOptions {
use_regex: true,
case_sensitive: true,
..FindOptions::default()
};
assert_eq!(
doc.find_all(r"[A-Z]\w+", &cased).unwrap().len(),
2,
"`[A-Z]` must still find the capitals — folding case away would leave none"
);
}
#[test]
fn a_regex_that_matches_the_empty_string_replaces_nothing() {
for pattern in [r"x*", r"y?", r"\b"] {
let doc = doc_with("Elena rentra chez elle.");
let opts = FindOptions {
use_regex: true,
..FindOptions::default()
};
assert!(
doc.find_all(pattern, &opts).unwrap().is_empty(),
"{pattern:?} matches the empty string — it must report no matches"
);
let count = doc
.replace_text(pattern, "XX", true, &ReplaceOptions::new(opts))
.unwrap();
assert_eq!(count, 0, "{pattern:?} must not splice anything");
assert_eq!(
djot(&doc),
"Elena rentra chez elle.",
"the prose must be untouched by {pattern:?}"
);
}
}
#[test]
fn a_regex_with_an_optional_part_still_finds_its_real_matches() {
let doc = doc_with("Elena et Elenas.");
let opts = FindOptions {
use_regex: true,
..FindOptions::default()
};
let hits = doc.find_all(r"elenas?", &opts).unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].matched_text, "Elena");
assert_eq!(hits[1].matched_text, "Elenas");
}
#[test]
fn folding_a_large_document_stays_cheap() {
let paragraph = "Aurélien traversa la forêt où l'ombre s'étirait, et le café refroidissait \
doucement sur la table de chêne.\n\n";
let doc = doc_with(¶graph.repeat(400));
let started = std::time::Instant::now();
for _ in 0..10 {
let hits = doc.find_all("cafe", &loose()).unwrap();
assert_eq!(hits.len(), 400);
}
let per_search = started.elapsed() / 10;
assert!(
per_search < std::time::Duration::from_millis(120),
"a folded search over ~40k words took {per_search:?} — a per-char allocation would \
look exactly like this, and it runs on every keystroke"
);
}