diff --git a/Csproj/Commands/PruneLinks.cs b/Csproj/Commands/PruneLinks.cs index 0fd114a..869b98f 100644 --- a/Csproj/Commands/PruneLinks.cs +++ b/Csproj/Commands/PruneLinks.cs @@ -1,7 +1,9 @@ using System.ComponentModel; + using Spectre.Console.Cli; using Spectre.Console; using Csproj.DomainServices; +using Csproj.Infrastructure; // ReSharper disable UnusedAutoPropertyAccessor.Global // ReSharper disable ClassNeverInstantiated.Global @@ -10,12 +12,18 @@ namespace Csproj.Commands; internal sealed class PruneLinks : Command { + private const string CsProj = ".csproj"; + public class Settings : CommandSettings { - [Description("Solution file path (.sln)")] + [Description("Solution file path (.sln or .slnx)")] [CommandOption("-s|--solution")] public string SolutionPath { get; set; } = string.Empty; + [Description("Project file path (.csproj)")] + [CommandOption("--csproj")] + public string CsprojPath { get; set; } = string.Empty; + [Description("Dryrun mode. Only show what would be changed.")] [CommandOption("-D|--dryrun")] public bool DryRun { get; set; } @@ -33,132 +41,192 @@ public class Settings : CommandSettings public string GraphMdPath { get; set; } = string.Empty; } + /// + /// Executes the prune links command, validating input and performing project reference pruning. + /// + /// The command context. + /// The command settings. See . + /// Exit code: 0 for success, -1 for error. public override int Execute(CommandContext context, Settings settings) { - if (string.IsNullOrWhiteSpace(settings.SolutionPath) || !File.Exists(settings.SolutionPath)) + var errorMessage = ValidateInput(settings); + if (errorMessage is not null) { - AnsiConsole.MarkupLine($"[red]Solution file not found: {settings.SolutionPath}[/]"); + AnsiConsole.MarkupLine($"[red]{errorMessage}[/]"); return -1; } - var projects = SolutionFileParser.GetAllProjectPaths(settings.SolutionPath).ToList(); - var originalGraph = ProjectManipulator.BuildDependencyGraph(projects); - - // Find root project(s) once + (List projects, string rootPath) = GetProjectsAndRootPath(settings); var allProjects = projects.ToHashSet(); - - // Prune redundant links and get updated graph + var originalGraph = ProjectManipulator.BuildDependencyGraph(projects); var changes = ProjectManipulator.PruneRedundantLinks(originalGraph, settings.DryRun, settings.Backup); - var displayGraph = settings.DryRun ? originalGraph : ProjectManipulator.BuildDependencyGraph(projects); // Rebuild after prune - - if (!settings.DryRun) - { - // Rebuild graph after pruning - displayGraph = ProjectManipulator.BuildDependencyGraph(projects); - } + var displayGraph = SelectDisplayGraph(settings, projects, originalGraph); if (settings.Verbose) { - var displayReferencedProjects = new HashSet(); - foreach (var r in displayGraph.Values.SelectMany(refs => refs).Where(r => r.EndsWith(".csproj"))) - { - displayReferencedProjects.Add(r); - } - var displayRootProjects = allProjects.Except(displayReferencedProjects).ToList(); - foreach (var proj in displayRootProjects) - { - var tree = new Tree($"[bold]{Path.GetFileName(proj)}[/]"); - PrintReferenceTree(displayGraph, proj, tree, []); - AnsiConsole.Write(tree); - } + DisplayReferenceTrees(displayGraph, allProjects); } - // Determine output file path for --graph-md - string? outputPath; - var solutionDir = Path.GetDirectoryName(settings.SolutionPath); if (!string.IsNullOrWhiteSpace(settings.GraphMdPath)) { - outputPath = Path.IsPathRooted(settings.GraphMdPath) - ? settings.GraphMdPath - : Path.Combine(solutionDir ?? string.Empty, settings.GraphMdPath); + OutputDependencyGraphMarkdown(settings, displayGraph, allProjects, rootPath); } - else + + ReportChanges(changes); + return 0; + } + + /// + /// Selects the dependency graph to display based on dry run mode. + /// + /// The command settings. See . + /// List of project paths. + /// The original dependency graph. + /// The dependency graph to display. + private static Dictionary> SelectDisplayGraph( + Settings settings, + List projects, + Dictionary> originalGraph) + { + return settings.DryRun ? originalGraph : ProjectManipulator.BuildDependencyGraph(projects); + } + + /// + /// Reports the changes made to project references. + /// + /// List of change descriptions. + private static void ReportChanges(List changes) + { + if (changes.Count == 0) { - var solutionName = Path.GetFileNameWithoutExtension(settings.SolutionPath); - outputPath = Path.Combine(solutionDir ?? string.Empty, solutionName + ".md"); + AnsiConsole.MarkupLine("[green]No redundant links found.[/]"); + return; } - if (!string.IsNullOrWhiteSpace(outputPath)) + foreach (var change in changes) { - var outputFileName = Path.GetFileName(outputPath); - // Use displayGraph for Markdown - var mdReferencedProjects = new HashSet(); - foreach (var r in displayGraph.Values.SelectMany(refs => refs).Where(r => r.EndsWith(".csproj"))) - { - mdReferencedProjects.Add(r); - } - var mdRootProjects = allProjects.Except(mdReferencedProjects).ToList(); - var mdTitle = mdRootProjects.Count > 0 ? Path.GetFileNameWithoutExtension(mdRootProjects[0]) : "dependencies"; - var mermaidMd = GenerateMermaidMarkdown(displayGraph, mdTitle, outputFileName); - File.WriteAllText(outputPath, mermaidMd); - AnsiConsole.MarkupLine($"[green]Dependency graph written to:[/] {outputPath}"); + AnsiConsole.MarkupLine($"[yellow]{change}[/]"); } + } - if (changes.Count == 0) - AnsiConsole.MarkupLine("[green]No redundant links found.[/]"); + /// + /// Gets the list of projects and the root path from the provided settings. + /// + /// The command settings. See . + /// Tuple containing the list of projects and the root path. + private static (List projects, string rootPath) GetProjectsAndRootPath(Settings settings) + { + bool hasSolution = !string.IsNullOrWhiteSpace(settings.SolutionPath); + if (hasSolution) + { + var projects = SolutionFileParser.GetAllProjectPaths(settings.SolutionPath).ToList(); + return (projects, settings.SolutionPath); + } else { - foreach (var change in changes) - AnsiConsole.MarkupLine($"[yellow]{change}[/]"); + var projects = ProjectManipulator.CollectAllReferencedProjects(settings.CsprojPath); + return (projects, settings.CsprojPath); } - - return 0; } - private static void PrintReferenceTree(Dictionary> graph, string proj, object parent, HashSet visited) + /// + /// Validates the input settings for the command. + /// + /// The command settings. See . + /// Error message if invalid, otherwise null. + private static string? ValidateInput(Settings settings) { - visited.Add(proj); - foreach (var reference in graph[proj]) + bool hasSolution = !string.IsNullOrWhiteSpace(settings.SolutionPath); + bool hasCsproj = !string.IsNullOrWhiteSpace(settings.CsprojPath); + + if (hasSolution == hasCsproj) { - var nodeLabel = reference.EndsWith(".csproj") ? Path.GetFileName(reference) : reference; - TreeNode child; - switch (parent) + return ("You must specify either --solution or --csproj, but not both."); + } + + if (hasSolution) + { + if (!File.Exists(settings.SolutionPath)) { - case Tree tree: - child = tree.AddNode(nodeLabel); - break; - case TreeNode node: - child = node.AddNode(nodeLabel); - break; - default: - continue; + return ($"Solution file not found: {settings.SolutionPath}"); } - if (reference.EndsWith(".csproj") && !visited.Contains(reference) && graph.ContainsKey(reference)) + + var ext = Path.GetExtension(settings.SolutionPath).ToLowerInvariant(); + if (ext != ".sln" && ext != ".slnx") { - PrintReferenceTree(graph, reference, child, visited); + return ($"Unsupported solution file extension: {ext}. Only .sln and .slnx are supported."); } } + else + { + if (!File.Exists(settings.CsprojPath)) + return ($"Project file not found: {settings.CsprojPath}"); + } + return null; } - private static string GenerateMermaidMarkdown(Dictionary> graph, string title, string fileName) + /// + /// Displays the reference trees for each root project using . + /// + /// The dependency graph to display. + /// All project paths. + private static void DisplayReferenceTrees(Dictionary> displayGraph, HashSet allProjects) { - var sb = new System.Text.StringBuilder(); - sb.AppendLine($"# {fileName}\n"); - sb.AppendLine("````mermaid"); - sb.AppendLine("---"); - sb.AppendLine($"title: {title}"); - sb.AppendLine("---"); - sb.AppendLine("graph TD"); - foreach (var kvp in graph) + var displayReferencedProjects = new HashSet(); + foreach (var r in displayGraph.Values.SelectMany(refs => refs).Where(r => r.EndsWith(CsProj))) { - var from = Path.GetFileNameWithoutExtension(kvp.Key); - foreach (var toProj in kvp.Value.Where(x => x.EndsWith(".csproj"))) - { - var to = Path.GetFileNameWithoutExtension(toProj); - sb.AppendLine($" {from} --> {to}"); - } + displayReferencedProjects.Add(r); + } + var displayRootProjects = allProjects.Except(displayReferencedProjects).ToList(); + foreach (var proj in displayRootProjects) + { + var tree = new Tree($"[bold]{Path.GetFileName(proj)}[/]"); + ProjectManipulator.PrintReferenceTree(displayGraph, proj, tree, []); + AnsiConsole.Write(tree); + } + } + + /// + /// Outputs the dependency graph as a Markdown file with Mermaid syntax using . + /// + /// The command settings. See . + /// The dependency graph to display. + /// All project paths. + /// The root solution or project path. + private static void OutputDependencyGraphMarkdown( + Settings settings, + Dictionary> displayGraph, + HashSet allProjects, + string rootPath) + { + var solutionDir = Path.GetDirectoryName(rootPath); + var outputPath = Path.IsPathRooted(settings.GraphMdPath) + ? settings.GraphMdPath + : Path.Combine(solutionDir ?? string.Empty, settings.GraphMdPath); + var outputFileName = Path.GetFileName(outputPath); + var mdReferencedProjects = new HashSet(); + + foreach (var r in displayGraph.Values.SelectMany(refs => refs).Where(r => r.EndsWith(CsProj))) + { + mdReferencedProjects.Add(r); + } + + List mdRootProjects; + string mdTitle; + bool hasCsproj = !string.IsNullOrWhiteSpace(settings.CsprojPath); + if (hasCsproj) + { + mdRootProjects = [settings.CsprojPath]; + mdTitle = Path.GetFileNameWithoutExtension(settings.CsprojPath); + } + else + { + mdRootProjects = allProjects.Except(mdReferencedProjects).ToList(); + mdTitle = mdRootProjects.Count > 0 ? Path.GetFileNameWithoutExtension(mdRootProjects[0]) : "dependencies"; } - sb.AppendLine("````"); - return sb.ToString(); + + var mermaidMd = GraphMarkdownUtil.GenerateMermaidMarkdown(displayGraph, mdTitle, outputFileName, mdRootProjects); + File.WriteAllText(outputPath, mermaidMd); + AnsiConsole.MarkupLine($"[green]Dependency graph written to:[/] {outputPath}"); } } diff --git a/Csproj/DomainServices/ProjectManipulator.cs b/Csproj/DomainServices/ProjectManipulator.cs index 40a23e3..7035e1d 100644 --- a/Csproj/DomainServices/ProjectManipulator.cs +++ b/Csproj/DomainServices/ProjectManipulator.cs @@ -1,4 +1,7 @@ -using System.Xml.Linq; +using System.Xml; +using System.Xml.Linq; + +using Spectre.Console; // ReSharper disable once InvertIf @@ -245,4 +248,99 @@ private static void RemoveNuGetReference(string projPath, string nuget, bool bac doc.Save(projPath); } + /// + /// Collects all referenced projects starting from the specified root .csproj file. + /// + /// The path to the root .csproj file. + /// A containing all referenced project file paths. + /// + public static List CollectAllReferencedProjects(string rootCsproj) + { + var found = new HashSet(StringComparer.OrdinalIgnoreCase); + var stack = new Stack(); + stack.Push(rootCsproj); + while (stack.Count > 0) + { + var current = stack.Pop(); + if (!found.Add(current)) continue; + foreach (var reference in GetProjectReferencesFromFile(current) + .Where(reference => reference.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + && !found.Contains(reference) + && File.Exists(reference))) + { + stack.Push(reference); + } + } + return found.ToList(); + } + + /// + /// Retrieves the project references from a given .csproj file. + /// + /// The path to the .csproj file. + /// A of project reference file paths. + /// + private static List GetProjectReferencesFromFile(string csprojPath) + { + var references = new List(); + try + { + var doc = new XmlDocument(); + doc.Load(csprojPath); + var nodes = doc.SelectNodes("//ProjectReference[@Include]"); + if (nodes != null) + { + var baseDir = Path.GetDirectoryName(csprojPath) ?? string.Empty; + references.AddRange( + nodes.OfType() + .Select(node => node.Attributes?["Include"]?.Value) + .Where(include => !string.IsNullOrWhiteSpace(include)) + .Select(include => Path.GetFullPath(Path.Combine(baseDir, include!))) + ); + } + } + catch + { + // Ignore parse errors, treat as no references + } + return references; + } + + /// + /// Prints the project reference tree starting from the specified project. + /// + /// A representing the project dependency graph. + /// The current project being processed. + /// The parent node in the tree structure. + /// A of already visited projects to avoid infinite recursion. + /// + public static void PrintReferenceTree( + Dictionary> graph, + string proj, + object parent, + HashSet visited) + { + visited.Add(proj); + foreach (var reference in graph[proj]) + { + var nodeLabel = reference.EndsWith(".csproj") ? Path.GetFileName(reference) : reference; + TreeNode child; + switch (parent) + { + case Tree tree: + child = tree.AddNode(nodeLabel); + break; + case TreeNode node: + child = node.AddNode(nodeLabel); + break; + default: + continue; + } + if (reference.EndsWith(".csproj") && !visited.Contains(reference) && graph.ContainsKey(reference)) + { + PrintReferenceTree(graph, reference, child, visited); + } + } + } + } diff --git a/Csproj/DomainServices/SolutionFileParser.cs b/Csproj/DomainServices/SolutionFileParser.cs index e8c9479..18073b5 100644 --- a/Csproj/DomainServices/SolutionFileParser.cs +++ b/Csproj/DomainServices/SolutionFileParser.cs @@ -1,28 +1,51 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Xml.Linq; +using System.Xml.Linq; + +// ReSharper disable InvertIf namespace Csproj.DomainServices; + internal static class SolutionFileParser { - public static IEnumerable GetProjects(TextReader solutionContents, string projectExtension, string solutionFolder) + /// + /// Parses a solution file and retrieves the paths of all projects with the specified extension. + /// + /// The contents of the solution file as a . + /// The file extension of the projects to retrieve (e.g., ".csproj"). + /// The folder containing the solution file. + /// An enumerable of full paths to the projects with the specified extension. + public static IEnumerable GetProjects( + TextReader solutionContents, + string projectExtension, + string solutionFolder) { string? firstLine = solutionContents.ReadLine(); - return firstLine == null - ? Enumerable.Empty() - : firstLine.StartsWith("") - ? GetProjectsFromSlnx(solutionContents, projectExtension, solutionFolder) - : GetProjectsFromSln(solutionContents, projectExtension, solutionFolder); + if (firstLine is null) + { + return []; + } + + // Determines the solution type and calls the appropriate parser. + return firstLine.StartsWith("") + ? GetProjectsFromSlnx(solutionContents, projectExtension, solutionFolder) + : GetProjectsFromSln(solutionContents, projectExtension, solutionFolder); } - private static IEnumerable GetProjectsFromSlnx(TextReader textReader, string projectExtension, string solutionFolder) + /// + /// Parses a .slnx solution file and retrieves the paths of all projects with the specified extension. + /// + /// The contents of the .slnx file as a . + /// The file extension of the projects to retrieve (e.g., ".csproj"). + /// The folder containing the solution file. + /// An enumerable of full paths to the projects with the specified extension. + private static IEnumerable GetProjectsFromSlnx( + TextReader textReader, + string projectExtension, + string solutionFolder) { XDocument xml = XDocument.Parse($"{textReader.ReadToEnd()}"); - var projectElements = xml.Root?.Elements().Where(element => element.Name == "Project") ?? Enumerable.Empty(); + // Recursively find all elements + var projectElements = xml.Descendants("Project"); foreach (var projectElement in projectElements) { string? path = projectElement.Attribute("Path")?.Value; @@ -33,10 +56,19 @@ private static IEnumerable GetProjectsFromSlnx(TextReader textReader, st } } - private static IEnumerable GetProjectsFromSln(TextReader solutionContents, string projectExtension, string solutionFolder) + /// + /// Parses a .sln solution file and retrieves the paths of all projects with the specified extension. + /// + /// The contents of the .sln file as a . + /// The file extension of the projects to retrieve (e.g., ".csproj"). + /// The folder containing the solution file. + /// An enumerable of full paths to the projects with the specified extension. + private static IEnumerable GetProjectsFromSln( + TextReader solutionContents, + string projectExtension, + string solutionFolder) { - string? line = null; - while ((line = solutionContents.ReadLine()) != null) + while (solutionContents.ReadLine() is { } line) { if (line.StartsWith("Project(")) { @@ -50,6 +82,11 @@ private static IEnumerable GetProjectsFromSln(TextReader solutionContent } } + /// + /// Retrieves the paths of all projects in a solution file. + /// + /// The full path to the solution file. + /// An enumerable of full paths to the projects in the solution file. public static IEnumerable GetAllProjectPaths(string solutionPath) { using var reader = new StreamReader(solutionPath); diff --git a/Csproj/Infrastructure/GraphMarkdownUtil.cs b/Csproj/Infrastructure/GraphMarkdownUtil.cs new file mode 100644 index 0000000..ee21231 --- /dev/null +++ b/Csproj/Infrastructure/GraphMarkdownUtil.cs @@ -0,0 +1,63 @@ +using System.Text; + +namespace Csproj.Infrastructure; + +public static class GraphMarkdownUtil +{ + /// +/// Generates a Mermaid Markdown representation of a project dependency graph. +/// +/// A representing the project dependency graph, where keys are project paths and values are lists of referenced project paths. +/// The title of the Mermaid graph. +/// The name of the file to be displayed as the Markdown header. +/// A of root projects to start the graph traversal from. +/// A containing the Mermaid Markdown representation of the graph. +/// + public static string GenerateMermaidMarkdown( + Dictionary> graph, + string title, + string fileName, + List rootProjects) + { + var sb = new StringBuilder(); + sb.AppendLine($"# {fileName}\n"); + sb.AppendLine("````mermaid"); + sb.AppendLine("---"); + sb.AppendLine($"title: {title}"); + sb.AppendLine("---"); + sb.AppendLine("graph TD"); + var visited = new HashSet(); + foreach (var root in rootProjects) + { + WriteMermaidEdges(graph, root, sb, visited); + } + sb.AppendLine("````"); + return sb.ToString(); + } + + /// + /// Recursively writes edges of the Mermaid graph for a given project and its dependencies. + /// + /// A representing the project dependency graph. + /// The current project being processed. + /// The used to construct the Mermaid graph. + /// A of already visited projects to avoid infinite recursion. + /// + private static void WriteMermaidEdges( + Dictionary> graph, + string proj, + StringBuilder sb, + HashSet visited) + { + if (!visited.Add(proj)) return; + var from = Path.GetFileNameWithoutExtension(proj); + if (!graph.TryGetValue(proj, out var refs)) return; + foreach (var toProj in refs.Where(x => x.EndsWith(".csproj"))) + { + var to = Path.GetFileNameWithoutExtension(toProj); + sb.AppendLine($" {from} --> {to}"); + WriteMermaidEdges(graph, toProj, sb, visited); + } + } +} + diff --git a/README.md b/README.md index ae08792..cb5e2d9 100644 --- a/README.md +++ b/README.md @@ -138,39 +138,67 @@ csproj licenseheaders create ``` DESCRIPTION: -Inspect and remove redundant project/NuGet references in solution projects. Optionally outputs a dependency graph in Markdown/Mermaid format. +Inspect and remove redundant project/NuGet references in solution or project files. Optionally outputs a dependency graph in Markdown/Mermaid format. USAGE: csproj prunelinks [OPTIONS] --solution + csproj prunelinks [OPTIONS] --csproj OPTIONS: -h, --help Prints help information - -s, --solution Solution file path (.sln) + -s, --solution Solution file path (.sln or .slnx) + --csproj Project file path (.csproj) -D, --dryrun Only show what would be changed, do not modify files -b, --backup Create a backup of the project file before editing -v, --verbose Show the reference tree for each project - --graph-md [file] Output the dependency graph as a Markdown file with Mermaid syntax. If no file is specified, defaults to .md in the solution directory. + --graph-md [file] Output the dependency graph as a Markdown file with Mermaid syntax. If no file is specified, defaults to .md or .md in the solution/project directory. + +Note: You must specify either --solution or --csproj, but not both. ``` -#### Example: Remove redundant links and output dependency graph +#### Example: Remove redundant links and output dependency graph for a solution ```bash csproj prunelinks --solution MySolution.sln --graph-md ``` This will create `MySolution.md` in the same directory as the solution, containing a Mermaid graph of project dependencies. +#### Example: Remove redundant links and output dependency graph for a single project + +```bash +csproj prunelinks --csproj MyProject.csproj --graph-md +``` +This will create `MyProject.md` in the same directory as the project file, containing a Mermaid graph of its dependencies. + #### Example: Specify a custom Markdown output file ```bash csproj prunelinks --solution MySolution.sln --graph-md dependencies.md ``` +#### Example: Show reference trees for each root project + +```bash +csproj prunelinks --solution MySolution.sln --verbose +``` + +#### Example: Dry run mode (show what would be changed, do not modify files) + +```bash +csproj prunelinks --solution MySolution.sln --dryrun +``` + +#### Output +- If no redundant links are found: `No redundant links found.` +- If changes are detected, each change is listed in yellow. +- If --graph-md is specified, a Markdown file with a Mermaid diagram is generated. + #### Example Mermaid Markdown output ```markdown # MySolution.md -````mermaid +```mermaid --- title: MySolution ---