1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

fd を 500 行の Rust で再実装してみて分かったこと

1
Last updated at Posted at 2026-05-04

きっかけ

ripgrepfdbateza ——初めて使った人が「速い」と驚く 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 クレート(fdripgrep が使っている)がこれを全部やってくれる:

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: *.logdebug.log を置いてアサートしたら、ログが見えていた。ドキュメントを 30 秒読んで原因が分かった。

hidden(true) は隠しファイルを「スキップする」。 「隠しファイルの非表示を強制するか?」という意味。命名は内部視点では合理的だが、2 回間違えた。

walkdir はフォールバック

--no-gitignore のときは ignore のマジックを一切使いたくない。walkdirignore の下にある)で素朴な深さ優先走査:

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 の各ノブがどう組み合わさるかだ。

小さな「劣化再実装」はソフトウェアを理解するために過小評価された方法だと思う。ソースを読むより、劣化版を作ったほうが、本物が後からシンプルに見える。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?