Introduction
Learn how to get started with file globbing by creating a console project to list all Visual Studio solutions (.sln) under a specific folder along with project names for the solution exported to a json file.
The reason for using solution and project files is that every reader here using Microsoft Visual Studio should have at least two while if the example were for Word documents or specific image types the code presented may not work so well.
Other opportunities besides using a conventual console project there are commandline dotnet tools C# .NET Tools with System.CommandLine which the code presented can be done in.
Important
As written, its assumed the user has permission to read from the provided folder, if the user may not have permission to read from the folder wrapper the code in a try-catch.
NET Framework
Since NET8 is the current framework NET8 Core is used although the core code will work with earlier NET frameworks.
Learning points
- File Globbing
- Asynchronous operations performance
- Using Actions rather than delegates/events
- Separation of work into classes and containers which we will call them models.
Documentation
The bulk of documentation was written use Jetbrain's A.I. service which is subscription based Visual Studio service.
Sample patterns
To match files in the file system based on user-defined patterns, start by instantiating a Matcher object. A Matcher can be instantiated with no parameters, or with a System.StringComparison parameter, which is used internally for comparing patterns to file names.
Value | Description |
---|---|
*.txt | All files with .txt file extension. |
*.* | All files with an extension |
* | All files in top-level directory. |
.* | File names beginning with '.'. |
*word* | All files with 'word' in the filename. |
readme.* | All files named 'readme' with any file extension. |
styles/*.css | All files with extension '.css' in the directory 'styles/'. |
scripts/*/* | All files in 'scripts/' or one level of subdirectory under 'scripts/'. |
images*/* | All files in a folder with name that is or begins with 'images'. |
**/* | All files in any subdirectory. |
dir/**/* | All files in any subdirectory under 'dir/'. |
../shared/* | All files in a diretory named "shared" at the sibling level to the base directory |
In this example the pattern will be **/*.sln
and **/*.csproj
.
-
**/*.sln
when used gets all Visual Studio solution file under a path. -
**/*.csproj
gets all project files under a folder with a solution.
The following snippet creates a Matcher which searches a directory for the matching pattern.
Matcher matcher = new();
matcher.AddInclude("**/*.sln");
And for more complex operations there is AddExcludePatterns which when used ignores one or patterns. The following is setup to get all .cs files but exclude .cs that have Assembler, Designer, Global or c.cs.
string[] include = { "**/*.cs" };
string[] exclude =
{
"**/*Assembly*.cs",
"**/*Designer*.cs",
"**/*Global*.cs",
"**/*g.cs"
};
The above has a full code sample in the following repository.
Models
The follow model will be used to capture solutions and list of project names.
/// <summary>
/// Represents a collection of solutions, each containing details about its name, folder, file name, and associated projects.
/// </summary>
internal class Solutions
{
/// <summary>
/// Get/set the name of the solution.
/// </summary>
/// <value>
/// The name of the solution.
/// </value>
public string Name { get; set; }
/// <summary>
/// Get/set the folder path where the solution is located.
/// </summary>
public string Folder { get; set; }
/// <summary>
/// Get/set the file name of the solution.
/// </summary>
/// <value>
/// A string representing the name of the solution file.
/// </value>
public string FileName { get; set; }
/// <summary>
/// Get/set the list of project file names associated with the solution.
/// </summary>
/// <value>
/// A list of project file names w/o path.
/// </value>
public List<string> Projects { get; set; } = [];
}
The following model is a container for each match found in _GlobSolutions _class, _ProcessSolutionFolderAsync _method.
/// <summary>
/// Represents a matched file item within a directory, including its folder and file name.
/// </summary>
public class FileMatchItem
{
public FileMatchItem(string sender)
{
Folder = Path.GetDirectoryName(sender);
FileName = Path.GetFileName(sender);
}
public string Folder { get; init; }
public string FileName { get; init; }
public override string ToString() => $"{Folder}\\{FileName}";
}
Working class
GetSolutionNames method accepts the path to a folder with one or more solutions which passes control to ProcessSolutionFolderAsync method.
ProcessSolutionFolderAsync method:
First parameter is the path to the folder containing one or more Visual Studio solutions.
Second parameter Action<FileMatchItem, string>
represents the defined action to fire off, in this case GlobSolutions.ProcessFile which first checks if the solution name is in the list (in the same class), if not its added, otherwise project names are added.
Next control is handed over to GetProjectFiles method which is passed, the current solution path which uses globbing to get project names for the current solution.
internal class GlobSolutions
{
public static List<Solutions> Solutions = [];
/// <summary>
/// Asynchronously retrieves and processes the names of solution files in the specified directory.
/// </summary>
/// <param name="path">The directory path to search for solution files.</param>
public static async Task GetSolutionNames(string path)
{
await ProcessSolutionFolderAsync(path, ProcessFile);
}
/// <summary>
/// Processes a matched file and appends its details to the internal StringBuilder.
/// </summary>
/// <param name="fileMatch">The matched file item to process.</param>
/// <param name="solutionItem"></param>
private static void ProcessFile(FileMatchItem fileMatch, string solutionItem)
{
var solution = Solutions.FirstOrDefault(x => x.Name == solutionItem);
if (solution is not null)
{
solution.Projects.Add(fileMatch.FileName);
}
else
{
solution = new Solutions
{
Name = solutionItem,
FileName = Path.GetFileName(solutionItem),
Folder = Path.GetDirectoryName(solutionItem)
};
Solutions.Add(solution);
}
}
/// <summary>
/// Asynchronously processes solution files in the specified folder.
/// </summary>
/// <param name="folder">The folder to search for solution files.</param>
/// <param name="foundAction">The action to perform when a solution file is found.</param>
private static async Task ProcessSolutionFolderAsync(string folder, Action<FileMatchItem, string> foundAction)
{
Matcher matcher = new();
matcher.AddInclude("**/*.sln");
var files = matcher.GetResultsInFullPath(folder);
var tasks = files.Select(async file =>
{
foundAction?.Invoke(new FileMatchItem(file), file);
var list = await GetProjectFiles(Path.GetDirectoryName(file));
foreach (var item in list)
{
foundAction?.Invoke(item, file);
}
});
await Task.WhenAll(tasks);
}
/// <summary>
/// Asynchronously retrieves a list of project files in the specified parent folder.
/// </summary>
/// <param name="parentFolder">The parent folder to search for project files.</param>
/// <returns>The task result contains a list of <see cref="FileMatchItem"/> representing the project files found.</returns>
public static async Task<List<FileMatchItem>> GetProjectFiles(string parentFolder)
{
List<FileMatchItem> list = [];
Matcher matcher = new();
matcher.AddIncludePatterns(["**/*.csproj"]);
await Task.Run(() =>
{
foreach (var file in matcher.GetResultsInFullPath(parentFolder))
{
list.Add(new FileMatchItem(file));
}
});
return list;
}
}
ProcessSolutionFolderAsync performance
The first iteration of the code ran and found 152 solutions and over 1,300 projects which ran slow.
private static async Task ProcessSolutionFolderAsync(string folder, Action<FileMatchItem, string> foundAction)
{
Matcher matcher = new();
matcher.AddIncludePatterns(["**/*.sln"]);
await Task.Run(async () =>
{
foreach (var file in matcher.GetResultsInFullPath(folder))
{
foundAction?.Invoke(new FileMatchItem(file), file);
var list = await GetProjectFiles(Path.GetDirectoryName(file));
foreach (var item in list)
{
foundAction?.Invoke(item, file);
}
}
});
}
The reason appears to have been from invoking the action in the foreach.
GitHub Copilot to the rescue
Beings A.I. is so helpful, Copilot was invoked with /optimize and the results are shown below shaving off over 2 seconds of execution time. The author could had made the changes which might have taken ten minutes while Copilot did it in less than 20 seconds.
Start-up code
- path variable is the folder to scan for Visual Studio solutions
- Asserts if the folder exists
- Using NuGet package Kurukuru to create a spinner which goes away once globbing operation finishes
Note
An alias was created in the project file as the need was to point to Kurukuru rather than Spectre.Console NuGet package which also has a spinner class.
<ItemGroup>
<PackageReference Include="Kurukuru" Version="1.4.2" />
<Using Include="Kurukuru" Alias="KB" />
</ItemGroup>
-
List<Solutions> data = GlobSolutions.Solutions
provides access to the found solutions. - Next the results in
data
are written to Results.json via native json serialization. - Next, present how many solutions were found and total project count.
using SolutionFinderApp.Classes;
using System.Text.Json;
using SolutionFinderApp.Models;
namespace SolutionFinderApp;
internal partial class Program
{
private static async Task Main(string[] args)
{
/*
* Set this to a path with one or more Visual Studio solutions
*/
const string path = @"TODO";
if (!Directory.Exists(path))
{
AnsiConsole.MarkupLine($"[red]The specified path does not exist:[/] [yellow]{path}[/]");
Console.ReadLine();
return;
}
/*
* Scan folder with a simple spinner to apse the user
* KP alias is defined in the project file
*/
using (var spinner = new KB.Spinner($"Scanning {path}"))
{
spinner.Start();
await GlobSolutions.GetSolutionNames(path);
}
List<Solutions> data = GlobSolutions.Solutions;
await File.WriteAllTextAsync("Results.json", JsonSerializer.Serialize(data,Options));
Console.Clear();
AnsiConsole.MarkupLine($"[cyan]Total solutions:[/] [yellow]{data.Count}[/] [cyan]in[/] [yellow]{path}[/]");
var projectCount = data.Sum(solution => solution.Projects.Count);
AnsiConsole.MarkupLine($"[cyan]Total projects:[/] [yellow]{projectCount}[/]");
Console.ReadLine();
}
/// <summary>
/// Gets the options for JSON serialization, configured to format the JSON output with indentation.
/// </summary>
/// <value>
/// The <see cref="JsonSerializerOptions"/> instance configured with <c>WriteIndented</c> set to <c>true</c>.
/// </value>
private static JsonSerializerOptions Options => new() { WriteIndented = true };
}
Why use Action rather than delegate/event.
In short less code as Action
is a Delegate
. It is defined like this
public delegate void Action();
Which is better? rather than which is better, it should be what is a developer comfortable with while at the same time its good to understand and know how to use both.
Summary
In the article code has been presented to get started with using file globbing. From here the are opportunities for other uses beside working against solution file. Also, considering creating dotnet tools with file globbing. For getting started with dotnet tools see C# .NET Tools with System.CommandLine.