From d89e2230f8a6f52d55922d8ead1cbfab3869f717 Mon Sep 17 00:00:00 2001 From: Rik Schreurs Date: Tue, 24 Mar 2026 10:31:16 +0100 Subject: [PATCH] Add support for slnx solution files - Add SlnxParser to parse the new XML-based solution format - Update PathUtility to recognize and handle .slnx files - Support automatic discovery of .slnx files in directories - Add test fixture and unit test for slnx parsing --- .../Snitch.Tests.Fixtures.slnx | 12 ++++ .../Expectations/Slnx.Default.verified.txt | 50 +++++++++++++ src/Snitch.Tests/ProgramTests.cs | 16 +++++ src/Snitch/Analysis/Utilities/PathUtility.cs | 16 +++-- src/Snitch/Analysis/Utilities/SlnxParser.cs | 71 +++++++++++++++++++ 5 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx create mode 100644 src/Snitch.Tests/Expectations/Slnx.Default.verified.txt create mode 100644 src/Snitch/Analysis/Utilities/SlnxParser.cs diff --git a/src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx b/src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx new file mode 100644 index 0000000..f463c82 --- /dev/null +++ b/src/Snitch.Tests.Fixtures/Snitch.Tests.Fixtures.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/Snitch.Tests/Expectations/Slnx.Default.verified.txt b/src/Snitch.Tests/Expectations/Slnx.Default.verified.txt new file mode 100644 index 0000000..1fe2be4 --- /dev/null +++ b/src/Snitch.Tests/Expectations/Slnx.Default.verified.txt @@ -0,0 +1,50 @@ +Analyzing... +Analyzing Snitch.Tests.Fixtures.slnx +Analyzing Foo... +Analyzing Bar... +Analyzing Baz... +Analyzing Qux... +Analyzing Zap... +Analyzing Quux... +Analyzing Quuux... +Analyzing Thud... +Analyzing Thuuud... +Analyzing FSharp... + +╭─────────────────────────────────────────────────────────────────╮ +│ Packages that can be removed from Bar: │ +│ ┌──────────────────────┬──────────────────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────────────┼──────────────────────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └──────────────────────┴──────────────────────────────────────┘ │ +│ │ +│ Packages that can be removed from Baz: │ +│ ┌──────────────────────┬──────────────────────────────────────┐ │ +│ │ Package │ Referenced by │ │ +│ ├──────────────────────┼──────────────────────────────────────┤ │ +│ │ Autofac │ Foo │ │ +│ └──────────────────────┴──────────────────────────────────────┘ │ +│ │ +│ Packages that might be removed from Qux: │ +│ ┌───────────┬───────────┬─────────────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├───────────┼───────────┼─────────────────────────────────────┤ │ +│ │ Autofac │ 4.9.3 │ Downgraded from 4.9.4 in Foo │ │ +│ └───────────┴───────────┴─────────────────────────────────────┘ │ +│ │ +│ Packages that might be removed from Zap: │ +│ ┌──────────────────┬──────────┬───────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├──────────────────┼──────────┼───────────────────────────────┤ │ +│ │ Newtonsoft.Json │ 12.0.3 │ Updated from 12.0.1 in Foo │ │ +│ │ Autofac │ 4.9.3 │ Downgraded from 4.9.4 in Foo │ │ +│ └──────────────────┴──────────┴───────────────────────────────┘ │ +│ │ +│ Packages that might be removed from Thuuud: │ +│ ┌─────────────────┬──────────────┬────────────────────────────┐ │ +│ │ Package │ Version │ Reason │ │ +│ ├─────────────────┼──────────────┼────────────────────────────┤ │ +│ │ Newtonsoft.Json │ 13.0.2-beta2 │ Updated from 12.0.1 in Foo │ │ +│ └─────────────────┴──────────────┴────────────────────────────┘ │ +╰─────────────────────────────────────────────────────────────────╯ diff --git a/src/Snitch.Tests/ProgramTests.cs b/src/Snitch.Tests/ProgramTests.cs index f264bcd..6855117 100644 --- a/src/Snitch.Tests/ProgramTests.cs +++ b/src/Snitch.Tests/ProgramTests.cs @@ -222,5 +222,21 @@ public async Task Should_Return_Expected_Result_For_FSharp_Not_Specifying_Framew exitCode.ShouldBe(0); await Verifier.Verify(output); } + + [Fact] + [Expectation("Slnx", "Default")] + public async Task Should_Return_Expected_Result_For_Slnx_Solution() + { + // Given + var fixture = new Fixture(); + var solution = Fixture.GetPath("Snitch.Tests.Fixtures.slnx"); + + // When + var (exitCode, output) = await Fixture.Run(solution); + + // Then + exitCode.ShouldBe(0); + await Verifier.Verify(output); + } } } diff --git a/src/Snitch/Analysis/Utilities/PathUtility.cs b/src/Snitch/Analysis/Utilities/PathUtility.cs index 1f7d35e..b4acfe3 100644 --- a/src/Snitch/Analysis/Utilities/PathUtility.cs +++ b/src/Snitch/Analysis/Utilities/PathUtility.cs @@ -53,6 +53,11 @@ private static List GetProjectsFromFile(string path) return GetProjectsFromSolution(path); } + if (path.EndsWith(".slnx", StringComparison.InvariantCulture)) + { + return SlnxParser.GetProjectsFromSlnx(path); + } + throw new InvalidOperationException("Project or solution file do not exist."); } @@ -61,8 +66,11 @@ private static List FindProjects(string? root, out string entry) root ??= Environment.CurrentDirectory; var slns = Directory.GetFiles(root, "*.sln"); + var slnxs = Directory.GetFiles(root, "*.slnx"); + var allSolutions = new List(slns); + allSolutions.AddRange(slnxs); - if (slns.Length == 0) + if (allSolutions.Count == 0) { var subProjects = Directory.GetFiles(root, "*.csproj"); if (subProjects.Length == 0) @@ -77,14 +85,14 @@ private static List FindProjects(string? root, out string entry) entry = subProjects[0]; return new List(new[] { subProjects[0] }); } - else if (slns.Length > 1) + else if (allSolutions.Count > 1) { throw new InvalidOperationException("More than one solution file found."); } else { - entry = slns[0]; - return GetProjectsFromSolution(slns[0]); + entry = allSolutions[0]; + return GetProjectsFromFile(allSolutions[0]); } } diff --git a/src/Snitch/Analysis/Utilities/SlnxParser.cs b/src/Snitch/Analysis/Utilities/SlnxParser.cs new file mode 100644 index 0000000..3c38b42 --- /dev/null +++ b/src/Snitch/Analysis/Utilities/SlnxParser.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml.Linq; + +namespace Snitch.Analysis.Utilities +{ + internal static class SlnxParser + { + public static List GetProjectsFromSlnx(string slnxPath) + { + if (!File.Exists(slnxPath)) + { + throw new FileNotFoundException($"Solution file not found: {slnxPath}"); + } + + var slnxDirectory = Path.GetDirectoryName(slnxPath) + ?? throw new InvalidOperationException("Could not determine solution directory."); + + var doc = XDocument.Load(slnxPath); + var solution = doc.Root; + + if (solution == null || solution.Name.LocalName != "Solution") + { + throw new InvalidOperationException("Invalid slnx file: missing Solution root element."); + } + + var projects = new List(); + CollectProjects(solution, slnxDirectory, projects); + + return projects; + } + + private static void CollectProjects(XElement element, string slnxDirectory, List projects) + { + foreach (var child in element.Elements()) + { + if (child.Name.LocalName == "Project") + { + var pathAttr = child.Attribute("Path"); + if (pathAttr != null && !string.IsNullOrWhiteSpace(pathAttr.Value)) + { + var projectPath = pathAttr.Value; + + // Only include MSBuild project files (.csproj, .fsproj, .vbproj) + if (IsMSBuildProject(projectPath)) + { + var absolutePath = Path.GetFullPath(Path.Combine(slnxDirectory, projectPath)); + if (!projects.Contains(absolutePath)) + { + projects.Add(absolutePath); + } + } + } + } + else if (child.Name.LocalName == "Folder") + { + // Recursively collect projects from folders + CollectProjects(child, slnxDirectory, projects); + } + } + } + + private static bool IsMSBuildProject(string path) + { + return path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".fsproj", StringComparison.OrdinalIgnoreCase) + || path.EndsWith(".vbproj", StringComparison.OrdinalIgnoreCase); + } + } +}