きっかけ
ripgrep、fd、bat、eza ——初めて使った人が「速い」と驚く Rust CLI がある。fd は毎日使っている。ソースも何度か読んだ。でも読むのと作るのは違う。
fdlite を作った。fd の意図的に小さく、意図的に不完全な再実装。fd の有用性のうちどこまでが依存クレートのおかげで、どこからが手書きなのかを確かめるために。
短い答え:クレートのおかげの部分が多い。最初の動作版は 300 行未満。テスト込みの出荷版は約 500 行。
📦 GitHub: https://github.com/sen-ltd/fdlite
作ったもの
- 正規表現マッチ(デフォルト)とグロブマッチ(
--glob) -
.gitignore自動対応 - ファイルタイプフィルター(
--type f|d|l) - 拡張子フィルター(
--extension rs) - 除外グロブ(
--exclude) - 深さ制限(
--depth) - 隠しファイル切り替え(
--hidden) -
--print0(xargs パイプライン用) -
grep/fd互換の終了コード
やらないこと:並列ディレクトリ走査、カラー出力、スマートケース、コマンド実行(-x/-X)、完全な Unicode グロブ文法。
技術的なポイント
ignore クレートが一番難しい部分をやってくれる
.gitignore のパースは外から見ると小さいが、実際は巨大。! 否定、先頭スラッシュアンカー、末尾スラッシュのディレクトリ限定、** ワイルドカード、行順序の優先度、親ディレクトリ探索、グローバル ~/.config/git/ignore——正しい実装は最低 1 週間の仕事。
ignore クレート(fd と ripgrep が使っている)がこれを全部やってくれる:
use ignore::WalkBuilder;
let mut builder = WalkBuilder::new(&opts.root);
builder
.hidden(!opts.include_hidden)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.require_git(false)
.parents(true);
驚いた点が 2 つ:
require_git(false) はデフォルトではない。 デフォルトだと git リポジトリ外の .gitignore は無視される。テストで最初にこれが壊れた。fixture ツリーに .gitignore: *.log と debug.log を置いてアサートしたら、ログが見えていた。ドキュメントを 30 秒読んで原因が分かった。
hidden(true) は隠しファイルを「スキップする」。 「隠しファイルの非表示を強制するか?」という意味。命名は内部視点では合理的だが、2 回間違えた。
walkdir はフォールバック
--no-gitignore のときは ignore のマジックを一切使いたくない。walkdir(ignore の下にある)で素朴な深さ優先走査:
use walkdir::WalkDir;
let mut walker = WalkDir::new(&opts.root);
if let Some(d) = opts.max_depth {
walker = walker.max_depth(d);
}
2 つのウォーカー、1 つの accept 関数。違いはイテレータのセットアップだけで、フィルターロジックは共通。
フィルターパイプラインは 1 関数
タイプフィルター、除外グロブ、メインパターン——すべて 1 つの accept 関数に集約:
fn accept(opts: &WalkOptions, path: &Path) -> Option<PathBuf> {
// タイプフィルター → 除外グロブ → メインパターン
// ...
let rel = path.strip_prefix(&opts.root).unwrap_or(path).to_path_buf();
Some(rel)
}
新しいフィルターの追加は 1 ハンクの変更で済む。symlink_metadata() を使うのがポイント。metadata() はシンボリックリンクを辿るので ft.is_symlink() が常に false になる。
正規表現とグロブのインターフェース共有
trait PathMatch: Send + Sync {
fn matches(&self, s: &str) -> bool;
}
ウォーカーはどちらの方言が使われているか知る必要がない。Glob 型は内部で正規表現にコンパイルされる。グロブ→正規表現変換は 40 行の手書き。
マッチ対象はファイル名であってフルパスではない。fdlite '^src$' は src/ 配下の全ファイルではなく、ファイル名が src のエントリにマッチする。fd と同じ挙動だが、パスの文字列化に対して is_match を呼ぶと間違えやすいので、テスト regex_matches_on_name_not_parent_dirs で固定した。
テスト
36 件。ユニット 21 + インテグレーション 12 + マッチャー 3。インテグレーションテストは tempfile::tempdir() で一時ディレクトリを作り、CARGO_BIN_EXE_fdlite 経由で実バイナリを呼ぶ。
おわりに
docker build -t fdlite https://github.com/sen-ltd/fdlite.git
docker run --rm -v /tmp/demo:/work fdlite '\.txt$' /work
ランタイムイメージは約 10 MB。
fdlite を作って学んだのは .gitignore のパース方法ではない。ignore クレートがそれをやってくれた。学んだのは、fd / ripgrep の作者たちが難しい部分をいかに丁寧にパッケージしているか、そして WalkBuilder の各ノブがどう組み合わさるかだ。
小さな「劣化再実装」はソフトウェアを理解するために過小評価された方法だと思う。ソースを読むより、劣化版を作ったほうが、本物が後からシンプルに見える。
