Skip to content

필사 모드: RustでCLIを作る:clapとripgrep・fd・batの秘密

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 静かなCLIルネサンス

この数年で、あなたが毎日使っているコマンドラインツールのかなりの部分が、静かにRustで書き直されてきました。grepripgrep(rg)に、findfdに、catbatに、lsezaに、cdzoxide(z)に。さらに、ファイルの変更を監視してテストを再実行するbacon、ディスク使用量を見るdust、プロセスビューアのbottom(btm)まで、リストは増え続けています。

これは偶然ではありません。コマンドラインツールは、Rustの強みがほぼ完璧に噛み合う領域です。起動が速くなければならず(GCのウォームアップがない)、予測可能に速くなければならず(GCの一時停止がない)、配布が簡単でなければならず(ランタイム依存のない単一バイナリ)、安全でなければなりません(無数のファイルとエンコーディングを扱ってもクラッシュしない)。この記事はそのルネサンスの背景をたどり、自分でCLIを作るときに実際に何が必要かをコードで示します。

Rustの文法に馴染みがなければ、読みながらこのサイトのRust学習ラボで所有権やトレイトといった基礎を並行して身につけてみてください。

最初のCLI — clapのderive API

Rustエコシステムにおける引数パースの事実上の標準はclapです。かつてはビルダースタイルで引数を一つずつ登録していましたが、今推奨されるのはderive APIです。構造体を一つ定義して#[derive(Parser)]を付けると、フィールドがそのままコマンドライン引数になります。

まずCargo.tomlに依存を追加します。

[dependencies]
clap = { version = "4", features = ["derive"] }

では、ファイル内のパターンを探すごく小さなgrepクローンを作ってみましょう。

use clap::Parser;
use std::fs;

/// ファイルからパターンを探す小さなツール
#[derive(Parser)]
#[command(version, about)]
struct Cli {
    /// 探すパターン
    pattern: String,

    /// 検索するファイルのパス
    path: std::path::PathBuf,

    /// 大文字小文字を無視するか
    #[arg(short, long)]
    ignore_case: bool,
}

fn main() {
    let cli = Cli::parse();

    let content = fs::read_to_string(&cli.path).expect("ファイルを読めませんでした");

    for (num, line) in content.lines().enumerate() {
        let found = if cli.ignore_case {
            line.to_lowercase().contains(&cli.pattern.to_lowercase())
        } else {
            line.contains(&cli.pattern)
        };
        if found {
            println!("{}: {}", num + 1, line);
        }
    }
}

注目すべき点がいくつもあります。フィールドpatternpathは位置引数(positional argument)になります。#[arg(short, long)]が付いたignore_case-iまたは--ignore-caseフラグになります。フィールド上の///ドキュメントコメントはそのままヘルプテキストになります。そして#[command(version, about)]のおかげで--version--helpが無料で手に入ります。

ビルドして--helpを実行すると、一行も書いていないきれいな使い方の画面が出ます。

$ minigrep --help
ファイルからパターンを探す小さなツール

Usage: minigrep [OPTIONS] <PATTERN> <PATH>

Arguments:
  <PATTERN>  探すパターン
  <PATH>     検索するファイルのパス

Options:
  -i, --ignore-case  大文字小文字を無視するか
  -h, --help         Print help
  -V, --version      Print version

肝心なのは、型が契約になっているという点です。pathPathBufとして宣言したのでclapが文字列をパスに変換してくれ、ignore_caseboolとして宣言したのでフラグの有無が自動的に真偽になります。引数が欠けていたり形式が誤っていたりすれば、clapが自らエラーメッセージを出して終了します。

サブコマンド — gitスタイルのツール

