Skip to content

mruby-regexp: String#split dispatches to_int where the rest of mruby does not, and the respond_to? guarding it is redefinable #7003

Description

@takumin

mruby-regexp's String#split override converts a non-Integer limit by asking the
argument for to_int:

# mrbgems/mruby-regexp/mrblib/string_regexp.rb:137-144
if limit.respond_to?(:to_int)
  limit = limit.to_int
  unless limit.is_a?(Integer)
    raise TypeError, "no implicit conversion of #{limit.class} to Integer)"
  end
else
  limit = limit.__to_int
end

This is the only place in mruby's own Ruby code that dispatches to_int as an implicit
conversion. Nothing in core does it, and the C implementation this override replaces does
not either:

o = Object.new
def o.to_int
  2
end

"a,b,c".split(",", o)     # ["a", "b,c"]   <- the override
"a,b,c".__split(",", o)   # TypeError      <- the core C split it aliased away
Array.new(o)              # TypeError
[1, 2, 3][o]              # TypeError
"ab" * o                  # TypeError

CRuby accepts the object in every one of those. mruby accepts it in exactly one, and that
one is a gem override.

Why this is worth asking about rather than just fixing

The gem is more permissive than the core it sits on, which is the situation
mruby/mruby#6994 settled the other way for the pattern argument. String#match resolves
its pattern with a plain type check rather than rb_check_string_type's to_str protocol,
on the grounds that mruby has no implicit String conversion in core at all, and matching
CRuby there would make the gem more permissive than its host. That decision is pinned with a
test (599856dfe).

The to_int dispatch predates that decision. It arrived in b61e44b35
("mruby-regexp: coerce String#split limit argument", 2026-06-25), whose stated purpose was to
stop a non-Integer limit such as nil from reaching limit > 0 and failing with an
unrelated comparison error. Normalizing nil is the part that fixed a real bug; accepting
to_int came along with it. The to_str policy was written afterwards, so nothing was
inconsistent at the time.

Symptom 1: the guard can be redefined, and only here

respond_to? is redefinable, so an argument that claims to_int without defining it gets
past the guard and fails inside the conversion:

class Liar
  def respond_to?(name, include_all = false)
    true
  end
end

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

respond_to_missing? reaches the same place, so defining that alone is enough.

CRuby raises TypeError however respond_to? answered, because rb_check_funcall falls
back to method_missing and treats its absence as "not convertible". Note that the
divergence runs one way only: an argument that defines to_int and denies it raises in
both, since CRuby honours a redefined respond_to? too.

Core mruby has no such hole, because it never asks:

Array.new(Liar.new)            # mruby: TypeError
[1, 2, 3][Liar.new]            # mruby: TypeError
"a,b,c".split(/,/, Liar.new)   # mruby: NoMethodError

So this is not a core limitation the gem inherits. It is the cost of the gem implementing a
conversion protocol its host does not have.

Symptom 2: the error message has no CRuby form to match

Having its own conversion means having its own message for a to_int that returns the wrong
type, and CRuby's two forms do not fit it:

  • no implicit conversion of X into Integer is what CRuby says when there is no to_int.
  • can't convert X to Integer (X#to_int gives Y) is what it says when to_int returns the
    wrong type, which is this branch.

The override raises no implicit conversion of Float to Integer), which borrows the first
form, fills it with the class the conversion returned rather than the argument's, writes
to where CRuby writes into, and closes a paren it never opened. The stray paren is a
separate typo and #7004 drops it; the rest is what this issue is about.

Adopting the second form needs the argument's real class, and the only way to read that from
mrblib is Object#class, which is redefinable exactly like the is_a? beside it:

class Masked
  def to_int; "x"; end
  def class; 42; end
end
# with CRuby's wording, from mrblib: can't convert 42 to Integer (42#to_int gives String)

If the to_int dispatch goes, so does this branch and the question with it: the message
becomes mrb_ensure_integer_type()'s, the same one every other index in mruby produces.

