Create a command-line gem from scratch with Thor (part two)

in #ruby6 years ago (edited)

This is the last in a two-part post on creating a Ruby cli gem from scratch. Part one found here.


Picking up from our last post, we want to start working on our user interface. In this case we want a cli that looks something like this:

#-> 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
  2 - Stare at it

Thor

We're going to use Thor, which is a toolkit for building command-line tools and applications. In fact, Bundler itself was written using Thor. Let's add it to the bottom of our gemspec and re-install:

# spiralizer.gemspec
spec.add_dependency "thor"
---
# from cmd line:
> bundle install

Now, we just need to require it in and start using it!

require 'thor'
...

But, this is also a good time to start separating our code a bit, so we don't let things get to messy. So, let's create a file called lib/spiralizer/cli.rb, and move our require statement into this file. Then we will stub out a class under the Spiralizer namespace and inherit from Thor:

require 'thor'
require 'spiralizer'

class Spiralizer::CLI < Thor
end

Back in our main module we will need to require in this new file:

require "spiralizer/version"
require "spiralizer/cli"

module Spiralizer
...
end

Our cli is gem that users can install on their systems. We want them to have an executable that will take a simple command to spin up prompt we defined earlier:

Say the user enters spiralizer go for example. We would want output similar to what we envisioned above:

$ >spiralizer go

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

Thor gives us some easy-to-use tooling to get this thing going in no time. We'll get started by defining our go method, and use the desc macro annotation get some magic documentation:

require 'thor'
require 'spiralizer'

class Spiralizer::CLI < Thor
  desc 'go', 'starts a prompt that brings users to spiral heaven'
  def go
    say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
    action = ask '> '
    say "\nyou picked #{action}"
    exit!
  end
end

Thor provides some intuitive methods like say to output something to the user, and ask to prompt the user for input. They work just as expected. Notice that you can assign the result of ask to a variable.

To start playing around with it, we just need to create a new directory called exe (alternatively, we could have scaffolded our gem with a --exe option but here we are). It should be a sibling directory of lib and bin. cd exe && touch spiralizer to create a file. Then, give it executable permissions: > chmod +x exe/spiralizer, and dump this inside:

#!/usr/bin/env ruby

require 'spiralizer/cli'
Spiralizer::CLI.start

And let's give 'er a whirl:

> bundle exec exe/spiralizer

Commands:
  spiralizer go              # starts a prompt that brings users to spiral heaven
  spiralizer help [COMMAND]  # Describe available commands or one specific command

Notice the helpful output Thor gives us. We have two commands available: go and help. Let's try them out:

> bundle exec exe/spiralizer help

Commands:
  spiralizer go              # starts a prompt that brings users to spiral heaven
  spiralizer help [COMMAND]  # Describe available commands or one specific command

> bundle exec exe/spiralizer go

What would you like to do? (choose a number)
 1 - Build a matrix
 2 - Generate a random matrix
>  1

you picked 1

So, help is the default action, and we can see our new go command is registered properly. Right now our prompt gives us two options, let's add functionality for our second option. When a user enters '2' we want to spit out a random matrix. We can do a quick and dirty version of this and just hard code some matrix range/dimension pairs that we will feed to our matrix factory. Let's make a constant called MATRICES that itself is a matrix:

MATRICES = [
  ['A-L', '4x3'],
  ['M-X', '3x4'],
  ['1-8', '2x4'],
  ['1-144', '12x12'],
  ['C-J', '4x2'],
  ['A-Z', '13x2']
].freeze

We now need a semi-random way to pick one of these: range_response, dimensions = MATRICES[rand(6)]. This will give us a range value as a string like 'C-j' and dimensions like 4x2. We'll then split that range string up, so we can feed it into our factory:

range = range_response.split('-')
matrix = Spiralizer::Matrix.the_matrix(range: (range.first..range.last), dimensions: dimensions)

Thor wants you to put helper methods for your class in a no_commands block, so let's create one of those
and put all this together so its somewhat presentable. Here's what we have so far:

class Spiralizer::CLI < Thor
  attr_reader :matrix

  MATRICES = [
    ['A-L', '4x3'],
    ['M-X', '3x4'],
    ['1-8', '2x4'],
    ['1-144', '12x12'],
    ['C-J', '4x2'],
    ['A-Z', '13x2']
  ].freeze

  desc 'go', 'starts a prompt that brings users to spiral heaven'
  def go
    say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
    action = ask '> '
    say "\n"

    range, dimensions = random_range_and_dimensions
    @matrix = Spiralizer::Matrix.the_matrix(range: range, dimensions: dimensions)
    output_matrix
  end

  no_commands do
    def random_range_and_dimensions
      say "\ngenerating..."
      sleep 1
      range_response, dimensions = MATRICES[rand(6)]
      range = range_response.split('-')
      return (range.first..range.last), dimensions
    end

    def output_matrix
      say "\nHere is your M A T R I X\n------------------------"
      matrix.each { |inner| say inner.join("\t") }
      exit!
    end
  end
end

You'll notice that any input we give the prompt at this point will just generate a random matrix from our list. We're making progress!

> bundle exec exe/spiralizer go

What would you like to do? (choose a number)
 1 - Build a matrix
 2 - Generate a random matrix
>  2


generating...

Here is your M A T R I X
------------------------
1 2
3 4
5 6
7 8

Next, let's hook up a follow up prompt that asks us if we want to spiralize the matrix. Update your output_matrix method:

