From ec7fe9e64af296bc17a0328ea4ecfcea877d95a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Wagenf=C3=BChr?= Date: Thu, 8 Jan 2026 11:22:32 +0100 Subject: [PATCH 1/5] upgrade stuff --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 2 +- build/Build.cs | 2 ++ build/SlnParser.Build.csproj | 2 +- src/SlnParser.Tests/IntegrationTests.cs | 2 +- src/SlnParser.Tests/SlnParser.Tests.csproj | 17 ++++++++++------- src/SlnParser.sln | 2 ++ src/SlnParser/SlnParser.csproj | 2 +- 8 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f663fe1..4c7f8fb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,6 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 6.0.x + dotnet-version: 10.0.x - name: Run Build and Test using Nuke run: ./build.ps1 -Target Test -Configuration Release diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4de53c3..f0ed802 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,6 @@ jobs: - name: Setup .NET Core uses: actions/setup-dotnet@v1 with: - dotnet-version: 6.0.x + dotnet-version: 10.0.x - name: Run Publish using Nuke run: ./build.ps1 -Target Publish -Configuration Release --nuget_api_key ${{ secrets.NUGET_API_KEY }} diff --git a/build/Build.cs b/build/Build.cs index 25a3a38..d2e0e1f 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -48,8 +48,10 @@ public class Build : NukeBuild .Executes(() => { if (GitVersion == null) + { Logger.Warn( "GitVersion appears to be null. Have a look at it! Versions are defaulting to 0.1.0 for now..."); + } DotNetBuild(s => s .SetProjectFile(Solution) diff --git a/build/SlnParser.Build.csproj b/build/SlnParser.Build.csproj index bd66f25..e24896f 100644 --- a/build/SlnParser.Build.csproj +++ b/build/SlnParser.Build.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net10.0 CS0649;CS0169 .. diff --git a/src/SlnParser.Tests/IntegrationTests.cs b/src/SlnParser.Tests/IntegrationTests.cs index 08e761f..df50f6f 100644 --- a/src/SlnParser.Tests/IntegrationTests.cs +++ b/src/SlnParser.Tests/IntegrationTests.cs @@ -1,4 +1,4 @@ -using FluentAssertions; +using AwesomeAssertions; using SlnParser.Contracts; using System.ComponentModel; using System.IO; diff --git a/src/SlnParser.Tests/SlnParser.Tests.csproj b/src/SlnParser.Tests/SlnParser.Tests.csproj index 3e51af1..6ef3233 100644 --- a/src/SlnParser.Tests/SlnParser.Tests.csproj +++ b/src/SlnParser.Tests/SlnParser.Tests.csproj @@ -1,16 +1,19 @@ - net6.0 + net10.0 - - - - - - + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/SlnParser.sln b/src/SlnParser.sln index ced71fb..bb2237b 100644 --- a/src/SlnParser.sln +++ b/src/SlnParser.sln @@ -8,6 +8,8 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{6D0A7ECB-8812-42C3-8CB4-3DD2C8296591}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + ..\.github\workflows\build.yml = ..\.github\workflows\build.yml + ..\.github\workflows\release.yml = ..\.github\workflows\release.yml EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SlnParser.Tests", "SlnParser.Tests\SlnParser.Tests.csproj", "{BB52A27D-766E-4ECD-B888-BD86405134C1}" diff --git a/src/SlnParser/SlnParser.csproj b/src/SlnParser/SlnParser.csproj index f5ca58e..fb5026e 100644 --- a/src/SlnParser/SlnParser.csproj +++ b/src/SlnParser/SlnParser.csproj @@ -34,7 +34,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive From 14a21137ac2f246faf0ac2660fdeac6bbe2223be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Wagenf=C3=BChr?= Date: Thu, 8 Jan 2026 12:27:09 +0100 Subject: [PATCH 2/5] implement first pass of SLNX parsing --- src/SlnParser.Tests/IntegrationTests.cs | 1262 +++++++++++------ src/SlnParser.Tests/SlnParser.Tests.csproj | 2 +- src/SlnParser.Tests/Solutions/Empty.slnx | 1 + .../Solutions/ProjectWithoutPlatform.slnx | 10 + src/SlnParser.Tests/Solutions/SlnParser.slnx | 12 + src/SlnParser.Tests/Solutions/TestSln.sln | 2 +- src/SlnParser.Tests/Solutions/TestSln.slnx | 23 + .../ParseSolutionFailedException.cs | 9 - .../UnexpectedSolutionStructureException.cs | 9 - src/SlnParser/Contracts/ISolutionParser.cs | 8 +- src/SlnParser/SolutionParser.cs | 132 +- 11 files changed, 1011 insertions(+), 459 deletions(-) create mode 100644 src/SlnParser.Tests/Solutions/Empty.slnx create mode 100644 src/SlnParser.Tests/Solutions/ProjectWithoutPlatform.slnx create mode 100644 src/SlnParser.Tests/Solutions/SlnParser.slnx create mode 100644 src/SlnParser.Tests/Solutions/TestSln.slnx diff --git a/src/SlnParser.Tests/IntegrationTests.cs b/src/SlnParser.Tests/IntegrationTests.cs index df50f6f..ca229f4 100644 --- a/src/SlnParser.Tests/IntegrationTests.cs +++ b/src/SlnParser.Tests/IntegrationTests.cs @@ -5,444 +5,846 @@ using System.Linq; using Xunit; -namespace SlnParser.Tests +namespace SlnParser.Tests; + +public class IntegrationTests { - public class IntegrationTests + [Fact] + [Category("ParseSolution:Empty")] + public void Parse_WithEmptySolutionFile_IsParsedCorrectly() + { + var solutionFile = LoadSolution("Empty"); + + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .FileFormatVersion + .Should() + .Be(string.Empty); + + var visualStudioVersion = solution.VisualStudioVersion; + + visualStudioVersion + .MinimumVersion + .Should() + .Be(string.Empty); + + visualStudioVersion + .Version + .Should() + .Be(string.Empty); + + solution + .Guid + .Should() + .Be(null); + + solution + .ConfigurationPlatforms + .Should() + .HaveCount(0); + + solution + .AllProjects + .Should() + .HaveCount(0); + + solution + .Projects + .Should() + .HaveCount(0); + } + + [Fact] + [Category("ParseSolution:SlnParser")] + public void Should_Be_Able_To_Parse_SlnParser_Solution_Correctly() + { + var solutionFile = LoadSolution("SlnParser"); + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .Name + .Should() + .Be("SlnParser"); + solution + .File + .FullName + .Should() + .Contain(@"Solutions\SlnParser.sln"); + + solution + .FileFormatVersion + .Should() + .Be("12.00"); + + solution + .VisualStudioVersion + .Version + .Should() + .Be("17.0.31410.414"); + + solution + .VisualStudioVersion + .MinimumVersion + .Should() + .Be("10.0.40219.1"); + + // -- Solution Configuration Platforms + + solution + .ConfigurationPlatforms + .Should() + .HaveCount(6); + + solution + .ConfigurationPlatforms + .ElementAt(0) + .Name + .Should() + .Be("Debug|Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(0) + .Configuration + .Should() + .Be("Debug"); + + solution + .ConfigurationPlatforms + .ElementAt(0) + .Platform + .Should() + .Be("Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(1) + .Name + .Should() + .Be("Debug|x64"); + + solution + .ConfigurationPlatforms + .ElementAt(1) + .Configuration + .Should() + .Be("Debug"); + + solution + .ConfigurationPlatforms + .ElementAt(1) + .Platform + .Should() + .Be("x64"); + + solution + .ConfigurationPlatforms + .ElementAt(2) + .Name + .Should() + .Be("Debug|x86"); + + solution + .ConfigurationPlatforms + .ElementAt(2) + .Configuration + .Should() + .Be("Debug"); + + solution + .ConfigurationPlatforms + .ElementAt(2) + .Platform + .Should() + .Be("x86"); + + solution + .ConfigurationPlatforms + .ElementAt(3) + .Name + .Should() + .Be("Release|Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(3) + .Configuration + .Should() + .Be("Release"); + + solution + .ConfigurationPlatforms + .ElementAt(3) + .Platform + .Should() + .Be("Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(4) + .Name + .Should() + .Be("Release|x64"); + + solution + .ConfigurationPlatforms + .ElementAt(4) + .Configuration + .Should() + .Be("Release"); + + solution + .ConfigurationPlatforms + .ElementAt(4) + .Platform + .Should() + .Be("x64"); + + solution + .ConfigurationPlatforms + .ElementAt(5) + .Name + .Should() + .Be("Release|x86"); + + solution + .ConfigurationPlatforms + .ElementAt(5) + .Configuration + .Should() + .Be("Release"); + + solution + .ConfigurationPlatforms + .ElementAt(5) + .Platform + .Should() + .Be("x86"); + + // -- Projects + solution + .AllProjects + .Should() + .HaveCount(3); + + // 1. Project - ClassLib + solution + .AllProjects + .ElementAt(0) + .Should() + .BeOfType(); + solution + .AllProjects + .ElementAt(0) + .Name + .Should() + .Be("SlnParser"); + solution + .AllProjects + .ElementAt(0) + .As() + .File + .FullName + .Should() + .Contain(@"SlnParser\SlnParser.csproj"); + solution + .AllProjects + .ElementAt(0) + .Type + .Should() + .Be(ProjectType.CSharp); + + solution + .AllProjects + .ElementAt(0) + .As() + .ConfigurationPlatforms + .Should() + .Contain(config => config.Name.Equals("Debug|Any CPU.ActiveCfg")); + + // 2. Project - Solution Folder + solution + .AllProjects + .ElementAt(1) + .Should() + .BeOfType(); + solution + .AllProjects + .ElementAt(1) + .Name + .Should() + .Be("Solution Items"); + solution + .AllProjects + .ElementAt(1) + .As() + .Projects + .Should() + .BeEmpty(); + solution + .AllProjects + .ElementAt(1) + .Type + .Should() + .Be(ProjectType.SolutionFolder); + + // 3. Project - Test Project + solution + .AllProjects + .ElementAt(2) + .Should() + .BeOfType(); + solution + .AllProjects + .ElementAt(2) + .Name + .Should() + .Be("SlnParser.Tests"); + solution + .AllProjects + .ElementAt(2) + .As() + .File + .FullName + .Should() + .Contain(@"SlnParser.Tests\SlnParser.Tests.csproj"); + solution + .AllProjects + .ElementAt(2) + .Type + .Should() + .Be(ProjectType.CSharp); + + solution + .AllProjects + .ElementAt(2) + .As() + .ConfigurationPlatforms + .Should() + .Contain(config => config.Name.Equals("Debug|x86.Build.0")); + } + + [Fact] + [Category("ParseSolution:TestSln")] + public void Should_Be_Able_To_Parse_TestSln_Solution_Correctly() + { + var solutionFile = LoadSolution("TestSln"); + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .AllProjects + .Should() + .HaveCount(8); + + solution + .Projects + .Should() + .HaveCount(4); + + var firstSolutionFolder = solution + .AllProjects + .OfType() + .FirstOrDefault(folder => folder.Name == "SolutionFolder1"); + + Assert.NotNull(firstSolutionFolder); + + firstSolutionFolder + .Files + .Should() + .Contain(file => file.Name == "something.txt" || + file.Name == "test123.txt" || + file.Name == "test456.txt"); + + var nestedSolutionFolder = solution + .AllProjects + .OfType() + .FirstOrDefault(folder => folder.Name == "NestedSolutionFolder"); + + Assert.NotNull(nestedSolutionFolder); + + nestedSolutionFolder + .Files + .Should() + .Contain(file => file.Name == "testNested1.txt"); + } + + [Fact] + [Category("ParseSolution:ProjectWithoutPlatform")] + public void Parse_WithProjectWithoutPlatform_IsParsedCorrectly() { - [Fact] - public void Parse_WithEmptySolutionFile_IsParsedCorrectly() - { - var solutionFile = LoadSolution("Empty"); - - var sut = new SolutionParser(); - - var solution = sut.Parse(solutionFile); - - solution - .FileFormatVersion - .Should() - .Be(string.Empty); - - var visualStudioVersion = solution.VisualStudioVersion; - - visualStudioVersion - .MinimumVersion - .Should() - .Be(string.Empty); - - visualStudioVersion - .Version - .Should() - .Be(string.Empty); - - solution - .Guid - .Should() - .Be(null); - - solution - .ConfigurationPlatforms - .Should() - .HaveCount(0); - - solution - .AllProjects - .Should() - .HaveCount(0); - - solution - .Projects - .Should() - .HaveCount(0); - } - - [Fact] - [Category("ParseSolution:SlnParser")] - public void Should_Be_Able_To_Parse_SlnParser_Solution_Correctly() - { - var solutionFile = LoadSolution("SlnParser"); - var sut = new SolutionParser(); - - var solution = sut.Parse(solutionFile); - - solution - .Name - .Should() - .Be("SlnParser"); - solution - .File - .FullName - .Should() - .Contain(@"Solutions\SlnParser.sln"); - - solution - .FileFormatVersion - .Should() - .Be("12.00"); - - solution - .VisualStudioVersion - .Version - .Should() - .Be("17.0.31410.414"); - - solution - .VisualStudioVersion - .MinimumVersion - .Should() - .Be("10.0.40219.1"); - - // -- Solution Configuration Platforms - - solution - .ConfigurationPlatforms - .Should() - .HaveCount(6); - - solution - .ConfigurationPlatforms - .ElementAt(0) - .Name - .Should() - .Be("Debug|Any CPU"); - - solution - .ConfigurationPlatforms - .ElementAt(0) - .Configuration - .Should() - .Be("Debug"); - - solution - .ConfigurationPlatforms - .ElementAt(0) - .Platform - .Should() - .Be("Any CPU"); - - solution - .ConfigurationPlatforms - .ElementAt(1) - .Name - .Should() - .Be("Debug|x64"); - - solution - .ConfigurationPlatforms - .ElementAt(1) - .Configuration - .Should() - .Be("Debug"); - - solution - .ConfigurationPlatforms - .ElementAt(1) - .Platform - .Should() - .Be("x64"); - - solution - .ConfigurationPlatforms - .ElementAt(2) - .Name - .Should() - .Be("Debug|x86"); - - solution - .ConfigurationPlatforms - .ElementAt(2) - .Configuration - .Should() - .Be("Debug"); - - solution - .ConfigurationPlatforms - .ElementAt(2) - .Platform - .Should() - .Be("x86"); - - solution - .ConfigurationPlatforms - .ElementAt(3) - .Name - .Should() - .Be("Release|Any CPU"); - - solution - .ConfigurationPlatforms - .ElementAt(3) - .Configuration - .Should() - .Be("Release"); - - solution - .ConfigurationPlatforms - .ElementAt(3) - .Platform - .Should() - .Be("Any CPU"); - - solution - .ConfigurationPlatforms - .ElementAt(4) - .Name - .Should() - .Be("Release|x64"); - - solution - .ConfigurationPlatforms - .ElementAt(4) - .Configuration - .Should() - .Be("Release"); - - solution - .ConfigurationPlatforms - .ElementAt(4) - .Platform - .Should() - .Be("x64"); - - solution - .ConfigurationPlatforms - .ElementAt(5) - .Name - .Should() - .Be("Release|x86"); - - solution - .ConfigurationPlatforms - .ElementAt(5) - .Configuration - .Should() - .Be("Release"); - - solution - .ConfigurationPlatforms - .ElementAt(5) - .Platform - .Should() - .Be("x86"); - - // -- Projects - solution - .AllProjects - .Should() - .HaveCount(3); - - // 1. Project - ClassLib - solution - .AllProjects - .ElementAt(0) - .Should() - .BeOfType(); - solution - .AllProjects - .ElementAt(0) - .Name - .Should() - .Be("SlnParser"); - solution - .AllProjects - .ElementAt(0) - .As() - .File - .FullName - .Should() - .Contain(@"SlnParser\SlnParser.csproj"); - solution - .AllProjects - .ElementAt(0) - .Type - .Should() - .Be(ProjectType.CSharp); - - solution - .AllProjects - .ElementAt(0) - .As() - .ConfigurationPlatforms - .Should() - .Contain(config => config.Name.Equals("Debug|Any CPU.ActiveCfg")); - - // 2. Project - Solution Folder - solution - .AllProjects - .ElementAt(1) - .Should() - .BeOfType(); - solution - .AllProjects - .ElementAt(1) - .Name - .Should() - .Be("Solution Items"); - solution - .AllProjects - .ElementAt(1) - .As() - .Projects - .Should() - .BeEmpty(); - solution - .AllProjects - .ElementAt(1) - .Type - .Should() - .Be(ProjectType.SolutionFolder); - - // 3. Project - Test Project - solution - .AllProjects - .ElementAt(2) - .Should() - .BeOfType(); - solution - .AllProjects - .ElementAt(2) - .Name - .Should() - .Be("SlnParser.Tests"); - solution - .AllProjects - .ElementAt(2) - .As() - .File - .FullName - .Should() - .Contain(@"SlnParser.Tests\SlnParser.Tests.csproj"); - solution - .AllProjects - .ElementAt(2) - .Type - .Should() - .Be(ProjectType.CSharp); - - solution - .AllProjects - .ElementAt(2) - .As() - .ConfigurationPlatforms - .Should() - .Contain(config => config.Name.Equals("Debug|x86.Build.0")); - } - - [Fact] - [Category("ParseSolution:TestSln")] - public void Should_Be_Able_To_Parse_TestSln_Solution_Correctly() - { - var solutionFile = LoadSolution("TestSln"); - var sut = new SolutionParser(); - - var solution = sut.Parse(solutionFile); - - solution - .AllProjects - .Should() - .HaveCount(8); - - solution - .Projects - .Should() - .HaveCount(4); - - var firstSolutionFolder = solution - .AllProjects - .OfType() - .FirstOrDefault(folder => folder.Name == "SolutionFolder1"); - - Assert.NotNull(firstSolutionFolder); - - firstSolutionFolder - .Files - .Should() - .Contain(file => file.Name == "something.txt" || - file.Name == "test123.txt" || - file.Name == "test456.txt"); - - var nestedSolutionFolder = solution - .AllProjects - .OfType() - .FirstOrDefault(folder => folder.Name == "NestedSolutionFolder"); - - Assert.NotNull(nestedSolutionFolder); - - nestedSolutionFolder - .Files - .Should() - .Contain(file => file.Name == "testNested1.txt"); - } - - [Fact] - public void Parse_WithProjectWithoutPlatform_IsParsedCorrectly() - { - var solutionFile = LoadSolution("ProjectWithoutPlatform"); - - var sut = new SolutionParser(); - - var solution = sut.Parse(solutionFile); - - solution - .ConfigurationPlatforms - .Should() - .HaveCount(1); - - var configurationPlatform = solution - .ConfigurationPlatforms - .Single(); - - configurationPlatform - .Configuration - .Should() - .Be("SolutionConfigurationName"); - - configurationPlatform - .Platform - .Should() - .Be("SolutionPlatformName"); - - solution - .AllProjects - .Should() - .HaveCount(1); - - solution - .Projects - .Should() - .HaveCount(1); - - var project = solution.Projects.Single(); - project.Id.Should().Be("D5BDBC46-CEAF-4C92-8335-31450B76914F"); - project.Name.Should().Be("Test"); - project.TypeGuid.Should().Be("D183A3D8-5FD8-494B-B014-37F57B35E655"); - project.Type.Should().Be(ProjectType.Unknown); - } + var solutionFile = LoadSolution("ProjectWithoutPlatform"); + + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .ConfigurationPlatforms + .Should() + .HaveCount(1); + + var configurationPlatform = solution + .ConfigurationPlatforms + .Single(); + + configurationPlatform + .Configuration + .Should() + .Be("SolutionConfigurationName"); + + configurationPlatform + .Platform + .Should() + .Be("SolutionPlatformName"); + + solution + .AllProjects + .Should() + .HaveCount(1); + + solution + .Projects + .Should() + .HaveCount(1); + + var project = solution.Projects.Single(); + project.Id.Should().Be("D5BDBC46-CEAF-4C92-8335-31450B76914F"); + project.Name.Should().Be("Test"); + project.TypeGuid.Should().Be("D183A3D8-5FD8-494B-B014-37F57B35E655"); + project.Type.Should().Be(ProjectType.Unknown); + } - [Fact] - public void Parse_WithSolutionGuid_IsParsedCorrectly() - { - var solutionFile = LoadSolution("SolutionGuid"); + [Fact] + [Category("ParseSolution:SolutionGuid")] + public void Parse_WithSolutionGuid_IsParsedCorrectly() + { + var solutionFile = LoadSolution("SolutionGuid"); - var sut = new SolutionParser(); + var sut = new SolutionParser(); - var solution = sut.Parse(solutionFile); + var solution = sut.Parse(solutionFile); - solution - .Guid - .Should() - .Be("7F92F20E-4C3D-4316-BF60-105559EFEAFF"); - } + solution + .Guid + .Should() + .Be("7F92F20E-4C3D-4316-BF60-105559EFEAFF"); + } + + [Fact] + [Category("ParseSlnxSolution:Empty")] + public void ParseSlnx_WithEmptySolutionFile_IsParsedCorrectly() + { + var solutionFile = LoadXmlSolution("Empty"); + + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .FileFormatVersion + .Should() + .Be(string.Empty); + + var visualStudioVersion = solution.VisualStudioVersion; + + visualStudioVersion + .MinimumVersion + .Should() + .Be(string.Empty); + + visualStudioVersion + .Version + .Should() + .Be(string.Empty); + + solution + .Guid + .Should() + .Be(null); + + solution + .ConfigurationPlatforms + .Should() + .HaveCount(0); + + solution + .AllProjects + .Should() + .HaveCount(0); + + solution + .Projects + .Should() + .HaveCount(0); + } + + [Fact] + [Category("ParseSlnxSolution:SlnParser")] + public void Should_Be_Able_To_Parse_SlnParser_SlnxSolution_Correctly() + { + var solutionFile = LoadXmlSolution("SlnParser"); + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .Name + .Should() + .Be("SlnParser"); + solution + .File + .FullName + .Should() + .Contain(@"Solutions\SlnParser.sln"); + + // -- Solution Configuration Platforms + + solution + .ConfigurationPlatforms + .Should() + .HaveCount(6); + + solution + .ConfigurationPlatforms + .ElementAt(0) + .Name + .Should() + .Be("Debug|Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(0) + .Configuration + .Should() + .Be("Debug"); + + solution + .ConfigurationPlatforms + .ElementAt(0) + .Platform + .Should() + .Be("Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(1) + .Name + .Should() + .Be("Debug|x64"); + + solution + .ConfigurationPlatforms + .ElementAt(1) + .Configuration + .Should() + .Be("Debug"); + + solution + .ConfigurationPlatforms + .ElementAt(1) + .Platform + .Should() + .Be("x64"); + + solution + .ConfigurationPlatforms + .ElementAt(2) + .Name + .Should() + .Be("Debug|x86"); + + solution + .ConfigurationPlatforms + .ElementAt(2) + .Configuration + .Should() + .Be("Debug"); + + solution + .ConfigurationPlatforms + .ElementAt(2) + .Platform + .Should() + .Be("x86"); + + solution + .ConfigurationPlatforms + .ElementAt(3) + .Name + .Should() + .Be("Release|Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(3) + .Configuration + .Should() + .Be("Release"); + + solution + .ConfigurationPlatforms + .ElementAt(3) + .Platform + .Should() + .Be("Any CPU"); + + solution + .ConfigurationPlatforms + .ElementAt(4) + .Name + .Should() + .Be("Release|x64"); + + solution + .ConfigurationPlatforms + .ElementAt(4) + .Configuration + .Should() + .Be("Release"); + + solution + .ConfigurationPlatforms + .ElementAt(4) + .Platform + .Should() + .Be("x64"); + + solution + .ConfigurationPlatforms + .ElementAt(5) + .Name + .Should() + .Be("Release|x86"); + + solution + .ConfigurationPlatforms + .ElementAt(5) + .Configuration + .Should() + .Be("Release"); + + solution + .ConfigurationPlatforms + .ElementAt(5) + .Platform + .Should() + .Be("x86"); + + // -- Projects + solution + .AllProjects + .Should() + .HaveCount(3); + + // 1. Project - ClassLib + solution + .AllProjects + .ElementAt(0) + .Should() + .BeOfType(); + solution + .AllProjects + .ElementAt(0) + .Name + .Should() + .Be("SlnParser"); + solution + .AllProjects + .ElementAt(0) + .As() + .File + .FullName + .Should() + .Contain(@"SlnParser\SlnParser.csproj"); + solution + .AllProjects + .ElementAt(0) + .Type + .Should() + .Be(ProjectType.CSharp); + + solution + .AllProjects + .ElementAt(0) + .As() + .ConfigurationPlatforms + .Should() + .Contain(config => config.Name.Equals("Debug|Any CPU.ActiveCfg")); + + // 2. Project - Solution Folder + solution + .AllProjects + .ElementAt(1) + .Should() + .BeOfType(); + solution + .AllProjects + .ElementAt(1) + .Name + .Should() + .Be("Solution Items"); + solution + .AllProjects + .ElementAt(1) + .As() + .Projects + .Should() + .BeEmpty(); + solution + .AllProjects + .ElementAt(1) + .Type + .Should() + .Be(ProjectType.SolutionFolder); + + // 3. Project - Test Project + solution + .AllProjects + .ElementAt(2) + .Should() + .BeOfType(); + solution + .AllProjects + .ElementAt(2) + .Name + .Should() + .Be("SlnParser.Tests"); + solution + .AllProjects + .ElementAt(2) + .As() + .File + .FullName + .Should() + .Contain(@"SlnParser.Tests\SlnParser.Tests.csproj"); + solution + .AllProjects + .ElementAt(2) + .Type + .Should() + .Be(ProjectType.CSharp); + + solution + .AllProjects + .ElementAt(2) + .As() + .ConfigurationPlatforms + .Should() + .Contain(config => config.Name.Equals("Debug|x86.Build.0")); + } + + [Fact] + [Category("ParseSlnxSolution:TestSln")] + public void Should_Be_Able_To_Parse_TestSln_SlnxSolution_Correctly() + { + var solutionFile = LoadXmlSolution("TestSln"); + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .AllProjects + .Should() + .HaveCount(8); + + solution + .Projects + .Should() + .HaveCount(8); + + var firstSolutionFolder = solution + .AllProjects + .OfType() + .FirstOrDefault(folder => folder.Name == "SolutionFolder1"); + + Assert.NotNull(firstSolutionFolder); + + firstSolutionFolder + .Files + .Should() + .Contain(file => file.Name == "something.txt" || + file.Name == "test123.txt" || + file.Name == "test456.txt"); + + var nestedSolutionFolder = solution + .AllProjects + .OfType() + .FirstOrDefault(folder => folder.Name == "NestedSolutionFolder"); + + Assert.NotNull(nestedSolutionFolder); + + nestedSolutionFolder + .Files + .Should() + .Contain(file => file.Name == "testNested1.txt"); + } + + [Fact] + [Category("ParseSlnxSolution:ProjectWithoutPlatform")] + public void ParseSlnx_WithProjectWithoutPlatform_IsParsedCorrectly() + { + var solutionFile = LoadXmlSolution("ProjectWithoutPlatform"); + + var sut = new SolutionParser(); + + var solution = sut.Parse(solutionFile); + + solution + .ConfigurationPlatforms + .Should() + .HaveCount(1); + + var configurationPlatform = solution + .ConfigurationPlatforms + .Single(); + + configurationPlatform + .Configuration + .Should() + .Be("SolutionConfigurationName"); + + configurationPlatform + .Platform + .Should() + .Be("SolutionPlatformName"); + + solution + .AllProjects + .Should() + .HaveCount(1); + + solution + .Projects + .Should() + .HaveCount(1); + + var project = solution.Projects.Single(); + project.Id.Should().Be("D5BDBC46-CEAF-4C92-8335-31450B76914F"); + project.Name.Should().Be("Test"); + project.TypeGuid.Should().Be("D183A3D8-5FD8-494B-B014-37F57B35E655"); + project.Type.Should().Be(ProjectType.Unknown); + } - private static FileInfo LoadSolution(string solutionName) - { - var solutionFileName = $"./Solutions/{solutionName}.sln"; - var solutionFile = new FileInfo(solutionFileName); + private static FileInfo LoadSolution(string solutionName) + { + var solutionFileName = $"./Solutions/{solutionName}.sln"; + var solutionFile = new FileInfo(solutionFileName); - if (!solutionFile.Exists) - throw new FileNotFoundException(); + return !solutionFile.Exists ? throw new FileNotFoundException() : solutionFile; + } + + private static FileInfo LoadXmlSolution(string solutionName) + { + var solutionFileName = $"./Solutions/{solutionName}.slnx"; + var solutionFile = new FileInfo(solutionFileName); - return solutionFile; - } + return !solutionFile.Exists ? throw new FileNotFoundException() : solutionFile; } } diff --git a/src/SlnParser.Tests/SlnParser.Tests.csproj b/src/SlnParser.Tests/SlnParser.Tests.csproj index 6ef3233..c8d8bf3 100644 --- a/src/SlnParser.Tests/SlnParser.Tests.csproj +++ b/src/SlnParser.Tests/SlnParser.Tests.csproj @@ -24,7 +24,7 @@ - + PreserveNewest diff --git a/src/SlnParser.Tests/Solutions/Empty.slnx b/src/SlnParser.Tests/Solutions/Empty.slnx new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/src/SlnParser.Tests/Solutions/Empty.slnx @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/SlnParser.Tests/Solutions/ProjectWithoutPlatform.slnx b/src/SlnParser.Tests/Solutions/ProjectWithoutPlatform.slnx new file mode 100644 index 0000000..e827af4 --- /dev/null +++ b/src/SlnParser.Tests/Solutions/ProjectWithoutPlatform.slnx @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/SlnParser.Tests/Solutions/SlnParser.slnx b/src/SlnParser.Tests/Solutions/SlnParser.slnx new file mode 100644 index 0000000..8b69ed7 --- /dev/null +++ b/src/SlnParser.Tests/Solutions/SlnParser.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/SlnParser.Tests/Solutions/TestSln.sln b/src/SlnParser.Tests/Solutions/TestSln.sln index fa98cf1..6e77f0d 100644 --- a/src/SlnParser.Tests/Solutions/TestSln.sln +++ b/src/SlnParser.Tests/Solutions/TestSln.sln @@ -25,7 +25,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WpfAppNotInASolutionFolder" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormsApp1", "WinFormsApp1\WinFormsApp1.csproj", "{D4E03E9B-EE01-462A-B2F3-45AC775ADC7E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApplication", "WebApplication\WebApplication.csproj", "{20AD21B6-0ABB-4DB5-8BA0-D9896E58E3E4}" +Project("{E6FDF86B-F3D1-11D4-8576-0002A516ECE8}") = "WebApplication", "WebApplication\WebApplication.csproj", "{20AD21B6-0ABB-4DB5-8BA0-D9896E58E3E4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/SlnParser.Tests/Solutions/TestSln.slnx b/src/SlnParser.Tests/Solutions/TestSln.slnx new file mode 100644 index 0000000..8e7c916 --- /dev/null +++ b/src/SlnParser.Tests/Solutions/TestSln.slnx @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SlnParser/Contracts/Exceptions/ParseSolutionFailedException.cs b/src/SlnParser/Contracts/Exceptions/ParseSolutionFailedException.cs index e0b38ee..6578b5c 100644 --- a/src/SlnParser/Contracts/Exceptions/ParseSolutionFailedException.cs +++ b/src/SlnParser/Contracts/Exceptions/ParseSolutionFailedException.cs @@ -1,13 +1,11 @@ using System; using System.IO; -using System.Runtime.Serialization; namespace SlnParser.Contracts.Exceptions { /// /// Exception that is thrown when a solution could not be parsed /// - [Serializable] public class ParseSolutionFailedException : Exception { /// @@ -21,12 +19,5 @@ public ParseSolutionFailedException(FileInfo solutionFile, Exception inner) inner) { } - - /// - protected ParseSolutionFailedException( - SerializationInfo info, - StreamingContext context) : base(info, context) - { - } } } diff --git a/src/SlnParser/Contracts/Exceptions/UnexpectedSolutionStructureException.cs b/src/SlnParser/Contracts/Exceptions/UnexpectedSolutionStructureException.cs index d330df0..7e7972d 100644 --- a/src/SlnParser/Contracts/Exceptions/UnexpectedSolutionStructureException.cs +++ b/src/SlnParser/Contracts/Exceptions/UnexpectedSolutionStructureException.cs @@ -1,12 +1,10 @@ using System; -using System.Runtime.Serialization; namespace SlnParser.Contracts.Exceptions { /// /// An that describes an unexpected structure of a Solution /// - [Serializable] public class UnexpectedSolutionStructureException : Exception { /// @@ -25,12 +23,5 @@ public UnexpectedSolutionStructureException(string message) : base(message) public UnexpectedSolutionStructureException(string message, Exception inner) : base(message, inner) { } - - /// - protected UnexpectedSolutionStructureException( - SerializationInfo info, - StreamingContext context) : base(info, context) - { - } } } diff --git a/src/SlnParser/Contracts/ISolutionParser.cs b/src/SlnParser/Contracts/ISolutionParser.cs index 020d456..631fa91 100644 --- a/src/SlnParser/Contracts/ISolutionParser.cs +++ b/src/SlnParser/Contracts/ISolutionParser.cs @@ -8,21 +8,21 @@ namespace SlnParser.Contracts public interface ISolutionParser { /// - /// Parses the provided solution file (sln) + /// Parses the provided solution file (sln or slnx) /// /// The path to the solution file that you want to parse /// The parsed ISolution Parse(string solutionFileName); /// - /// Parses the provided solution file (sln) + /// Parses the provided solution file (sln or slnx) /// /// The path to the solution file that you want to parse /// The parsed ISolution Parse(FileInfo solutionFile); /// - /// Safely Parses the provided solution file (sln) + /// Safely Parses the provided solution file (sln or slnx) /// /// The path to the solution file that you want to parse /// The parsed @@ -30,7 +30,7 @@ public interface ISolutionParser bool TryParse(string solutionFileName, out ISolution? solution); /// - /// Safely Parses the provided solution file (sln) + /// Safely Parses the provided solution file (sln or slnx) /// /// The path to the solution file that you want to parse /// The parsed diff --git a/src/SlnParser/SolutionParser.cs b/src/SlnParser/SolutionParser.cs index 60bc5a1..2ab0d80 100644 --- a/src/SlnParser/SolutionParser.cs +++ b/src/SlnParser/SolutionParser.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Xml; namespace SlnParser { @@ -13,6 +14,7 @@ namespace SlnParser public sealed class SolutionParser : ISolutionParser { private readonly IEnumerable _solutionEnrichers; + private readonly IProjectTypeMapper _projectTypeMapper = new ProjectTypeMapper(); /// /// Creates a new instance of @@ -51,12 +53,17 @@ public ISolution Parse(FileInfo solutionFile) throw new ArgumentNullException(nameof(solutionFile)); if (!solutionFile.Exists) throw new FileNotFoundException("Provided Solution-File does not exist", solutionFile.FullName); - if (!solutionFile.Extension.Equals(".sln")) - throw new InvalidDataException("The provided file is not a solution file!"); + + var fileExtension = solutionFile.Extension; try { - var solution = ParseInternal(solutionFile); + var solution = fileExtension switch + { + ".sln" => ParseSlnInternal(solutionFile), + ".slnx" => ParseSlnxInternal(solutionFile), + _ => throw new InvalidDataException($"The provided file '{solutionFile.FullName}' is not a solution file!"), + }; return solution; } catch (Exception exception) @@ -94,11 +101,12 @@ public bool TryParse(FileInfo solutionFile, out ISolution? solution) } } - private ISolution ParseInternal(FileInfo solutionFile) + private ISolution ParseSlnInternal(FileInfo solutionFile) { var solution = new Solution { - Name = Path.GetFileNameWithoutExtension(solutionFile.FullName), File = solutionFile + Name = Path.GetFileNameWithoutExtension(solutionFile.FullName), + File = solutionFile, }; var allLines = File.ReadAllLines(solutionFile.FullName); var allLinesTrimmed = allLines @@ -115,6 +123,48 @@ private ISolution ParseInternal(FileInfo solutionFile) return solution; } + private ISolution ParseSlnxInternal(FileInfo solutionFile) + { + // the new SLNX has no information about the file format version, VS version and minimal VS version, so it's omitted. + var solution = new Solution + { + Name = Path.GetFileNameWithoutExtension(solutionFile.FullName), + File = solutionFile, + }; + + var fileContent = File.ReadAllText(solutionFile.FullName); + if (string.IsNullOrEmpty(fileContent.Trim())) + { + return solution; + } + + var xmlReaderSettings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, // Prevent XXE + XmlResolver = null, // Disable external entity resolution + }; + using var xmlContentStream = solutionFile.OpenRead(); + var xmlDocument = new XmlDocument(); + + using var xmlReader = XmlReader.Create(xmlContentStream, xmlReaderSettings); + xmlDocument.Load(xmlReader); + + var root = xmlDocument.DocumentElement; + if (root == null) throw new UnexpectedSolutionStructureException($"Solution file '{solutionFile.FullName}' does not contain a root element"); + + var structuredProjects = new List(); + + foreach (var xmlElement in root.ChildNodes.OfType()) + { + var projectsFromElement = ParseSlnxElement(xmlElement, solutionFile); + structuredProjects.AddRange(projectsFromElement); + } + + solution.AllProjects = structuredProjects; + solution.Projects = structuredProjects; + return solution; + } + private static void ProcessLine(string line, Solution solution) { ProcessSolutionFileFormatVersion(line, solution); @@ -153,5 +203,77 @@ private static void ProcessMinimumVisualStudioVersion(string line, ISolution sol solution.VisualStudioVersion.MinimumVersion = minimumVisualStudioVersion; } + + private List ParseSlnxElement(XmlElement xmlElement, FileInfo solutionFile) + { + return xmlElement.Name switch + { + "Folder" => ParseSlnxFolder(xmlElement, solutionFile), + "Project" => ParseSlnxProject(xmlElement, solutionFile), + _ => new List(), + }; + } + + private List ParseSlnxFolder(XmlElement xmlElement, FileInfo solutionFile) + { + var folderName = xmlElement.GetAttribute("Name"); + if (string.IsNullOrWhiteSpace(folderName)) + { + throw new UnexpectedSolutionStructureException($"Could not find solution folder name attribute in solution '{solutionFile.FullName}'"); + } + + var actualFolderName = folderName.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries).Last(); + + var solutionFolderTypeId = new Guid("2150E333-8FDC-42A3-9474-1A3956D46DE8"); + var solutionFolder = new SolutionFolder(Guid.Empty, actualFolderName, solutionFolderTypeId, ProjectType.SolutionFolder); + var projects = new List + { + solutionFolder, + }; + + foreach (var childElement in xmlElement.ChildNodes.OfType()) + { + var childProjects = ParseSlnxElement(childElement, solutionFile); + foreach (var childProject in childProjects) + { + solutionFolder.AddProject(childProject); + projects.Add(childProject); + } + } + + return projects; + } + + private List ParseSlnxProject(XmlElement xmlElement, FileInfo solutionFile) + { + const string defaultProjectTypeId = "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"; + + var projectPath = xmlElement.GetAttribute("Path"); + if (string.IsNullOrWhiteSpace(projectPath)) + { + throw new UnexpectedSolutionStructureException($"Could not find solution project path attribute in solution file '{solutionFile.FullName}'."); + } + + var projectTypeIdText = xmlElement.GetAttribute("Type"); + projectTypeIdText = string.IsNullOrWhiteSpace(projectTypeIdText) + ? defaultProjectTypeId + // the new project type id notation does not quite fit the known format, so it is adjusted. + : $"{{{projectTypeIdText.ToUpper()}}}"; + var projectTypeId = Guid.Parse(projectTypeIdText); + var projectType = _projectTypeMapper.Map(projectTypeId); + + var projectFilePath = new FileInfo(projectPath); + var isRelative = Path.IsPathRooted(projectFilePath.FullName); + var projectFullPath = isRelative + ? Path.Combine(solutionFile.Directory?.FullName ?? string.Empty, projectFilePath.FullName) + : projectFilePath.FullName; + + // the project name is part of its path. + var actualProjectName = Path.GetFileNameWithoutExtension(projectFullPath); + + // elements in the SLNX file don't have any IDs anymore, so it's omitted. + var solutionProject = new SolutionProject(Guid.Empty, actualProjectName, projectTypeId, projectType, new FileInfo(projectFullPath)); + return new List { solutionProject }; + } } } From afd37e73e7825ce609726f0f2fee7ffe70680372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Wagenf=C3=BChr?= Date: Thu, 8 Jan 2026 13:25:02 +0100 Subject: [PATCH 3/5] implement configuration and solution file parsing Closes #30 --- src/SlnParser.Tests/IntegrationTests.cs | 156 +++--------------- src/SlnParser/Helper/SlnxParser.cs | 207 ++++++++++++++++++++++++ src/SlnParser/SolutionParser.cs | 119 +------------- 3 files changed, 234 insertions(+), 248 deletions(-) create mode 100644 src/SlnParser/Helper/SlnxParser.cs diff --git a/src/SlnParser.Tests/IntegrationTests.cs b/src/SlnParser.Tests/IntegrationTests.cs index ca229f4..ea0c7df 100644 --- a/src/SlnParser.Tests/IntegrationTests.cs +++ b/src/SlnParser.Tests/IntegrationTests.cs @@ -509,21 +509,14 @@ public void Should_Be_Able_To_Parse_SlnParser_SlnxSolution_Correctly() solution .ConfigurationPlatforms .Should() - .HaveCount(6); + .HaveCount(3); solution .ConfigurationPlatforms .ElementAt(0) .Name .Should() - .Be("Debug|Any CPU"); - - solution - .ConfigurationPlatforms - .ElementAt(0) - .Configuration - .Should() - .Be("Debug"); + .Be("Any CPU"); solution .ConfigurationPlatforms @@ -537,14 +530,7 @@ public void Should_Be_Able_To_Parse_SlnParser_SlnxSolution_Correctly() .ElementAt(1) .Name .Should() - .Be("Debug|x64"); - - solution - .ConfigurationPlatforms - .ElementAt(1) - .Configuration - .Should() - .Be("Debug"); + .Be("x64"); solution .ConfigurationPlatforms @@ -558,153 +544,75 @@ public void Should_Be_Able_To_Parse_SlnParser_SlnxSolution_Correctly() .ElementAt(2) .Name .Should() - .Be("Debug|x86"); - - solution - .ConfigurationPlatforms - .ElementAt(2) - .Configuration - .Should() - .Be("Debug"); - - solution - .ConfigurationPlatforms - .ElementAt(2) - .Platform - .Should() .Be("x86"); solution .ConfigurationPlatforms - .ElementAt(3) - .Name - .Should() - .Be("Release|Any CPU"); - - solution - .ConfigurationPlatforms - .ElementAt(3) - .Configuration - .Should() - .Be("Release"); - - solution - .ConfigurationPlatforms - .ElementAt(3) - .Platform - .Should() - .Be("Any CPU"); - - solution - .ConfigurationPlatforms - .ElementAt(4) - .Name - .Should() - .Be("Release|x64"); - - solution - .ConfigurationPlatforms - .ElementAt(4) - .Configuration - .Should() - .Be("Release"); - - solution - .ConfigurationPlatforms - .ElementAt(4) - .Platform - .Should() - .Be("x64"); - - solution - .ConfigurationPlatforms - .ElementAt(5) - .Name - .Should() - .Be("Release|x86"); - - solution - .ConfigurationPlatforms - .ElementAt(5) - .Configuration - .Should() - .Be("Release"); - - solution - .ConfigurationPlatforms - .ElementAt(5) + .ElementAt(2) .Platform .Should() .Be("x86"); - + // -- Projects solution .AllProjects .Should() .HaveCount(3); - - // 1. Project - ClassLib + + // 1. Project - Solution Folder solution .AllProjects .ElementAt(0) .Should() - .BeOfType(); + .BeOfType(); solution .AllProjects .ElementAt(0) .Name .Should() - .Be("SlnParser"); + .Be("Solution Items"); solution .AllProjects .ElementAt(0) - .As() - .File - .FullName + .As() + .Projects .Should() - .Contain(@"SlnParser\SlnParser.csproj"); + .BeEmpty(); solution .AllProjects .ElementAt(0) .Type .Should() - .Be(ProjectType.CSharp); - - solution - .AllProjects - .ElementAt(0) - .As() - .ConfigurationPlatforms - .Should() - .Contain(config => config.Name.Equals("Debug|Any CPU.ActiveCfg")); - - // 2. Project - Solution Folder + .Be(ProjectType.SolutionFolder); + + // 2. Project - Test Project solution .AllProjects .ElementAt(1) .Should() - .BeOfType(); + .BeOfType(); solution .AllProjects .ElementAt(1) .Name .Should() - .Be("Solution Items"); + .Be("SlnParser.Tests"); solution .AllProjects .ElementAt(1) - .As() - .Projects + .As() + .File + .FullName .Should() - .BeEmpty(); + .Contain(@"SlnParser.Tests\SlnParser.Tests.csproj"); solution .AllProjects .ElementAt(1) .Type .Should() - .Be(ProjectType.SolutionFolder); + .Be(ProjectType.CSharp); - // 3. Project - Test Project + // 3. Project - ClassLib solution .AllProjects .ElementAt(2) @@ -715,7 +623,7 @@ public void Should_Be_Able_To_Parse_SlnParser_SlnxSolution_Correctly() .ElementAt(2) .Name .Should() - .Be("SlnParser.Tests"); + .Be("SlnParser"); solution .AllProjects .ElementAt(2) @@ -723,21 +631,13 @@ public void Should_Be_Able_To_Parse_SlnParser_SlnxSolution_Correctly() .File .FullName .Should() - .Contain(@"SlnParser.Tests\SlnParser.Tests.csproj"); + .Contain(@"SlnParser\SlnParser.csproj"); solution .AllProjects .ElementAt(2) .Type .Should() .Be(ProjectType.CSharp); - - solution - .AllProjects - .ElementAt(2) - .As() - .ConfigurationPlatforms - .Should() - .Contain(config => config.Name.Equals("Debug|x86.Build.0")); } [Fact] @@ -805,11 +705,6 @@ public void ParseSlnx_WithProjectWithoutPlatform_IsParsedCorrectly() .ConfigurationPlatforms .Single(); - configurationPlatform - .Configuration - .Should() - .Be("SolutionConfigurationName"); - configurationPlatform .Platform .Should() @@ -826,7 +721,6 @@ public void ParseSlnx_WithProjectWithoutPlatform_IsParsedCorrectly() .HaveCount(1); var project = solution.Projects.Single(); - project.Id.Should().Be("D5BDBC46-CEAF-4C92-8335-31450B76914F"); project.Name.Should().Be("Test"); project.TypeGuid.Should().Be("D183A3D8-5FD8-494B-B014-37F57B35E655"); project.Type.Should().Be(ProjectType.Unknown); diff --git a/src/SlnParser/Helper/SlnxParser.cs b/src/SlnParser/Helper/SlnxParser.cs new file mode 100644 index 0000000..39ad7ba --- /dev/null +++ b/src/SlnParser/Helper/SlnxParser.cs @@ -0,0 +1,207 @@ +using SlnParser.Contracts; +using SlnParser.Contracts.Exceptions; +using SlnParser.Contracts.Helper; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; + +namespace SlnParser.Helper +{ + internal sealed class SlnxParser + { + private readonly IProjectTypeMapper _projectTypeMapper = new ProjectTypeMapper(); + + public ISolution Parse(FileInfo solutionFile) + { + // the new SLNX has no information about the file format version, VS version and minimal VS version, so it's omitted. + var solution = new Solution + { + Name = Path.GetFileNameWithoutExtension(solutionFile.FullName), + File = solutionFile, + }; + + var fileContent = File.ReadAllText(solutionFile.FullName); + if (string.IsNullOrEmpty(fileContent.Trim())) + { + return solution; + } + + var xmlReaderSettings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, // Prevent XXE + XmlResolver = null, // Disable external entity resolution + }; + using var xmlContentStream = solutionFile.OpenRead(); + var xmlDocument = new XmlDocument(); + + using var xmlReader = XmlReader.Create(xmlContentStream, xmlReaderSettings); + xmlDocument.Load(xmlReader); + + var root = xmlDocument.DocumentElement; + if (root == null) throw new UnexpectedSolutionStructureException($"Solution file '{solutionFile.FullName}' does not contain a root element"); + + var structuredProjects = new List(); + + foreach (var xmlElement in root.ChildNodes.OfType()) + { + var projectsFromElement = ParseSlnxElement(xmlElement, solutionFile); + structuredProjects.AddRange(projectsFromElement); + } + + solution.AllProjects = structuredProjects.AsReadOnly(); + solution.Projects = structuredProjects.AsReadOnly(); + solution.ConfigurationPlatforms = ParseSolutionConfigurations(root).AsReadOnly(); + + return solution; + } + + private List ParseSlnxElement(XmlElement xmlElement, FileInfo solutionFile) + { + return xmlElement.Name switch + { + "Folder" => ParseSlnxFolder(xmlElement, solutionFile), + "Project" => ParseSlnxProject(xmlElement, solutionFile), + _ => new List(), + }; + } + + private List ParseSlnxFolder(XmlElement xmlElement, FileInfo solutionFile) + { + var folderName = xmlElement.GetAttribute("Name"); + if (string.IsNullOrWhiteSpace(folderName)) + { + throw new UnexpectedSolutionStructureException($"Could not find solution folder name attribute in solution '{solutionFile.FullName}'"); + } + + var actualFolderName = folderName.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries).Last(); + + var solutionFolderTypeId = new Guid("2150E333-8FDC-42A3-9474-1A3956D46DE8"); + var solutionFolder = new SolutionFolder(Guid.Empty, actualFolderName, solutionFolderTypeId, ProjectType.SolutionFolder); + var projects = new List + { + solutionFolder, + }; + + foreach (var childElement in xmlElement.ChildNodes.OfType()) + { + if (childElement.Name == "File") + { + var solutionFolderFile = ParseSolutionFolderFile(childElement, solutionFile); + if (solutionFolderFile == null) + { + throw new UnexpectedSolutionStructureException($"Could not parse file of solution folder '{actualFolderName}' in solution file '{solutionFile.FullName}'"); + } + + solutionFolder.AddFile(solutionFolderFile); + } + + var childProjects = ParseSlnxElement(childElement, solutionFile); + foreach (var childProject in childProjects) + { + solutionFolder.AddProject(childProject); + projects.Add(childProject); + } + } + + return projects; + } + + private List ParseSlnxProject(XmlElement xmlElement, FileInfo solutionFile) + { + const string defaultProjectTypeId = "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"; + + var projectPath = xmlElement.GetAttribute("Path"); + if (string.IsNullOrWhiteSpace(projectPath)) + { + throw new UnexpectedSolutionStructureException($"Could not find solution project path attribute in solution file '{solutionFile.FullName}'."); + } + + var projectTypeIdText = xmlElement.GetAttribute("Type"); + projectTypeIdText = string.IsNullOrWhiteSpace(projectTypeIdText) + ? defaultProjectTypeId + // the new project type id notation does not quite fit the known format, so it is adjusted. + : $"{{{projectTypeIdText.ToUpper()}}}"; + var projectTypeId = Guid.Parse(projectTypeIdText); + var projectType = _projectTypeMapper.Map(projectTypeId); + + var projectFullPath = EnsureAbsolute(projectPath, solutionFile); + + // the project name is part of its path. + var actualProjectName = Path.GetFileNameWithoutExtension(projectFullPath); + + // elements in the SLNX file don't have any IDs anymore, so it's omitted. + var solutionProject = new SolutionProject(Guid.Empty, actualProjectName, projectTypeId, projectType, new FileInfo(projectFullPath)); + + var projectConfigurations = ParseProjectConfigurations(xmlElement); + foreach (var configuration in projectConfigurations) solutionProject.AddConfigurationPlatform(configuration); + + return new List { solutionProject }; + } + + private static List ParseSolutionConfigurations(XmlElement rootElement) + { + var configurations = new List(); + var configurationPlatforms = rootElement.SelectNodes("Configurations/Platform"); + if (configurationPlatforms == null) + { + return configurations; + } + + foreach (XmlNode configurationPlatform in configurationPlatforms) + { + if (configurationPlatform.Attributes == null) continue; + + var name = configurationPlatform.Attributes["Name"].Value; + // there is not much info anymore + var platform = new ConfigurationPlatform(name, string.Empty, name); + configurations.Add(platform); + } + + return configurations; + } + + private static List ParseProjectConfigurations(XmlElement projectElement) + { + var configurations = new List(); + var configurationPlatforms = projectElement.SelectNodes("Platform"); + if (configurationPlatforms == null) + { + return configurations; + } + + foreach (XmlNode configurationPlatform in configurationPlatforms) + { + if (configurationPlatform.Attributes == null) continue; + + var projectName = configurationPlatform.Attributes["Project"].Value; + // there is not much info anymore + var platform = new ConfigurationPlatform(projectName, string.Empty, projectName); + configurations.Add(platform); + } + + return configurations; + } + + private static FileInfo? ParseSolutionFolderFile(XmlElement xmlElement, FileInfo solutionFile) + { + var path = xmlElement.GetAttribute("Path"); + if (string.IsNullOrWhiteSpace(path)) return null; + + var fullPath = EnsureAbsolute(path, solutionFile); + var fullPathInfo = new FileInfo(fullPath); + return fullPathInfo; + } + + private static string EnsureAbsolute(string path, FileInfo solutionFile) + { + var filePath = new FileInfo(path); + var isRelative = Path.IsPathRooted(filePath.FullName); + var fileFullPath = isRelative + ? Path.Combine(solutionFile.Directory?.FullName ?? string.Empty, filePath.FullName) + : filePath.FullName; + return fileFullPath; + } + } +} diff --git a/src/SlnParser/SolutionParser.cs b/src/SlnParser/SolutionParser.cs index 2ab0d80..9e8138a 100644 --- a/src/SlnParser/SolutionParser.cs +++ b/src/SlnParser/SolutionParser.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Xml; namespace SlnParser { @@ -14,7 +13,7 @@ namespace SlnParser public sealed class SolutionParser : ISolutionParser { private readonly IEnumerable _solutionEnrichers; - private readonly IProjectTypeMapper _projectTypeMapper = new ProjectTypeMapper(); + private readonly SlnxParser _slnxParser = new SlnxParser(); /// /// Creates a new instance of @@ -61,7 +60,7 @@ public ISolution Parse(FileInfo solutionFile) var solution = fileExtension switch { ".sln" => ParseSlnInternal(solutionFile), - ".slnx" => ParseSlnxInternal(solutionFile), + ".slnx" => _slnxParser.Parse(solutionFile), _ => throw new InvalidDataException($"The provided file '{solutionFile.FullName}' is not a solution file!"), }; return solution; @@ -123,48 +122,6 @@ private ISolution ParseSlnInternal(FileInfo solutionFile) return solution; } - private ISolution ParseSlnxInternal(FileInfo solutionFile) - { - // the new SLNX has no information about the file format version, VS version and minimal VS version, so it's omitted. - var solution = new Solution - { - Name = Path.GetFileNameWithoutExtension(solutionFile.FullName), - File = solutionFile, - }; - - var fileContent = File.ReadAllText(solutionFile.FullName); - if (string.IsNullOrEmpty(fileContent.Trim())) - { - return solution; - } - - var xmlReaderSettings = new XmlReaderSettings - { - DtdProcessing = DtdProcessing.Prohibit, // Prevent XXE - XmlResolver = null, // Disable external entity resolution - }; - using var xmlContentStream = solutionFile.OpenRead(); - var xmlDocument = new XmlDocument(); - - using var xmlReader = XmlReader.Create(xmlContentStream, xmlReaderSettings); - xmlDocument.Load(xmlReader); - - var root = xmlDocument.DocumentElement; - if (root == null) throw new UnexpectedSolutionStructureException($"Solution file '{solutionFile.FullName}' does not contain a root element"); - - var structuredProjects = new List(); - - foreach (var xmlElement in root.ChildNodes.OfType()) - { - var projectsFromElement = ParseSlnxElement(xmlElement, solutionFile); - structuredProjects.AddRange(projectsFromElement); - } - - solution.AllProjects = structuredProjects; - solution.Projects = structuredProjects; - return solution; - } - private static void ProcessLine(string line, Solution solution) { ProcessSolutionFileFormatVersion(line, solution); @@ -203,77 +160,5 @@ private static void ProcessMinimumVisualStudioVersion(string line, ISolution sol solution.VisualStudioVersion.MinimumVersion = minimumVisualStudioVersion; } - - private List ParseSlnxElement(XmlElement xmlElement, FileInfo solutionFile) - { - return xmlElement.Name switch - { - "Folder" => ParseSlnxFolder(xmlElement, solutionFile), - "Project" => ParseSlnxProject(xmlElement, solutionFile), - _ => new List(), - }; - } - - private List ParseSlnxFolder(XmlElement xmlElement, FileInfo solutionFile) - { - var folderName = xmlElement.GetAttribute("Name"); - if (string.IsNullOrWhiteSpace(folderName)) - { - throw new UnexpectedSolutionStructureException($"Could not find solution folder name attribute in solution '{solutionFile.FullName}'"); - } - - var actualFolderName = folderName.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries).Last(); - - var solutionFolderTypeId = new Guid("2150E333-8FDC-42A3-9474-1A3956D46DE8"); - var solutionFolder = new SolutionFolder(Guid.Empty, actualFolderName, solutionFolderTypeId, ProjectType.SolutionFolder); - var projects = new List - { - solutionFolder, - }; - - foreach (var childElement in xmlElement.ChildNodes.OfType()) - { - var childProjects = ParseSlnxElement(childElement, solutionFile); - foreach (var childProject in childProjects) - { - solutionFolder.AddProject(childProject); - projects.Add(childProject); - } - } - - return projects; - } - - private List ParseSlnxProject(XmlElement xmlElement, FileInfo solutionFile) - { - const string defaultProjectTypeId = "{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}"; - - var projectPath = xmlElement.GetAttribute("Path"); - if (string.IsNullOrWhiteSpace(projectPath)) - { - throw new UnexpectedSolutionStructureException($"Could not find solution project path attribute in solution file '{solutionFile.FullName}'."); - } - - var projectTypeIdText = xmlElement.GetAttribute("Type"); - projectTypeIdText = string.IsNullOrWhiteSpace(projectTypeIdText) - ? defaultProjectTypeId - // the new project type id notation does not quite fit the known format, so it is adjusted. - : $"{{{projectTypeIdText.ToUpper()}}}"; - var projectTypeId = Guid.Parse(projectTypeIdText); - var projectType = _projectTypeMapper.Map(projectTypeId); - - var projectFilePath = new FileInfo(projectPath); - var isRelative = Path.IsPathRooted(projectFilePath.FullName); - var projectFullPath = isRelative - ? Path.Combine(solutionFile.Directory?.FullName ?? string.Empty, projectFilePath.FullName) - : projectFilePath.FullName; - - // the project name is part of its path. - var actualProjectName = Path.GetFileNameWithoutExtension(projectFullPath); - - // elements in the SLNX file don't have any IDs anymore, so it's omitted. - var solutionProject = new SolutionProject(Guid.Empty, actualProjectName, projectTypeId, projectType, new FileInfo(projectFullPath)); - return new List { solutionProject }; - } } } From 86d4248293d0fb8603e83e936cc0dfb061cac6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Wagenf=C3=BChr?= Date: Thu, 8 Jan 2026 13:27:02 +0100 Subject: [PATCH 4/5] update docs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d30c813..449c011 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![GitHub forks](https://img.shields.io/github/forks/OptiSchmopti/CsvProc9000?style=social)](https://github.com/OptiSchmopti/CsvProc9000/network/members) [![GitHub watchers](https://img.shields.io/github/watchers/OptiSchmopti/CsvProc9000?style=social)](https://github.com/OptiSchmopti/CsvProc9000/watchers) -🛠️ .NET: Easy (to use) Parser for your .NET Solution (.sln) Files. This project targets `netstandard2.0` so it can basically be used anywhere you want. I've not yet run any performance tests. +🛠️ .NET: Easy (to use) Parser for your .NET Solution (s, .slnx) Files. This project targets `netstandard2.0` so it can basically be used anywhere you want. I've not yet run any performance tests. ## 💻 Usage @@ -18,7 +18,7 @@ ```cs var parser = new SolutionParser(); -var parsedSolution = parser.Parse("path/to/your/solution.sln"); +var parsedSolution = parser.Parse("path/to/your/solution.sln"); // or ".slnx" ``` From 7f13802298953e0be18025a4dcb8dfd5208648bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Wagenf=C3=BChr?= Date: Thu, 8 Jan 2026 13:33:39 +0100 Subject: [PATCH 5/5] fix build? --- build/Build.cs | 15 +++++++-------- build/SlnParser.Build.csproj | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/build/Build.cs b/build/Build.cs index d2e0e1f..b241557 100644 --- a/build/Build.cs +++ b/build/Build.cs @@ -1,16 +1,15 @@ +using System.IO; using System.Linq; using Nuke.Common; using Nuke.Common.CI; -using Nuke.Common.Execution; using Nuke.Common.IO; using Nuke.Common.ProjectModel; using Nuke.Common.Tools.DotNet; using Nuke.Common.Tools.GitVersion; using Nuke.Common.Utilities.Collections; -using static Nuke.Common.IO.FileSystemTasks; +using Serilog; using static Nuke.Common.Tools.DotNet.DotNetTasks; -[CheckBuildProjectConfigurations] [ShutdownDotNetAfterServerBuild] public class Build : NukeBuild { @@ -31,8 +30,8 @@ public class Build : NukeBuild .Before(Restore) .Executes(() => { - SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(DeleteDirectory); - BuildDirectory.GlobDirectories("**/.output").ForEach(DeleteDirectory); + SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(dir => Directory.Delete(dir, true)); + BuildDirectory.GlobDirectories("**/.output").ForEach(dir => Directory.Delete(dir, true)); }); Target Restore => _ => _ @@ -49,7 +48,7 @@ public class Build : NukeBuild { if (GitVersion == null) { - Logger.Warn( + Log.Warning( "GitVersion appears to be null. Have a look at it! Versions are defaulting to 0.1.0 for now..."); } @@ -85,7 +84,7 @@ public class Build : NukeBuild foreach (var packProject in packProjects) { - using var block = Logger.Block($"Packing {packProject.Name}"); + Log.Information("Packing {ProjectName}...", packProject.Name); DotNetPack(s => s .SetProject(packProject.Path) .SetVersion(GitVersion?.NuGetVersionV2 ?? "0.1.0") @@ -109,7 +108,7 @@ public class Build : NukeBuild foreach (var package in packages) { - Logger.Info($"Publishing {package}"); + Log.Information("Publishing {PackageName}...", package); DotNetNuGetPush(s => s .SetApiKey(NuGetApiKey) .SetSymbolApiKey(NuGetApiKey) diff --git a/build/SlnParser.Build.csproj b/build/SlnParser.Build.csproj index e24896f..a1e9e6d 100644 --- a/build/SlnParser.Build.csproj +++ b/build/SlnParser.Build.csproj @@ -11,7 +11,7 @@ - +