tools.cli: Clojureでコマンドライン引数を扱う

この投稿は、[Clojure Contrib Library Advent Calendar 2013][clj-contrib-ac]の6日目の記事です。

----

# tools.cliとは

[tools.cli][tools.cli]は、コマンドライン引数のパーサライブラリです。

Contribがまだひとつの巨大なライブラリであったときの`clojure.contrib.command-line`に相当するものです。シンプルなコマンドライン引数のパース機能を提供するもので、オプションの設定、ヘルプの自動生成などを行えます。v0.3.0からはClojureScriptでも使用できるようになりました。

# インストール

Leiningenを使っているなら、project.clj内のdependenciesに以下を加えましょう。

```clj
[org.clojure/tools.cli "0.3.5"]
```

# 使い方

tools.cliが提供するのは基本的に`parse-opts`関数のみです。この`parse-opts`関数に、引数とオプションなどの設定を渡すことでパースを行います。

```clj
(ns my.program
  (:require [clojure.tools.cli :refer [parse-opts]])
  (:gen-class))

(def cli-options
  ;; 引数が必要なオプション
  [["-p" "--port PORT" "Port number"
    :default 80
    :parse-fn #(Integer/parseInt %)
    :validate [#(< 0 % 0x10000) "Must be a number between 0 and 65536"]]
   ;; 複数回入力可能なオプション
   ["-v" nil "Verbosity level"
    :id :verbosity
    :default 0
    :assoc-fn (fn [m k _] (update-in m [k] inc))]
   ;; デフォルトがnilのbooleanオプション
   ["-h" "--help"]])

(defn -main [& args]
  (parse-opts args cli-options))
```

上のプログラムに対して、例えば次のようなコマンドを実行したとします。

```bash
my-program -vvvp8080 foo --help --invalid-opt
```

すると`parse-opts`関数は次のマップを返します。

```clj
{:options   {:port 8080
             :verbosity 3
             :help true}

 :arguments ["foo"]

 :summary   "  -p, --port PORT  80  Port number
               -v                   Verbosity level
               -h, --help"

 :errors    ["Unknown option: \"--invalid-opt\""]}
```

このマップを用いて、内部の処理を切り替えたり、ヘルプを表示したりするわけです。

## オプション

オプションは1種類につき、ひとつのベクタで設定します。

### booleanオプション

デフォルトでは、オプションは指定された際に`true`となるbooleanオプションになります。

```clj
["-h" "--help"]
```

2番目の引数はロングオプション名です。これからハイフンを除いたものが、返り値マップのキー名として使用されます。上の例では`:help`となります。ロングオプション名を指定しない場合、`:id`を使って明示的に返り値マップのキー名を指定する必要があります。

```clj
["-h" nil "Show help"
 :id :help]
```

3番目の引数はオプションの説明文です。

### 引数ありオプション

ロングオプション名にパラメータ名を書くことで、引数が必要なオプションとなります。

```clj
["-p" "--port PORT"]
```

### デフォルト値

オプションが指定されなかったときのデフォルト値は`:default`で指定します。

```clj
["-p" "--port PORT" "Port number"
 :default 80]
```

### オプション引数のパース

コマンドライン引数は全て文字列として扱われますが、ポート番号の場合は数値として解釈して欲しいですよね。その場合は`:parse-fn`を使います。

```clj
["-p" "--port PORT" "Port number"
 :default 80
 :parse-fn #(Integer. %)]
```

### 複数回指定時の挙動

同一オプションが複数回指定された場合、デフォルトでは上書きされます。`:assoc-fn`を設定すると、そのオプションが呼ばれた数だけ数値をカウントするようなオプションも作れます。

```clj
["-v" nil "Verbosity level"
 :id :verbosity
 :default 0
 :assoc-fn (fn [m k _] (update-in m [k] inc))]
```

上記は`-v`の回数をカウントし、`:verbosity`キーで返り値マップから回数を取り出せます。

### オプション引数のチェック

オプションの引数が特定の条件を満たす必要があるなど、validationを設定することもできます。例えば、ポート番号は0〜65536の整数でなければならない場合、

```clj
["-p" "--port PORT" "Port number"
 :parse-fn #(Integer/parseInt %)
 :validate [#(< 0 % 0x10000) "Must be a number between 0 and 65536"]]
```

のように書けます。

## オプションサマリー

`parse-opts`関数はオプションのサマリー文字列を返します。

```
  -p, --port PORT  80  Port number
  -v                   Verbosity level
  -h, --help
```

このサマリーは、より大きなヘルプテキストの一部として使われることを想定されています。ちなみに、デフォルトのサマリー文字列のフォーマットが気に入らなければ、`:summary-fn`で好きなように変更できます。

## エラーハンドリング

`parse-opts`関数はエラーをthrowする代わりに、エラーメッセージを含んだベクタを返します。

```clj
{:options   {:help ...}
 :arguments ["foo"]
 :summary   "  -p, --port PORT  80  Port number ..."
 :errors    ["Unknown option: \"--invalid-opt\""]}
```

エラーメッセージの表示などに活用することができます。

# 使用例

それでは、Leiningenで適当なプロジェクトを作成し、tools.cliを試してみましょう。

