Skip to content

mruby-regexp: fix MatchData#[] for a negative index and an unknown name - #7000

Merged
matz merged 2 commits into
mruby:masterfrom
takumin:matchdata-aref-index-name
Aug 3, 2026
Merged

mruby-regexp: fix MatchData#[] for a negative index and an unknown name#7000
matz merged 2 commits into
mruby:masterfrom
takumin:matchdata-aref-index-name

Conversation

@takumin

@takumin takumin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

MatchData#[] resolves its argument in two branches, and both answer nil for
an argument CRuby handles differently: a negative index is rejected outright,
and a name that resolves to no group is treated as a failed match.

md = /(a)(b)/.match("ab")
md[-1]                       # CRuby: "b", mruby: nil
md[-2]                       # CRuby: "a", mruby: nil

/(?<x>a)/.match("a")[:zz]    # CRuby: IndexError, mruby: nil
/(?<x>a)/.match("a")["zz"]   # CRuby: IndexError, mruby: nil

Out-of-range positive indices already answer nil correctly and are unchanged.

A negative index

matchdata_aref() tested idx < 0 at the point where it reads the capture
table and gave up there, so counting back from the last group never worked.
Adding the group count in the numeric branch is the whole fix; the name branch
reaches the same label with a group number that is already non-negative, so
only the upper bound has to stay.

The lower bound follows CRuby's rb_reg_nth_match(), which discards the
adjusted index unless it is positive rather than merely non-negative:

    else {
        nth += RMATCH_REGS(match)->num_regs;
        if (nth <= 0) return Qnil;
    }

Two consequences are worth stating, because both look like bugs until you read
that:

md[-3]   # nil, not "ab": a negative index never reaches group 0
md[-4]   # nil, not IndexError: out of range downwards is nil like upwards

Both are pinned in the tests.

An unknown group name

A String or Symbol that names no capture group answered nil, which is
indistinguishable from a group that matched nothing, so a typo in a named
capture surfaced somewhere far from the mistake. CRuby raises IndexError, and
it does so even when the pattern declares no named group at all:

/(a)/.match("a")[:zz]   # CRuby: IndexError (undefined group name reference: zz)

The message is formatted with %l rather than %v. %v runs the value
through mrb_obj_as_string(), which dispatches to_s, so a redefined
Symbol#to_s could choose the text of an argument check. %l copies the bytes
directly, which also keeps matchdata_aref() free of any call back into the
VM.

Deliberately not in this change

  • The md[start, length] and md[range] forms, which CRuby also accepts.
    [] is defined here with MRB_ARGS_REQ(1), and adding the extra forms is a
    larger piece of work.
  • The TypeError message for an argument that is neither an index nor a name.
    md[nil] says nil cannot be converted to Integer where CRuby says
    no implicit conversion from nil to integer; that phrasing comes from
    mrb_as_int() and is mruby-wide, so it is not this gem's to change.
  • MatchData#begin and #end, which take their argument as "i" and so
    reject a group name outright, and answer nil where CRuby raises. They are
    the same class of bug in the neighbouring methods, but their rules differ
    (they raise for every index they cannot use, including a negative one), so
    they are better handled on their own.

One more heads-up for whoever reviews this: the name lookup a few lines above
compares lengths after truncating the requested length to uint16_t while the
memcmp() next to it uses the untruncated length, which reads out of bounds
for a name longer than 65535 bytes. That is a memory-safety bug rather than a
compatibility one, it is reachable from plain Ruby, and it is not touched here.
I will send it as a separate one-line change so it can be reviewed and
backported on its own.

Testing

Two assert blocks in mrbgems/mruby-regexp/test/regexp.rb, one per branch,
covering the CRuby rules above including the two counter-intuitive ones. Every
row in this description was checked against CRuby 4.0.6 rather than assumed.

rake test is green on a default host build:

build total OK KO
default (ASCII-8BIT) 1933 1915 0

bintest is 105 OK, 0 KO. No existing test changed.

The two commits are independent and each passes the suite on its own, so
either can be dropped if only one of the two behaviours is wanted.

Summary by CodeRabbit

  • Bug Fixes

    • Improved MatchData#[] behavior for negative capture indices.
    • Prevented out-of-range negative indices from incorrectly selecting the full match.
    • Undefined named capture references now raise IndexError instead of returning nil.
  • Tests

    • Added coverage for negative indices and missing named captures.

takumin added 2 commits August 3, 2026 11:48
`MatchData#[]` rejected every negative index, so counting back from the last
group answered nil:

```ruby
md = /(a)(b)/.match("ab")
md[-1]   # CRuby: "b", mruby: nil
md[-2]   # CRuby: "a", mruby: nil
```

Add the group count to a negative index in the numeric branch, and keep only
the upper bound at `found:`. The name branch reaches that label with a group
number that is already non-negative, so it is unaffected.

The lower bound follows CRuby's `rb_reg_nth_match()`, which discards the
adjusted index unless it is positive rather than merely non-negative. A
negative index therefore never reaches group 0, so `md[-3]` above stays nil
instead of answering the whole match. An out-of-range negative index is nil
like an out-of-range positive one; `IndexError` is not involved.
A name that resolves to no capture group answered nil, so a typo in a named
capture failed somewhere later instead of at the call:

```ruby
/(?<x>a)/.match("a")[:zz]   # CRuby: IndexError, mruby: nil
/(?<x>a)/.match("a")["zz"]  # CRuby: IndexError, mruby: nil
/(a)/.match("a")[:zz]       # CRuby: IndexError, mruby: nil
```

The last row is not an oversight: CRuby raises even when the pattern declares
no named group at all, because the argument still names nothing. Out-of-range
indices keep answering nil, which is what CRuby does for them; only names
raise.

Format the name with `%l` rather than `%v`. `%v` runs the value through
`mrb_obj_as_string()`, which dispatches `to_s`, so a redefined `Symbol#to_s`
could pick the text of an argument check. `%l` copies the bytes and leaves
`matchdata_aref()` free of any call back into the VM.
@takumin
takumin requested a review from matz as a code owner August 3, 2026 02:59
@coderabbitai

coderabbitai Bot commented Aug 3, 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: 427285ea-f8d6-4107-87ad-7a32eab4fa79

📥 Commits

Reviewing files that changed from the base of the PR and between 2fdad90 and 4c35b03.

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

📝 Walkthrough

Walkthrough

MatchData#[] now normalizes negative capture indices without selecting group 0 and raises IndexError for undefined named captures. Tests cover numeric and named lookup behavior.

Changes

MatchData indexing behavior

Layer / File(s) Summary
Update MatchData lookup behavior
mrbgems/mruby-regexp/src/regexp.c, mrbgems/mruby-regexp/test/regexp.rb
Negative indices count backward from the last capture group. Indices at or below zero return nil. Undefined symbol and string group names raise IndexError. Tests cover these cases.

Estimated code review effort: 3 (Moderate) | ~15–30 minutes

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 identifies both main changes: negative-index handling and unknown-name handling in MatchData#[].
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