git commitgit pushのように、一つのツールが複数のサブコマンドを持つ構造はとても一般的です。clapでは列挙型(enum)で表現します。各バリアント(variant)が一つのサブコマンドになります。

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(version, about)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// 新しい項目を追加する
    Add {
        /// 追加する内容
        text: String,
    },
    /// 項目の一覧を表示する
    List {
        /// 完了した項目も含めるか
        #[arg(short, long)]
        all: bool,
    },
    /// 項目を完了にする
    Done {
        /// 完了する項目の番号
        id: u32,
    },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Add { text } => {
            println!("追加: {}", text);
        }
        Commands::List { all } => {
            println!("一覧 (完了を含む: {})", all);
        }
        Commands::Done { id } => {
            println!("完了: 項目 {}", id);
        }
    }
}

これでこのツールはtodo add "牛乳を買う"todo list --alltodo done 3のように呼び出されます。各サブコマンドは自分だけの引数とフラグを持ち、matchで分岐するとコンパイラがすべてのバリアントを漏れなく処理したか検査してくれます。サブコマンドを一つ追加すればmatchも直さなければならないので、処理を忘れることができません。

なぜ今Rustなのか — GCのない性能

これらのツールが速いのにはいくつかの理由がありますが、最も根本的なのはガベージコレクタがないという点です。

Go、Java、Nodeのようなランタイムは便利ですが、二つの税を課します。第一に、プログラム起動時にランタイムを初期化する遅延があります。コマンドラインツールは何千回も繰り返し呼ばれるため、呼び出しごとの起動遅延が積み重なると体感されます。第二に、実行中にGCが介入して予測不能な一時停止を起こします。Rustはコンパイル時の所有権ルールでメモリを管理するので、ランタイムGCそのものがありません。プログラムは即座に起動し、止まることなく一定の速度で動きます。

ここに二つの低レベルな武器が加わります。

  • SIMD — 現代のCPUは一つの命令で複数のバイトを同時に処理するベクトル命令を提供します。ripgrepが依存するmemchrクレートはSIMDで一度に16・32・64バイトを走査して特定のバイトを探します。素朴なバイトごとのループよりはるかに速いです。
  • mmap — ファイルをカーネルバッファからコピーしてくる代わりに、プロセスの仮想アドレス空間に直接マッピングして巨大なバイト配列のように扱います。大きなファイルを一つ走査するとき、コピーの一段階が消えます。

ただし誤解してはいけません。「Rustだから速い」は半分だけ正しいのです。Rustは軽い抽象化と安全な並行性という土台を与えるだけで、速さの本質はアルゴリズムと設計にあります。ripgrepが速い本当の理由が気になるなら、このブログのripgrepはなぜそんなに速いのかで、正規表現エンジンの選択から並列走査まで詳しく扱っています。

配布 — 単一の静的バイナリ

CLIツールの成否は性能だけでなく、配布の手軽さで決まります。ここでRustは決定的な利点を持ちます。

cargo build --releaseを実行すると、一つの実行ファイルが出ます。このファイルはインタプリタもランタイムもnode_modulesも必要としません。ユーザーはPythonのバージョンを合わせたりJVMを入れたりする必要はなく、ただバイナリを一つダウンロードして実行権限を与えて実行するだけです。

# リリースビルド(最適化オン)
cargo build --release

# 成果物はここに一つだけ
./target/release/mytool

# 完全な静的リンクが必要なら musl ターゲットを使う
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl

特にmuslターゲットでビルドすると、C標準ライブラリまで静的に含まれ、配布先のシステムライブラリのバージョンにまったく依存しない完全な静的バイナリになります。こうしたバイナリは最小のDockerイメージ(scratchやalpine)にそのまま入れても動きます。配布が「ファイルを一つコピーする」で終わること、これがCLIの作者にとってもユーザーにとっても大きな魅力です。

あると嬉しいクレート

