From a3a98cf7450fe375af844e8d7fa3f1b4eabcf395 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 10 Nov 2026 18:05:25 -0500 Subject: [PATCH] functions: REGEXREPLACE keeps only the requested occurrence's captures (#3288) * REGEXREPLACE keeps only the requested occurrence's captures * address review: do not truncate REGEXREPLACE occurrence on 32-bit targets --- crates/functions/src/lib.rs | 6 ++++++ crates/functions/src/text.rs | 22 +++++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index ccc61b026..ab50fe62f 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -308,6 +308,12 @@ mod tests { assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026")); assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-")); assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#")); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(-2.0)]), Value::text("a#b2")); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(-3.0)]), Value::text("a1b2")); + // 2^32 is no occurrence the text has (and on 32-bit targets it must not wrap to 0). + for occurrence in [4_294_967_296.0, -4_294_967_296.0] { + assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(occurrence)]), Value::text("a1b2")); + } // One match whose replacement repeats it 10,000 times: refused before it is built. assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value)); assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value)); diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 24674a93c..9d61340d2 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -914,13 +914,25 @@ fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { out.push_str(s.get(last..).unwrap_or_default()); return check_len(out); } - let caps: Vec = re.captures_iter(&s).collect(); - let k = occ.unsigned_abs() as usize; - let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) }; - let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) }; + // An occurrence past what `usize` holds (32-bit targets) names no match: the text is returned as it was. + let Ok(k) = usize::try_from(occ.unsigned_abs()) else { return text_val(s) }; + // Keeps only the wanted match's captures: the nth from the front, or a window of the last `k`. + let cap = if occ > 0 { + re.captures_iter(&s).nth(k - 1) + } else { + let mut last = std::collections::VecDeque::new(); + for cap in re.captures_iter(&s) { + if last.len() == k { + last.pop_front(); + } + last.push_back(cap); + } + if last.len() == k { last.pop_front() } else { None } + }; + let Some(cap) = cap else { return text_val(s) }; let Some(m) = cap.get(0) else { return text_val(s) }; out.push_str(s.get(..m.start()).unwrap_or_default()); - expand_capped(cap, &rep, &mut out, limit).ok_or(CellError::Value)?; + expand_capped(&cap, &rep, &mut out, limit).ok_or(CellError::Value)?; out.push_str(s.get(m.end()..).unwrap_or_default()); check_len(out) }