以下の内容はhttps://tech.guitarrapc.com/entry/2025/01/21/235900より取得しました。


C#でファイルのグロブ検索する

Bashでファイル検索するときにグロブ(Glob)を使うことがあります。C#でもグロブ検索したくなったので状況を見てみましょう。

グロブ検索とは

glob (programming) - Wikipedia)に詳細がありますが、グロブ検索とはディレクトリ内のファイルやフォルダ名に対してパターンマッチングを行い、一致する名前を持つファイルやフォルダを検索する方法です。特定の名前パターン(ワイルドカードなど)を指定して検索する際に意識せず使うことがあるアレです。便利。

C#でファイル検索するといえば、Directory.EnumerateFilesDirectory.GetFilesですね。*を使ったワイルドカード検索や再帰的なディレクトリ探索が可能なため、C#においてはglobのような専用の仕組みを利用する機会や需要はあまり多くないと感じます。

グロブパターン

POSIX.2(IEEE Std 1003.2)で標準化されているグロブパターンには、以下の2種類があります。

  1. メタ文字(ワイルドカード)

    • ?:任意の1文字に一致
    • *:任意の文字列(0回以上の文字)に一致
  2. 範囲/セット指定

    • [...]:括弧内の文字に一致。以下のオプションがあります:
      • 先頭が!または^の場合:否定を意味し、括弧内の文字以外に一致
      • 範囲指定(例:[a-z]):指定された範囲の文字に一致

グロブ動作を確認するためBashで次のフォルダ構成に対してグロブ検索してみましょう。echoが便利なのでこれを用います。1ディレクトリは今回考慮外なのでファイルのみを対象とします。

$ tree
.
├── Program.cs
├── Program.sh
└── github
     └── FooBar.txt

# *ワイルドカードでヒットする
$ echo Prog*
Program.cs Program.sh

# 1文字を任意としてもヒットするものがない
$ echo Prog?
Prog?

# 1文字を任意としてもヒットした
$ echo Progra?.cs
Program.cs

# 範囲で指定してもヒットするものがない
$ echo Program.c[a-g]
Program.c[a-g]

# 範囲のsがヒット
$ echo Program.c[o-z]
Program.cs

# a-g以外でヒット
$ echo Program.s[^a-g]
Program.sh
$ echo Program.s[!a-g]
Program.sh

# **で再帰的に検索する
$ echo git**/Foo*
github/FooBar.txt

C#でグロブ検索する

C#でグロブ検索、特にファイル検索だけ考えてみましょう。今回紹介するものの対応状況は次の通りです。

ライブラリ * ? [...] ** 備考
Directory.EnumerateFiles × 独自実装で**をサポート可能
Microsoft.Extensions.FileSystemGlobbing × × Microsoft公式だがグロブパターンは網羅できていない。?や範囲構文は捨ててる
kthompson/glob パッケージはNuGetにない。GitHub Packageseに公開されている

本筋とずれますが、ライブラリによってグロブパターンにマッチしたときの挙動が「マッチしたかを返す(boolean)」「マッチしたパターンのパスを返す」「マッチしたフルパスを返す」でずれが生じているようです。シェルの体験的には「マッチしたかを返す」「マッチしたパターンのパスを返す」がいいでしょうし、C#のDirectory.EnumerateFilesなどと合わせるなら「マッチしたかを返す」「マッチしたフルパスを返す」が手触りよく感じます。

順に見ていきましょう。GlobSearch.GetFiles("Prog*")のようにパターンを渡してファイル一覧を返す形で考えてみます。

Directory.EnumerateFilesを使う

雑にファイル検索だけ実装する場合、Directory.EnumerateFilesを使って次のように書けます。EnumerateFiles*?をサポートしているので、単純なケースではBashのグロブと同じように動作します。が、細かい動作は無視した実装になります。

var pattern = args[0];
ArgumentNullException.ThrowIfNull(pattern);

foreach (var item in GlobSearch.GetFiles(pattern))
{
    Console.WriteLine(item);
}

public static class GlobSearch
{
    public static IEnumerable<string> GetFiles(string pattern)
    {
        var directory = GetParentDirectoryPath(pattern);
        var searchPattern = Path.GetFileName(pattern);
        var searchOption = pattern.Contains("**") ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;

        if (!Directory.Exists(directory))
            throw new DirectoryNotFoundException($"The directory '{directory}' does not exist.");

        return Directory.EnumerateFiles(directory, searchPattern, searchOption);
    }

