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 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 Binary[2]. 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 issue[3] in the Rails (Ruby) implementation of the same method.

[1]: 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)

[2]: 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))'

[3]: The issue was one of several I discovered when writing comprehensive test-cases for my MRI change.