glob pattern matching in .NET

Viewed 31031

Is there a built-in mechanism in .NET to match patterns other than Regular Expressions? I'd like to match using UNIX style (glob) wildcards (* = any number of any character).

I'd like to use this for a end-user facing control. I fear that permitting all RegEx capabilities will be very confusing.

15 Answers

I found the actual code for you:

Regex.Escape( wildcardExpression ).Replace( @"\*", ".*" ).Replace( @"\?", "." );

I wrote a FileSelector class that does selection of files based on filenames. It also selects files based on time, size, and attributes. If you just want filename globbing then you express the name in forms like "*.txt" and similar. If you want the other parameters then you specify a boolean logic statement like "name = *.xls and ctime < 2009-01-01" - implying an .xls file created before January 1st 2009. You can also select based on the negative: "name != *.xls" means all files that are not xls.

Check it out. Open source. Liberal license. Free to use elsewhere.

I don't know if the .NET framework has glob matching, but couldn't you replace the * with .*? and use regexes?

Unfortunately the accepted answer will not handle escaped input correctly, because string .Replace("\*", ".*") fails to distinguish between "*" and "\*" - it will happily replace "*" in both of these strings, leading to incorrect results.

Instead, a basic tokenizer can be used to convert the glob path into a regex pattern, which can then be matched against a filename using Regex.Match. This is a more robust and flexible solution.

Here is a method to do this. It handles ?, *, and **, and surrounds each of these globs with a capture group, so the values of each glob can be inspected after the Regex has been matched.

static string GlobbedPathToRegex(ReadOnlySpan<char> pattern, ReadOnlySpan<char> dirSeparatorChars)
{
    StringBuilder builder = new StringBuilder();
    builder.Append('^');

    ReadOnlySpan<char> remainder = pattern;

    while (remainder.Length > 0)
    {
        int specialCharIndex = remainder.IndexOfAny('*', '?');

        if (specialCharIndex >= 0)
        {
            ReadOnlySpan<char> segment = remainder.Slice(0, specialCharIndex);

            if (segment.Length > 0)
            {
                string escapedSegment = Regex.Escape(segment.ToString());
                builder.Append(escapedSegment);
            }

            char currentCharacter = remainder[specialCharIndex];
            char nextCharacter = specialCharIndex < remainder.Length - 1 ? remainder[specialCharIndex + 1] : '\0';

            switch (currentCharacter)
            {
                case '*':
                    if (nextCharacter == '*')
                    {
                        // We have a ** glob expression
                        // Match any character, 0 or more times.
                        builder.Append("(.*)");

                        // Skip over **
                        remainder = remainder.Slice(specialCharIndex + 2);
                    }
                    else
                    {
                        // We have a * glob expression
                        // Match any character that isn't a dirSeparatorChar, 0 or more times.
                        if(dirSeparatorChars.Length > 0) {
                            builder.Append($"([^{Regex.Escape(dirSeparatorChars.ToString())}]*)");
                        }
                        else {
                            builder.Append("(.*)");
                        }

                        // Skip over *
                        remainder = remainder.Slice(specialCharIndex + 1);
                    }
                    break;
                case '?':
                    builder.Append("(.)"); // Regex equivalent of ?

                    // Skip over ?
                    remainder = remainder.Slice(specialCharIndex + 1);
                    break;
            }
        }
        else
        {
            // No more special characters, append the rest of the string
            string escapedSegment = Regex.Escape(remainder.ToString());
            builder.Append(escapedSegment);
            remainder = ReadOnlySpan<char>.Empty;
        }
    }

    builder.Append('$');

    return builder.ToString();
}

The to use it:

string testGlobPathInput = "/Hello/Test/Blah/**/test*123.fil?";
string globPathRegex = GlobbedPathToRegex(testGlobPathInput, "/"); // Could use "\\/" directory separator chars on Windows

Console.WriteLine($"Globbed path: {testGlobPathInput}");
Console.WriteLine($"Regex conversion: {globPathRegex}");

string testPath = "/Hello/Test/Blah/All/Hail/The/Hypnotoad/test_somestuff_123.file";
Console.WriteLine($"Test Path: {testPath}");
var regexGlobPathMatch = Regex.Match(testPath, globPathRegex);

Console.WriteLine($"Match: {regexGlobPathMatch.Success}");

for(int i = 0; i < regexGlobPathMatch.Groups.Count; i++) {
    Console.WriteLine($"Group [{i}]: {regexGlobPathMatch.Groups[i]}");
}

Output:

Globbed path: /Hello/Test/Blah/**/test*123.fil?
Regex conversion: ^/Hello/Test/Blah/(.*)/test([^/]*)123\.fil(.)$
Test Path: /Hello/Test/Blah/All/Hail/The/Hypnotoad/test_somestuff_123.file
Match: True
Group [0]: /Hello/Test/Blah/All/Hail/The/Hypnotoad/test_somestuff_123.file
Group [1]: All/Hail/The/Hypnotoad
Group [2]: _somestuff_
Group [3]: e

I have created a gist here as a canonical version of this method:

https://gist.github.com/crozone/9a10156a37c978e098e43d800c6141ad

Related