Contributing to Ruby
Extending the Range class
Posted by Owen Stephens on July 16, 2019
Contributing to Ruby
Contributing to a well-known, established project such as Ruby may seem
daunting at first, but once you get stuck in it's not too bad. In this post,
I'll show how you can get started, with a high-level overview of the steps I
took to extend the core
released in Ruby 2.6.0.
def valid_loan_value?(loan_value) (1000..5000).cover?(loan_value) end
Range extension allows
cover? to accept another Range, rather than just
a single element:
(1..10).cover?((3..6)) # => true
which can make certain range-checks more succinct and readable.
At Bamboo, certain
customers are allowed to top-up their loans: the existing loan is settled, and
the customer receives an additional amount of their choice (from a fixed
range). To ensure we don't lend outside our criteria, we ensure that the
Range of amounts (offset by a "settlement" amount) is covered by our
valid loan value
Range. The code we want to write looks like:
MIN_VALUE = 100 MAX_VALUE = 500 def valid_top_up_loan_value?(settlement_amount) top_up_value_min = MIN_VALUE + settlement_amount top_up_value_max = MAX_VALUE + settlement_amount top_up_value_range = (top_up_value_min..top_up_value_max) (1000..5000).cover?(top_up_value_range) end
For example, we have
valid_top_up_loan_value?(1234) # => true
top_up_value_range = (1334..1834), which is covered by
Having worked out the feature I'd like to use, my first stop was to open the Ruby issue tracker, where changes to Ruby (well, MRI, really) are planned and discussed, and search for similar issues. The search functionality is a little unwieldy, but persevere - you can find many interesting discussions and ideas when hunting for an existing issue!
After finding an issue that sounded like what I needed, I added a "+ 1 - we'd like this!".
As it happened, I had actually already implemented a similar method in Ruby as
a monkey-patch to the
Range class, and had an idea about some tests for the
new feature which I attached to the ticket (these tests were very important
for catching edge-cases, later).
$ ./configure && make # lots of output... $ ./ruby --disable-gems -e 'puts "It works..."' It works...
I then started looking at the implementation of the
Range Ruby class, which
is contained in
range.c, specifically, the
function. I also added
some tests that I wanted to pass to the
test_cover method in
After finding the right place to make the change, the next (trickier) step is to get stuck in and make the required changes in C and add tests. I made heavy use of lldb to help debug errors, and referred to the excellent Ruby Under a Microscope book to help understand MRI's internals.
Once I had (what I thought was) a working patch, I committed my changes in git,
then exported the (v1) patch with
git format patch HEAD~1 -v1, and uploaded
the file to the MRI bug-tracker.
After some discussion,
fixing an error and
encorporating some feedback
I was asked by Matz himself
to justify the change.
With my use-case, and Masaya Tarui's (an MRI-committer), Matz accepted the
suggested change, but asked for me to change the implementation slightly -
rather than add a new method, extend
cover? to also accept a Range argument.
After making the requested change, and having a few more discussions, my final
amended patch was exported with
git format patch HEAD~1 -v6, uploaded to
bug-tracker, and committed
a few days later, and released in Ruby 2.6.0.
Contributing to Ruby needn't be scary - find a ticket you care about on the
tracker, and get stuck in! The hard work to make the change, get it reviewed
and approved was worth it; it was really rewarding to later see the change I
in BigBinary's "Ruby 2.6" series. Having implemented the
cover? extension in
MRI, I was also able to spot and fix
a subtle issue in the Rails (Ruby) implementation of the same method.
: In fact, we define a simple
Range#offset offset method to do this neatly for us, defined as:
Range.class_exec do def offset(value) Range.new(first + value, last + value, exclude_end?) end end (1..10).offset(32) # => (33..42)
: The following simple Dockerfile shows the necessary steps with the latest Ubuntu release image (< 200MB to download) to build Ruby from source, and apply my patch:
FROM ubuntu:19.04 RUN apt-get update RUN apt install -y autoconf bison build-essential curl git libssl-dev ruby zlib1g-dev # Obtain the mri source; checkout a known revision that my patch was built using RUN git clone https://github.com/ruby/ruby WORKDIR ruby RUN git checkout e1a8d281eb0d34acbf016c9f9fcd2ba91962dbe7 RUN autoconf && ./configure RUN make # Avoid needing to install the built ruby, by passing --disable-gems, otherwise # we'll see: # <internal:gem_prelude>:2:in `require': cannot load such file -- rubygems.rb (LoadError) # This should print false, as my change hasn't yet been added: RUN ./ruby --disable-gems -e 'p (1..10).cover?((1..5))' # Now obtain and apply my patch, and rebuild RUN curl https://bugs.ruby-lang.org/attachments/download/7280/v6-0001-range.c-allow-cover-to-accept-Range-argument.patch \ | git apply - RUN make # This should print true as my feature has been added. RUN ./ruby --disable-gems -e 'p (1..10).cover?((1..5))'
: The issue was one of several I discovered when writing comprehensive test-cases for my MRI change.