diff --git a/bld/Commands/DepsCommand.cs b/bld/Commands/DepsCommand.cs new file mode 100644 index 0000000..025e999 --- /dev/null +++ b/bld/Commands/DepsCommand.cs @@ -0,0 +1,179 @@ +using bld.Infrastructure; +using bld.Models; +using bld.Services; +using System.CommandLine; +using System.CommandLine.Parsing; +using System.IO; + +namespace bld.Commands; + +internal sealed class DepsCommand : BaseCommand { + + private readonly Option _nugetOption = new Option("--nuget") { + Description = "Include NuGet package references in the dependency tree.", + DefaultValueFactory = _ => true + }; + + private readonly Option _projectOption = new Option("--project", "-p") { + Description = "Specific project file to analyze. If not provided, analyzes all projects in the solution.", + Validators = { + v => { + var path = v.GetValueOrDefault(); + if (path is not null && !File.Exists(path)) { + v.AddError($"Project file {path} does not exist."); + } + } + } + }; + + public DepsCommand(IConsoleOutput console) : base("deps", "Show dependency tree for projects in a solution.", console) { + Add(_rootOption); + Add(_depthOption); + Add(_logLevelOption); + Add(_vsToolsPath); + Add(_noResolveVsToolsPath); + Add(_nugetOption); + Add(_projectOption); + Add(_rootArgument); + } + + protected override async Task ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken) { + var options = new CleaningOptions { + LogLevel = parseResult.GetValue(_logLevelOption), + Depth = parseResult.GetValue(_depthOption), + VSToolsPath = parseResult.GetValue(_vsToolsPath), + NoResolveVSToolsPath = parseResult.GetValue(_noResolveVsToolsPath), + }; + + if (!options.NoResolveVSToolsPath && string.IsNullOrEmpty(options.VSToolsPath)) { + options.VSToolsPath = TryResolveVSToolsPath(out var vsRoot); + options.VSRootPath = vsRoot; + } + + base.Console = new SpectreConsoleOutput(options.LogLevel); + + var rootPath = parseResult.GetValue(_rootArgument) ?? parseResult.GetValue(_rootOption); + if (string.IsNullOrWhiteSpace(rootPath)) { + rootPath = Environment.CurrentDirectory; + } + + var specificProject = parseResult.GetValue(_projectOption); + var includeNuget = parseResult.GetValue(_nugetOption); + + // Initialize MSBuild + MSBuildService.RegisterMSBuildDefaults(base.Console, options); + + var errorSink = new ErrorSink(base.Console); + var dependencyAnalyzer = new DependencyAnalyzer(base.Console, errorSink, options); + + try { + if (!string.IsNullOrEmpty(specificProject)) { + // Analyze specific project + await AnalyzeSpecificProject(specificProject, dependencyAnalyzer, includeNuget); + } + else { + // Analyze solution + await AnalyzeSolution(rootPath, options, errorSink, dependencyAnalyzer, includeNuget); + } + } + catch (Exception ex) { + base.Console.WriteError($"Error analyzing dependencies: {ex.Message}"); + return 1; + } + + return 0; + } + + private Task AnalyzeSpecificProject(string projectPath, DependencyAnalyzer analyzer, bool includeNuget) { + base.Console.WriteInfo($"Analyzing dependencies for project: {Path.GetFileName(projectPath)}"); + base.Console.WriteInfo(""); + + var dependencyTree = analyzer.BuildDependencyTree(projectPath, includeNuget); + if (dependencyTree != null) { + PrintDependencyTree(dependencyTree, 0, includeNuget); + } + else { + base.Console.WriteWarning($"Could not analyze project: {projectPath}"); + } + return Task.CompletedTask; + } + + private async Task AnalyzeSolution(string rootPath, CleaningOptions options, ErrorSink errorSink, DependencyAnalyzer analyzer, bool includeNuget) { + var scanner = new SlnScanner(options, errorSink); + var slnParser = new SlnParser(base.Console, errorSink); + + bool foundSolutions = false; + await foreach (var slnPath in scanner.Enumerate(rootPath)) { + foundSolutions = true; + var sln = new Sln(slnPath); + base.Console.WriteInfo($"Analyzing dependencies for solution: {Path.GetFileName(sln.Path)}"); + base.Console.WriteInfo(""); + + var projects = new List(); + await foreach (var proj in slnParser.ParseSolution(sln.Path)) { + // Take only Debug configuration for dependency analysis + if (string.Equals(proj.Configuration, "Debug", StringComparison.OrdinalIgnoreCase)) { + projects.Add(proj); + } + } + + var dependencyTrees = analyzer.BuildSolutionDependencyTree(projects, includeNuget); + + if (dependencyTrees.Any()) { + foreach (var tree in dependencyTrees) { + PrintDependencyTree(tree, 0, includeNuget); + base.Console.WriteInfo(""); + } + } + else { + base.Console.WriteWarning($"No dependencies found in solution: {sln.Path}"); + } + } + + if (!foundSolutions) { + base.Console.WriteWarning($"No solution files found in: {rootPath}"); + } + } + + private void PrintDependencyTree(DependencyNode node, int depth, bool includeNuget, HashSet? visited = null) { + visited ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + // Prevent infinite recursion in case of circular references + if (visited.Contains(node.DependencyInfo.ProjectPath)) { + var circularIndent = new string(' ', depth * 2); + base.Console.WriteInfo($"{circularIndent}├── {node.DependencyInfo.ProjectName} [CIRCULAR REFERENCE]"); + return; + } + + visited.Add(node.DependencyInfo.ProjectPath); + + var indent = new string(' ', depth * 2); + var projectName = node.DependencyInfo.ProjectName; + var targetFramework = !string.IsNullOrEmpty(node.DependencyInfo.TargetFramework) + ? $" ({node.DependencyInfo.TargetFramework})" + : ""; + + if (depth == 0) { + base.Console.WriteInfo($"📦 {projectName}{targetFramework}"); + } + else { + base.Console.WriteInfo($"{indent}├── {projectName}{targetFramework}"); + } + + // Print project dependencies + foreach (var projectDep in node.ProjectDependencies) { + PrintDependencyTree(projectDep, depth + 1, includeNuget, visited); + } + + // Print package dependencies + if (includeNuget && node.PackageDependencies.Any()) { + foreach (var packageDep in node.PackageDependencies.OrderBy(p => p.PackageId)) { + var packageIndent = new string(' ', (depth + 1) * 2); + var privateAssets = packageDep.IsPrivateAssets ? " [Private]" : ""; + base.Console.WriteInfo($"{packageIndent}├── 📎 {packageDep.PackageId} v{packageDep.Version}{privateAssets}"); + } + } + + visited.Remove(node.DependencyInfo.ProjectPath); + } +} \ No newline at end of file diff --git a/bld/Commands/RootCommand.cs b/bld/Commands/RootCommand.cs index 91ed31a..170ebd3 100644 --- a/bld/Commands/RootCommand.cs +++ b/bld/Commands/RootCommand.cs @@ -11,5 +11,6 @@ public RootCommand() : base("bld") { Add(new CleanCommand(console)); Add(new StatsCommand(console)); + Add(new DepsCommand(console)); } } diff --git a/bld/Models/DependencyModels.cs b/bld/Models/DependencyModels.cs new file mode 100644 index 0000000..fad2d35 --- /dev/null +++ b/bld/Models/DependencyModels.cs @@ -0,0 +1,42 @@ +namespace bld.Models; + +/// +/// Represents a project reference dependency +/// +internal record ProjectReference { + public string ProjectPath { get; init; } = string.Empty; + public string ProjectName { get; init; } = string.Empty; + public bool IsResolved { get; init; } = true; +} + +/// +/// Represents a NuGet package reference dependency +/// +internal record PackageReference { + public string PackageId { get; init; } = string.Empty; + public string Version { get; init; } = string.Empty; + public bool IsPrivateAssets { get; init; } = false; +} + +/// +/// Represents a project and its dependencies +/// +internal record DependencyInfo { + public string ProjectPath { get; init; } = string.Empty; + public string ProjectName { get; init; } = string.Empty; + public string TargetFramework { get; init; } = string.Empty; + public IReadOnlyList ProjectReferences { get; init; } = Array.Empty(); + public IReadOnlyList PackageReferences { get; init; } = Array.Empty(); +} + +/// +/// Represents a node in the dependency tree +/// +internal class DependencyNode { + public DependencyInfo DependencyInfo { get; set; } = null!; + public List ProjectDependencies { get; set; } = new(); + public List PackageDependencies { get; set; } = new(); + + // To prevent circular dependencies during tree traversal + public bool IsVisiting { get; set; } = false; +} \ No newline at end of file diff --git a/bld/Services/DependencyAnalyzer.cs b/bld/Services/DependencyAnalyzer.cs new file mode 100644 index 0000000..a64dc23 --- /dev/null +++ b/bld/Services/DependencyAnalyzer.cs @@ -0,0 +1,201 @@ +using bld.Infrastructure; +using bld.Models; +using Microsoft.Build.Evaluation; +using System.IO; + +namespace bld.Services; + +/// +/// Analyzes project dependencies and builds dependency trees +/// +internal sealed class DependencyAnalyzer(IConsoleOutput Console, ErrorSink ErrorSink, CleaningOptions Options) { + + private Dictionary _globalProperties = default!; + + private Dictionary GlobalProperties => _globalProperties ??= + Options.VSToolsPath is null ? + new Dictionary() + : Init(Options); + + private static Dictionary Init(CleaningOptions Options) { + var dict = new Dictionary(2); + if (Options.VSToolsPath is { }) dict["VSToolsPath"] = Options.VSToolsPath; + if (Options.VSRootPath is { } && Directory.Exists(Path.Combine(Options.VSRootPath, "MSBuild"))) dict["MSBuildExtensionsPath"] = Path.Combine(Options.VSRootPath, "MSBuild"); + + return dict; + } + + /// + /// Extracts dependency information from a project file + /// + internal DependencyInfo? ExtractDependencies(ProjCfg proj) { + string projectPath = proj.Path; + string configuration = proj.Configuration; + + using (var projectCollection = new ProjectCollection()) { + var project = default(Project); + + var properties = new Dictionary(GlobalProperties); + properties["Configuration"] = configuration; + try { + project = new Project(projectPath, properties, null, projectCollection); + } + catch (Exception xcptn) { + ErrorSink.AddError($"Failed to load project.", exception: xcptn, config: proj); + Console.WriteError($"{projectPath} could not be parsed: {xcptn.Message}."); + return default; + } + + static string? Safe(string value) => value is string && !string.IsNullOrEmpty(value) ? value : default; + + // Extract project references + var projectReferences = new List(); + var projectReferenceItems = project.GetItems("ProjectReference"); + foreach (var item in projectReferenceItems) { + var refPath = item.EvaluatedInclude; + var fullRefPath = Path.IsPathRooted(refPath) ? refPath : Path.GetFullPath(Path.Combine(Path.GetDirectoryName(projectPath)!, refPath)); + + var projectName = Path.GetFileNameWithoutExtension(refPath); + if (item.HasMetadata("Name")) { + projectName = item.GetMetadataValue("Name"); + } + + projectReferences.Add(new ProjectReference { + ProjectPath = fullRefPath, + ProjectName = projectName, + IsResolved = File.Exists(fullRefPath) + }); + } + + // Extract package references + var packageReferences = new List(); + var packageReferenceItems = project.GetItems("PackageReference"); + foreach (var item in packageReferenceItems) { + var packageId = item.EvaluatedInclude; + var version = item.GetMetadataValue("Version"); + + // Handle central package management - if Version is empty, try to get it from PackageVersion items + if (string.IsNullOrEmpty(version)) { + var packageVersionItems = project.GetItems("PackageVersion"); + var packageVersionItem = packageVersionItems.FirstOrDefault(pv => string.Equals(pv.EvaluatedInclude, packageId, StringComparison.OrdinalIgnoreCase)); + if (packageVersionItem != null) { + version = packageVersionItem.GetMetadataValue("Version"); + } + + // If still empty, try to evaluate a property reference + if (string.IsNullOrEmpty(version)) { + // Some projects might use property references like $(SomePackageVersion) + version = project.GetPropertyValue($"{packageId}Version") ?? "Unknown"; + } + } + + var privateAssets = string.Equals(item.GetMetadataValue("PrivateAssets"), "all", StringComparison.OrdinalIgnoreCase); + + packageReferences.Add(new PackageReference { + PackageId = packageId, + Version = version ?? "Unknown", + IsPrivateAssets = privateAssets + }); + } + + var info = new DependencyInfo { + ProjectPath = projectPath, + ProjectName = Safe(project.GetPropertyValue("ProjectName")) ?? Path.GetFileNameWithoutExtension(projectPath), + TargetFramework = Safe(project.GetPropertyValue("TargetFramework")) ?? string.Empty, + ProjectReferences = projectReferences, + PackageReferences = packageReferences + }; + + return info; + } + } + + /// + /// Builds a dependency tree starting from a root project + /// + internal DependencyNode? BuildDependencyTree(string rootProjectPath, bool includeNuget = true) { + var visitedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); + var projectCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + return BuildDependencyTreeRecursive(rootProjectPath, visitedProjects, projectCache, includeNuget); + } + + /// + /// Builds dependency tree from all projects in a solution + /// + internal List BuildSolutionDependencyTree(IEnumerable projects, bool includeNuget = true) { + var visitedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); + var projectCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + var rootNodes = new List(); + + // First, cache all project dependencies + foreach (var proj in projects) { + var depInfo = ExtractDependencies(proj); + if (depInfo != null && !projectCache.ContainsKey(depInfo.ProjectPath)) { + projectCache[depInfo.ProjectPath] = depInfo; + } + } + + // Build trees for projects that aren't referenced by others (root projects) + var allReferencedProjects = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var depInfo in projectCache.Values) { + foreach (var projRef in depInfo.ProjectReferences) { + allReferencedProjects.Add(projRef.ProjectPath); + } + } + + foreach (var proj in projects) { + if (!allReferencedProjects.Contains(proj.Path)) { + var rootNode = BuildDependencyTreeRecursive(proj.Path, visitedProjects, projectCache, includeNuget); + if (rootNode != null) { + rootNodes.Add(rootNode); + } + } + } + + return rootNodes; + } + + private DependencyNode? BuildDependencyTreeRecursive(string projectPath, HashSet visitedProjects, Dictionary projectCache, bool includeNuget) { + // Avoid circular dependencies + if (visitedProjects.Contains(projectPath)) { + return null; + } + + visitedProjects.Add(projectPath); + + // Try to get from cache first + DependencyInfo? depInfo = null; + if (projectCache.TryGetValue(projectPath, out depInfo)) { + // Use cached version + } + else { + // Extract dependencies if not cached + var projCfg = new ProjCfg(new Proj(projectPath, null), "Debug", null); + depInfo = ExtractDependencies(projCfg); + } + + if (depInfo == null) { + visitedProjects.Remove(projectPath); + return null; + } + + var node = new DependencyNode { + DependencyInfo = depInfo, + PackageDependencies = includeNuget ? depInfo.PackageReferences.ToList() : new List() + }; + + // Process project references recursively + foreach (var projRef in depInfo.ProjectReferences) { + if (projRef.IsResolved && File.Exists(projRef.ProjectPath)) { + var childNode = BuildDependencyTreeRecursive(projRef.ProjectPath, visitedProjects, projectCache, includeNuget); + if (childNode != null) { + node.ProjectDependencies.Add(childNode); + } + } + } + + visitedProjects.Remove(projectPath); + return node; + } +} \ No newline at end of file