    private static string GetParentDirectoryPath(string path)
    {
        // `**`を含む場合は親ディレクトリを取得する
        var directory = Path.GetDirectoryName(path);
        while (!string.IsNullOrEmpty(directory) && (directory.Contains("**")))
        {
            directory = Path.GetDirectoryName(directory);
        }

        // 最悪カレントディレクトリにフォールバック
        if (string.IsNullOrEmpty(directory))
            directory = Directory.GetCurrentDirectory();

        return Path.GetFullPath(directory);
    }
}

範囲指定ができないものの、同じような挙動になっています。File IOを触る時って、フルパス返してほしいんですよね。

# *もサポートされている
$ dotnet run -- "Prog*"
/home/guitarrapc/Program.sh
/home/guitarrapc/Program.cs

# ?もサポートされている
$ dotnet run -- "Prog?"
$ dotnet run -- "Progra?.cs"
/home/guitarrapc/Program.cs

# 範囲構文はサポートなし
$ dotnet run -- "Program.c[a-g]"
$ dotnet run -- "Program.c[o-z]"
$ dotnet run -- "Program.s[^a-g]"
$ dotnet run -- "Program.s[!a-g]"

# **/*もサポートされている
$ dotnet run -- "git**/Foo*"
/home/guitarrapc/github/FooBar.txt

Microsoft.Extensions.FileSystemGlobbingを用いる

.NET的にはグロブ検索するためのライブラリとして、Microsoft.Extensions.FileSystemGlobbingが提供されています。このライブラリを使うことで、より柔軟なグロビングを行うことができます。File globbing - .NET | Microsoft Learnにドキュメントも用意されているので、詳しい使い方はこれを参照してください。

このライブラリはグロブパターンのうち、ワイルドカード***に対応し、?[...]での範囲指定はサポートしていません。ただし、範囲指定はレンジ相当処理があるのでこれで対応できます。名前と実体がずれているのがきになりますが、制約を受け入れられるならこのライブラリでもよいでしょう。サポートされているグロブパターンは次の通りです。

image

ワイルドカードでファイル検索だけをしたいならあまり難しいことを考慮しなくても書けます。GetGlobRootAndInputPatternはパターンからルートディレクトリと検索パターンを取得するヘルパーメソッドなので無視します。重要なのは、Matcher.AddInclude()には検索するパターン、Execute()には検索ルートディレクトリを指定することです。Filesプロパティにはマッチしたファイル名が入っているのですが、Directory.EnumerateFilesのように扱えるようフルパスに変換しています。

var pattern = args[0];
ArgumentNullException.ThrowIfNull(pattern);

foreach (var item in GlobSearch.GetFiles(pattern))
{
    Console.WriteLine(item);
}

public static class GlobSearch
{
    public static IEnumerable<string> GetFiles(string pattern)
    {
        var (rootDirectory, includePattern) = GetGlobRootAndInputPattern(pattern);
        if (!Directory.Exists(rootDirectory))
            throw new DirectoryNotFoundException($"The directory '{rootDirectory}' does not exist.");
        var files = new Microsoft.Extensions.FileSystemGlobbing.Matcher()
          .AddInclude(includePattern) // 検索パターンを指定
          .Execute(new Microsoft.Extensions.FileSystemGlobbing.Abstractions.DirectoryInfoWrapper(new DirectoryInfo(rootDirectory))) // 検索のルートディレクトリを指定
          .Files
          .Select(x => Path.Combine(rootDirectory, x.Path)); // フルパスに変換
        return files;
    }

    private static (string rootDirectory, string includePattern) GetGlobRootAndInputPattern(string pattern)
    {
        var normalizedPattern = Path.GetFullPath(pattern).Replace('\\', '/');
        var splitted = normalizedPattern.Split('/', StringSplitOptions.TrimEntries).AsSpan();
        var indexOfRoot = 0;
        foreach (var item in splitted)
        {
            if (item.Contains('*')) // ?をサポートしない
                break;
            indexOfRoot++;
        }
        // グロブパターンじゃない
        if (indexOfRoot == splitted.Length)
            return (Path.GetDirectoryName(normalizedPattern) ?? Directory.GetCurrentDirectory(), Path.GetFileName(pattern));

        // 非Windowsはルートディレクトリが空文字列になる対策
        var rootMarker = normalizedPattern[0] == '/' ? "/" : "";
        // .NET9ならPath.Combine(splitted[..indexOfRoot]);
        var rootDirectory = rootMarker + Path.Combine(splitted[..indexOfRoot].ToArray());
        var fullRootDirectory = Path.GetFullPath(rootDirectory);
        var includePattern = normalizedPattern[(rootDirectory.Length + rootMarker.Length)..];
        return (fullRootDirectory, includePattern);
    }
}

