Ler blog em Português

Creating generators and executables with Thor

Read in 7 minutes

Thor is an amazing library for creating generators. It gives you methods for creating and copying files and directories, defining symbolic links, read remote files, and more. And is the perfect companion for gems that need to generate a project structure, just like Rails.

The first thing you need is defining the generator class.

require 'thor'
require 'thor/group'

class MyGem::Generator < Thor::Group
  include Thor::Actions
  desc 'Generate a new filesystem structure'
end

The Thor::Group class is perfect for generators because allows you to execute all actions at once. To define the actions of your generator, you must include the Thor::Actions module.

require 'thor'
require 'thor/group'

class MyGem::Generator < Thor::Group
  include Thor::Actions
  desc 'Generate a new filesystem structure'

  def self.source_root
    File.dirname(__FILE__) + '/../../templates'
  end

  def create_config_file
    copy_file 'config.yml', 'config/mygem.yml'
  end

  def create_git_files
    copy_file 'gitignore', '.gitignore'
    create_file 'images/.gitkeep'
    create_file 'text/.gitkeep'
  end

  def create_output_directory
    empty_directory 'output'
  end
end

As you can see, we’re defining the MyGem::Generator.source_root, which is your generator templates directory.

To execute this generator, you have to instantiate the MyGem::Generator class.

generator = MyGem::Generator.new
generator.destination_root = '/some/path'
generator.invoke_all

We’re defining the destination through the generator.destination_root property; it’s optional and will use the current directory by default.

Creating an executable

You can create your CLI using the OptionParser standard library, although it can be really hard to manage the complexity for more sofisticated interfaces. But fear not; Thor can help you with that too.

To define a new CLI with Thor, create a class that inherits from the Thor class.

require 'thor'

class MyGem::Cli < Thor
end

Your executable file needs to call the MyGem::Cli.start method. You can pass an array that represents the ARGV, specially good when writing tests.

#!/usr/bin/env ruby

require 'mygem'
MyGem::Cli.start

Your executable file must have execution permission; you can do it by running the chmod.

chmod +x bin/mygem

Now you can execute ./bin/mygem.

$ ./bin/mygem
Commands:
  mygem help [COMMAND]  # Describe available commands or one specific command

Our script doesn’t do much yet; let’s add an option for displaying the program version. To define a new command switch, you can use the Thor.map method.

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    desc 'version', 'Display version'
    map %w[-v --version] => :version

    def version
      say "MyGem #{VERSION}"
    end
  end
end

If you run ./bin/mygem again you’ll see that we now have a version command available. This happens because all public methods are considered as commands; define a private method if you don’t want to make it available as a command.

$ ./bin/mygem
Commands:
  mygem help [COMMAND]  # Describe available commands or one specific command
  mygem version         # Display MyGem version

Now you use one of the following options to display the program version.

$ ./bin/mygem -v
MyGem 0.1.0

$ ./bin/mygem --version
MyGem 0.1.0

$ ./bin/mygem version
MyGem 0.1.0

To create a new generator, you’ll probably want something like mygem new <some path>. This means that you need a positional argument, so just define a method that receives an argument.

require 'thor'

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    desc 'version', 'Display MyGem version'
    map %w[-v --version] => :version

    def version
      say "MyGem #{VERSION}"
    end

    desc 'new PATH', 'Create a new static website'
    def new(path)
      path = File.expand_path(path)
      say "Creating site at #{path}"
    end
  end
end

Now you can execute the new command. Notice that Thor validates the argument presence for you, returning an error message when the argument is missing.

$ ./bin/mygem new foo
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo

$ ./bin/mygem new
ERROR: "mygem new" was called with no arguments
Usage: "mygem new PATH"

You may need to accept some options for initializing your project. Rails does this all the time, like rails new myapp -d postgresql. To define a new option for your command, use the Thor.option method.

require 'thor'

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    desc 'version', 'Display MyGem version'
    map %w[-v --version] => :version

    def version
      say "MyGem #{VERSION}"
    end

    desc 'new PATH', 'Create a new static website'
    option :javascript_engine, :default => 'babeljs', :aliases => '-j'
    def new(path)
      path = File.expand_path(path)
      say "Creating site at #{path}"
      say options
    end
  end
end

In this example, we can define the JavaScript engine by using --javascript-engine or its -j alias. Thor is smart and will automatically convert the option name to a hyphenated version.

$ ./bin/mygem new foo
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"javascript_engine"=>"babeljs"}

$ ./bin/mygem new foo --javascript-engine coffeescript
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"javascript_engine"=>"coffeescript"}

$ ./bin/mygem new foo -j coffeescript
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"javascript_engine"=>"coffeescript"}

The Thor.option accepts other parameters, like type coercion and if it’s a required option.

option :file, :type => :array, :aliases => :files
option :force, :type => :boolean, :default => false
option :database, :required => true