```clj:core.clj
(ns cli-example.core
  (:require [clojure.string :as string]
            [clojure.tools.cli :refer [parse-opts]])
  (:import java.net.InetAddress)
  (:gen-class))

(def cli-options
  [["-p" "--port PORT" "Port number"
    :default 80
    :parse-fn #(Integer/parseInt %)
    :validate [#(< 0 % 0x10000) "Must be a number between 0 and 65536"]]
   ["-H" "--hostname HOST" "Remote host"
    :default (InetAddress/getByName "localhost")
    :default-desc "localhost"
    :parse-fn #(InetAddress/getByName %)]
   [nil "--detach" "Detach from controlling process"]
   ["-v" nil "Verbosity level; may be specified multiple times to increase value"
    :id :verbosity
    :default 0
    :assoc-fn (fn [m k _] (update-in m [k] inc))]
   ["-h" "--help"]])

(defn usage [options-summary]
  (->> ["This is my program. There are many like it, but this one is mine."
        ""
        "Usage: program-name [options] action"
        ""
        "Options:"
        options-summary
        ""
        "Please refer to the manual page for more information."]
       (string/join \newline)))

(defn error-msg [errors]
  (str "The following errors occurred while parsing your command:\n\n"
       (string/join \newline errors)))

(defn exit [status msg]
  (println msg)
  (System/exit status))

(defn -main [& args]
  (let [{:keys [options arguments errors summary]} (parse-opts args cli-options)]
    (cond
     (:help options) (exit 0 (usage summary))
     (not= (count arguments) 1) (exit 1 (usage summary))
     errors (exit 1 (error-msg errors)))
    (println options)
    (println arguments)))
```

`lein run`で実行してみましょう。

```
$ lein run -- -h
This is my program. There are many like it, but this one is mine.

Usage: program-name [options] action

Options:
  -p, --port PORT      80         Port number
  -H, --hostname HOST  localhost  Remote host
      --detach                    Detach from controlling process
  -v                              Verbosity level; may be specified multiple times to increase value
  -h, --help

Please refer to the manual page for more information.
```

```
$ lein run -vvvp8080 foo
{:verbosity 3, :hostname #<Inet4Address localhost/127.0.0.1>, :port 8080}
[foo]
```

# サブコマンド

コマンドラインツールを作ろうとしたとき、サブコマンドを複数用意して、機能を分けたいときがあります。例えばGitはサブコマンドを用いて`git add`, `git commit`, `git push`というように使えますね。

v0.3.0以降のtools.cliでは`:in-order`を使用することで、サブコマンドを設定することができます。また、他のライブラリを利用する方法もあります。

## tools.cli with :in-order

例えば次のようなコマンドが実行されたとき、

```bash
git --git-dir=/other/proj/.git log --oneline --graph
```

`:in-order`が設定されていなかったり、`false`だった場合には、`parse-opts`は次のようにパースします。

```
options:   [[--git-dir /other/proj/.git]
            [--oneline]
            [--graph]]
arguments: [log]
```

しかし、`:in-order`を`true`に設定した場合には、

```
options:   [[--git-dir /other/proj/.git]]
arguments: [log --oneline --graph]
```

になります。

`:in-order`を`true`にすると、未設定の引数が見つかった時点でコマンドライン引数のパースを止め、残りを全て`arguments`に格納します。ここでは`log`が未設定の引数なので、それ以降の`--oneline --graph`が`arguments`に入っています。

これを利用すれば、`(first arguments)`をサブコマンドとしてディスパッチすることができます。サブコマンドに対する引数`(rest arguments)`を用いて、再度`parse-opts`関数でパースすれば良いわけです。

## clj-sub-command

[clj-sub-command][clj-sub-command]は私が作成したライブラリで、tools.cliと併用することを前提に作られています。
関数の引数やヘルプ文字列を極力tools.cliに似せてあります。また、サブコマンドの前に共通のオプションを指定することもできます。

使用するには、以下のdependencyを追加します。

```clj
[clj-sub-command "0.3.0"]
```

`sub-command`関数を用いて、サブコマンドをパースします。

```clj
(ns foo.core
  (:require [clj-sub-command.core :refer [sub-command candidate-message]]))

(defn -main [& args]
  (let [[opts cmd args help cands]
        (sub-command args
                     "Usage: foo [-h] {cmd1,cmd2} ..."
                     :options  [["-h" "--help" "Show help" :default false :flag true]]
                     :commands [["cmd1" "Description for cmd1"]
                                ["cmd2" "Description for cmd2"]])]
    (when (:help opts)
      (println help)
      (System/exit 0))
    (case cmd
      :cmd1 (f1 args)
      :cmd2 (f2 args)
      (do (println (str "Invalid command. See 'foo --help'.\n\n"
                        (candidate-message cands)))
          (System/exit 1)))))
```

ヘルプはこんな感じです。

```
Usage: foo [-h] {cmd1,cmd2} ...

 Options                Default  Desc
 -------                -------  ----
 -h, --no-help, --help  false    Show help

 Command   Desc
 -------   ----
 cmd1      Description for cmd1
 cmd2      Description for cmd2
```

間違ったコマンドを打ち込むと、候補を出力してくれます。

```console
$ foo cmd3
Invalid command. See 'foo --help'.

Did you mean one of these?
        cmd1
        cmd2
```

各コマンドに渡される引数のパースには、別途tools.cliを用いる想定です。例えば上の例では、`f1`, `f2`関数内で`parse-opts`関数を使います。

----

tools.cliの回はこれにて終了です。
お役に立てば幸いです。

明日は@athos0220さんの『[tools.traceで実行トレースをとる][tools.trace]』です。

[clj-contrib-ac]: http://connpass.com/event/3991/
[tools.cli]: https://github.com/clojure/tools.cli
[cliopatra]: https://github.com/runa-dev/cliopatra
[clj-sub-command]: https://github.com/totakke/clj-sub-command
[tools.trace]: http://athos.hatenablog.com/entry/tracing_execution_with_tools_trace
[xcoo]: http://www.xcoo.jp/
[chrovis]: https://chrov.is