動作を見てみましょう。[a-g]のような範囲構文を直接サポートしていませんが、API的にはAddIncludeで分解してあげればサポートできます。

# *もサポートされている
$ dotnet run -- "Prog*"
/home/guitarrapc/Program.sh
/home/guitarrapc/Program.cs

# ?はサポートなし
$ dotnet run -- "Prog?"
$ dotnet run -- "Progra?.cs"

# 範囲構文はサポートなし
$ dotnet run -- "Program.c[a-g]"
$ dotnet run -- "Program.c[o-z]"
$ dotnet run -- "Program.s[^a-g]"
$ dotnet run -- "Program.s[!a-g]"

# **/*もサポートされている
$ dotnet run -- "git**/Foo*"
/home/guitarrapc/github/FooBar.txt

kthompson/glob

kthompson/globというライブラリはグロブパターンを網羅しています。こちらはメンテナンスが続いているので良い感じですが、パッケージ公開がGitHub PackageseのみでNuGetにないのが注意です。2betaパッケージしかないのも気になるところです。

dotnet add package Glob --version 2.0.13-beta-g89420df152

使い方は次のようになります。

using GlobExpressions;

var pattern = args[0];
ArgumentNullException.ThrowIfNull(pattern);

foreach (var item in GlobSearch.GetFiles(pattern))
{
    Console.WriteLine(item);
}

public static class GlobSearch
{
    public static IEnumerable<string> GetFiles(string pattern)
    {
        var (rootDirectory, includePattern) = GetGlobRootAndInputPattern(pattern);
        var root = new DirectoryInfo(rootDirectory);
        return root.GlobFiles(includePattern).Select(x => x.FullName);
    }

    private static (string rootDirectory, string includePattern) GetGlobRootAndInputPattern(string pattern)
    {
        var normalizedPattern = Path.GetFullPath(pattern).Replace('\\', '/');
        var splitted = normalizedPattern.Split('/', StringSplitOptions.TrimEntries).AsSpan();
        var indexOfRoot = 0;
        foreach (var item in splitted)
        {
            if (item.Contains('*') || item.Contains('?') || item.Contains('['))
                break;
            indexOfRoot++;
        }
        // グロブパターンじゃない
        if (indexOfRoot == splitted.Length)
            return (Path.GetDirectoryName(normalizedPattern) ?? Directory.GetCurrentDirectory(), Path.GetFileName(pattern));

        // 非Windowsはルートディレクトリが空文字列になる対策
        var rootMarker = normalizedPattern[0] == '/' ? "/" : "";
        // .NET9ならPath.Combine(splitted[..indexOfRoot]);
        var rootDirectory = rootMarker + Path.Combine(splitted[..indexOfRoot].ToArray());
        var fullRootDirectory = Path.GetFullPath(rootDirectory);
        var includePattern = normalizedPattern[(rootDirectory.Length + rootMarker.Length)..];
        return (fullRootDirectory, includePattern);
    }
}

動作を見てみましょう。一通りのグロブパターンがサポートされています。えらい。

# *もサポートされている
$ dotnet run -- "Prog*"
/home/guitarrapc/Program.sh
/home/guitarrapc/Program.cs

# ?もサポートされている
$ dotnet run -- "Prog?"
$ dotnet run -- "Progra?.cs"
/home/guitarrapc/Program.cs

# 範囲構文もサポートされている
$ dotnet run -- "Program.c[a-g]"
$ dotnet run -- "Program.c[o-z]"
/home/guitarrapc/Program.cs
$ dotnet run -- "Program.s[^a-g]"
$ dotnet run -- "Program.s[!a-g]"
/home/guitarrapc/Program.sh

# **/*もサポートされている
$ dotnet run -- "git**/Foo*"
/home/guitarrapc/github/FooBar.txt

まとめ

C#でグロブパターンをPOSIX規格通りにサポートするのは思ったより手薄ですね。Windowsのみサポートという時代が長かったのも背景にあったりするんですかねー、しょうがない。

ほとんどの場合は***で事足りることが多いのでMicrosoft.Extensions.FileSystemGlobbingを使うことでサクッとグロブ検索もどきが可能です。範囲指定が必要な場合はkthompson/globを使うことで対応できます。簡易的な独自実装は事故りやすいので、ちゃんと書くときは頑張りましょう。


  1. lsで動作確認するのを当初考えましたが、グロブ検索はBashシェルが処理して、lsコマンドがグロブを扱うわけじゃないためechoを使いました
  2. いつのまにかGitHub PAT認証やソース追加不要でパッケージを導入できるようになってました。



以上の内容はhttps://tech.guitarrapc.com/entry/2025/01/21/235900より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14