引数パースがclapなら、残りのよくある必要を満たすクレートたちがあります。良いCLIはたいていこの組み合わせで作られます。

  • anyhow — アプリケーションレベルのエラー処理を快適にします。多様なエラー型を一つにまとめ、?演算子で伝播させ、失敗の経路に文脈を付け足せます。
  • indicatif — 進捗バー(progress bar)とスピナーを描きます。ファイルを多く処理するツールなら、ユーザーに進捗を見せることが大きな違いを生みます。
  • crossterm — 端末をクロスプラットフォームで制御します。色、カーソル移動、画面クリア、キー入力の検出などを、Windows・macOS・Linuxで同じように扱えます。

anyhowでエラーに文脈を付ける例を見てみましょう。

use anyhow::{Context, Result};
use std::fs;

fn load_config(path: &str) -> Result<String> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("設定ファイルを読めませんでした: {}", path))?;
    Ok(content)
}

fn main() -> Result<()> {
    let config = load_config("config.toml")?;
    println!("設定 {} バイトを読みました", config.len());
    Ok(())
}

mainResultを返すようにすると、エラーが発生したときにRustが自らメッセージを出力し、0以外の終了コードで終わります。with_contextで付けた説明のおかげで、ユーザーは「ファイルなし」のような低レベルのエラーではなく、「設定ファイルを読めませんでした」という理解できるメッセージを見ます。

indicatifで進捗バーを描く例も簡単です。

use indicatif::ProgressBar;
use std::thread;
use std::time::Duration;

fn main() {
    let total = 1000;
    let bar = ProgressBar::new(total);

    for _ in 0..total {
        // 実際にはここでファイルを一つ処理する
        thread::sleep(Duration::from_millis(2));
        bar.inc(1);
    }

    bar.finish_with_message("完了");
}

良いCLIが守る作法

速くて安全なだけでは足りません。先ほどのツールたちが愛されるのは、ユーザー体験を丁寧に気遣うからです。あなたがツールを作るときも次を覚えておくとよいでしょう。

  • 標準ストリームを尊重する。 正常な出力はstdoutへ、エラーと診断はstderrへ送ります。そうすればユーザーがmytool | otherのようにパイプでつなぐとき、診断メッセージがデータを汚しません。
  • パイプを検出する。 出力が端末ではなくパイプにつながれたら、色コードを切るのが礼儀です。ほとんどのツールがこう動き、必要なら--color alwaysのようなオプションで強制させます。
  • 終了コードを守る。 成功は0、失敗は0以外。シェルスクリプトがあなたのツールを条件文に入れられるべきです。
  • 良いデフォルトを選ぶ。 ユーザーがたいてい望む動作を設定なしのデフォルトにし、例外的な状況だけフラグで開きます。これがripgrepが.gitignoreをデフォルトで尊重する理由です。
  • シェル補完を提供する。 clapはbash・zsh・fish用の補完スクリプトを生成できます。些細に見えても使い勝手を大きく引き上げます。

バージョン管理ツールを触りながらCLIの感覚を身につけたいなら、このサイトのGitプレイグラウンドでコマンドの動作を実際に試せます。

おわりに

CLIルネサンスは言語のファンダムではなく、適合の結果です。速い起動、予測可能な性能、単一バイナリでの配布、メモリ安全。この四つがコマンドラインツールという問題に正確に噛み合い、その上でripgrep・fd・batといったツールが生まれました。

あなたの最初のツールは大げさである必要はありません。#[derive(Parser)]を付けた構造体一つから始め、サブコマンドを付け、anyhowでエラーを整え、必要ならindicatifで進捗を見せてください。そして最後に標準ストリームと終了コードのような小さな作法を守れば、あなたのツールも人々が毎日手に馴染ませて使うあのリストに入ることができます。

参考資料

현재 단락 (1/145)

この数年で、あなたが毎日使っているコマンドラインツールのかなりの部分が、静かにRustで書き直されてきました。`grep`は`ripgrep`(rg)に、`find`は`fd`に、`cat`は`bat...

작성 글자: 0원문 글자: 6,750작성 단락: 0/145