Go to English Blog

Criando executáveis com Thor

Leia em 6 minutos

Eu já falei sobre o uso do Thor para criar geradores em um artigo aqui do blog. Desta vez, vamos ver como ele pode ser usado para criar uma CLI.

O Ruby possui suporte para parsing de opções de CLI através da biblioteca OptionParser. No entanto, ela não é muito simples de usar quando sua interface é um pouco mais complicada.

Para criar um arquivo executável, primeiro defina uma classe que herde da classe Thor; ela possui todas as facilidades para se criar uma CLI.

require 'thor'

module Troy
  class Cli < Thor
  end
end

No seu arquivo binário, execute essa classe como no código à seguir:

#!/usr/bin/env ruby

require 'troy'
Troy::Cli.start

O seu arquivo binário deve ter permissão de execução; você pode fazer isso com o comando chmod.

chmod +x bin/troy

Pronto! Você já pode executar o script troy.

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

Nosso comando ainda não faz nada. Vamos adicionar uma opção para exibir a versão de nosso programa. Para definir um novo switch, você precisa dar uma descrição que será utilizada na ajuda. Você também vai precisar especificar os switches aceitos com o método Thor.map, especificando qual método deve ser executado.

module Troy
  VERSION = '0.1.0'

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

    def version
      say "Troy #{Troy::VERSION}"
    end
  end
end

Se você executar o comando troy, verá que o sub-comando version será exibido na ajuda. Isso acontece porque todos os métodos públicos são considerados como sub-comandos. Se você não quiser disponibilizar um comando, basta definí-lo como privado.

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

Agora você pode exibir a versão de seu programa com umas das três variações à seguir.

$ ./bin/troy -v
Troy 0.1.0

$ ./bin/troy --version
Troy 0.1.0

$ ./bin/troy version
Troy 0.1.0

Se você pretende criar um gerador, provavelmente irá implementar algo como troy new <caminho>, que recebe argumentos posicionais. Neste caso, basta definir um método que recebe os argumentos que precisa.

require 'thor'

module Troy
  VERSION = '0.1.0'

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

    def version
      say "Troy #{Troy::VERSION}"
    end

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

Agora você pode executar o sub-comando new. Caso o argumento não seja passado, uma mensagem será exibida.

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

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

Imagine que você quer permitir que o gerador seja configurado através de switches. O Rails faz isso constantemente; por exemplo, é possível especificar o banco de dados com rails new myapp -d postgresql. Para definir opções de um comando, utilize o método Thor.option.

require 'thor'

module Troy
  VERSION = '0.1.0'

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

    def version
      say "Troy #{Troy::VERSION}"
    end

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

No exemplo anterior é possível especificar a engine de JavaScript com o switch --javascript-engine, ou seu alias -j. Isso mesmo; o Thor converte automaticamente o nome da opção com underscores para uma versão com hífen.

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

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

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

Se você quiser, pode especificar o tipo de cada opção, além de definir se é uma opção obrigatória ou não. Veja alguns exemplos de definição de opções.

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

Por padrão o Thor não valida quais opções foram passadas para um comando. Isso significa que embora o comando thor new espere um argumento posicional, nós podemos erroneamente passar uma opção em seu lugar. Veja o exemplo abaixo:

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

Perceba como o Thor pensou que o switch -h fosse o argumento posicional. Para resolver este problema, basta fazer a validação das opções passadas com o método Thor.check_unknown_options!.

module Troy
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

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

    def version
      say "Troy #{Troy::VERSION}"
    end

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

Se você executar o mesmo comando, verá uma mensagem de erro.

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

Imagine que você quer dar a opção de executar todos os comandos com um nível de verbosidade maior. Você pode fazer isso com o método Thor.class_option.

module Troy
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

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

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

    def version
      say "Troy #{Troy::VERSION}"
    end

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

Se você quiser, agora pode definir a opção --verbose.

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

Eventualmente você precisará validar os valores dos argumentos passados. Você pode inclusive querer interromper a execução, exibindo uma mensagem de erro. Para fazer isso, basta lançar uma exceção com a classe Thor::Error. No exemplo à seguir, vamos validar se o diretório de output já existe ou não.

module Troy
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

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

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

    def version
      say "Troy #{Troy::VERSION}"
    end

    desc 'new PATH', 'Create a new Troy 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 Troy site at #{path}"
      say options
    end
  end
end

Agora, caso o diretório de output exista, a execução será interrompida e uma mensagem será exibida. No entanto, o código de saída do comando será definido como 0, o que no mundo *nix significa sucesso.

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

$ echo $?
0

Para resolver este problema, defina o método Troy::<abbr title="Command-Line Interface">CLI</abbr>.exit_on_failure?, retornando sempre true como resultado.

module Troy
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

    def self.exit_on_failure?
      true
    end

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

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

    def version
      say "Troy #{Troy::VERSION}"
    end

    desc 'new PATH', 'Create a new Troy 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 Troy site at #{path}"
      say options
    end
  end
end

Ao executar o mesmo comando você verá que o código de saída agora é 1.

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

$ echo $?
1

Você pode inclusive fazer a saída do comando ser colorida. Basta usar o método set_color.

module Troy
  VERSION = '0.1.0'

  class Cli < Thor
    check_unknown_options!

    def self.exit_on_failure?
      true
    end

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

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

    def version
      say "Troy #{Troy::VERSION}"
    end

    desc 'new PATH', 'Create a new Troy 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 Troy site at #{path}"
      say options
    end
  end
end

Agora, a mesma mensagem de erro será exibida com a cor vermelha.

Thor - Saída com cores

Finalizando

O Thor é a lib que mais gosto de usar para criar CLIs e geradores de arquivos. Embora existam outras alternativas, poder usar a mesma biblioteca que um projeto tão grande e importante como o Rails usa nos traz um nível de segurança e confiabilidade maior.

Vale lembrar que a melhor maneira de distribuir o seu executável e gerador de código é empacotando-o como uma gem. Para saber mais sobre como fazer isso, leia o artigo que publiquei aqui no blog.