Skip to content

mruby-regexp: set $&, $`, $' and $+ after a match - #7025

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-match-globals
Aug 9, 2026
Merged

mruby-regexp: set $&, $`, $' and $+ after a match#7025
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-match-globals

Conversation

@takumin

@takumin takumin commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

create_matchdata() assigns $~ and $1 to $9, but leaves the four remaining
match globals untouched. They read as nil after a match that already holds
everything they describe.

/b(c)/ =~ "abc"
$&    # CRuby: "bc",  mruby: nil
$`    # CRuby: "a",   mruby: nil
$'    # CRuby: "",    mruby: nil
$+    # CRuby: "c",   mruby: nil

Cause

Nothing below regexp.c treats these names specially.
PM_BACK_REFERENCE_READ_NODE in codegen.c emits a plain OP_GETGV on the
reference's own name, the same opcode PM_GLOBAL_VARIABLE_READ_NODE emits, so a
read of $& resolves through the ordinary global table just as $1 does. The
compiler side already works; the four are simply never assigned.

Change

Assign them in create_matchdata(), next to the $1 to $9 loop, from the same
capture array and through the same re_byte_substr() helper. That is the only
point at which a successful match is known together with both the subject and the
capture array, so its four call sites (exec_match(), regexp_gsub_str(),
regexp_sub_str() and regexp_scan()) inherit the assignment exactly as they
inherit $~ today.

$&, $` and $' follow from the whole match offsets. The latter two are
the expressions matchdata_pre() and matchdata_post() evaluate, but those are
instance methods that need a receiver, so routing the globals through them would
mean a method dispatch from C for what one re_byte_substr() call does.

$+ is the last group that actually participated, which is not
captures[(num_captures-1)*2], because the last group in the pattern is
frequently the one that did not match:

/(a)|(b)/ =~ "a"
$+    # "a", from group 1, since group 2 never participated

so it needs a backwards scan from md->num_captures - 1 down to group 1. A
pattern with no groups leaves that scan with nothing to find and yields nil,
which is what CRuby reports.

clear_match_globals() sets the four to nil alongside $~ and the nth_syms
loop. Without that a failed match leaves a stale $& visible, which is the same
defect in the other direction and is what the existing test for $1 guards
against.

ensure_nth_syms() interns the four and is renamed to ensure_match_syms(), now
that it covers more than $1 to $9. Both callers already invoke it, so no new
call site is needed and the interning stays out of the per-match path.

regexp_match_p() deliberately sets no match globals, and the existing
Regexp#match? - does not update last match test asserts that. It keeps setting
none.

Cost and lifetime

re_byte_substr() allocates through mrb_str_new() and therefore copies the
bytes, so the four values describe the subject as it was at match time and no
later mutation of it can invalidate them.

The cost is four mrb_gv_set() calls per match on top of the nine the $1 to
$9 loop already issues, and four more strings retained until the next match or
the next clear. $` and $' are the ones that matter there, since between
them they can pin a copy of nearly the whole subject. Globals are a direct GC
root, so mrb_gv_set() needs no write barrier and none was added.

In regexp_gsub_str() and regexp_scan() the loop's mrb_gc_arena_restore()
runs before the trailing create_matchdata() call, so the new allocations are
made after the arena is already unwound.

Known limit

Assigning $~ directly still updates none of the four, because they are set only
from inside create_matchdata(). That is the existing behaviour of $1 to $9,
which these four now share rather than diverge from; closing it would mean
deriving all of them from $~ at read time, which is a separate change.

Testing

rake test passes: 1950 tests, 0 failures, 0 crashes. It also passes with
MRB_UTF8_STRING enabled (1970 tests, 0 failures), where the four report the
same multibyte substrings as CRuby:

$ cat t.rb
"日本語です" =~ /本(語)/
p [$&, $`, $', $+]
$ ./build/utf8/bin/mruby t.rb
["本語", "日", "です", "語"]

Behaviour checked against CRuby 4.0.6.

Summary by CodeRabbit

  • New Features

    • Regular expression matches now provide whole-match, pre-match, post-match, and last-capture values through the corresponding global match variables.
    • These values are cleared when a match fails.
  • Bug Fixes

    • Corrected handling of global capture variables, including cases with optional or missing captures.

`create_matchdata()` assigns `$~` and `$1` to `$9`, but leaves the four
remaining match globals untouched, so they read as `nil` after a match
that already holds everything they describe.

```ruby
/b(c)/ =~ "abc"
$&    # CRuby: "bc",  mruby: nil
$`    # CRuby: "a",   mruby: nil
$'    # CRuby: "",    mruby: nil
$+    # CRuby: "c",   mruby: nil
```

Nothing below `regexp.c` treats these names specially.
`PM_BACK_REFERENCE_READ_NODE` emits a plain `OP_GETGV` on the
reference's own name, so a read of `$&` resolves through the ordinary
global table just as `$1` does. They are simply never assigned.

Assign them next to the `$1` to `$9` loop, from the same capture array
and through the same `re_byte_substr()` helper. `$&`, `` $` `` and `$'`
follow from the whole match offsets. The latter two are the expressions
`matchdata_pre()` and `matchdata_post()` evaluate, but those are
instance methods that need a receiver, so routing the globals through
them would mean a method dispatch from C for what one
`re_byte_substr()` call does.

`$+` is the last group that actually participated, which is not
`captures[(num_captures-1)*2]`, because the last group in the pattern is
frequently the one that did not match:

```ruby
/(a)|(b)/ =~ "a"
$+    # "a", from group 1, since group 2 never participated
```

so it needs a backwards scan. A pattern with no groups leaves that scan
with nothing to find and yields `nil`, which is what CRuby reports.

Clear the four in `clear_match_globals()` alongside `$~` and the
`nth_syms` loop. Without that a failed match leaves a stale `$&`
visible, which is the same defect in the other direction.

`create_matchdata()` is the only point at which a successful match is
known together with both the subject and the capture array, and its four
call sites inherit the assignment exactly as they inherit `$~` today.
`regexp_match_p()` deliberately sets no match globals and keeps setting
none.

`re_byte_substr()` copies through `mrb_str_new()`, so the four values
describe the subject as it was at match time and no later mutation of it
can invalidate them. The cost is four `mrb_gv_set()` calls per match on
top of the nine the `$1` to `$9` loop already issues, and four strings
retained until the next match or the next clear. `` $` `` and `$'` are
the ones that matter there, since between them they can pin a copy of
nearly the whole subject.

The four symbols are interned in `ensure_nth_syms()`, renamed to
`ensure_match_syms()` now that it covers more than `$1` to `$9`, so they
stay out of the per-match path.

Assigning `$~` directly still updates none of them, because they are set
only from inside `create_matchdata()`. That is the existing behaviour of
`$1` to `$9`, which these four now share rather than diverge from.
@takumin
takumin requested a review from matz as a code owner August 9, 2026 11:04
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1729d8e3-3a0c-4e60-a044-e5130380df39

📥 Commits

Reviewing files that changed from the base of the PR and between 4461d87 and 6750908.

📒 Files selected for processing (2)
  • mrbgems/mruby-regexp/src/regexp.c
  • mrbgems/mruby-regexp/test/regexp.rb

📝 Walkthrough

Walkthrough

Regexp matching now supports $&, $```, $', and $+. Successful matches assign their values, $+` selects the last participating capture, and failed matches clear all added variables.

Changes

Regexp match global variables

Layer / File(s) Summary
Initialize and clear match globals
mrbgems/mruby-regexp/src/regexp.c, mrbgems/mruby-regexp/test/regexp.rb
The symbol cache and reset path now include $&, $```, $', and $+`. Tests verify clearing after a failed match.
Populate values after successful matches
mrbgems/mruby-regexp/src/regexp.c, mrbgems/mruby-regexp/test/regexp.rb
Successful matches assign the full match, prefix, suffix, and last participating capture. Tests cover matches with and without capture groups.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RegexpMatching
  participant MatchData
  participant MatchGlobals
  RegexpMatching->>MatchData: create match data
  MatchData->>MatchGlobals: assign $&, $``, and $'
  MatchData->>MatchGlobals: assign the last participating capture to $+
  RegexpMatching->>MatchGlobals: clear match globals after a failed match
Loading

Possibly related PRs

  • mruby/mruby#6993: Both changes modify regexp match-state behavior and match globals.

Suggested reviewers: matz, nattzn

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: setting the four regexp match globals after a match.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants