Find/Change: Change takes the match Find Next selected; Selection is translated

Change used to accept a selection only when Change All's matches (from
the start of the target) included it. Find Next can select text those
never produce: the rest of a Find Format stretch from a caret inside it,
or an overlapping match ("aa" in "aaa" from the second a). Both commands
now share one definition (`Search::found_edit`). Without a pattern, a
selection inside one stretch is a match; with one, the pattern's match
from the selection's start must be the selection, and a GREP replacement
is expanded for that match (`Grep::replacement_at`).

"Selection" has a row in the shared table (Spanish and Chinese fell back
to English); the French entry moves there from `fr.rs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeerandClaude Opus 5.5 committed 2026-10-22 00:23:35 +02:00
1 parent ac63bdc7ff
commit 33f496a05b
4 files changed
+100 -21

No files matched your search

+10
View File
@@ -378,6 +378,16 @@ impl Grep {
self.re.find_iter(text).take(limit).map(|m| m.map(|m| m.range()).map_err(run_err)).collect()
}
/// The first match starting at or after byte `start`, with its replacement: `template`
/// expanded as [`Self::replacements`] does.
pub fn replacement_at(&self, text: &str, start: usize, template: &str) -> Result<Option<(Range<usize>, String)>, GrepError> {
if start > text.len() || !text.is_char_boundary(start) {
return Ok(None);
}
let Some(caps) = self.re.captures_from_pos(text, start).map_err(run_err)? else { return Ok(None) };
Ok(caps.get(0).map(|m| (m.range(), fancy_regex::Expander::default().expansion(template, &caps))))
}
/// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see
/// [`translate_replacement`]). Stops after the first match when `first_only`.
pub fn replacements(&self, text: &str, template: &str, first_only: bool) -> Result<Vec<(Range<usize>, String)>, GrepError> {
+89 -20
View File
@@ -260,6 +260,33 @@ impl Search {
}
}
/// The edit Change makes when the selection `range` (offsets in `text`, target `r` of story
/// `sid`) is what Find Next found there, so the two commands agree on what a match is. With
/// no pattern: a selection inside one stretch with the Find Format (Find Next selects the rest
/// of a stretch from inside it). With a pattern: the match it makes from the selection's start
/// must be the selection, overlapping matches Find Next can select included.
#[allow(clippy::too_many_arguments)]
fn found_edit(
&self,
d: &Document,
sid: StoryId,
r: &Range<usize>,
text: &str,
range: &Range<usize>,
rep: &str,
grep: bool,
) -> Result<Option<(Range<usize>, String)>> {
let spans = self.spans(d, sid, r);
let Some(re) = &self.re else {
let inside = !range.is_empty() && spans.unwrap_or_default().iter().any(|s| s.start <= range.start && range.end <= s.end);
return Ok(inside.then(|| (range.clone(), rep.to_string())));
};
let found =
if grep { re.replacement_at(text, range.start, rep) } else { re.find_at(text, range.start).map(|m| m.map(|m| (m, rep.to_string()))) };
let found = found.map_err(|e| bad("find.change", e))?;
Ok(found.filter(|(m, _)| m == range && spans.as_ref().is_none_or(|s| within(s, m))))
}
/// The edits Change All makes in `text` (target `r` of story `sid`): `rep` expanded for each
/// GREP match, else `rep` itself.
#[allow(clippy::too_many_arguments)]
@@ -531,26 +558,28 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
let format_only = str_param(p, "change").unwrap_or("").is_empty() && (char_style.is_some() || para_style.is_some() || !over.is_empty());
let context = scope(s, p)?;
let targets = context.targets.clone();
// Change: only the text Find Next found, the selection, while it is still a match.
let found = if bool_or(p, "found", false) {
// Change: only the text Find Next found, the selection, while it is still a match (as Find
// Next defines one), with its replacement.
let (found, found_out) = if bool_or(p, "found", false) {
let st = s.doc()?;
let selected = st.selection.text.filter(|t| t.cell.is_none() && !t.is_caret()).map(|t| (t.story, t.range()));
let hit = selected.filter(|(sid, range)| {
targets.iter().any(|(tid, r)| {
tid == sid
&& r.start <= range.start
&& range.end <= r.end
&& target_text(&st.doc, *tid, r)
.and_then(|text| re.replacements(&st.doc, *tid, r, text, &rep, grep, false))
.is_ok_and(|edits| edits.iter().any(|(m, _)| r.start + m.start == range.start && r.start + m.end == range.end))
let hit = selected.and_then(|(sid, range)| {
targets.iter().find_map(|(tid, r)| {
if *tid != sid || range.start < r.start || r.end < range.end {
return None;
}
let text = target_text(&st.doc, *tid, r).ok()?;
let local = range.start - r.start..range.end - r.start;
let (m, out) = re.found_edit(&st.doc, *tid, r, text, &local, &rep, grep).ok()??;
Some(((sid, r.start + m.start..r.start + m.end), out))
})
});
match hit {
Some(hit) => Some(hit),
Some((hit, out)) => (Some(hit), Some(out)),
None => return Ok(json!({"count": 0})),
}
} else {
None
(None, None)
};
let (count, changed) = s.edit(|d, sel| {
let styles = d.styles.clone();
@@ -563,14 +592,21 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
let Some(story) = d.story(sid) else { continue };
// Collect matches first (positions refer to the original text), apply back to front.
let text = story.text.get(r.clone()).ok_or_else(|| bad("find", "invalid selection bounds or UTF-8 boundary"))?;
let mut edits: Vec<(std::ops::Range<usize>, String)> = re
.replacements(d, sid, &r, text, &rep, grep, first_only)?
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect();
if let Some((_, range)) = &found {
edits.retain(|(m, _)| m == range);
}
let edits: Vec<(std::ops::Range<usize>, String)> = match (&found, &found_out) {
// Change: the found text alone, in the target it lies in.
(Some((_, range)), Some(out)) => {
if r.start <= range.start && range.end <= r.end {
vec![(range.clone(), out.clone())]
} else {
Vec::new()
}
}
_ => re
.replacements(d, sid, &r, text, &rep, grep, first_only)?
.into_iter()
.map(|(m, out)| (r.start + m.start..r.start + m.end, out))
.collect(),
};
let Some(st) = d.story_mut(sid) else { continue };
for (range, new) in edits.iter().rev() {
let changed = if format_only {
@@ -853,6 +889,39 @@ mod tests {
assert_eq!(s.doc().unwrap().selection.text.unwrap().range(), 12..15);
}
/// Change accepts what Find Next selected even when Change All wouldn't match there: the
/// rest of a Find Format stretch from a caret inside it, and an overlapping match.
#[test]
fn change_takes_any_selection_find_next_made() {
// A caret inside a bold stretch: Find Next with only a Find Format selects the rest of it.
let (mut s, sid) = scope_session("plain bold text");
s.execute("style.character.create", &json!({"name": "Strong", "chars": {"fontStyle": "Bold"}})).unwrap();
scope_select(&mut s, sid, 6, 10);
s.execute("style.character.apply", &json!({"name": "Strong"})).unwrap();
scope_select(&mut s, sid, 8, 8);
let p = json!({"find": "", "change": "", "findFormat": {"charStyle": "Strong"}, "attrs": {"underline": true}, "scope": "story"});
let hit = s.execute("find.next", &p).unwrap();
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(8), Some(10)));
let mut change = p.clone();
change["found"] = json!(true);
assert_eq!(s.execute("find.change", &change).unwrap()["count"], 1);
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!((st.format_after(7).over.underline, st.format_after(8).over.underline), (None, Some(true)), "only the selected rest");
// "aa" in "aaa" from the second a: Find Next selects 1..3, which Change All never finds.
for grep in [false, true] {
let (mut s, sid) = scope_session("aaa");
scope_select(&mut s, sid, 1, 1);
let (find, change) = if grep { ("(a)a", "<$1>") } else { ("aa", "b") };
let p = json!({"find": find, "change": change, "grep": grep, "scope": "story"});
let hit = s.execute("find.next", &p).unwrap();
assert_eq!((hit["start"].as_u64(), hit["end"].as_u64()), (Some(1), Some(3)));
let mut found = p.clone();
found["found"] = json!(true);
assert_eq!(s.execute("find.change", &found).unwrap()["count"], 1, "grep: {grep}");
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().text, if grep { "a<a>" } else { "ab" });
}
}
#[test]
fn change_first_changes_one_match_in_the_whole_document() {
for grep in [false, true] {
+1
View File
@@ -1058,6 +1058,7 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Load Styles", ["Formate laden", "Charger les styles", "Cargar estilos", "スタイルを読み込み", "加载样式"]),
("Selection", ["Auswahl", "Sélection", "Selección", "選択範囲", "选区"]),
(
"Differs from this document",
["Weicht von diesem Dokument ab", "Diffère de ce document", "Difiere de este documento", "このドキュメントと異なります", "与此文档不同"],
-1
View File
@@ -160,7 +160,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Scripts", "Scripts"),
("Section Marker", "Marque de section"),
("Section Symbol", "Symbole de section"),
("Selection", "Sélection"),
("Sentence case", "Majuscule en début de phrase"),
("Simulate Paper Color", "Simuler la couleur du papier"),
("Single Left Quotation Mark", "Guillemet simple ouvrant"),