From e29b6ecaf1682b5f78050731d77d6b0b84e1b466 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 13 Oct 2025 10:24:22 +0200 Subject: [PATCH 1/5] Enhance PruneLinks command to support project file path and validate input options --- Csproj/Commands/PruneLinks.cs | 154 ++++++++++++++++++++++++++-------- 1 file changed, 119 insertions(+), 35 deletions(-) diff --git a/Csproj/Commands/PruneLinks.cs b/Csproj/Commands/PruneLinks.cs index 0fd114a..e39c2ba 100644 --- a/Csproj/Commands/PruneLinks.cs +++ b/Csproj/Commands/PruneLinks.cs @@ -1,4 +1,6 @@ using System.ComponentModel; +using System.Xml; + using Spectre.Console.Cli; using Spectre.Console; using Csproj.DomainServices; @@ -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)")] [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; } @@ -35,32 +43,52 @@ public class Settings : CommandSettings public override int Execute(CommandContext context, Settings settings) { - if (string.IsNullOrWhiteSpace(settings.SolutionPath) || !File.Exists(settings.SolutionPath)) + // Validate that exactly one of --solution or --csproj is provided + bool hasSolution = !string.IsNullOrWhiteSpace(settings.SolutionPath); + bool hasCsproj = !string.IsNullOrWhiteSpace(settings.CsprojPath); + if (hasSolution == hasCsproj) { - AnsiConsole.MarkupLine($"[red]Solution file not found: {settings.SolutionPath}[/]"); + AnsiConsole.MarkupLine("[red]You must specify either --solution or --csproj, but not both.[/]"); return -1; } - var projects = SolutionFileParser.GetAllProjectPaths(settings.SolutionPath).ToList(); - var originalGraph = ProjectManipulator.BuildDependencyGraph(projects); + List projects; + string rootPath; + if (hasSolution) + { + if (!File.Exists(settings.SolutionPath)) + { + AnsiConsole.MarkupLine($"[red]Solution file not found: {settings.SolutionPath}[/]"); + return -1; + } + projects = SolutionFileParser.GetAllProjectPaths(settings.SolutionPath).ToList(); + rootPath = settings.SolutionPath; + } + else + { + if (!File.Exists(settings.CsprojPath)) + { + AnsiConsole.MarkupLine($"[red]Project file not found: {settings.CsprojPath}[/]"); + return -1; + } + // Recursively collect all referenced projects + projects = CollectAllReferencedProjects(settings.CsprojPath); + rootPath = settings.CsprojPath; + } - // Find root project(s) once + var originalGraph = ProjectManipulator.BuildDependencyGraph(projects); var allProjects = projects.ToHashSet(); - - // Prune redundant links and get updated graph var changes = ProjectManipulator.PruneRedundantLinks(originalGraph, settings.DryRun, settings.Backup); - var displayGraph = settings.DryRun ? originalGraph : ProjectManipulator.BuildDependencyGraph(projects); // Rebuild after prune - + var displayGraph = settings.DryRun ? originalGraph : ProjectManipulator.BuildDependencyGraph(projects); if (!settings.DryRun) { - // Rebuild graph after pruning displayGraph = ProjectManipulator.BuildDependencyGraph(projects); } if (settings.Verbose) { var displayReferencedProjects = new HashSet(); - foreach (var r in displayGraph.Values.SelectMany(refs => refs).Where(r => r.EndsWith(".csproj"))) + foreach (var r in displayGraph.Values.SelectMany(refs => refs).Where(r => r.EndsWith(CsProj))) { displayReferencedProjects.Add(r); } @@ -74,32 +102,32 @@ public override int Execute(CommandContext context, Settings settings) } // 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) + var solutionDir = Path.GetDirectoryName(rootPath); + var outputPath = Path.IsPathRooted(settings.GraphMdPath) ? settings.GraphMdPath : Path.Combine(solutionDir ?? string.Empty, settings.GraphMdPath); - } - else - { - var solutionName = Path.GetFileNameWithoutExtension(settings.SolutionPath); - outputPath = Path.Combine(solutionDir ?? string.Empty, solutionName + ".md"); - } - - if (!string.IsNullOrWhiteSpace(outputPath)) - { 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"))) + 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); + List mdRootProjects; + string mdTitle; + 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"; + } + var mermaidMd = GenerateMermaidMarkdown(displayGraph, mdTitle, outputFileName, mdRootProjects); File.WriteAllText(outputPath, mermaidMd); AnsiConsole.MarkupLine($"[green]Dependency graph written to:[/] {outputPath}"); } @@ -140,7 +168,7 @@ private static void PrintReferenceTree(Dictionary> graph, s } } - private static string GenerateMermaidMarkdown(Dictionary> graph, string title, string fileName) + private static string GenerateMermaidMarkdown(Dictionary> graph, string title, string fileName, List rootProjects) { var sb = new System.Text.StringBuilder(); sb.AppendLine($"# {fileName}\n"); @@ -149,16 +177,72 @@ private static string GenerateMermaidMarkdown(Dictionary> g sb.AppendLine($"title: {title}"); sb.AppendLine("---"); sb.AppendLine("graph TD"); - foreach (var kvp in graph) + var visited = new HashSet(); + foreach (var root in rootProjects) { - 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}"); - } + WriteMermaidEdges(graph, root, sb, visited); } sb.AppendLine("````"); return sb.ToString(); } + + private static void WriteMermaidEdges(Dictionary> graph, string proj, System.Text.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); + } + } + + private 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(); + } + + // Helper to parse .csproj and get all referenced .csproj files (absolute 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; + } } From b8c296a6a0ba95d070b69eb0654e881a1e756184 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 13 Oct 2025 10:38:40 +0200 Subject: [PATCH 2/5] Refactor project manipulation methods and add GraphMarkdownUtil for dependency graph generation --- Csproj/Commands/PruneLinks.cs | 111 +------------------- Csproj/DomainServices/ProjectManipulator.cs | 100 +++++++++++++++++- Csproj/Infrastructure/GraphMarkdownUtil.cs | 63 +++++++++++ 3 files changed, 166 insertions(+), 108 deletions(-) create mode 100644 Csproj/Infrastructure/GraphMarkdownUtil.cs diff --git a/Csproj/Commands/PruneLinks.cs b/Csproj/Commands/PruneLinks.cs index e39c2ba..46d4497 100644 --- a/Csproj/Commands/PruneLinks.cs +++ b/Csproj/Commands/PruneLinks.cs @@ -1,9 +1,9 @@ using System.ComponentModel; -using System.Xml; using Spectre.Console.Cli; using Spectre.Console; using Csproj.DomainServices; +using Csproj.Infrastructure; // ReSharper disable UnusedAutoPropertyAccessor.Global // ReSharper disable ClassNeverInstantiated.Global @@ -72,7 +72,7 @@ public override int Execute(CommandContext context, Settings settings) return -1; } // Recursively collect all referenced projects - projects = CollectAllReferencedProjects(settings.CsprojPath); + projects = ProjectManipulator.CollectAllReferencedProjects(settings.CsprojPath); rootPath = settings.CsprojPath; } @@ -96,7 +96,7 @@ public override int Execute(CommandContext context, Settings settings) foreach (var proj in displayRootProjects) { var tree = new Tree($"[bold]{Path.GetFileName(proj)}[/]"); - PrintReferenceTree(displayGraph, proj, tree, []); + ProjectManipulator.PrintReferenceTree(displayGraph, proj, tree, []); AnsiConsole.Write(tree); } } @@ -127,7 +127,7 @@ public override int Execute(CommandContext context, Settings settings) mdRootProjects = allProjects.Except(mdReferencedProjects).ToList(); mdTitle = mdRootProjects.Count > 0 ? Path.GetFileNameWithoutExtension(mdRootProjects[0]) : "dependencies"; } - var mermaidMd = GenerateMermaidMarkdown(displayGraph, mdTitle, outputFileName, mdRootProjects); + var mermaidMd = GraphMarkdownUtil.GenerateMermaidMarkdown(displayGraph, mdTitle, outputFileName, mdRootProjects); File.WriteAllText(outputPath, mermaidMd); AnsiConsole.MarkupLine($"[green]Dependency graph written to:[/] {outputPath}"); } @@ -142,107 +142,4 @@ public override int Execute(CommandContext context, Settings settings) return 0; } - - private 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); - } - } - } - - private static string GenerateMermaidMarkdown(Dictionary> graph, string title, string fileName, List rootProjects) - { - 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"); - var visited = new HashSet(); - foreach (var root in rootProjects) - { - WriteMermaidEdges(graph, root, sb, visited); - } - sb.AppendLine("````"); - return sb.ToString(); - } - - private static void WriteMermaidEdges(Dictionary> graph, string proj, System.Text.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); - } - } - - private 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(); - } - - // Helper to parse .csproj and get all referenced .csproj files (absolute 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; - } } 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/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); + } + } +} + From 3a982f49603c7676082edae7b898b7776bd3ade4 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 13 Oct 2025 10:49:32 +0200 Subject: [PATCH 3/5] Enhance solution file handling to support .slnx extension and improve project retrieval methods --- Csproj/Commands/PruneLinks.cs | 8 ++- Csproj/DomainServices/SolutionFileParser.cs | 71 ++++++++++++++++----- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/Csproj/Commands/PruneLinks.cs b/Csproj/Commands/PruneLinks.cs index 46d4497..4d78304 100644 --- a/Csproj/Commands/PruneLinks.cs +++ b/Csproj/Commands/PruneLinks.cs @@ -16,7 +16,7 @@ internal sealed class PruneLinks : Command 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; @@ -61,6 +61,12 @@ public override int Execute(CommandContext context, Settings settings) AnsiConsole.MarkupLine($"[red]Solution file not found: {settings.SolutionPath}[/]"); return -1; } + var ext = Path.GetExtension(settings.SolutionPath).ToLowerInvariant(); + if (ext != ".sln" && ext != ".slnx") + { + AnsiConsole.MarkupLine($"[red]Unsupported solution file extension: {ext}. Only .sln and .slnx are supported.[/]"); + return -1; + } projects = SolutionFileParser.GetAllProjectPaths(settings.SolutionPath).ToList(); rootPath = settings.SolutionPath; } 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); From ab1ff71babf2178df8d53fbd47b4ccec69630111 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 13 Oct 2025 11:02:56 +0200 Subject: [PATCH 4/5] Refactor PruneLinks command to improve input validation, enhance dependency graph output, and streamline project retrieval logic --- Csproj/Commands/PruneLinks.cs | 229 +++++++++++++++++++++++----------- 1 file changed, 155 insertions(+), 74 deletions(-) diff --git a/Csproj/Commands/PruneLinks.cs b/Csproj/Commands/PruneLinks.cs index 4d78304..869b98f 100644 --- a/Csproj/Commands/PruneLinks.cs +++ b/Csproj/Commands/PruneLinks.cs @@ -41,111 +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) { - // Validate that exactly one of --solution or --csproj is provided + var errorMessage = ValidateInput(settings); + if (errorMessage is not null) + { + AnsiConsole.MarkupLine($"[red]{errorMessage}[/]"); + return -1; + } + + (List projects, string rootPath) = GetProjectsAndRootPath(settings); + var allProjects = projects.ToHashSet(); + var originalGraph = ProjectManipulator.BuildDependencyGraph(projects); + var changes = ProjectManipulator.PruneRedundantLinks(originalGraph, settings.DryRun, settings.Backup); + var displayGraph = SelectDisplayGraph(settings, projects, originalGraph); + + if (settings.Verbose) + { + DisplayReferenceTrees(displayGraph, allProjects); + } + + if (!string.IsNullOrWhiteSpace(settings.GraphMdPath)) + { + OutputDependencyGraphMarkdown(settings, displayGraph, allProjects, rootPath); + } + + 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) + { + AnsiConsole.MarkupLine("[green]No redundant links found.[/]"); + return; + } + + foreach (var change in changes) + { + AnsiConsole.MarkupLine($"[yellow]{change}[/]"); + } + } + + /// + /// 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 + { + var projects = ProjectManipulator.CollectAllReferencedProjects(settings.CsprojPath); + return (projects, settings.CsprojPath); + } + } + + /// + /// Validates the input settings for the command. + /// + /// The command settings. See . + /// Error message if invalid, otherwise null. + private static string? ValidateInput(Settings settings) + { bool hasSolution = !string.IsNullOrWhiteSpace(settings.SolutionPath); bool hasCsproj = !string.IsNullOrWhiteSpace(settings.CsprojPath); + if (hasSolution == hasCsproj) { - AnsiConsole.MarkupLine("[red]You must specify either --solution or --csproj, but not both.[/]"); - return -1; + return ("You must specify either --solution or --csproj, but not both."); } - - List projects; - string rootPath; + if (hasSolution) { if (!File.Exists(settings.SolutionPath)) { - AnsiConsole.MarkupLine($"[red]Solution file not found: {settings.SolutionPath}[/]"); - return -1; + return ($"Solution file not found: {settings.SolutionPath}"); } + var ext = Path.GetExtension(settings.SolutionPath).ToLowerInvariant(); if (ext != ".sln" && ext != ".slnx") { - AnsiConsole.MarkupLine($"[red]Unsupported solution file extension: {ext}. Only .sln and .slnx are supported.[/]"); - return -1; + return ($"Unsupported solution file extension: {ext}. Only .sln and .slnx are supported."); } - projects = SolutionFileParser.GetAllProjectPaths(settings.SolutionPath).ToList(); - rootPath = settings.SolutionPath; } else { if (!File.Exists(settings.CsprojPath)) - { - AnsiConsole.MarkupLine($"[red]Project file not found: {settings.CsprojPath}[/]"); - return -1; - } - // Recursively collect all referenced projects - projects = ProjectManipulator.CollectAllReferencedProjects(settings.CsprojPath); - rootPath = settings.CsprojPath; + return ($"Project file not found: {settings.CsprojPath}"); } + return null; + } - var originalGraph = ProjectManipulator.BuildDependencyGraph(projects); - var allProjects = projects.ToHashSet(); - var changes = ProjectManipulator.PruneRedundantLinks(originalGraph, settings.DryRun, settings.Backup); - var displayGraph = settings.DryRun ? originalGraph : ProjectManipulator.BuildDependencyGraph(projects); - if (!settings.DryRun) + /// + /// 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 displayReferencedProjects = new HashSet(); + foreach (var r in displayGraph.Values.SelectMany(refs => refs).Where(r => r.EndsWith(CsProj))) { - displayGraph = ProjectManipulator.BuildDependencyGraph(projects); + displayReferencedProjects.Add(r); } - - if (settings.Verbose) + var displayRootProjects = allProjects.Except(displayReferencedProjects).ToList(); + foreach (var proj in displayRootProjects) { - 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)}[/]"); - ProjectManipulator.PrintReferenceTree(displayGraph, proj, tree, []); - AnsiConsole.Write(tree); - } + var tree = new Tree($"[bold]{Path.GetFileName(proj)}[/]"); + ProjectManipulator.PrintReferenceTree(displayGraph, proj, tree, []); + AnsiConsole.Write(tree); } + } - // Determine output file path for --graph-md - if (!string.IsNullOrWhiteSpace(settings.GraphMdPath)) + /// + /// 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))) { - 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); - // 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); - } - List mdRootProjects; - string mdTitle; - 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"; - } - var mermaidMd = GraphMarkdownUtil.GenerateMermaidMarkdown(displayGraph, mdTitle, outputFileName, mdRootProjects); - File.WriteAllText(outputPath, mermaidMd); - AnsiConsole.MarkupLine($"[green]Dependency graph written to:[/] {outputPath}"); + mdReferencedProjects.Add(r); + } + + List mdRootProjects; + string mdTitle; + bool hasCsproj = !string.IsNullOrWhiteSpace(settings.CsprojPath); + if (hasCsproj) + { + mdRootProjects = [settings.CsprojPath]; + mdTitle = Path.GetFileNameWithoutExtension(settings.CsprojPath); } - - if (changes.Count == 0) - AnsiConsole.MarkupLine("[green]No redundant links found.[/]"); else { - foreach (var change in changes) - AnsiConsole.MarkupLine($"[yellow]{change}[/]"); + mdRootProjects = allProjects.Except(mdReferencedProjects).ToList(); + mdTitle = mdRootProjects.Count > 0 ? Path.GetFileNameWithoutExtension(mdRootProjects[0]) : "dependencies"; } - - return 0; + + var mermaidMd = GraphMarkdownUtil.GenerateMermaidMarkdown(displayGraph, mdTitle, outputFileName, mdRootProjects); + File.WriteAllText(outputPath, mermaidMd); + AnsiConsole.MarkupLine($"[green]Dependency graph written to:[/] {outputPath}"); } } From baa6dc76d323a3f094fbd309130b70afeae21a87 Mon Sep 17 00:00:00 2001 From: HandyS11 Date: Mon, 13 Oct 2025 11:07:29 +0200 Subject: [PATCH 5/5] Update README to reflect support for project files and enhanced output options in PruneLinks command --- README.md | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) 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 ---