def output_matrix
  say "\nHere is your M A T R I X\n------------------------"
  matrix.each { |arr| say arr.join("\t") }

  say "\nWould you like to spiralize it? (choose a number)\n 1 - Yes\n 2 - No"
  action = ask '> '
  say "\n"
  if action == '1'
    say Spiralizer::Spiralize.new(matrix: matrix).perform
  end

  exit!
end

We are now prompted for more input after the matrix has been generated! Pretty neat.

> bundle exec exe/spiralizer go

What would you like to do? (choose a number)
 1 - Build a matrix
 2 - Generate a random matrix
>  2


generating...

Here is your M A T R I X
------------------------
M N O
P Q R
S T U
V W X

Would you like to spiralize it? (choose a number)
 1 - Yes
 2 - No
>  1

m n o r u x w v s p q t

This is looking good but we still need to handle our first option to allow users to create their own matrix. Let's add a method to handle that input now:

  def user_range_and_dimensions
    range_response = ask("Please provide range. Acceptable format: (A-L) or (1-6)\n> ")
    values         = range_response.split('-')
    dimensions     = ask("Please provide dimensions. Acceptable format: (4x3) or (3x2)\n> ")
    return (values.first..values.last), dimensions
  end

We want this to trigger when the user chooses '1' from our introduction prompt so we need to re-work our go method:

def go
  say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
  action = ask '> '
  say "\n"

  case action
  when '1' then range, dimensions = user_range_and_dimensions
  when '2' then range, dimensions = random_range_and_dimensions
  else exit!
  end

  range, dimensions = random_range_and_dimensions
  @matrix = Spiralizer::Matrix.the_matrix(range: range, dimensions: dimensions)
  output_matrix
rescue Spiralizer::InvalidInput => e
  say "\nUh oh! #{e.message}"
ensure
  say "\nexiting..."
  exit!
end

You should be getting the hang of how to use Thor. Our class is need of some cleanup, but with some refactoring we end up with something like this:

require 'thor'
require 'spiralizer'

class Spiralizer::CLI < Thor
  MATRICES = [
    ['A-L', '4x3'],
    ['M-X', '3x4'],
    ['1-8', '2x4'],
    ['1-144', '12x12'],
    ['C-J', '4x2'],
    ['A-Z', '13x2']
  ].freeze

  attr_reader :action, :matrix

  desc 'go', 'starts a prompt that brings users to spiral heaven'
  def go
    clear_screen!
    intro_prompt
    build_matrix
    output_matrix
    action_prompt
    spiralize!
  rescue Spiralizer::InvalidInput => e
    say "\nUh oh! #{e.message}"
  ensure
    quit_softly
  end

  no_commands do
    def intro_prompt
      say "What would you like to do? (choose a number)\n 1 - Build a matrix\n 2 - Generate a random matrix"
      @action = ask '> '
    end

    def build_matrix
      say "\n"

      case action
      when '1' then range, dimensions = user_range_and_dimensions
      when '2' then range, dimensions = random_range_and_dimensions
      else quit_softly
      end

      @matrix = Spiralizer::Matrix.the_matrix(range: range, dimensions: dimensions)
    end

    def user_range_and_dimensions
      range_response = ask("Please provide range. Acceptable format: (A-L) or (1-6)\n> ")
      values         = range_response.split('-')
      dimensions     = ask("Please provide dimensions. Acceptable format: (4x3) or (3x2)\n> ")
      return (values.first..values.last), dimensions
    end

    def random_range_and_dimensions
      say "\ngenerating..."
      pause_for_effect
      range_response, dimensions = MATRICES[rand(6)]
      range = range_response.split('-')
      return (range.first..range.last), dimensions
    end

    def action_prompt
      say "\nWhat would you like to do with it? (choose a number)\n 1 - Spiralize it\n 2 - Look at it"
      @action = ask '> '
    end

    def output_matrix
      say "\nHere is your M A T R I X\n------------------------"
      matrix.each { |inner| say inner.join("\t") }
    end

    def spiralize!
      say "\n"
      say Spiralizer::Spiralize.new(matrix: matrix).perform if action == '1'
    end

    def clear_screen!
      return system 'cls' if Gem.win_platform?
      system 'clear'
    end

    def pause_for_effect
      sleep 0.5
    end

    def quit_softly
      say "\nexiting..."
      pause_for_effect
      exit!
    end
  end
end

Now, let's try it all out:

What would you like to do? (choose a number)
 1 - Build a matrix
 2 - Generate a random matrix
>  1

Please provide range. Acceptable format: (A-L) or (1-6)
>  1-6
Please provide dimensions. Acceptable format: (4x3) or (3x2)
>  2x3

Here is your M A T R I X
------------------------
1 2
3 4
5 6

What would you like to do with it? (choose a number)
 1 - Spiralize it
 2 - Look at it
>  1

1 2 4 6 5 3

exiting...

Our little gem is complete! There is a lot of cleanup we can and should do, but this is where this blog post ends.
Feel free to add new functionality and experiment further with Thor. I've gone ahead and added a Crisscross class for
example in my repo.

Oh! One more thing, if yoy run bundle exec rake install bundler will install this gem on your system as a global executable so you can then call it like this:

> spiralizer go

If you ever need to uninstall it for some insane reason just run gem uninstall spiralizer.

You can find a finalized version of the code here.

Well, there you have it. We've built a cli gem from scratch using Bundler and Thor. I hope this post serves you well and until next time!

(づ。◕‿‿◕。)づ :・゚✧ ✧゚・:・゚✧ ✧゚・