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, )",