Create a command-line gem from scratch with Thor

in #ruby6 years ago (edited)

This is the first in a two-part post on creating a Ruby command-line gem from scratch. Part two found here.


Let's write a command-line interface (cli) as a Ruby gem. We need the gem to do something slightly challenging so let's take a popular interview question:

The Matrix Spiralizer

Create an application that takes a matrix (two-dimensional array) and returns a string. The matrix may be of arbitrary size and must consist of uppercase English letters. The returned string must consist of all elements of the matrix ordered in a clockwise spiral pattern starting with element [0, 0]. Each letter must be converted to lowercase and separated by a single whitespace character.

For example, given the following matrix:

[
  [A B C D],
  [E F G H],
  [I J K L]
]

A B C D
E F G H
I J K L

The resulting string would be:
"a b c d h l k j i e f g"

Sound fun? Let's jump in.

Getting Started

There are many algorithms you can use to solve this little puzzle, but before we get to the implementation that I went with we need to scaffold our gem. Make sure you have Bundler installed (gem install bundler) and then:

> bundle gem spiralizer && cd spiralizer

This command creates a scaffold directory for our new gem and, if we have Git installed, initializes a Git repository in this directory. If this is the first time running the bundle gem command, you will be asked about what kind of default files you'd like to include in all future gems. Let's use RSpec for testing our gem. Notice that Bundler scaffolded out a spec/ directory for our convenience. We need to make sure we specify a recent version of RSpec as a development dependency.

Open the spiralizer.gemspec file and bump up the version at the bottom, if needed. While I'm at it, I'm just gonna bump up the Rake and Bundler versions, as well as update the stubbed out summary and description.

> spec.summary     = %q{A gem that takes a matrix and returns a formatted string}
> spec.description = spec.summary
...
> spec.add_development_dependency "bundler", "~> 1.16"
> spec.add_development_dependency "rake", "~> 11.2"
> spec.add_development_dependency "rspec", "~> 3.6"
> spec.add_development_dependency "pry"

Feel free to bump these versions and add to that description. Later on we will be installing the Thor gem, so you could install that now if you feel like it. In any case, we'll circle back to that later.

You may have noticed that we have a Gemfile. Open it up and its pretty empty. All you should see is:

source "https://rubygems.org"
gemspec

That gemspec method is defined in the Bundler gem under lib/bundler/dsl.rb. Because we call that gemspec method in our Gemfile, Bundler will automatically add this gem to a group called "development" which we can then reference any time we want to load the gems defined in our gemspec file. Additionally, anybody who now runs gem install spiralizer --dev will get these dev dependencies installed too.

Run bundle install and the gems will be installed, and generate our Gemfile.lock (which is responsible for ensuring that every system this library is developed on has the exact same gems). Bundler detects the spiralizer gem, loads the gemspec and bundles just like any other gem. With all this boilerplate out of the way, we can now start addressing our algorithm.

So, how do we solve our little problem? The solution I came up with works like the Ouroboros above. Say we have with this:

[
  [A B C D],
  [E F G H],
  [I J K L]
]

We start at the first index which is this inner array: [A B C D]. All we need to do is join that whole row together and we have the beginnings of our desired string. Next, we just need to grab the next index of our outer array, reverse it and join that together. Then we concatenate the result to our previous string, and so on. The approach I'd like to take here is to run this whole thing in a loop that will deconstruct, consume, and discard as we go. When there is nothing left, we know we are done. Hence the reference to the Ouroboros. Here's a semi-visual breakdown of how I want the algorithm to make its way through our matrix:

Step One:

go from left to right until you hit the end, then discard

  a b c d
  E F G H
  I J K L

Step Two:

grab last index of remaining inner arrays, then discard

  E F G h
  I J K l

Step Three:

take the last array, reverse crawl it, then discard

  E F G
  i j k

Step Four:

grab first index of remaining inner arrays, climb the ladder, then discard

  e F G

Then repeat until there is nothing left to consume.

Let's write a simple test to get this going. Open up spec/spiralizer_spec.rb and you should see some examples scaffolded for you. The first one is useful, so we'll keep it around:

  it "has a version number" do
    expect(Spiralizer::VERSION).not_to be nil
  end

If you run the spec (rspec spec/spiralizer_spec.rb:2) it will pass. That is because bundler defined a VERSION constant for us already in lib/spiralizer/version.rb. So, that's nice. Let's delete that second example that "does something useful", and get back to our implementation.

We want to define a useful example that will help us work towards our algorithm we defined above. Let's start by defining a matrix of chars and an expected result string:

RSpec.describe Spiralizer do
  context 'character matrix' do
    let(:matrix) do
      [%w[A B C D], %w[E F G H], %w[I J K L], %w[M N O P], %w[Q R S T]]
    end

    let(:expected_result) { %Q{a b c d h l p t s r q m i e f g k o n j} }

    it 'returns a string in spiraling order' do
    end
  end
end

Now, we just need an assertion. We want to pass our matrix in, and get back our expected string. So, what do we want our interface to look like? In lib/spiralizer.rb you'll notice a comment from bundler saying: # Your code goes here...

We will get to work in there. Let's add a nested class called class Spiralizer that will have a perform method that does the work. Here's our assertion:

RSpec.describe Spiralizer do
  context 'character matrix' do
    let(:matrix) do
      [%w[A B C D], %w[E F G H], %w[I J K L], %w[M N O P], %w[Q R S T]]
    end

    let(:expected_result) { %Q{a b c d h l p t s r q m i e f g k o n j} }

    it 'returns a string in spiraling order' do
      expect(Spiralizer::Spiralize.new(matrix: matrix).perform).to eq expected_result
    end
  end
end

Let's add one more example for handling a matrix of integers:

RSpec.describe Spiralizer do
  ...
  context 'integer matrix' do
    let(:matrix) do
      [[1,2,3], [10, 11, 4], [9, 12, 5], [8, 7, 6]]
    end

    let(:expected_result) { %Q{1 2 3 4 5 6 7 8 9 10 11 12} }

    it 'handles numbers with multiple digits' do
      expect(Spiralizer::Spiralize.new(matrix: matrix).perform).to eq expected_result
    end
  end
end

It's pretty much the same thing. We want to pass in our pre-defined matrix (note the keyword argument) and spiralize it. Right now this test is red. If you want to try your own solution, now would be the time to pause reading further and give it a try.


Skipping ahead for brevity, here is a solution I came up with:

class Spiralizer::Spiralize
  def initialize(matrix:)
    @matrix  = matrix
    @result  = String.new
    @padding = String.new ' '
  end

  def perform
    until matrix.empty? do
      @result += @matrix.shift.join(padding).concat(padding)
      break if matrix.empty?
      @result += @matrix.map(&:pop).join(padding).concat(padding)
      @result += @matrix.pop.reverse.join(padding).concat(padding)
      @result += @matrix.map(&:shift).reverse.join(padding).concat(padding)
    end

    @result.downcase.strip
  end

  private

  attr_reader :padding, :result, :matrix
end

Our Spiralizer::Spiralize class creates a few instance variables for our pre-defined matrix, and a couple of strings: one empty, and one with a space for adding padding to our string output. perform does the work by looping over each inner array of characters, shifting and popping as it goes until the matrix is consumed. This took a little trial and error to figure out, but I think the result is simple enough to follow. Our specs should now be passing.

So, this is working just fine but what happens if we enter some bad input? Let's add some naive validations to at least make sure we are only accept valid matrices. I'm imagining we want our validations to run immediately when we receive input from a user. We can define a simple error class called Spiralizer::InvalidInput, and a class method on our
spiralizer module that take our matrix in as an argument and analyzes its matrixiness. If it is not a matrix it will raise our error class. Pretty straight forward. Here's my test:

RSpec.describe Spiralizer do
  ...
  context 'invalid input' do
    let(:notamatrix) { [{a: 'b'}, 'bye'] }

    it 'only takes matrices' do
      expect{ Spiralizer::Spiralize.new(matrix: notamatrix) }
        .to raise_error Spiralizer::InvalidInput
    end
  end
end

Our tests are red again, so let's add our code:

module Spiralizer
  class InvalidInput < StandardError; end

  def self.validate_input(matrix)
    unless matrix.respond_to?(:first) && matrix.first.respond_to?(:join)
      fail Spiralizer::InvalidInput.new("spiralize only accepts a matrix")
    end
  end

  class Spiralize
    def initialize(matrix:)
      Spiralizer.validate_input(matrix)
      ...
    end
    ...
  end
end

...and run our tests again. Green.

This is a good time to think a little about what kind of application we'd like. Let's go for a command line interface that prompts a user for input to spiralize. When we start up the program we'd like a list of options to choose from, so the user can know what they are able to do. For example:

#-> what would you like to do?
  1 - Build a matrix
  2 - Generate a random matrix

#-> now that we have a matrix, would you like to spiralize it?
  1 - spiralize it, duh
  2 - nope

In order to get this going were going to have to add a few missing pieces to our baby gem. We're going to want to add a matrix factory that can handle user input. Without matrices to spiralize this gem is no good. We'll also need to build out our user facing cli. Let's add some specs for a matrix factory. Our factory will be class within the same namespace called Matrix. We would like to pass in some values and paramaters on how we'd like this matrix to look. Let's say a range of characters and the dimensions of the matrix.

For example, if we had a range of characters ('A'..'L') we could build a 3x4, 2x6, or a 4x3 matrix:

(2 x 6)

A B
C D
E F
G H
I J
K L

(3x4)

A B C
D E F
G H I
J K L

(4x3)

A B C D
E F G H
I J K L

So, we need to be able to accept a range of values, and the desired dimensions of our matrix. Let's mix it up and make it a class method called the_matrix just 'cause. We'll add our first assertion right under the others for now:

RSpec.describe Spiralizer do
  ...
  describe 'Matrix' do
    context 'character matrix' do
      let(:matrix) do
        Spiralizer::Matrix.the_matrix(range: 'A'..'L', dimensions: '4x3')
      end

      let(:expected_matrix) do
        [%w[A B C D], %w[E F G H], %w[I J K L]]
      end

      it 'returns a matrix from a given range of letters' do
        expect(matrix).to eq expected_matrix
      end
    end
  end

The happy path is easy enough but we are talking about user input so we need to add tests for as many cases we can conceive of and get to work. I'm not trying to be exhaustive here, since this is just for fun. Here is where I stopped:

RSpec.describe Spiralizer do
  ...
  describe 'Matrix' do
    ...
    context 'uneven range of numbers' do
      let(:matrix) do
        Spiralizer::Matrix.the_matrix(range: 1..7, dimensions: '3x2')
      end

      let(:expected_matrix) do
        [[1,2,3], [4,5,6]]
      end

      it 'returns a matrix from a given range of numbers, excluding last number' do
        expect(matrix).to eq expected_matrix
      end
    end

    context 'invalid input' do
      it 'raises when range not passed' do
        expect{ Spiralizer::Matrix.the_matrix(range: 'hi', dimensions: '3x2') }
          .to raise_error Spiralizer::InvalidInput, 'valid range expected'
      end

      it 'raises when mal-formatted dimensions are passed' do
        expect{ Spiralizer::Matrix.the_matrix(range: (1..4), dimensions: '3 2') }
          .to raise_error Spiralizer::InvalidInput, 'dimensions must be a string in \'2x4\' format'
      end

      it 'raises when matrix cannot be built with given input' do
        expect{ Spiralizer::Matrix.the_matrix(range: (1..4), dimensions: '3x2') }
          .to raise_error Spiralizer::InvalidInput, 'can\'t build matrix with range/dimensions pair'
      end
    end
  end
end

As you can see, I'm anticipating a user could pass in an uneven total of characters. We don't want to deal with odd numbers, so we'll just lop that last char off and move on. We also guard against malformatted ranges, and dimensions, and just give up when we can't build a matrix with the input supplied. And our tests are now red again.

Let's get those passing:

class Matrix
  attr_reader :range, :dimensions

  INVALID_PAIR       = 'can\'t build matrix with range/dimensions pair'.freeze
  INVALID_RANGE      = 'valid range expected'.freeze
  INVALID_DIMENSIONS = 'dimensions must be a string in \'2x4\' format'.freeze

  def self.the_matrix(range:, dimensions:)
    new(range, dimensions).build
  end

  def initialize(range, dimensions)
    @range = range
    @dimensions = dimensions
    validate_range_and_dimenstions
  end

  def validate_range_and_dimenstions
    fail Spiralizer::InvalidInput.new(INVALID_RANGE)      unless valid_range?
    fail Spiralizer::InvalidInput.new(INVALID_DIMENSIONS) unless valid_dimensions?
    fail Spiralizer::InvalidInput.new(INVALID_PAIR)       unless valid_pair?

    puts('WARNING: The last character will be excluded.') if range.count.odd?
  end

  def build
    range_enum = range.each
    Array.new(cols) { Array.new(rows) { range_enum.next } }
  end

  private

  def valid_range?
    range.respond_to?(:to_a)
  end

  def valid_dimensions?
    dimensions.respond_to?(:split) && dimensions =~ /\d+\s?x+\s?\d+/
  end

  def valid_pair?
    (range.count / rows) == cols || (range.count / cols) == rows
  end

  def split_dimensions
    @split_dimensions ||= dimensions.split('x').map(&:to_i)
  end

  def rows
    @rows ||= split_dimensions.first
  end

  def cols
    @cols ||= split_dimensions.last
  end
end

As mentioned above, our entry point will be from the_matrix class method. Feel free not to do that, I just wanted to have a method called the_matrix. :)

We new up the object from there passing in our params and validate the input with the validate_range_and_dimenstions method. If the input passes the validations we return back to the_matrix and call build on our newly instantiated object. build created an enum out of our range and calls Enumerator's next method to fill in the values of our matrix nested inside of the inner columns of our initial array.

I've glossed over some of the complexities involved, but going into Ruby's enum classes is outside of the scope of this post. Take a few minutes to look over the code as needed. At this point, our tests are green again. We can now create matrices and spiralize them, and we have decent test coverage to boot.


In part two of this series, we will begin working on our cli making use of the Thor gem. Until then, thanks for reading!

(ง ͠° ͟ل͜ ͡°)ง