Skip to content

mruby-regexp: read the real type of String#split's limit - #7004

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:string-split-limit-type
Aug 3, 2026
Merged

mruby-regexp: read the real type of String#split's limit#7004
matz merged 1 commit into
mruby:masterfrom
takumin:string-split-limit-type

Conversation

@takumin

@takumin takumin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Related to #7003, which 8f85a84 already settled by dropping the to_int dispatch. This
pull request does not close that issue. It is the follow-up 8f85a84 deliberately left in
place: the is_a? guard standing in front of the conversion. Rebased onto that commit. The
stray-paren commit is gone, since the branch that raised that message no longer exists.

String#split's limit argument is converted in the override this gem installs over the
core method. The conversion is guarded by limit.is_a?(Integer), which asks the argument
for its own type. is_a? is redefinable, so an argument claiming to be an Integer skips the
conversion entirely and reaches the split loop as itself.

What happens next depends on which operators that argument answers. With none, the failure
surfaces as a NoMethodError naming an internal comparison, which says nothing about the
limit being wrong:

class FakeInt
  def is_a?(klass)
    true
  end
end

"a,b,c".split(/,/, FakeInt.new)
# CRuby: TypeError (no implicit conversion of FakeInt into Integer)
# mruby: NoMethodError (undefined method '>' for FakeInt)

With ==, > and - answered, there is no error at all: the loop runs to completion with
a non-Integer limit and returns a plausible wrong result.

class Cmp
  def is_a?(klass)
    true
  end

  def ==(other)
    false
  end

  def >(other)
    true
  end

  def -(other)
    1
  end
end

"a,b,c".split(/,/, Cmp.new)
# CRuby: TypeError (no implicit conversion of Cmp into Integer)
# mruby: ["a", "b,c"]

This is the same hole #7001 closed for the pattern argument, left open one argument over.

The two halves of the same method disagree

The conversion sits ahead of the delegation to __split, so a String or nil pattern runs
through it too. It survives only because the core implementation converts the limit again in
C, where the argument gets no say:

"a,b,c".split(",",  FakeInt.new)   # TypeError (FakeInt cannot be converted to Integer)
"a,b,c".split(/,/, FakeInt.new)    # NoMethodError (undefined method '>' for FakeInt)

Two calls to one method, one argument, two answers. The regexp path is the one with no C
conversion behind it, and it is the one that gets it wrong.

Fix

Module#=== reads the real type and cannot be redefined, substituted for the one is_a?
the limit block still has:

-    if limit_given && !limit.is_a?(Integer)
+    # `is_a?` is redefinable, so a limit claiming to be an Integer would skip
+    # that conversion and reach the arithmetic below as itself. `Module#===`
+    # reads the real type and cannot be redefined.
+    if limit_given && !(Integer === limit)
       limit = limit.__to_int
     end

This is the substitution #7001 made for the pattern argument a few lines below, so the two
guards in this method now read the same way. 8f85a84 collapsed the body behind this guard
to a single __to_int, and that call is exactly what a redefined is_a? skips.

Dropping the guard altogether and writing limit = limit.__to_int if limit_given would
close the same hole with no redefinable call at all. It is not what this does: 8f85a84
kept that line deliberately, and the substitution above is the shape #7001 already
established for this method. One measured consequence of keeping it is recorded below.

What keeping the guard leaves behind

A BigInt limit is a real Integer, so it passes the guard under either spelling and never
reaches __to_int. The regexp path then runs the loop with it, while the String path
converts it in C and raises. Measured on a plain host build, mruby-bigint being part of
math.gembox and so of default.gembox:

big = 2 ** 70
"a,b,c".split(/,/, big)   # mruby: ["a", "b", "c"]   CRuby: RangeError
"a,b,c".split(",",  big)  # mruby: RangeError        CRuby: RangeError

This is the same two-halves disagreement described above, on a different argument, and it
predates this pull request: limit.is_a?(Integer) answers true for a BigInt exactly as
Integer === limit does, so the change neither introduces nor closes it. Removing the guard
would close it, by sending every limit through mrb_ensure_int_type(), but that is a
behaviour change on a case no test covers, and a wider question than the redefinable guard
this pull request is about. Recorded here rather than folded in, and happy to file it
separately if it is worth fixing.

Test

Added to mrbgems/mruby-regexp/test/regexp.rb:

class StringSplitLimitIsALiar
  def is_a?(klass)
    true
  end
end

class StringSplitLimitComparable
  def is_a?(klass)
    true
  end

  def ==(other)
    false
  end

  def >(other)
    true
  end

  def -(other)
    1
  end
end

assert("String#split limit cannot pose as an Integer") do
  # `is_a?` is redefinable, so a limit claiming to be an Integer used to skip
  # the conversion and reach the split loop as itself.
  assert_raise(TypeError) { "a,b,c".split(/,/, StringSplitLimitIsALiar.new) }
  # The String pattern delegates to __split, which converts the limit again in
  # C, so this one held before the fix too. Asserted so that the two halves of
  # the method stay pinned to the same answer.
  assert_raise(TypeError) { "a,b,c".split(",", StringSplitLimitIsALiar.new) }

  # Answering the operators the loop uses used to produce a wrong result
  # instead of an error.
  assert_raise(TypeError) { "a,b,c".split(/,/, StringSplitLimitComparable.new) }
end

The two classes cover distinct pre-fix paths: StringSplitLimitIsALiar blew up on
limit > 0, StringSplitLimitComparable returned a wrong array with no error at all. They
are named top-level classes rather than anonymous ones because that is how this file already
writes an is_a? liar, in StringMatchIsALiar a few hundred lines above.

The String#split with regexp limit test 8f85a84 rewrote is untouched. The
respond_to? liar it added is a different route from the is_a? one pinned here.

Verified on a default host build at 8f85a8413:

  • With this commit: 1941 tests, 1923 OK, 0 failures, 0 crashes, plus 105 bintests.
  • With the test kept and the guard reverted to limit.is_a?(Integer), rake test reports
    Fail: String#split limit cannot pose as an Integer, so the test pins the guard rather
    than passing either way.

Comparison against CRuby 4.0.6

Run side by side on the four cases the guard governs:

case mruby at 8f85a84 mruby after CRuby 4.0.6
split(/,/, FakeInt.new) NoMethodError TypeError TypeError
split(",", FakeInt.new) TypeError TypeError TypeError
split(/,/, Cmp.new) ["a", "b,c"] TypeError TypeError
split(",", Cmp.new) TypeError TypeError TypeError

After the change the exception class agrees in every case and the return value agrees in
every case. What is left is wording, from mrb_ensure_integer_type():

mruby: TypeError: FakeInt cannot be converted to Integer
CRuby: TypeError: no implicit conversion of FakeInt into Integer

That is the wording 8f85a84 chose deliberately for every non-Integer limit, and this
change does not touch it.

Unaffected and green: an ordinary Integer limit, a Float limit, nil, and limits of 0, 1
and -1.

@takumin
takumin requested a review from matz as a code owner August 3, 2026 07:15
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

String#split now uses non-overridable integer validation for its limit argument. Tests cover spoofed integer objects and misleading operator implementations for regexp and string patterns.

Changes

String split limit validation

Layer / File(s) Summary
Limit validation and regression coverage
mrbgems/mruby-regexp/mrblib/string_regexp.rb, mrbgems/mruby-regexp/test/regexp.rb
String#split replaces the overridable is_a? check with an Integer === check. Tests assert TypeError for spoofed integer limits and cover regexp-pattern and string-pattern handling.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

Possibly related PRs

  • mruby/mruby#7001 — Both PRs harden String#split type validation with non-overridable checks and regression tests.

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: validating the real type of String#split's limit.
✨ 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
mrbgems/mruby-regexp/test/regexp.rb (1)

1081-1117: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a regression assertion for the corrected TypeError message.

The added cases verify only TypeError. They do not protect the message fixed at mrbgems/mruby-regexp/mrblib/string_regexp.rb Line 143. Add an invalid to_int case and assert no implicit conversion of Float to Integer, so the extra ) cannot return without a test failure.

Proposed test
   assert_raise(TypeError) { "a,b,c".split(/,/, cmp.new) }
+
+  invalid = Class.new do
+    def to_int; 1.5; end
+  end
+  error = assert_raise(TypeError) { "a,b,c".split(/,/, invalid.new) }
+  assert_equal("no implicit conversion of Float to Integer", error.message)
 end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mrbgems/mruby-regexp/test/regexp.rb` around lines 1081 - 1117, Add a
regression case in the String#split limit tests using an object whose to_int
returns a Float, and assert that splitting raises TypeError with the exact
message “no implicit conversion of Float to Integer”. Keep the existing liar and
comparator cases unchanged, and target the conversion path exercised by
String#split with the regexp pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@mrbgems/mruby-regexp/test/regexp.rb`:
- Around line 1081-1117: Add a regression case in the String#split limit tests
using an object whose to_int returns a Float, and assert that splitting raises
TypeError with the exact message “no implicit conversion of Float to Integer”.
Keep the existing liar and comparator cases unchanged, and target the conversion
path exercised by String#split with the regexp pattern.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e0f7d77f-826d-4901-88fb-e9a3ce48f253

📥 Commits

Reviewing files that changed from the base of the PR and between 6581212 and fe72d8f.

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

@takumin
takumin force-pushed the string-split-limit-type branch 2 times, most recently from 15d28ac to 6d61652 Compare August 3, 2026 07:56
@takumin takumin changed the title mruby-regexp: type-check String#split's limit argument mruby-regexp: read the real type of String#split's limit Aug 3, 2026
The limit conversion is guarded by `limit.is_a?(Integer)`, which asks the
argument for its own type. `is_a?` is redefinable, so an argument claiming
to be an Integer skips the conversion entirely and reaches the split loop
as itself.

Answering none of the operators the loop uses, the failure surfaces as a
`NoMethodError` naming an internal comparison, which says nothing about the
limit being wrong. Answering `==`, `>` and `-`, there is no error at all:
the loop runs to completion with a non-Integer limit and returns a
plausible wrong result.

```ruby
class Cmp
  def is_a?(klass); true; end
  def ==(other); false; end
  def >(other); true; end
  def -(other); 1; end
end

"a,b,c".split(/,/, Cmp.new)   # CRuby: TypeError
                              # mruby: ["a", "b,c"]
```

`Module#===` reads the real type and cannot be redefined, the same
substitution mruby#7001 made for the pattern argument a few lines below.

The two halves of the method used to disagree about one argument. A String
pattern delegates to `__split`, which converts the limit again in C, where
the argument gets no say, so only the regexp path was reachable with an
unconverted limit. Both paths now raise the same `TypeError`.

8f85a84 dropped the `to_int` dispatch this guard used to stand in front
of, so the block behind it is now a single `__to_int` call. That call is
what a redefined `is_a?` still skips, so the guard remains the only thing
between such an argument and the loop.

Related to mruby#7003.
@takumin
takumin force-pushed the string-split-limit-type branch from 6d61652 to d09f626 Compare August 3, 2026 08:35
@matz

matz commented Aug 3, 2026

Copy link
Copy Markdown
Member

This needs a rebase, and the conflict is my doing rather than yours.

I answered #7003 with option 1 and pushed it as 8f85a84, which removes the to_int branch entirely. This PR is based on 0426f7d7b2, from before that, and two of its three hunks edit lines that no longer exist.

I also told you in #7003 that "the is_a? one still applies, I left that line alone on purpose so your patch lands on it unchanged". The line does survive, but I was looking at the line and not at your patch, which also touches the block above and below it. That was my mistake, and you had already pushed this before my comment went up.

What is left after the rebase is one line:

-    if limit_given && !limit.is_a?(Integer)
+    if limit_given && !(Integer === limit)

The other two hunks are gone with the branch that held them: the inner unless limit.is_a?(Integer) no longer exists, and the stray paren went with the raise beside it.

The fix itself I want. I checked both of your cases on current master and they both still reproduce:

FakeInt : NoMethodError: undefined method '>' for FakeInt
Cmp     : ["a", "b,c"]

The second one is the one that concerns me. No exception at all, a non-Integer limit driving the loop to completion, and a plausible wrong answer coming back. That is worse than the NoMethodError, and it is the same shape as the sub(:sym) { } case in #7001: an argument that answers enough of a protocol to keep going, where the honest answer is a TypeError. Module#=== is the right instrument here for the same reason it was there.

Please keep the tests. The FakeInt and Cmp classes cover something nothing else in the tree does, and they carry across the rebase untouched.

@matz
matz merged commit cde0ccc into mruby:master Aug 3, 2026
21 checks passed
@matz

matz commented Aug 3, 2026

Copy link
Copy Markdown
Member

Merged. Please disregard my previous comment: you had already rebased before I wrote it.

Your rebased commit is dated 08:29 and my comment went up at 09:40. I read a stale head SHA and a stale diff, saw the pre-rebase content, and wrote a whole comment asking you to do something you had finished over an hour earlier. I should have re-fetched before posting; there was no excuse for it, since checking is one command.

The one thing the comment got right is that the rebase left exactly one line, and that is what you had already pushed:

-    if limit_given && !limit.is_a?(Integer)
+    if limit_given && !(Integer === limit)

Verified on your head (d09f626): all three cases raise now, and the suite is green (2108 OK, 0 KO).

FakeInt(/,/)   -> TypeError: FakeInt cannot be converted to Integer
FakeInt(",")   -> TypeError: FakeInt cannot be converted to Integer
Cmp(/,/)       -> TypeError: Cmp cannot be converted to Integer

Thank you for the extra split(",", ...) row. Pinning both halves of the method to the same answer is the right instinct, since the String pattern takes the __split path and would drift away silently otherwise.

Between #7001, #7003 and this one, redefinable guards have come up three times in a day, each with Module#=== as the answer. That is worth writing down rather than rediscovering, so I will look at whether the C conventions section is the right place for it.

@takumin

takumin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for reviewing, verifying the behavior on the rebased head, and merging the PR.

I also appreciate the clarification about the stale diff. No problem at all.

Your point about documenting this pattern is especially helpful. Using Module#=== for guards that must not be influenced by redefined methods seems like a valuable convention to record, given that the same issue appeared across #7001, #7003, and this PR.

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