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() {