Summary
String.prototype.replace(regex, replacerFn) should call the replacement callback with the full ECMAScript argument list:
(match, ...captures, offset, string, groups?)
Perry's regex replacement callback path currently passes only a shortened argument list, drops captures beyond the first two, omits the original input string, and does not pass the named-capture groups object. Dynamic/any-typed string dispatch also explicitly does not support function replacements yet.
Expected Node behavior
Local Node v25.9.0 probe:
function argsFor(pattern, input) {
let seen;
input.replace(pattern, function () {
seen = Array.from(arguments).map(v =>
typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)
);
return "x";
});
console.log(String(pattern), seen.length, seen.join("|"));
}
argsFor(/b/, "abc");
argsFor(/(a)(b)(c)/, "abc");
argsFor(/(?<word>\w+)-(?<num>\d+)/, "abc-123");
Output:
/b/ 3 b|1|abc
/(a)(b)(c)/ 6 abc|a|b|c|0|abc
/(?<word>\w+)-(?<num>\d+)/ 6 abc-123|abc|123|0|abc-123|{"word":"abc","num":"123"}
Current Perry behavior in source
crates/perry-runtime/src/regex.rs::js_string_replace_regex_fn() documents the divergence:
/// The callback receives (match, p1, p2, ..., offset, string)
/// We simplify to (match, ...groups, offset) since the full string is rarely needed.
The implementation then uses fixed closure-call helpers:
- no captures:
js_closure_call2(callback, match, offset)
- one capture:
js_closure_call3(callback, match, p1, offset)
- two or more captures:
js_closure_call4(callback, match, p1, p2, offset)
That misses Node's trailing string argument, omits the named-capture groups object, and drops capture groups after p2.
The typed/codegen path in crates/perry-codegen/src/lower_string_method.rs routes regex/function replacements to js_string_replace_regex_fn, so statically recognized calls inherit this shortened shape.
The dynamic path in crates/perry-runtime/src/object/native_call_method.rs has a replace / replaceAll branch whose comment says function replacements are not supported there yet, then coerces the second argument as a string replacement. This means any-typed or dynamically dispatched str.replace(re, fn) does not observe Node's callback semantics either.
Suggested tests
Add parity coverage for:
const calls = [];
"abc".replace(/(a)(b)(c)/, function () {
calls.push(Array.from(arguments));
return "x";
});
expect(calls[0].slice(0, 6)).toEqual(["abc", "a", "b", "c", 0, "abc"]);
let named;
"abc-123".replace(/(?<word>\w+)-(?<num>\d+)/, function () {
named = arguments[arguments.length - 1];
return "x";
});
expect(named).toEqual({ word: "abc", num: "123" });
Also cover the no-capture case, where Node still passes (match, offset, string).
Scope / non-goals
This is scoped to replacement callback argument shape for regex replacements, including dynamic dispatch. String replacement expansion such as $1 and $<name> is separate and has prior fixes/issues (#141, PR #844).
Summary
String.prototype.replace(regex, replacerFn)should call the replacement callback with the full ECMAScript argument list:Perry's regex replacement callback path currently passes only a shortened argument list, drops captures beyond the first two, omits the original input string, and does not pass the named-capture
groupsobject. Dynamic/any-typed string dispatch also explicitly does not support function replacements yet.Expected Node behavior
Local Node v25.9.0 probe:
Output:
Current Perry behavior in source
crates/perry-runtime/src/regex.rs::js_string_replace_regex_fn()documents the divergence:The implementation then uses fixed closure-call helpers:
js_closure_call2(callback, match, offset)js_closure_call3(callback, match, p1, offset)js_closure_call4(callback, match, p1, p2, offset)That misses Node's trailing
stringargument, omits the named-capturegroupsobject, and drops capture groups afterp2.The typed/codegen path in
crates/perry-codegen/src/lower_string_method.rsroutes regex/function replacements tojs_string_replace_regex_fn, so statically recognized calls inherit this shortened shape.The dynamic path in
crates/perry-runtime/src/object/native_call_method.rshas areplace/replaceAllbranch whose comment says function replacements are not supported there yet, then coerces the second argument as a string replacement. This means any-typed or dynamically dispatchedstr.replace(re, fn)does not observe Node's callback semantics either.Suggested tests
Add parity coverage for:
Also cover the no-capture case, where Node still passes
(match, offset, string).Scope / non-goals
This is scoped to replacement callback argument shape for regex replacements, including dynamic dispatch. String replacement expansion such as
$1and$<name>is separate and has prior fixes/issues (#141, PR #844).