圧倒亭グランパのブログ

30年後の自分にもわかるように書くブログ

Crystalの自作CLIビルダーライブラリを使ってgitコマンド風のCLIツールを作ってみる

この記事は Crystal Advent Calendar 2018 の3日目の記事です。

 

自作のCrystalのCLIビルダーライブラリをアップデートしたので、AdventCalendarに便乗して紹介します。

githubは以下です。 clim と言います。簡潔に、直感的にCLIツールを書けることを目指しています。

github.com

過去に紹介したブログ記事はこちらです。

at-grandpa.hatenablog.jp

この記事が古くなったのでupdate記事です。

 

目次

gitコマンドっぽいものを作ってみる

実際にgitっぽいCLIツールを作ってみます。

前準備

crystalのバージョンは 0.27.0 です。

$ crystal -v
Crystal 0.27.0 (2018-11-04)

LLVM: 6.0.1
Default target: x86_64-apple-macosx

crystalのinstallや仕様は、公式のドキュメントを御覧ください。

Introduction · GitBook

まずは crystal init コマンドで雛形を生成します。アプリ名は my_git とでもしましょう。

$ crystal init app my_git
    create  my_git/.gitignore
    create  my_git/.editorconfig
    create  my_git/LICENSE
    create  my_git/README.md
    create  my_git/.travis.yml
    create  my_git/shard.yml
    create  my_git/src/my_git.cr
    create  my_git/spec/spec_helper.cr
    create  my_git/spec/my_git_spec.cr
Initialized empty Git repository in /Users/y-tsuchida/test/blog/src/my_git/.git/
$ cd my_git
$ tree -a -I .git
.
├── .editorconfig
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── shard.yml
├── spec
│   ├── my_git_spec.cr
│   └── spec_helper.cr
└── src
    └── my_git.cr

2 directories, 9 files

次に、 clim を使用するため、 shard.yml に以下の記述をします。diffを示します。

diff --git a/shard.yml b/shard.yml
index 3b531ee..7eb70b2 100644
--- a/shard.yml
+++ b/shard.yml
@@ -11,3 +11,8 @@ targets:
 crystal: 0.27.0

 license: MIT
+
+dependencies:
+  clim:
+    github: at-grandpa/clim
+    version: 0.4.1

記述したら shards install コマンドでインストールします。

$ shards install
Fetching https://github.com/at-grandpa/clim.git
Installing clim (0.4.1)
$

インストールできました。これで clim を使用できます。

ではまず、 Hello world! を表示するCLIツールを作成してみましょう。編集するファイルは src/my_git.cr です。

src/my_git.cr

require "clim" # ライブラリを require

module MyGit
  # 継承して使う
  class Cli < Clim
    VERSION = "0.1.0"

    # CLIツールのメインのコマンドを定義
    main do
      # run ブロックが実際に実行される
      run do |opts, args|
        puts "Hello world!! #{args.join(", ")}!"
      end
    end
  end
end

MyGit::Cli.start(ARGV)
$ crystal build src/my_git.cr -o mygit
$ ./mygit Taro Miko
Hello world!! Taro, Miko!

コマンドの引数が args に入っていることがわかります。 args の型は Array(String) です。

さらにもっと拡張していきましょう。

versionを表示する

以下を目指します。

$ ./mygit --version
mygit version 0.1.0
$ ./mygit -v
mygit version 0.1.0

clim では version ディレクティブを使用するだけで実装できます。

require "clim"

module MyGit
  class Cli < Clim
    VERSION = "0.1.0"

    main do
      # runブロックの上にコマンドの定義を書いていく
      # `--version` で出力する文字列を引数にとる
      # short: "-v" を書くと `-v` でも出力される
      version "mygit version #{VERSION}", short: "-v"
      run do |opts, args|
        puts "Hello world!! #{args.join(", ")}!"
      end
    end
  end
end

MyGit::Cli.start(ARGV)
$ crystal build src/my_git.cr -o mygit
$ ./mygit --version
mygit version 0.1.0
$ ./mygit -v
mygit version 0.1.0

--version オプションを簡単に実装できました。

