I have a project where I need to search for directories and files. In fact I'm searching for paths in other projects. I don't want to just find files but I also want to exclude some directories or files from the results to avoid false positves.
In particular I'd like to exclude the following paths/patterns (not regex):
var directories = new[]
{
@".git",
@".idea",
@".vs",
@"_nuget",
@"*.Tests",
@"packages",
@"TestResults",
@"bin",
@"obj",
};
var files = new[]
{
@".xml",
@".config",
@".csproj",
};
There are not many wild cards in these patterns because files are matched at the end of the sting by default and directories are starting with a backslash ´\´. But it supports *
and ?
.
The core type is the QuickSearch
. It takes a path where it should start searching and optionally a QuickSearchCriteria
. Internally it does not work recursively but with a stack. Based on the search criteria it is able to exclude/include both files and/or directories from/in the results.
public interface IQuickSearch
{
IEnumerable<string> EnumeratePaths(string path, QuickSearchCriteria criteria = null);
}
public class QuickSearch : IQuickSearch
{
public IEnumerable<string> EnumeratePaths(string path, QuickSearchCriteria criteria = null)
{
criteria = criteria ?? new QuickSearchCriteria();
var directories = new Stack<IEnumerable<string>>
{
Directory.EnumerateDirectories(path)
};
while (directories.Any())
{
foreach (var directory in directories.Pop().Where(DirectoryMatches))
{
yield return directory;
if (criteria.Options.HasFlag(QuickSearchOptions.Recursive))
{
directories.Push(Directory.EnumerateDirectories(directory));
}
foreach (var file in Directory.EnumerateFiles(directory).Where(FileMatches))
{
yield return file;
}
}
}
bool DirectoryMatches(string directory)
{
return
criteria.ExcludeDirectory.Matches(directory) == false &&
criteria.Options.HasFlag(QuickSearchOptions.IncludeDirectories) &&
criteria.IncludeDirectory.Matches(directory);
}
bool FileMatches(string file)
{
return
criteria.ExcludeFile.Matches(file) == false &&
criteria.Options.HasFlag(QuickSearchOptions.IncludeFiles) &&
criteria.IncludeFile.Matches(file);
}
}
}
public static class StackExtensions
{
public static void Add<T>(this Stack<T> stack, T item)
{
stack.Push(item);
}
}
The QuickSearchCriteria
is just a simple class with several defaults:
public class QuickSearchCriteria
{
public DirectoryFilter ExcludeDirectory { get; set; } = DirectoryFilter.None;
public FileFilter ExcludeFile { get; set; } = FileFilter.None;
public DirectoryFilter IncludeDirectory { get; set; } = DirectoryFilter.All;
public FileFilter IncludeFile { get; set; } = FileFilter.All;
public QuickSearchOptions Options { get; set; } = QuickSearchOptions.IncludeBoth | QuickSearchOptions.Recursive;
}
[Flags]
public enum QuickSearchOptions
{
IncludeDirectories = 1,
IncludeFiles = 2,
Recursive = 4,
IncludeBoth = IncludeDirectories | IncludeFiles
}
Filters are based on the PathFilter
that provides some default implementations and stores patterns.
public abstract class PathFilter : IEnumerable<string>
{
// Using just a few symbols Windows is showing when renaming files.
public static readonly string InvalidFileNameChars = @"""<>|:*?\/";
private readonly string[] _patterns;
private readonly Lazy<Func<string, bool>> _matcher;
protected PathFilter(params string[] patterns)
{
_patterns = patterns;
_matcher = new Lazy<Func<string, bool>>(CreateMatcher);
}
protected PathFilter(Func<string, bool> matcher)
{
_patterns = new string[0];
_matcher = new Lazy<Func<string, bool>>(() => matcher);
}
protected abstract Func<string, bool> CreateMatcher();
public bool Matches(string path)
{
return path == null ? false : _matcher.Value(path);
}
public IEnumerator<string> GetEnumerator() => ((IEnumerable<string>)_patterns).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
Filters I currently use are regex-based so the use another base class the RegexPathFilter
. This class encapsulates common conversion of patterns to regex. Patterns that start and and with a slash /
are considered as regex.
public abstract class RegexPathFilter : PathFilter
{
protected RegexPathFilter(params string[] patterns) : base(patterns) { }
protected RegexPathFilter(Func<string, bool> matcher) : base(matcher) { }
protected override Func<string, bool> CreateMatcher()
{
var regexPatterns = this.ToLookup(IsRegex);
var expressions = regexPatterns[true].Select(p => p.TrimStart('/').TrimEnd('/')).Concat(CreateRegularExpressions(regexPatterns[false]));
var regex = new Regex($"({string.Join("|", expressions)})", RegexOptions.IgnoreCase);
return regex.IsMatch;
bool IsRegex(string value) => value.StartsWith("/") && value.EndsWith("/");
}
protected virtual IEnumerable<string> CreateRegularExpressions(IEnumerable<string> patterns)
{
return
from pattern in patterns
select Regex.Replace(pattern, @"\.", @"\.") into pattern
select Regex.Replace(pattern, @"\?", @".") into pattern
// Hard to decide which one to use.
//select Regex.Replace(pattern, @"\*", $@"[^{Regex.Escape(InvalidFileNameChars)}]*?") into pattern
select Regex.Replace(pattern, @"\*", $@".*?");
}
}
The two main filters are the DirectoryFilter
and the FileFilter
. Each of them customizes the patters further to create the final version that either must start with a \
or be at the end of the string so that I don't have to use .*?
for common exclusions like *.cs
.
public class DirectoryFilter : RegexPathFilter
{
public DirectoryFilter(params string[] patterns) : base(patterns) { }
private DirectoryFilter(Func<string, bool> matcher) : base(matcher) { }
public static DirectoryFilter None => new DirectoryFilter(_ => false);
public static DirectoryFilter All => new DirectoryFilter(_ => true);
public static DirectoryFilter Create(params string[] patterns)
{
return new DirectoryFilter(patterns);
}
protected override IEnumerable<string> CreateRegularExpressions(IEnumerable<string> patterns)
{
// Directories are matched at the \
return base.CreateRegularExpressions(patterns).Select(p => @"\\" + p);
}
}
public class FileFilter : RegexPathFilter
{
public FileFilter(params string[] patterns) : base(patterns) { }
private FileFilter(Func<string, bool> matcher) : base(matcher) { }
public static FileFilter None => new FileFilter(_ => false);
public static FileFilter All => new FileFilter(_ => true);
public static FileFilter Create(params string[] patterns)
{
return new FileFilter(patterns);
}
protected override IEnumerable<string> CreateRegularExpressions(IEnumerable<string> patterns)
{
// Files are matched at the end of the string.
return base.CreateRegularExpressions(patterns).Select(p => p + "$");
}
}
Example To use it I just need to instantiate the QuickSearch
and give it some search criteria:
var ignore = (DirectoryFilter.Create(directories), FileFilter.Create(files));
var pathEnumerator = new QuickSearch();
pathEnumerator
.EnumeratePaths(@"c:\temp\projects\testproject", new QuickSearchCriteria
{
ExcludeDirectory = ignore.Item1,
ExcludeFile = ignore.Item2
})
.Count()
.Dump();
It takes 0.040sec to enumerate a directory with 10.600 files and 1.400 folders yealding 417 results with the above ignore list on my notebook's SSD.