From b474525e4bcc2b43cae2eca911cd7e498c4650a3 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 6 Nov 2026 15:49:07 +0500 Subject: [PATCH] Find Whole Word: a change of style inside a word is not a word boundary Find and Replace matched each style run on its own, and the whole-word test looked only at the characters inside the run, so a run's end read as a word boundary: with cat coloured red in catfish, a Whole Word search for cat replaced it. The test now looks past the run's ends at the characters next to it in the neighbouring runs. Matches still stay inside one run, as the command documents. Fixes #5901 --- crates/engine/src/cmd/typemenu.rs | 26 ++++++++++++++++++++----- crates/engine/src/tests/menucmds.rs | 30 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index f678de8ce..2056ddfb4 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -625,6 +625,22 @@ fn is_word(c: char) -> bool { /// Byte ranges of `needle` in `hay`. pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> { + find_all_between(hay, needle, match_case, whole_word, None, None) +} + +/// [`find_all`] in run `i` of `runs`: a whole word looks past the run's ends at the characters +/// next to it, so a change of style inside a word is not a word boundary (#5901). Matches stay +/// inside the run. +fn find_in_run(runs: &[TextRun], i: usize, needle: &str, match_case: bool, whole_word: bool) -> Vec<(usize, usize)> { + let Some(run) = runs.get(i) else { return vec![] }; + let before = runs.get(..i).and_then(|r| r.iter().rev().find_map(|r| r.text.chars().next_back())); + let after = runs.get(i + 1..).and_then(|r| r.iter().find_map(|r| r.text.chars().next())); + find_all_between(&run.text, needle, match_case, whole_word, before, after) +} + +/// [`find_all`], with `before` and `after` the characters on either side of `hay` (none at the +/// text's ends), which a whole word must not touch either. +fn find_all_between(hay: &str, needle: &str, match_case: bool, whole_word: bool, before: Option, after: Option) -> Vec<(usize, usize)> { let nc: Vec = needle.chars().collect(); if nc.is_empty() { return vec![]; @@ -635,9 +651,9 @@ pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bo let mut i = 0; while i + nc.len() <= hc.len() { if (0..nc.len()).all(|k| eq(hc[i + k].1, nc[k])) { - let before = i.checked_sub(1).map(|j| hc[j].1); - let after = hc.get(i + nc.len()).map(|x| x.1); - if !whole_word || (!before.is_some_and(is_word) && !after.is_some_and(is_word)) { + let prev = i.checked_sub(1).map_or(before, |j| hc.get(j).map(|x| x.1)); + let next = hc.get(i + nc.len()).map_or(after, |x| Some(x.1)); + if !whole_word || (!prev.is_some_and(is_word) && !next.is_some_and(is_word)) { let start = hc[i].0; let end = hc.get(i + nc.len()).map(|x| x.0).unwrap_or(hay.len()); out.push((start, end)); @@ -685,7 +701,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let total: usize = ids .iter() .filter_map(|id| match &st.doc.node(*id)?.kind { - NodeKind::Text(t) => Some(t.runs.iter().map(|r| find_all(&r.text, &find, mc, ww).len()).sum::()), + NodeKind::Text(t) => Some((0..t.runs.len()).map(|i| find_in_run(&t.runs, i, &find, mc, ww).len()).sum::()), _ => None, }) .sum(); @@ -702,7 +718,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { let mut base = 0; for (ri, r) in t.runs.iter().enumerate() { if r.inline.is_none() { - for (a, b) in find_all(&r.text, &find, mc, ww) { + for (a, b) in find_in_run(&t.runs, ri, &find, mc, ww) { if r.text.get(a..b) != Some(rep.as_str()) { hits.push((ri, base + a, base + b, a, b)); } diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index cd48c1b98..ff7d362f3 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -1989,6 +1989,36 @@ fn find_and_replace_options() { assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err()); } +/// Whole Word looks past a change of style: `cat` coloured red inside `catfish`, or at the end of +/// `bobcat`, is part of a word, not a word of its own (#5901). A real word next to a run's end +/// still matches, and without Whole Word the part is replaced as before. +#[test] +fn find_whole_word_is_not_fooled_by_a_style_change_inside_a_word() { + let styled = |s: &mut Session, t: &str, a: usize, b: usize| { + let id = text(s, t); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": a, "end": b, "fill": "#ff0000"})).unwrap(); + let NodeKind::Text(tt) = &node(s, id).kind else { panic!() }; + assert!(tt.runs.len() >= 2, "{t}: two runs"); + id + }; + let mut s = session(); + let fish = styled(&mut s, "catfish", 0, 3); + let bob = styled(&mut s, "bobcat", 3, 6); + let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap(); + assert_eq!((r["count"].as_u64(), plain(&s, fish), plain(&s, bob)), (Some(0), "catfish".to_string(), "bobcat".to_string())); + // A word that ends where its run does, at a space or the text's end, is still a word. + let mut s = session(); + let spaced = styled(&mut s, "cat fish", 0, 3); + let tail = styled(&mut s, "a cat", 2, 5); + let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG", "wholeWord": true})).unwrap(); + assert_eq!((r["count"].as_u64(), plain(&s, spaced), plain(&s, tail)), (Some(2), "DOG fish".to_string(), "a DOG".to_string())); + // Without Whole Word, the part inside the word is found as before. + let mut s = session(); + let fish = styled(&mut s, "catfish", 0, 3); + let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "DOG"})).unwrap(); + assert_eq!((r["count"].as_u64(), plain(&s, fish)), (Some(1), "DOGfish".to_string())); +} + /// Find and Replace with no ids leaves the type on a locked artboard as it is. #[test] fn find_and_replace_leaves_type_on_a_locked_artboard() {