Rust製CLI作成支援パッケージSeahorseの紹介

めでたくメジャーバージョンがリリースされたので、 僕が最近ちょっとだけコミットしたRustのパッケージの紹介をします。

seahorse_logo

Seahorse

github.com

Seahorseは、RustでCLIツールを制作するときに助けになるパッケージです。
CLIツールを作る人がSeahorseを使うことで、以下のような処理を書くのが容易になります。

  1. ツールのメタ情報の設定
  2. サブコマンドを用いた処理の分岐
  3. コマンドライン引数の設定と、与えられた引数の適切な型へのパース

下にそれぞれの使い方を簡単に説明します。

① ツールのメタ情報の設定

とは

CLIツールとしてのメタ情報(名前や使い方や説明など、ヘルプテキストに表示されるもの;ヘルプテキストとは典型的には $ <コマンド> --help で出力されるもの )を設定することができます。

たとえば、$ cargoを実行すると

$ cargo
Rust's package manager

USAGE:
    cargo [OPTIONS] [SUBCOMMAND]

OPTIONS:
    -V, --version           Print version info and exit
        --list              List installed commands
        --explain <CODE>    Run `rustc --explain CODE`
    -v, --verbose           Use verbose output (-vv very verbose/build.rs output)
    -q, --quiet             No output printed to stdout
        --color <WHEN>      Coloring: auto, always, never
        --frozen            Require Cargo.lock and cache are up to date
        --locked            Require Cargo.lock is up to date
        --offline           Run without accessing the network
    -Z <FLAG>...            Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details
    -h, --help              Prints help information

こんな印字がされます。
自分でCLIツールを作る際に、これはどのように実装するでしょうか。

println!("Rust's package manager\n");
println!("USAGE:");
println!("    cargo [OPTIONS] [SUBCOMMAND]\n");
.
.
.

死にたくなりますね。

実装例

Seahorseを使うとこんなふうに書けます。

fn main() {
    let args: Vec<String> = env::args().collect();
    let app = App::new("cargo")
        .description("Rust's package manager")
        .usage("cargo [OPTIONS] [SUBCOMMAND]")
        .flag(
            Flag::new("version", FlagType::Bool)
                .alias("v")
                .usage("-v, --version: Print version info and exit"),
        )
        .flag(
            Flag::new("list", FlagType::Bool)
                .usage("--list: List installed commands"),
        );
    app.run(args);
}

これを実行してヘルプテキストを表示させると、

Name
    cargo

Description:
    Rust's package manager

Usage:
    cargo [OPTIONS] [SUBCOMMAND]
    -v, --version: Print version info and exit
    --list: List installed commands

こんなのが出力されます。
ヘルプテキストを印字するにしては過度な抽象化だと思うかもしれませんが、 ③に書くようにSeahorseでフラグを設定すると引数をパースするのが簡単になるという明確なメリットがあります。 ヘルプテキストが生成できる、というのはどちらかというと副次的な機能です。

② サブコマンドを用いた処理の分岐

とは

メインコマンドの後に続くコマンドがサブコマンドです。

gitを例にとって説明しますと、
$ git をメインコマンドとすると、$ git status$ git log 等を実行するときの statuslog がサブコマンドにあたります。

f:id:rnitta:20200609165605p:plain

実装例

さてSeahorseで、$ git status$ git logを受け付けて適当な印字をするアプリケーションを作ってみましょう。

fn status_action(c: &Context) {
    println!("status...");
}

fn log_action(c: &Context) {
    println!("log...");
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let app = App::new("gitではないもの")
        .command(Command::new("log").action(log_action))
        .command(Command::new("status").alias("st").action(status_action));
    app.run(args);
}

サブコマンドとはこういう使い方をするもの、という説明をするためだけに書いたので無能なgitが出来上がりました。
これでビルドすれば $ <メインコマンド> log$ <メインコマンド> status$ <メインコマンド> st も受け付けるCLIツールができます。

コマンドライン引数の設定と、与えられた引数の適切な型へのパース

とは

$ git log --author rnitta -n 10 というコマンドを打ったときに、
これらのスペースで区切られた文字列群はRustの世界からは std::env::args() で取得することができます。非常に便利な関数ですね。
std::env::args().collect::<Vec<String>>()とすれば、["git", "log", "--author", "rnitta", "-n", "10"] のようなVecが得られます。

これをパースして取り出しやすくしてくれる仕組みがあります。

実装例

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let app = App::new("git").command(
        Command::new("log")
            .usage("git log <options>")
            .action(log_action)
            .flag(Flag::new("author", FlagType::String).usage("git log --author <author_name>"))
            .flag(
                Flag::new("number", FlagType::Int)
                    .usage("git log --number(-n) <n>")
                    .alias("n"),
            ),
    );

    app.run(args);
}

fn log_action(c: &Context) {
    if let Ok(author) = c.string_flag("author") {
        println!("Author is {}", author);
    }
    if let Ok(number) = c.int_flag("number") { // => ちゃんとisizeにパースしてくれます!
        println!("Number is {}", number);
    }
}
$ cargo run -- log --author rnitta -n 2
Author is rnitta
Number is 2

コマンドライン引数を適当に抽象化してくれて便利ですね。

Seahorseの特徴

Seahorseは機能がミニマルなところが特徴的だと思います。

ミニマル はともすれば「機能が少ないだけでしょ」「実装サボってるだけでしょ」と思うかもしれませんが、 ミニマルはミニマルでいいところもあります。 それは、できることが多くない代わりに学習コストが低いことです。

同じCLI作成支援パッケージであるclapの使い方を調べてみましょう。

GitHub - clap-rs/clap: A full featured, fast Command Line Argument Parser for Rust

f:id:rnitta:20200604131827p:plain

マクロで書くこともできるし、メソッドスタイルでも書くことができるし、yamlでも設定が書ける...多機能ですが
ちょっとプロジェクトが大きくてエラーに当たったときに中身を読みに行くのがいやになってしまいますね。

一方でseahorseはREADMEを1分眺めれば大半の機能は把握できます。

  • メソッドチェーン的なスタイルでメタ情報が設定できて、
  • サブコマンドを設定することで複数の機能を1ツールにできて、
  • コマンドライン引数の値が特定の型にパースしやすくなる。

v1.0.0時点で提供されている機能はこんなところなはずです。 コード量もかなり控えめです。

あなたが仮にgitとかffmpegぐらいサブコマンドやオプションの多く複雑なツールを作ろうとしているならSeahorseでは力不足かもしれませんが、 多くのCLIツールではSeahorseで十分なはずです。

Any Contributions are Welcome

ちょっとしたCLIツールをRustで作る時に使ってみたり 使用方法をより詳細に説明するポストを書いてみたり バグをissueに投げてみたり 新機能を提案して実装してみたり リポジトリにStarしてみたり おまちしております

github.com

Seahorseを使ってつくってみたもの

お前らのコミットは汚い - Qiita

github.com