🆑

CLIサポートライブラリ Thor の知見メモ

2022/07/18に公開

https://github.com/rails/thor

はじめに

Rails とは無関係に Thor を使った CLI ツールを長い間触ってきて得た知識、特に注意点について書き残しておく。

--no-delete オプションを指定したら削除される

class App < Thor
  desc "func1", "(desc)"
  method_option :delete, type: :boolean
  def func1
    # --no-delete を指定したので false で来たところまでは合っている
    options[:delete]  # => false

    # デフォルトは true だがマージするので false になるだろう
    params = {
      delete: true,
    }.merge(options)

    # ところが
    params[:delete]   # => true

    # その結果、事故る
    if params[:delete]
      "削除しました"        # => "削除しました"
    end
  end
end

App.start(["func1", "--no-delete"])

options は悪しき HashWithIndifferentAccess のインスタンスになっていて、文字列とシンボルを区別しないでよくなること(これがメリットだとは思わない)よりもマージした際にキーが一致せずに事故るデメリットの方がはるかに大きい。

この問題を回避するためには options.transform_keys(&:to_sym) としてから merge せねばならない。もし ActiveSupport が入っている場合は options.to_options でもよい。ただし options = options.to_options と書くと nil.to_options を呼ぶことになり、さらにはまる。この場合は options = self.options.to_options と書かないといけない。

このように HashWithIndifferentAccess が絡むと急激に負債が増える。

自分で定義した say メソッドの挙動がおかしい

# 全体で使えるように say メソッドを定義したのだが……
module Kernel
  def say(message)
    puts message
    system "say '#{message}'"
  end
end

class App < Thor
  desc "func1", "(desc)"
  def func1
    say "OK"
  end
end

App.start(["func1"])

Mac では say コマンドが用意されているのでコマンドラインで say OK とするだけで OK としゃべってくれる。これを利用しているのだが、OK と表示されるだけでしゃべらない。

原因は say を上書きされていたというオチだった。Thor はありがた迷惑な次のメソッドたちをぶちまける。

mute mute? padding= indent ask say say_error say_status yes? no? print_in_columns print_table print_wrapped file_collision terminal_width error set_color

これらと名前がかぶると負ける。これに気づかず、解決までに相当な時間を要した。

引数がエラーでもステイタス0を返してしまう問題

class App < Thor
  def self.exit_on_failure?
    true
  end
end

と、書けばいいらしいが、なんでこれがデフォルトじゃないんだ! と、みんな青筋を立てている。

https://github.com/rails/thor/issues/244

しかし最初の Issue が立ってから10年放置っていう。

オプションを繰り返し指定する

class App < Thor
  desc "func1", "(desc)"
  method_option :foo, type: :array, repeatable: true
  method_option :bar, type: :hash,  repeatable: true
  def func1
    # 配列の場合はペアになっている (平らにするには flatten が必要)
    options[:foo]  # => [["1", "2"], ["3", "4"]]

    # ハッシュの場合は最初からフラットになっている
    options[:bar]  # => {"a"=>"1", "b"=>"2", "c"=>"3", "d"=>"4"}
  end
end

App.start([
    "func1",
    "--foo", "1", "2",
    "--foo", "3", "4",
    "--bar", "a:1", "b:2",
    "--bar", "c:3", "d:4",
  ])

同じオプションを指定した場合はエラーにはならず、後から指定した方が有効になる。もし、引数値を溜めたい場合は、repeatable: true とする。

ショートカットを無闇に設定するな

class App < Thor
  map "s" => :schedule
  desc "schedule", "スケジュール"
  def schedule
  end
end

上のようにすると sschedule が呼べるようになる。ただし、設定しなかった場合にのみ s で始まるコマンドを列挙してくれる便利機能が働くので、無闇に設定しない方がよい。

コマンド実行前にクラスオプションを受け取りたい

class App < Thor
  class_option :foo, type: :boolean

  def initialize(...)
    super

    # すべてのタスクで共有する前処理
    options[:foo]  # => true
  end

  desc "func1", "(desc)"
  def func1
  end
end

App.start(["func1", "--foo"])

上の例であれば func1 に入った直後で options を参照すればよい。しかし、コマンドが100個がある場合、100箇所に同じコードを差し込まなければならない。

このあたり何か用意してくれててもおかしくないがいくら調べてもわからなかった。とりあえず initialize の中で参照する方法にして問題はなかった。

options 固有のメソッドを活用するな

コマンド内で使える悪しき HashWithIndifferentAccess 型の options が、

options[:foo]  # => "bar"

となっているとき次のように書ける。

options.foo?         # => true
options.foo          # => "bar"
options.foo?("bar")  # => true

しかし、この使ってしまうと Hash との整合性が取れなくなり、逆にリファクタリングがたいへん難しくなるので、一切活用してはいけない。

long_desc を使うと改行が削除される問題

コマンドの説明文を long_desc メソッドで登録すると、--help xxx としたときにその説明文を最後に出力してくれる機能がある。

ただこれが余計なことをやりすぎで、改行コードを削除して詰めて画面幅で折り返すように整形される。また英語前提なので日本語なんか入れると折り返し位置が変になる。

この問題には次のように「何もしない」ようにすると使いやすくなる。

class Thor::Shell::Basic
  def print_wrapped(message, options = {})
    puts message
  end
end

Thor のサブクラスに一切のロジックを書くべからず

次のように書いていたら破綻した。

class App < Thor
  desc "foo_func", "(desc)"
  def foo_func
    foo_func_perform
  end

  desc "bar_func", "(desc)"
  def bar_func
    bar_func_perform
  end

  private

  def foo_func_perform
    # foo 専用の補助メソッド
  end

  def bar_func_perform
    # bar 専用の補助メソッド
  end
end

最初はシンプルでわかりやすかったが、そのまま膨らませていくと、プライベートメソッドが干渉し合ってメンテ不能に陥った。二つの機能 foo と bar は互いに何も関係がないにもかかわらず、同じスコープで共存しているのが失敗だった。

これは Rake タスクなどにも言えることで、エントリーポイントには完成されたメソッドを一行書くぐらいでないといけない。

可能だからといって同じスコープにプライベートメソッドをぶちまけてはいけない。そこからじわじわと崩壊していく。

便利メソッドが使えるのはサブクラスだけというジレンマ

Thor継承クラスが肥大化しがちな理由の1つとしてそこでしか便利メソッドが使えないからというのがある。

便利メソッドを活用していたコードを別のクラスに移動させるとたちまち undefine method の嵐になってしまうのでコードを分離できないというわけである。

これは Thor が用意した便利メソッドに依存させないのがいちばん良い。ただもう依存してしまって引き剥がせない場合は、次のようにすれば一応どこからで呼ぶことができる。

Thor::Base.shell.new.say("message")

Discussion