The three options, none of which this draft picks

  1. Drop the dispatch. limit = limit.__to_int for every non-Integer, which is
    mrb_ensure_integer_type() and asks the object nothing. Both symptoms go, the gem stops
    being the only to_int caller in the tree, and it lines up with the to_str decision.
    Costs the to_int support outright, and deletes four lines of
    String#split with regexp limit. Moves away from CRuby.
  2. Keep it and close the hole in C. Measured rather than guessed; see the section below.
    It closes this hole and opens a different one, so it does not reach CRuby either.
  3. Keep it as it is. The hole needs a redefined respond_to? to reach, which is not
    something ordinary code does.

Option 1 is the smallest change and the only one that needs no new C. Option 2 does not
finish the job. Which is wanted is a policy question about how far the gem should go beyond
its host, which is why this is filed as a question.

Option 2 was built and measured, and it does not get there

The obvious shape is a C helper that routes the limit through the core conversion machinery,
which is exempt from the "no VM callback" rule precisely because the dispatch is the
specification for an implicit-conversion protocol:

static mrb_value
regexp_to_int(mrb_state *mrb, mrb_value self)
{
  mrb_value val;
  mrb_get_args(mrb, "o", &val);

  if (mrb_integer_p(val)) return val;
  if (mrb_float_p(val)) return mrb_ensure_integer_type(mrb, val);
  return mrb_convert_type(mrb, val, MRB_TT_INTEGER, "Integer", "to_int");
}

That does close the hole. mrb_respond_to() calls mrb_obj_respond_to(), which runs
mrb_method_search_vm() against the method table, so an argument cannot claim a to_int
it does not define. The redefined-class problem in the message goes with it, since the
type name no longer comes from the object.

It closes it by not calling respond_to? at all, which is the new divergence. CRuby's
rb_check_funcall does honour a redefined respond_to?, so an object that defines
to_int and denies it raises there. Searching the method table converts it instead:

class RespondDenier
  def to_int
    2
  end

  def respond_to?(name, include_all = false)
    false
  end
end

"a,b,c".split(/,/, RespondDenier.new)
# CRuby:      TypeError
# mruby today: TypeError   <- agrees
# with the C helper: ["a", "b,c"]   <- no longer agrees

So the C helper trades one disagreement for another, and the one it introduces is a case
that agrees today. The messages do not land on CRuby's either, because %v renders the
value rather than its class:

with the C helper: TypeError: #<Object:0x...> cannot be converted to Integer by #to_int
CRuby:             TypeError: can't convert Object to Integer (Object#to_int gives Float)

with the C helper: TypeError: can't convert nil into "Integer"
CRuby:             TypeError: no implicit conversion from nil to integer

Where each implementation stands against CRuby, on the two arguments that separate them:

respond_to? claims a missing to_int respond_to? denies a real to_int
today (mrblib) NoMethodError TypeError
option 2 (C helper) TypeError converts, no error
CRuby 4.0.6 TypeError TypeError

Neither column is CRuby's. Getting both needs what rb_check_funcall does, which is to call
a redefined respond_to?, then fall back to method_missing and read its absence as "not
convertible". mruby has no such function: convert_type() (src/object.c) asks
mrb_respond_to() and dispatches, with no method_missing step. So a genuinely
CRuby-compatible to_int here is not a mruby-regexp change at all; it needs the protocol
itself in core, which is a much larger question than one gem's split.

Measured on 65812128d against CRuby 4.0.6. The full suite passes with the C helper applied
(1941 / 1923 OK / 0 KO, 105 bintests), which is the point: nothing in the tree currently
covers either behaviour.

Relation to other issues

Environment / how to reproduce

mruby-regexp is part of default.gembox (via stdlib.gembox), so a plain build is
enough to reproduce:

$ rake
$ ./build/host/bin/mruby -e 'class L; def respond_to?(n, a = false); true; end; end; "a,b".split(/,/, L.new)'
-e:1: undefined method 'to_int' for L (NoMethodError)
$ ./build/host/bin/mruby -e 'o = Object.new; def o.to_int; 2; end; p "a,b,c".split(",", o); begin; "a,b,c".__split(",", o); rescue => e; p e; end'
["a", "b,c"]
#<TypeError: Object cannot be converted to Integer>

Checked on master (6581212, 2026-08-03), compared against CRuby 4.0.6.

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