fncmd: 関数としてのCLIを実現するもう一つのRustクレート
先行技術
以前、このようなものが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
がターゲット名の接頭辞に基づいてサブコマンドを集計し、自動的に設定してくれます。
たとえばsub1
とsub2
というサブコマンドを作りたければ、自然に以下のようなディレクトリ構造が作られることになります。
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
へ
余談: あとは余談になりますが、clap
はv3からstructopt
のderive macroをそのまま取り込む動きがあります。まだベータ段階なのであまり好ましくないことは承知のうえで正式リリースされました、fncmd
はstructopt
に依存せずclap
に直接依存するようにしてあります。
これは解決したようです。argopt
で発生しているような、間接依存するクレートを手動でインストールしなければならないという状況も起こらないようにしてあります
ぜひ使ってみてください。
Discussion