helpを充実させる

clim は標準で --help オプションを搭載しています。いい感じにhelpを表示してくれます。そのための情報を定義しましょう。

require "clim"

module MyGit
  class Cli < Clim
    VERSION = "0.1.0"

    main do
      # `desc` や `usage` でhelpの内容を記述
      desc "my git command."
      usage "mygit [command] [arguments] [options]"
      version "mygit version #{VERSION}", short: "-v"
      run do |opts, args|
        puts "Hello world!! #{args.join(", ")}!"
      end
    end
  end
end

MyGit::Cli.start(ARGV)

desc はコマンドの説明、 usage は使い方を記述します。こうすると --help で次のように出力されます。

$ ./mygit --help

  my git command.

  Usage:

    mygit [command] [arguments] [options]

  Options:

    --help                           Show this help.
    -v, --version                    Show version.

desc の内容や usage の内容に加え、オプションの説明も表示してくれます。便利ですね。

オプションを追加する

オプションを定義するには option ディレクティブを使用します。

require "clim"

module MyGit
  class Cli < Clim
    VERSION = "0.1.0"

    main do
      desc "my git command."
      usage "mygit [command] [arguments] [options]"
      version "mygit version #{VERSION}", short: "-v"
      # オプションを定義
      option "-n NAME", "--namespace=NAME", # オプション名
        type: String,                       # 型
        desc: "namespace.",                 # 説明
        default: "default_namespace",       # デフォルト値
        required: false                     # 必須かどうか
      # 複数登録もできる
      option "-e PATH", "--exec-path=PATH",
        type: String,
        desc: "exec path.",
        required: false
      run do |opts, args|
        # オプション名でメソッド呼び出しできる
        unless opts.namespace.nil?
          puts "namespace is #{opts.namespace}."
        end
        unless opts.exec_path.nil?
          puts "exec path is #{opts.exec_path}."
        end
        puts "Hello world!! #{args.join(", ")}!"
      end
    end
  end
end

MyGit::Cli.start(ARGV)
$ crystal build src/my_git.cr -o mygit
$ ./mygit -n my_namespace Taro -e my_exec_path
namespace is my_namespace.
exec path is my_exec_path.
Hello world!! Taro!

オプションの実装も簡単にできました。 run ブロックに渡される opts 引数を介して値を取得します。 opts にはオプション名のメソッドが生えるので、それを呼び出します。 もちろん型も決まっています。

--help にもちゃんとオプションの説明が記載されます。

$ ./mygit --help

  my git command.

  Usage:

    mygit [command] [arguments] [options]

  Options:

    -n NAME, --namespace=NAME        namespace. [type:String] [default:"default_namespace"]
    -e PATH, --exec-path=PATH        exec path. [type:String]
    --help                           Show this help.
    -v, --version                    Show version.

サブコマンドを定義する

gitはサブコマンドがたくさんあります。 mygit にもサブコマンドを実装しましょう。定義は簡単です。 sub "{サブコマンド名}" ブロックを親コマンドの run ブロックの直後に置き、メインのコマンドと同じように定義します。例えば mygit log というサブコマンドを実装しましょう。

require "clim"

module MyGit
  class Cli < Clim
    main do
      # ...
      run do |opts, args|
        # ...
      end
      sub "log" do
        desc "my git log command."
        usage "mygit log [arguments] [options]"
        option "-q", "--quiet",
          type: Bool,
          desc: "suppress diff output."
        run do |opts, args|
          puts "[quiet mode]" if opts.quiet
          puts "Hello world!! #{args.join(", ")}!"
        end
      end
    end
  end
end

MyGit::Cli.start(ARGV)
$ crystal build src/my_git.cr -o mygit
$ ./mygit log --help

  my git log command.

  Usage:

    mygit log [arguments] [options]

  Options:

    -q, --quiet                      suppress diff output. [type:Bool]
    --help                           Show this help.

$ ./mygit log -q Taro
[quiet mode]
Hello world!! Taro!

