💲

fncmd: 関数としてのCLIを実現するもう一つのRustクレート

2021/11/28に公開

先行技術

以前、このようなものがTwitterで流れてきて感銘を受けたのを覚えています。

一つのCLIを一つの関数で表現する。この上なく美しく合理的な抽象化です[1]。一度これを知ってしまうと、もはや構造体を定義したりビルダーを使って手続き的に定義したりするのは本質を外しているのではないかとさえ思ってしまうほどです。

しかしながら、個人的にはサブコマンドの取り扱いに少々不満がありました。argoptでは、サブコマンドとなるべき関数には通常の#[cmd]の代わりに#[subcmd]を付与し、それを含む大元のコマンドには#[cmd]の代わりに#[cmd_group(commands = [sub1, sub2])]を付与し、しかもこのようにサブコマンド関数名を手動で列挙しなければなりません。こういった区別や手間は本質的なものではないはずで、取り除きたいという欲求がありました。

もう一つのソリューション

そこで、上記の点を解決したfncmdをリリースしました。nightlyでのみ使うことができます(理由は後述)。

簡単な使い方は以下のとおりです。適当にcargo new crate-name --binしたプロジェクトで、main.rsを以下のように変更します。main関数は基本的にpubにします(詳細は後述)。

// main.rs
use fncmd::fncmd;

/// Description of the command line tool
#[fncmd]
pub fn main(
  /// Argument foo
  #[opt(short, long)]
  foo: String,
  /// Argument bar
  #[opt(short, long)]
  bar: Option<String>,
) {
  println!("{:?} {:?}", foo, bar);
}

このコマンドから得られるヘルプメッセージは以下のようになります。

crate-name 0.1.0

Description of the command line tool

USAGE:
    crate-name [OPTIONS] --foo <FOO>

OPTIONS:
    -b, --bar <BAR>    Argument bar
    -f, --foo <FOO>    Argument foo
    -h, --help         Print help information
    -V, --version      Print version information

このように、サブコマンドまわり以外の部分、たとえば引数に付ける#[opt(...)]属性の使い方などはargoptのそれ(および内部で利用しているclap_derive#[clap(...)]属性)とほぼ同じであり、ここでは説明しません。以下ではargoptとの主な違いを書いていきます。

サブコマンド

fncmdでは、ユーザーが使うべきマクロはfncmd::fncmdただ一つです。独立のコマンドにも、サブコマンドにも、一律で#[fncmd]です。しかもこれはmain関数にしか付与することができません。それでは、どのようにしてサブコマンドを作るのでしょうか。

Cargoプロジェクトでは、通常、メインのバイナリターゲットはsrc/main.rsに配置し、追加のバイナリターゲットはsrc/bin以下に配置することができます。この追加のターゲットのmain関数に#[fncmd]を付与し、pubで公開[2]しておけば、あとはfncmdがターゲット名の接頭辞に基づいてサブコマンドを集計し、自動的に設定してくれます。

たとえばsub1sub2というサブコマンドを作りたければ、自然に以下のようなディレクトリ構造が作られることになります。

src
├── main.rs
└── bin
    ├── crate-name-sub1.rs
    └── crate-name-sub2.rs

自然にこのような構成になることは、コード分割の観点から有益です。仮にmain以外の任意の関数にも#[fncmd]を付与することを許してしまうと、すべてのサブコマンドが集められて腫れ上がった単一のmain.rsファイルが爆誕してしまいかねません。

複数コマンドやネストしたサブコマンド

前項で挙げたのはベーシックなユースケースであって、実のところ、「メインの」ターゲットか「追加の」ターゲットかという区別はfncmd側にはありません。Cargo.tomlには、ユーザーが手動で各ターゲットの設定を書くことができますが、そのようにしてもfncmdはまったく問題なく動作します。ディレクトリ構造ではなく接頭辞の階層構造に基づいてサブコマンド構造が作られるからです。

たとえば以下のような設定があったとします。

[[bin]]
name = "crate-name"
path = "src/clis/crate-name.rs"

[[bin]]
name = "another"
path = "src/clis/another.rs"

[[bin]]
name = "another-sub" # `pub`
path = "src/clis/another-sub.rs"

[[bin]]
name = "another-sub-subsub" # `pub`
path = "src/clis/another-sub-subsub.rs"

[[bin]]
name = "another-orphan" # non-`pub`
path = "src/clis/another-orphan.rs"

[[bin]]
name = "another-orphan-sub" # `pub`
path = "src/clis/another-orphan-sub.rs"

この設定からは以下のようなコマンドが得られます。

crate-name

another
└── another sub
    └── another sub subsub

another-orphan
└── another-orphan sub

another-orphanに注目するとわかりますが、pubで公開されていないターゲットは、接頭辞にかかわらず他のコマンドのサブコマンドとして含まれることがなくなります。pubでないことが意味を持つのは、このように「接頭辞を共有したいがサブコマンドとして含まれてほしくない」場合だけなので、基本的には深く考えずにpub fn mainとしておいても問題はないでしょう。

#[fncmd]マクロは、それが付与されたmain関数がどのターゲット名に対応するのか知るために、呼び出し箇所のファイルパスを取得する必要があります。これにはproc_macro_spanという不安定機能の背後に置かれているSpan::source_fileを呼ぶ必要があり、これがnightlyを要する理由です。

2022/2/22追記: 追加でstd::process::Terminationも使うようになりました。これは親コマンドおよびサブコマンド間で異なる返り値の型を混在させられるようにするためです。もちろん、Terminationを実装したユーザー定義の型も使えます。

async fn mainのサポート

2022/2/22追記: fncmdは、よく使われる#[tokio::main]#[async_std::main]など外部の属性マクロと併用可能になりましたfncmd自身は非同期ランタイム非依存であり、親コマンドおよびサブコマンド間で異なるランタイムのマクロを混用することもできます。というより、元々のmain関数を内部に埋め込んだ新しい関数を生成する実装になっているため、非同期系に限らず何でも対応可能のはずです。

余談: structoptからclap_derive

あとは余談になりますが、clapv3からstructoptのderive macroをそのまま取り込む動きがあります。まだベータ段階なのであまり好ましくないことは承知のうえで正式リリースされました、fncmdstructoptに依存せずclapに直接依存するようにしてあります。

argoptで発生しているような、間接依存するクレートを手動でインストールしなければならないという状況も起こらないようにしてありますこれは解決したようです

ぜひ使ってみてください。

脚注
  1. 後から知ったのですが、Pythonにもclickという似たようなコンセプトのものがあるみたいです。探せば他の言語にもありそう。 ↩︎

  2. 内部的には、main関数がpubになるわけではなく、暗黙的に定義される構造体などがpubになります。あくまで「このコマンドは他のコマンドのサブコマンドとして含まれうる」というロジックを表現するために、pubという概念を利用している、といった形となっています。 ↩︎

Discussion