Create a command-line gem from scratch with Thor
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!
Nice read. I leave an upvote for this article thumbsup