Skip to content

runtime: match String.replace regex callback arguments #2867

Description

@andrewtdiz

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions