Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>

<ItemGroup>
<PackageVersion Include="Buildalyzer.Workspaces" Version="7.1.0" />
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="8.0.0" />
<PackageVersion Include="DendroDocs.Shared" Version="0.4.2" />
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageVersion Include="Nuke.Common" Version="9.0.4" />
Expand All @@ -23,4 +23,4 @@
<PackageVersion Include="MSTest.TestFramework" Version="3.10.1" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
</ItemGroup>
</Project>
</Project>
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 106 additions & 0 deletions src/DendroDocs.Tool/AnalyzerSetup.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using Buildalyzer;
using Buildalyzer.Workspaces;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;

namespace DendroDocs.Tool;

Expand Down Expand Up @@ -42,8 +44,112 @@ public static AnalyzerSetup BuildProjectAnalyzer(string projectFile)
return new AnalyzerSetup(manager);
}

public static AnalyzerSetup BuildFolderAnalyzer(string folderPathOrPattern, IEnumerable<string> excludedProjects = default!)
{
var excludedSet = excludedProjects is not null ? new HashSet<string>(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<string> 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);
}
}
2 changes: 2 additions & 0 deletions src/DendroDocs.Tool/DendroDocs.Tool.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@
<Using Include="Microsoft.CodeAnalysis" />
<Using Include="Microsoft.CodeAnalysis.CSharp" />
<Using Include="Microsoft.CodeAnalysis.CSharp.Syntax" />
<Using Include="Microsoft.Extensions.FileSystemGlobbing" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Buildalyzer.Workspaces" />
<PackageReference Include="CommandLineParser" />
<PackageReference Include="DendroDocs.Shared" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
<PackageReference Include="Newtonsoft.Json.Schema" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.VisualBasic.Workspaces" />
Expand Down
5 changes: 4 additions & 1 deletion src/DendroDocs.Tool/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> ExcludedProjectPaths { get; set; } = [];

[Option("output", Required = true, HelpText = "The location of the output.")]
Expand Down
4 changes: 3 additions & 1 deletion src/DendroDocs.Tool/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ private static async Task<int> 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);
}
Expand Down
13 changes: 13 additions & 0 deletions src/DendroDocs.Tool/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/DendroDocs.Tool/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, )",
Expand Down
100 changes: 98 additions & 2 deletions tests/DendroDocs.Tool.Tests/AnalyzerSetup/AnalyzerSetupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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()
Expand All @@ -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();
}
}
7 changes: 7 additions & 0 deletions tests/DendroDocs.Tool.Tests/packages.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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, )"
}
},
Expand Down Expand Up @@ -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, )",
Expand Down
Loading