sub "{サブコマンド名}" のブロック内は、今までのコマンド定義と全く同じです。サブコマンドの run ブロックの下に、さらに sub "{サブコマンド名}" ブロックを記述すれば「サブサブコマンド」も実装できます。入れ子に制限はありません。「サブサブサブ...コマンド」も簡単に実装できます。

全コード

最終的に、 mygit logmygit branch を実装しました。また、見やすいように以下のことを行いました。

  • run ブロックで実行する処理は MyGit::Command クラスに委譲
  • mainのコマンドを実行するとhelpを表示

ディレクトリ構成は以下です。

.
├── .editorconfig
├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── lib
├── shard.lock
├── shard.yml
├── spec
│   ├── my_git_spec.cr
│   └── spec_helper.cr
└── src
    ├── my_git
    │   └── command.cr
    └── my_git.cr

src/my_git/command.cr を追加しました。各ファイルは次のようになっています。

src/my_git.cr

require "clim"
require "./my_git/*"

module MyGit
  class Cli < Clim
    VERSION = "0.1.0"

    main do
      desc "my git command."
      usage "mygit [command] [arguments] [options]"
      version "mygit version #{VERSION}", short: "-v"
      run do |opts, args|
        MyGit::Command.main(opts, args)
      end
      sub "log" do
        desc "my git log command."
        usage "mygit log [arguments] [options]"
        option "-q", "--quiet",
          type: Bool,
          desc: "suppress diff output.",
          required: false
        run do |opts, args|
          MyGit::Command.log(opts, args)
        end
      end
      sub "branch" do
        desc "my git branch command."
        usage "mygit branch [arguments] [options]"
        option "-m NAME", "--move=NAME",
          type: String,
          desc: "Move/rename a branch.",
          required: false
        run do |opts, args|
          MyGit::Command.branch(opts, args)
        end
      end
    end
  end
end

MyGit::Cli.start(ARGV)

src/my_git/command.cr

module MyGit
  class Command
    def self.main(opts, args)
      # opts.helpでhelpのStringが得られます
      puts opts.help
    end

    def self.log(opts, args)
      puts "[quiet mode]" if opts.quiet
      puts "Hello world!! #{args.join(", ")}!"
    end

    def self.branch(opts, args)
      unless opts.move.nil?
        puts "Move/rename: #{opts.move}"
      end
      puts "Hello world!! #{args.join(", ")}!"
    end
  end
end

実行結果は以下です。各コマンドを叩いてみました。

$ crystal build src/my_git.cr -o mygit
$ ./mygit

  my git command.

  Usage:

    mygit [command] [arguments] [options]

  Options:

    --help                           Show this help.
    -v, --version                    Show version.

  Sub Commands:

    log      my git log command.
    branch   my git branch command.

$ ./mygit -v
mygit version 0.1.0
$ ./mygit log --help

  my git log command.

  Usage:

    mygit log [arguments] [options]

  Options:

    -q, --quiet                      suppress diff output. [type:Bool]
    --help                           Show this help.

$ ./mygit log --quiet Taro
[quiet mode]
Hello world!! Taro!
$ ./mygit branch --help

  my git branch command.

  Usage:

    mygit branch [arguments] [options]

  Options:

    -m NAME, --move=NAME             Move/rename a branch. [type:String]
    --help                           Show this help.

$ ./mygit branch -m new_name Taro
Move/rename: new_name
Hello world!! Taro!

いい感じですね。

 

その他の機能

その他、 clim は以下のような機能があったりします。

  • help_template
    • 自由にhelpの形式をカスタマイズできる
  • alias
    • サブコマンドの定義の部分で alias "hoge", "fuga" と書けば、 hoge fuga でコマンドを実行できる

更に詳しくは、 README をみてください。

たまにupdateします

なにか欲しい機能を思いついたらupdateしていきます。ご意見大歓迎です。Crystalはサクッとかけて、コンパイルできて、バイナリ吐けて、便利ですねー(宣伝)!

というか、みなさんCrystal書いてみてはどうですか!!!!!

触った感想など、AdventCalendarに書いてみてはどうでしょう!!!!!

ぜひに!!!!!(必死

qiita.com