By default Thor won’t validate the options you’re passing to the executable. So when you combine a positional argument with an invalid option, you’ll end up with a unexpected behavior.

$ ./bin/mygem new -h
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/-h
{"javascript_engine"=>"babeljs"}

Did you see that the directory name is marked as -h? Thor thinks that -h is the positional argument, instead of an invalid option. You can reject invalid options by calling the Thor.check_unknown_options! method.

require 'thor'

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

    desc 'version', 'Display MyGem version'
    map %w[-v --version] => :version

    def version
      say "MyGem #{VERSION}"
    end

    desc 'new PATH', 'Create a new static website'
    option :javascript_engine, :default => 'babeljs', :aliases => '-j'
    def new(path)
      path = File.expand_path(path)
      say "Creating site at #{path}"
      say options
    end
  end
end

Now, invalid arguments are rejected and an error message is displayed.

$ ./bin/mygem new -h
Unknown switches '-h'

Another common task is having global options like --verbose, which can be applied to all commands. You can use the Thor.class_option for this.

require 'thor'

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

    desc 'version', 'Display MyGem version'
    map %w[-v --version] => :version

    class_option 'verbose',  :type => :boolean, :default => false

    def version
      say "MyGem #{VERSION}"
    end

    desc 'new PATH', 'Create a new static website'
    option :javascript_engine, :default => 'babeljs', :aliases => '-j'
    def new(path)
      path = File.expand_path(path)
      say "Creating site at #{path}"
      say options
    end
  end
end

With this option you can set the --verbose in every command and the Thor#options method will be populated with global and local options.

$ ./bin/mygem new foo --verbose
Creating site at /Users/fnando/Projects/samples/thor-bin-sample/foo
{"verbose"=>true, "javascript_engine"=>"babeljs"}

Eventually you’ll have to validate the arguments you’re receiving. You may even need to interrupt the execution, returning an error message. You can thrown exception with Thor::Error class, specifying the error message you want to display.

require 'thor'

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

    desc 'version', 'Display MyGem version'
    map %w[-v --version] => :version

    class_option 'verbose',  :type => :boolean, :default => false

    def version
      say "MyGem #{VERSION}"
    end

    desc 'new PATH', 'Create a new static website'
    option :javascript_engine, :default => 'babeljs', :aliases => '-j'
    def new(path)
      path = File.expand_path(path)
      raise Error, "ERROR: #{path} already exists." if File.exist?(path)

      say "Creating site at #{path}"
      say options
    end
  end
end

We’re returning an error message when the output directory already exists. The problem, in this case, is that the exit code of that failing command will be 0, which means “success” in the *nix world.

$ ./bin/mygem new cli.rb --verbose
ERROR: /Users/fnando/Projects/samples/thor-bin-sample/cli.rb already exists.

$ echo $?
0

You can fix this by defining the MyGem::Cli.exit_on_failure? method and returning true.

require 'thor'

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

    def self.exit_on_failure?
      true
    end

    desc 'version', 'Display MyGem version'
    map %w[-v --version] => :version

    class_option 'verbose',  :type => :boolean, :default => false

    def version
      say "MyGem #{VERSION}"
    end

    desc 'new PATH', 'Create a new static website'
    option :javascript_engine, :default => 'babeljs', :aliases => '-j'
    def new(path)
      path = File.expand_path(path)
      raise Error, "ERROR: #{path} already exists." if File.exist?(path)

      say "Creating site at #{path}"
      say options
    end
  end
end

Now the same execution will return the exit code as 1.

$ ./bin/mygem new cli.rb --verbose
ERROR: /Users/fnando/Projects/samples/thor-bin-sample/cli.rb already exists.

$ echo $?
1

You can even use colored output for better visualization; just use the set_color method.

require 'thor'

module MyGem
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

    def self.exit_on_failure?
      true
    end

    desc 'version', 'Display MyGem version'
    map %w[-v --version] => :version

    class_option 'verbose',  :type => :boolean, :default => false

    def version
      say "MyGem #{VERSION}"
    end

    desc 'new PATH', 'Create a new static website'
    option :javascript_engine, :default => 'babeljs', :aliases => '-j'
    def new(path)
      path = File.expand_path(path)
      raise Error, set_color("ERROR: #{path} already exists.", :red) if File.exist?(path)

      say "Creating site at #{path}"
      say options
    end
  end
end

Now the same error message would be displayed like this:

Thor - Colored output

Wrapping up

I really like Thor for creating CLIs and generators. It has all the features I need and is used by large projects such as Ruby on Rails.

To get more information about Thor, check out the documentation. The project’s wiki also have some useful information.

Finally, remember that creating a gem is still the best delivery method; the executable will be available on your $PATH and is easy to install it with gem install mygem.

Share: