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 Range
class,
released in Ruby 2.6.0.
What?
Ruby supports
Range
objects representing sets of values,
such as (1..10)
and ('a'...'f')
; an example use case is to help prevent out-of-bounds values:
def valid_loan_value?(loan_value)
(1000..5000).cover?(loan_value)
end
My 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.
Why?
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)1 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
as top_up_value_range = (1334..1834)
, which is covered by (1000..5000)
.
How?
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).
To start building a change to MRI (in C), the first step is to obtain the MRI source code and ensure you can build-and-run the resulting Ruby binary2. Something like:
$ ./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 range_cover
function. I also added
some tests that I wanted to pass to the test_cover
method in
the test-suite.
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.
Conclusion
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
made discussed
in BigBinary’s “Ruby 2.6” series. Having implemented the cover?
extension in
MRI, I was also able to spot and fix
a subtle issue3 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. ↩