diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3c3cc91..781f948 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,11 +3,11 @@
true
true
-
+
@@ -23,4 +23,4 @@
-
+
\ No newline at end of file
diff --git a/README.md b/README.md
index b1a67be..1c68563 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,20 @@ dotnet tool install --global DendroDocs.Tool
Example usage:
```shell
+# Analyze a solution file
dendrodocs-analyze --solution G:\DendroDocs\dotnet-shared-lib\DendroDocs.Shared.sln --output shared.json --pretty --verbose --exclude G:\DendroDocs\dotnet-shared-lib\build\_build.csproj
+
+# Analyze a single project file
+dendrodocs-analyze --project MyProject.csproj --output project.json --pretty
+
+# Analyze all projects in a folder
+dendrodocs-analyze --folder /path/to/projects --output folder.json --pretty
+
+# Use glob patterns to select specific projects
+dendrodocs-analyze --folder "src/**/*.csproj" --output matched.json --pretty
+
+# Exclude specific projects when analyzing a folder
+dendrodocs-analyze --folder /path/to/projects --exclude /path/to/unwanted.csproj,/path/to/test.csproj --output filtered.json
```
## Output
diff --git a/src/DendroDocs.Tool/AnalyzerSetup.cs b/src/DendroDocs.Tool/AnalyzerSetup.cs
index 110df73..f6edb2a 100644
--- a/src/DendroDocs.Tool/AnalyzerSetup.cs
+++ b/src/DendroDocs.Tool/AnalyzerSetup.cs
@@ -1,5 +1,7 @@
using Buildalyzer;
using Buildalyzer.Workspaces;
+using Microsoft.Extensions.FileSystemGlobbing;
+using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
namespace DendroDocs.Tool;
@@ -42,8 +44,112 @@ public static AnalyzerSetup BuildProjectAnalyzer(string projectFile)
return new AnalyzerSetup(manager);
}
+ public static AnalyzerSetup BuildFolderAnalyzer(string folderPathOrPattern, IEnumerable excludedProjects = default!)
+ {
+ var excludedSet = excludedProjects is not null ? new HashSet(excludedProjects, StringComparer.OrdinalIgnoreCase) : [];
+ var projectFiles = DiscoverProjectFiles(folderPathOrPattern);
+
+ if (!projectFiles.Any())
+ {
+ throw new InvalidOperationException($"No project files found in folder or pattern: {folderPathOrPattern}");
+ }
+
+ var manager = new AnalyzerManager();
+ foreach (var projectFile in projectFiles)
+ {
+ if (!excludedSet.Contains(projectFile))
+ {
+ manager.GetProject(projectFile);
+ }
+ }
+
+ var analysis = new AnalyzerSetup(manager);
+
+ // Filter out test projects and excluded projects
+ analysis.Projects = analysis.Projects
+ .Where(p => !ProjectContainsTestPackageReference(manager, p))
+ .Where(p => string.IsNullOrEmpty(p.FilePath) || !excludedSet.Contains(p.FilePath));
+
+ return analysis;
+ }
+
private static bool ProjectContainsTestPackageReference(AnalyzerManager manager, Project p)
{
return manager.Projects.First(mp => p.Id.Id == mp.Value.ProjectGuid).Value.ProjectFile.PackageReferences.Any(pr => pr.Name.Contains("Test", StringComparison.Ordinal));
}
+
+ private static IEnumerable DiscoverProjectFiles(string folderPathOrPattern)
+ {
+ // Check if it's a direct path to a folder
+ if (Directory.Exists(folderPathOrPattern))
+ {
+ return Directory.GetFiles(folderPathOrPattern, "*.csproj", SearchOption.AllDirectories);
+ }
+
+ // Handle glob patterns
+ var matcher = new Matcher();
+
+ // If the pattern doesn't contain wildcards, assume it's a folder that doesn't exist
+ if (!folderPathOrPattern.Contains('*') && !folderPathOrPattern.Contains('?'))
+ {
+ throw new DirectoryNotFoundException($"Folder not found: {folderPathOrPattern}");
+ }
+
+ // Handle as a glob pattern
+ string baseDirectory;
+ string pattern;
+
+ // Check if pattern is absolute or relative
+ if (Path.IsPathRooted(folderPathOrPattern))
+ {
+ // Absolute path - extract base directory and relative pattern
+ var parts = SplitAbsolutePattern(folderPathOrPattern);
+ baseDirectory = parts.BaseDirectory;
+ pattern = parts.Pattern;
+ }
+ else
+ {
+ // Relative path
+ baseDirectory = Directory.GetCurrentDirectory();
+ pattern = folderPathOrPattern;
+ }
+
+ matcher.AddInclude(pattern);
+ var result = matcher.Execute(new DirectoryInfoWrapper(new DirectoryInfo(baseDirectory)));
+ return result.Files.Select(f => Path.Combine(baseDirectory, f.Path));
+ }
+
+ private static (string BaseDirectory, string Pattern) SplitAbsolutePattern(string absolutePattern)
+ {
+ // Find the first wildcard
+ var wildcardIndex = Math.Min(
+ absolutePattern.IndexOf('*') >= 0 ? absolutePattern.IndexOf('*') : int.MaxValue,
+ absolutePattern.IndexOf('?') >= 0 ? absolutePattern.IndexOf('?') : int.MaxValue
+ );
+
+ if (wildcardIndex == int.MaxValue)
+ {
+ // No wildcards found, treat as directory
+ return (absolutePattern, "**/*.csproj");
+ }
+
+ // Find the last directory separator before the wildcard
+ var basePart = absolutePattern.Substring(0, wildcardIndex);
+ var lastSeparator = basePart.LastIndexOf(Path.DirectorySeparatorChar);
+
+ if (lastSeparator >= 0)
+ {
+ var baseDir = basePart.Substring(0, lastSeparator);
+ var relativePattern = absolutePattern.Substring(lastSeparator + 1);
+
+ // Ensure base directory exists
+ if (Directory.Exists(baseDir))
+ {
+ return (baseDir, relativePattern);
+ }
+ }
+
+ // Fallback to current directory
+ return (Directory.GetCurrentDirectory(), absolutePattern);
+ }
}
diff --git a/src/DendroDocs.Tool/DendroDocs.Tool.csproj b/src/DendroDocs.Tool/DendroDocs.Tool.csproj
index 5032590..27babeb 100644
--- a/src/DendroDocs.Tool/DendroDocs.Tool.csproj
+++ b/src/DendroDocs.Tool/DendroDocs.Tool.csproj
@@ -36,12 +36,14 @@
+
+
diff --git a/src/DendroDocs.Tool/Options.cs b/src/DendroDocs.Tool/Options.cs
index 3ba4f00..9763bdc 100644
--- a/src/DendroDocs.Tool/Options.cs
+++ b/src/DendroDocs.Tool/Options.cs
@@ -10,7 +10,10 @@ public class Options
[Option("project", Required = true, SetName = "project", HelpText = "The project to analyze.")]
public string? ProjectPath { get; set; }
- [Option("exclude", Required = false, SetName = "solution", Separator = ',', HelpText = "Any projects to exclude from analysis.")]
+ [Option("folder", Required = true, SetName = "folder", HelpText = "The folder to search for projects recursively, or a glob pattern to match specific project files (e.g., 'src/**/*.csproj').")]
+ public string? FolderPath { get; set; }
+
+ [Option("exclude", Required = false, Separator = ',', HelpText = "Any projects to exclude from analysis.")]
public IEnumerable ExcludedProjectPaths { get; set; } = [];
[Option("output", Required = true, HelpText = "The location of the output.")]
diff --git a/src/DendroDocs.Tool/Program.cs b/src/DendroDocs.Tool/Program.cs
index 93ed339..89cbc7d 100644
--- a/src/DendroDocs.Tool/Program.cs
+++ b/src/DendroDocs.Tool/Program.cs
@@ -31,7 +31,9 @@ private static async Task RunApplicationAsync(Options options)
using (var analyzer = options.SolutionPath is not null
? AnalyzerSetup.BuildSolutionAnalyzer(options.SolutionPath, options.ExcludedProjectPaths)
- : AnalyzerSetup.BuildProjectAnalyzer(options.ProjectPath!))
+ : options.ProjectPath is not null
+ ? AnalyzerSetup.BuildProjectAnalyzer(options.ProjectPath!)
+ : AnalyzerSetup.BuildFolderAnalyzer(options.FolderPath!, options.ExcludedProjectPaths))
{
await AnalyzeWorkspace(types, analyzer).ConfigureAwait(false);
}
diff --git a/src/DendroDocs.Tool/README.md b/src/DendroDocs.Tool/README.md
index 71903d9..1313590 100644
--- a/src/DendroDocs.Tool/README.md
+++ b/src/DendroDocs.Tool/README.md
@@ -22,7 +22,20 @@ dotnet tool install --global DendroDocs.Tool
## Example usage
```shell
+# Analyze a solution file
dendrodocs-analyze --solution G:\DendroDocs\dotnet-shared-lib\DendroDocs.Shared.sln --output shared.json --pretty --verbose --exclude G:\DendroDocs\dotnet-shared-lib\build\_build.csproj
+
+# Analyze a single project file
+dendrodocs-analyze --project MyProject.csproj --output project.json --pretty
+
+# Analyze all projects in a folder
+dendrodocs-analyze --folder /path/to/projects --output folder.json --pretty
+
+# Use glob patterns to select specific projects
+dendrodocs-analyze --folder "src/**/*.csproj" --output matched.json --pretty
+
+# Exclude specific projects when analyzing a folder
+dendrodocs-analyze --folder /path/to/projects --exclude /path/to/unwanted.csproj,/path/to/test.csproj --output filtered.json
```
## Output
diff --git a/src/DendroDocs.Tool/packages.lock.json b/src/DendroDocs.Tool/packages.lock.json
index 761a3a0..56260b1 100644
--- a/src/DendroDocs.Tool/packages.lock.json
+++ b/src/DendroDocs.Tool/packages.lock.json
@@ -64,6 +64,12 @@
"System.Threading.Channels": "7.0.0"
}
},
+ "Microsoft.Extensions.FileSystemGlobbing": {
+ "type": "Direct",
+ "requested": "[8.0.0, )",
+ "resolved": "8.0.0",
+ "contentHash": "OK+670i7esqlQrPjdIKRbsyMCe9g5kSLpRRQGSr4Q58AOYEe/hCnfLZprh7viNisSUUQZmMrbbuDaIrP+V1ebQ=="
+ },
"Microsoft.SourceLink.GitHub": {
"type": "Direct",
"requested": "[8.0.0, )",
diff --git a/tests/DendroDocs.Tool.Tests/AnalyzerSetup/AnalyzerSetupTests.cs b/tests/DendroDocs.Tool.Tests/AnalyzerSetup/AnalyzerSetupTests.cs
index 1b09d38..1e547a8 100644
--- a/tests/DendroDocs.Tool.Tests/AnalyzerSetup/AnalyzerSetupTests.cs
+++ b/tests/DendroDocs.Tool.Tests/AnalyzerSetup/AnalyzerSetupTests.cs
@@ -78,7 +78,8 @@ public void SolutionShouldFilterExcludedProjects()
// Assert
analyzerSetup.Projects.ShouldHaveSingleItem();
- analyzerSetup.Projects.ShouldAllBe(p => p.FilePath != null && p.FilePath.EndsWith("Project.csproj"));
+ analyzerSetup.Projects.ShouldAllBe(p => p.FilePath != null);
+ analyzerSetup.Projects.ShouldAllBe(p => p.FilePath!.EndsWith("Project.csproj"));
}
[TestMethod]
@@ -92,7 +93,95 @@ public void SolutionShouldLoadProject()
// Assert
analyzerSetup.Projects.ShouldHaveSingleItem();
- analyzerSetup.Projects.ShouldAllBe(p => p.FilePath != null && p.FilePath.EndsWith("Project.csproj"));
+ analyzerSetup.Projects.ShouldAllBe(p => p.FilePath != null);
+ analyzerSetup.Projects.ShouldAllBe(p => p.FilePath!.EndsWith("Project.csproj"));
+ }
+
+ [TestMethod]
+ public void FolderShouldLoadAllProjects()
+ {
+ // Arrange
+ var folderPath = SolutionPath;
+
+ // Act
+ using var analyzerSetup = AnalyzerSetup.BuildFolderAnalyzer(folderPath);
+
+ // Assert
+ analyzerSetup.Projects.Count().ShouldBe(3);
+
+ var projectPaths = analyzerSetup.Projects.Select(p => p.FilePath).ToList();
+ projectPaths.ShouldAllBe(path => path != null);
+ projectPaths.ShouldContain(path => path!.EndsWith("Project.csproj"));
+ projectPaths.ShouldContain(path => path!.EndsWith("OtherProject.csproj"));
+ projectPaths.ShouldContain(path => path!.EndsWith("AnotherProject.csproj"));
+ }
+
+ [TestMethod]
+ public void FolderShouldFilterTestProjects()
+ {
+ // Arrange
+ var basePath = GetBasePath();
+ var folderPath = Path.Combine(basePath, "AnalyzerSetupVerification");
+
+ // Act
+ using var analyzerSetup = AnalyzerSetup.BuildFolderAnalyzer(folderPath);
+
+ // Assert
+ // Should have 3 projects (excluding TestProject which has test packages)
+ analyzerSetup.Projects.Count().ShouldBe(3);
+
+ var projects = analyzerSetup.Projects.ToList();
+ projects.ShouldAllBe(p => p.FilePath != null);
+ projects.ShouldAllBe(p => !p.FilePath!.Contains("TestProject"));
+ }
+
+ [TestMethod]
+ public void FolderShouldFilterExcludedProjects()
+ {
+ // Arrange
+ var folderPath = SolutionPath;
+ var excludeProjectFile1 = Path.Combine(SolutionPath, "OtherProject", "OtherProject.csproj");
+ var excludeProjectFile2 = Path.Combine(SolutionPath, "AnotherProject", "AnotherProject.csproj");
+
+ // Act
+ using var analyzerSetup = AnalyzerSetup.BuildFolderAnalyzer(folderPath, [excludeProjectFile1, excludeProjectFile2]);
+
+ // Assert
+ analyzerSetup.Projects.ShouldHaveSingleItem();
+ analyzerSetup.Projects.ShouldAllBe(p => p.FilePath != null);
+ analyzerSetup.Projects.ShouldAllBe(p => p.FilePath!.EndsWith("Project.csproj"));
+ }
+
+ [TestMethod]
+ public void GlobPatternShouldFindMatchingProjects()
+ {
+ // Arrange
+ var basePath = GetBasePath();
+ var originalDir = Directory.GetCurrentDirectory();
+
+ try
+ {
+ // Change to the test base directory to make relative patterns work
+ Directory.SetCurrentDirectory(basePath);
+ var globPattern = "AnalyzerSetupVerification/**/*.csproj";
+
+ // Act
+ using var analyzerSetup = AnalyzerSetup.BuildFolderAnalyzer(globPattern);
+
+ // Assert
+ // Should find the 3 non-test projects that match the pattern (excludes TestProject due to test references)
+ analyzerSetup.Projects.Count().ShouldBe(3);
+
+ var projectPaths = analyzerSetup.Projects.Select(p => p.FilePath).ToList();
+ projectPaths.ShouldAllBe(path => path != null);
+ projectPaths.ShouldContain(path => path!.EndsWith("Project.csproj"));
+ projectPaths.ShouldContain(path => path!.EndsWith("OtherProject.csproj"));
+ projectPaths.ShouldContain(path => path!.EndsWith("AnotherProject.csproj"));
+ }
+ finally
+ {
+ Directory.SetCurrentDirectory(originalDir);
+ }
}
private static string GetSolutionPath()
@@ -103,4 +192,11 @@ private static string GetSolutionPath()
return Path.Combine(path.ToString(), "AnalyzerSetupVerification");
}
+
+ private static string GetBasePath()
+ {
+ var currentDirectory = Directory.GetCurrentDirectory().AsSpan();
+
+ return currentDirectory[..(currentDirectory.IndexOf("tests") + 6)].ToString();
+ }
}
diff --git a/tests/DendroDocs.Tool.Tests/packages.lock.json b/tests/DendroDocs.Tool.Tests/packages.lock.json
index baf776c..d28da46 100644
--- a/tests/DendroDocs.Tool.Tests/packages.lock.json
+++ b/tests/DendroDocs.Tool.Tests/packages.lock.json
@@ -530,6 +530,7 @@
"DendroDocs.Shared": "[0.4.2, )",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "[4.14.0, )",
"Microsoft.CodeAnalysis.VisualBasic.Workspaces": "[4.14.0, )",
+ "Microsoft.Extensions.FileSystemGlobbing": "[8.0.0, )",
"Newtonsoft.Json.Schema": "[4.0.1, )"
}
},
@@ -633,6 +634,12 @@
"System.Threading.Channels": "7.0.0"
}
},
+ "Microsoft.Extensions.FileSystemGlobbing": {
+ "type": "CentralTransitive",
+ "requested": "[8.0.0, )",
+ "resolved": "8.0.0",
+ "contentHash": "OK+670i7esqlQrPjdIKRbsyMCe9g5kSLpRRQGSr4Q58AOYEe/hCnfLZprh7viNisSUUQZmMrbbuDaIrP+V1ebQ=="
+ },
"Newtonsoft.Json.Schema": {
"type": "CentralTransitive",
"requested": "[4.0.1, )",