From 5c5f0229d729a770b659248b41f80956b06551c4 Mon Sep 17 00:00:00 2001 From: Meera Ruxmohan Date: Tue, 31 Dec 2024 10:51:12 -0600 Subject: [PATCH] WIP --- Directory.Packages.props | 4 +- ...osoft.VisualStudio.SlnGen.UnitTests.csproj | 61 +-- .../SlnFileTests.cs | 228 +++++---- .../Microsoft.VisualStudio.SlnGen.csproj | 117 ++--- src/Microsoft.VisualStudio.SlnGen/SlnFile.cs | 435 ++++++++---------- 5 files changed, 431 insertions(+), 414 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 488f1c5..cb4006a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,11 +6,12 @@ true - 17.12.6 + 17.14.0-preview-24624-01 17.11.4 9.0.0 8.0.0 6.0.0 + 1.0.28 @@ -25,6 +26,7 @@ + diff --git a/src/Microsoft.VisualStudio.SlnGen.UnitTests/Microsoft.VisualStudio.SlnGen.UnitTests.csproj b/src/Microsoft.VisualStudio.SlnGen.UnitTests/Microsoft.VisualStudio.SlnGen.UnitTests.csproj index 3532f6a..c7f049e 100644 --- a/src/Microsoft.VisualStudio.SlnGen.UnitTests/Microsoft.VisualStudio.SlnGen.UnitTests.csproj +++ b/src/Microsoft.VisualStudio.SlnGen.UnitTests/Microsoft.VisualStudio.SlnGen.UnitTests.csproj @@ -1,31 +1,32 @@ - - - net472;net8.0;net9.0 - $(TargetFrameworks);net10.0 - false - $(NoWarn);SA1600 - - - - - - - - - - - - - - - - - - - - - - - - + + + net472;net8.0;net9.0 + $(TargetFrameworks);net10.0 + false + $(NoWarn);SA1600 + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.VisualStudio.SlnGen.UnitTests/SlnFileTests.cs b/src/Microsoft.VisualStudio.SlnGen.UnitTests/SlnFileTests.cs index 06fabb9..1038b29 100644 --- a/src/Microsoft.VisualStudio.SlnGen.UnitTests/SlnFileTests.cs +++ b/src/Microsoft.VisualStudio.SlnGen.UnitTests/SlnFileTests.cs @@ -6,6 +6,9 @@ using Microsoft.Build.Evaluation; using Microsoft.Build.Utilities.ProjectCreation; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.VisualStudio.SolutionPersistence; +using Microsoft.VisualStudio.SolutionPersistence.Model; +using Microsoft.VisualStudio.SolutionPersistence.Serializer; using Shouldly; using System; using System.Collections.Generic; @@ -13,6 +16,8 @@ using System.Linq; using System.Reflection; using System.Text; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Microsoft.VisualStudio.SlnGen.UnitTests @@ -108,8 +113,9 @@ public void CustomConfigurationAndPlatforms() slnFile.AddProjects(new[] { projectA, projectB, projectC, projectD, projectE, projectF, projectG }); string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -195,8 +201,9 @@ public void CustomConfigurationAndPlatformsWithAlwaysBuildDisabled() slnFile.AddProjects(new[] { projectA, projectB, projectC, projectD, projectE }); string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); - slnFile.Save(solutionFilePath, useFolders: false, alwaysBuild: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false, alwaysBuild: false); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -234,8 +241,9 @@ public void CustomConfigurationAndPlatforms_IgnoresInvalidValues() slnFile.AddProjects(new[] { project }); string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -316,8 +324,9 @@ public void CustomConfigurationAndPlatforms_MapsAnyCPU() slnFile.AddProjects(new[] { projectA, projectB, projectC, projectD }); string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -334,6 +343,7 @@ public void CustomConfigurationAndPlatforms_MapsAnyCPU() public void ExistingSolutionIsReused() { string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); Guid projectGuid = Guid.Parse("7BE5A5CA-169D-4955-AB4D-EDDE662F4AE5"); @@ -357,9 +367,9 @@ public void ExistingSolutionIsReused() slnFile.AddProjects(new[] { project }); - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); - SlnFile.TryParseExistingSolution(solutionFilePath, out Guid solutionGuid, out _).ShouldBeTrue(); + SlnFile.TryParseExistingSolution(solutionFilePath, out Guid solutionGuid, out _, out serializer).ShouldBeTrue(); solutionGuid.ShouldBe(slnFile.SolutionGuid); @@ -373,10 +383,13 @@ public void ExistingSolutionIsReused() [Fact] public void MultipleProjects() { + string projectAPath = Path.GetRandomFileName(); + string projectBPath = Path.GetRandomFileName(); + SlnProject projectA = new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectA", + FullPath = Path.Combine(TestRootPath, projectAPath), + Name = Path.GetFileNameWithoutExtension(projectAPath), ProjectGuid = new Guid("C95D800E-F016-4167-8E1B-1D3FF94CE2E2"), ProjectTypeGuid = new Guid("88152E7E-47E3-45C8-B5D3-DDB15B2F0435"), IsMainProject = true, @@ -384,8 +397,8 @@ public void MultipleProjects() SlnProject projectB = new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectB", + FullPath = Path.Combine(TestRootPath, projectBPath), + Name = Path.GetFileNameWithoutExtension(projectBPath), ProjectGuid = new Guid("EAD108BE-AC70-41E6-A8C3-450C545FDC0E"), ProjectTypeGuid = new Guid("F38341C3-343F-421A-AE68-94CD9ADCD32F"), }; @@ -396,27 +409,31 @@ public void MultipleProjects() [Fact] public void NoFolders() { + string projectAPath = Path.GetRandomFileName(); + string projectBPath = Path.GetRandomFileName(); + string projectCPath = Path.GetRandomFileName(); + SlnProject[] projects = { new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectA", + FullPath = Path.Combine(TestRootPath, projectAPath), + Name = Path.GetFileNameWithoutExtension(projectAPath), ProjectGuid = new Guid("C95D800E-F016-4167-8E1B-1D3FF94CE2E2"), ProjectTypeGuid = new Guid("88152E7E-47E3-45C8-B5D3-DDB15B2F0435"), IsMainProject = true, }, new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectB", + FullPath = Path.Combine(TestRootPath, projectBPath), + Name = Path.GetFileNameWithoutExtension(projectBPath), ProjectGuid = new Guid("EAD108BE-AC70-41E6-A8C3-450C545FDC0E"), ProjectTypeGuid = new Guid("F38341C3-343F-421A-AE68-94CD9ADCD32F"), }, new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectC", + FullPath = Path.Combine(TestRootPath, projectCPath), + Name = Path.GetFileNameWithoutExtension(projectCPath), ProjectGuid = new Guid("EDD837F8-48ED-45E1-BC77-6387EC6466AC"), ProjectTypeGuid = new Guid("7C203CD8-314C-4358-AD5C-66152E899EAF"), }, @@ -428,7 +445,8 @@ public void NoFolders() [Fact] public void PathsWorkForAllDirectorySeparatorChars() { - const string solutionText = @"Microsoft Visual Studio Solution File, Format Version 12.00 + const string solutionText = @" +Microsoft Visual Studio Solution File, Format Version 12.00 Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""ProjectA"", ""ProjectA\ProjectA.csproj"", ""{E859E866-96F9-474E-A1EA-6539385AD236}"" EndProject Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""ProjectB"", ""ProjectB\ProjectB.csproj"", ""{893607F9-C204-4CB2-8BF2-1F71B4198CD2}"" @@ -495,7 +513,8 @@ public void PathsWorkForAllDirectorySeparatorChars() [Fact] public void ProjectsNotBuildable() { - const string solutionText = @"Microsoft Visual Studio Solution File, Format Version 12.00 + const string solutionText = @" +Microsoft Visual Studio Solution File, Format Version 12.00 Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""ProjectA"", ""ProjectA\ProjectA.csproj"", ""{E859E866-96F9-474E-A1EA-6539385AD236}"" EndProject Project(""{9A19103F-16F7-4668-BE54-9A1E7A4F7556}"") = ""ProjectB"", ""ProjectB\ProjectB.csproj"", ""{893607F9-C204-4CB2-8BF2-1F71B4198CD2}"" @@ -597,8 +616,8 @@ public void TestSlnGenFoldersPropertyToEnableFolderCreation() string solutionFilePath = GetSolutionFilePath(new Project[] { projectA, projectB, projectC }); string contents = File.ReadAllText(solutionFilePath); - contents.ShouldContain("\"..\\testB\","); - contents.ShouldContain("\"..\\testC\","); + contents.ShouldContain("Project(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"testB\", \"testB\","); + contents.ShouldContain("Project(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"testC\", \"testC\","); } [Fact] @@ -618,8 +637,8 @@ public void TestSlnGenFoldersPropertyToDisableFolderCreation() string solutionFilePath = GetSolutionFilePath(new Project[] { projectA, projectB, projectC }); string contents = File.ReadAllText(solutionFilePath); - contents.ShouldNotContain("\"..\\testB\","); - contents.ShouldNotContain("\"..\\testC\","); + contents.ShouldNotContain("Project(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"testB\", \"testB\","); + contents.ShouldNotContain("Project(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"testC\", \"testC\","); } [Fact] @@ -631,8 +650,8 @@ public void TestNoFolderCreation() string solutionFilePath = GetSolutionFilePath(new Project[] { projectA, projectB, projectC }); string contents = File.ReadAllText(solutionFilePath); - contents.ShouldNotContain("\"..\\testB\","); - contents.ShouldNotContain("\"..\\testC\","); + contents.ShouldNotContain("Project(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"testB\", \"testB\","); + contents.ShouldNotContain("Project(\"{2150E333-8FDC-42A3-9474-1A3956D46DE8}\") = \"testC\", \"testC\","); } [Fact] @@ -668,7 +687,7 @@ public void ProjectConfigurationPlatformOrderingSameAsProjects() { FullPath = Path.Combine(TestRootPath, "B", "B.csproj"), Name = "B", - ProjectGuid = new Guid("0CCA75AE-ED20-431E-8853-B9F54333E87A"), + ProjectGuid = new Guid("88152E7E-47E3-45C8-B5D3-DDB15B2F0435"), ProjectTypeGuid = new Guid(projectTypeGuid), Configurations = new[] { @@ -698,19 +717,21 @@ public void ProjectConfigurationPlatformOrderingSameAsProjects() }, }); - string path = Path.GetTempFileName(); + string path = Path.ChangeExtension(Path.GetTempFileName(), ".sln"); - slnFile.Save(path, useFolders: false); + slnFile.CreateSolutionDirectory(path); + slnFile.Save(SolutionSerializers.GetSerializerByMoniker(path), path, useFolders: false); string directoryName = new DirectoryInfo(TestRootPath).Name; File.ReadAllText(path).ShouldBe( - $@"Microsoft Visual Studio Solution File, Format Version 12.00 + $@" +Microsoft Visual Studio Solution File, Format Version 12.00 Project(""{{7E0F1516-6200-48BD-83FC-3EFA3AB4A574}}"") = ""C"", ""{directoryName}\C\C.csproj"", ""{{0CCA75AE-ED20-431E-8853-B9F54333E87A}}"" EndProject Project(""{{7E0F1516-6200-48BD-83FC-3EFA3AB4A574}}"") = ""A"", ""{directoryName}\A\A.csproj"", ""{{D744C26F-1CCB-456A-B490-CEB39334051B}}"" EndProject -Project(""{{7E0F1516-6200-48BD-83FC-3EFA3AB4A574}}"") = ""B"", ""{directoryName}\B\B.csproj"", ""{{0CCA75AE-ED20-431E-8853-B9F54333E87A}}"" +Project(""{{7E0F1516-6200-48BD-83FC-3EFA3AB4A574}}"") = ""B"", ""{directoryName}\B\B.csproj"", ""{{88152E7E-47E3-45C8-B5D3-DDB15B2F0435}}"" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -726,10 +747,10 @@ public void ProjectConfigurationPlatformOrderingSameAsProjects() {{D744C26F-1CCB-456A-B490-CEB39334051B}}.Debug|Any CPU.Build.0 = Debug|Any CPU {{D744C26F-1CCB-456A-B490-CEB39334051B}}.Release|Any CPU.ActiveCfg = Release|Any CPU {{D744C26F-1CCB-456A-B490-CEB39334051B}}.Release|Any CPU.Build.0 = Release|Any CPU - {{0CCA75AE-ED20-431E-8853-B9F54333E87A}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {{0CCA75AE-ED20-431E-8853-B9F54333E87A}}.Debug|Any CPU.Build.0 = Debug|Any CPU - {{0CCA75AE-ED20-431E-8853-B9F54333E87A}}.Release|Any CPU.ActiveCfg = Release|Any CPU - {{0CCA75AE-ED20-431E-8853-B9F54333E87A}}.Release|Any CPU.Build.0 = Release|Any CPU + {{88152E7E-47E3-45C8-B5D3-DDB15B2F0435}}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {{88152E7E-47E3-45C8-B5D3-DDB15B2F0435}}.Debug|Any CPU.Build.0 = Debug|Any CPU + {{88152E7E-47E3-45C8-B5D3-DDB15B2F0435}}.Release|Any CPU.ActiveCfg = Release|Any CPU + {{88152E7E-47E3-45C8-B5D3-DDB15B2F0435}}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -777,12 +798,14 @@ public void ProjectSolutionFolders() string[] solutionItems = new[] { Path.Combine(root, "SubFolder1", solutionItem1Name) }; string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); SlnFile slnFile = new SlnFile(); slnFile.AddProjects(projects, new Dictionary(), projects[1].FullPath); slnFile.AddSolutionItems(solutionItems); - slnFile.Save(solutionFilePath, useFolders: false); + + slnFile.Save(serializer, solutionFilePath, useFolders: false); SolutionFile s = SolutionFile.Parse(solutionFilePath); @@ -814,11 +837,12 @@ public void SaveToCustomLocationCreatesDirectory() directoryInfo.Exists.ShouldBeFalse(); - string fullPath = Path.Combine(directoryInfo.FullName, Path.GetRandomFileName()); + string fullPath = Path.Combine(directoryInfo.FullName, GetTempFileName(".sln")); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(fullPath); SlnFile slnFile = new SlnFile(); - slnFile.Save(fullPath, useFolders: false); + slnFile.Save(serializer, fullPath, useFolders: false); File.Exists(fullPath).ShouldBeTrue(); } @@ -872,10 +896,12 @@ public void SharedProject() [Fact] public void SingleProject() { + string filePath = Path.GetRandomFileName(); + string fileName = Path.GetFileNameWithoutExtension(filePath); SlnProject projectA = new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectA", + FullPath = Path.Combine(TestRootPath, filePath), + Name = fileName, ProjectGuid = new Guid("C95D800E-F016-4167-8E1B-1D3FF94CE2E2"), ProjectTypeGuid = new Guid("88152E7E-47E3-45C8-B5D3-DDB15B2F0435"), IsMainProject = true, @@ -887,7 +913,7 @@ public void SingleProject() [Fact] public void TryParseExistingSolution() { - FileInfo solutionFilePath = new FileInfo(GetTempFileName()); + FileInfo solutionFilePath = new FileInfo(GetTempFileName(".sln")); Dictionary projects = new Dictionary { @@ -897,8 +923,8 @@ public void TryParseExistingSolution() Dictionary folders = new Dictionary { - [@"FolderA"] = new Guid("9C915FE4-72A5-4368-8979-32B3983E6041"), - [@"FolderB"] = new Guid("D3A9F802-38CC-4F8D-8DE9-8DF9C8B7EADC"), + ["//FolderA//"] = new Guid("9C915FE4-72A5-4368-8979-32B3983E6041"), + ["//FolderB//"] = new Guid("D3A9F802-38CC-4F8D-8DE9-8DF9C8B7EADC"), }; Dictionary projectFiles = projects.ToDictionary(i => new FileInfo(Path.Combine(solutionFilePath.DirectoryName!, i.Key)), i => i.Value); @@ -952,7 +978,7 @@ public void TryParseExistingSolution() EndGlobalSection EndGlobal "); - SlnFile.TryParseExistingSolution(solutionFilePath.FullName, out Guid solutionGuid, out IReadOnlyDictionary projectGuidsByPath).ShouldBeTrue(); + SlnFile.TryParseExistingSolution(solutionFilePath.FullName, out Guid solutionGuid, out IReadOnlyDictionary projectGuidsByPath, out _).ShouldBeTrue(); solutionGuid.ShouldBe(Guid.Parse("CFFC4187-96EE-4465-B5B3-0BAFD3C14BB6")); @@ -962,28 +988,32 @@ public void TryParseExistingSolution() [Fact] public void WithFolders() { + string projectAPath = Path.GetRandomFileName(); + string projectBPath = Path.GetRandomFileName(); + string projectCPath = Path.GetRandomFileName(); + SlnProject[] projects = { new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectA", + FullPath = Path.Combine(TestRootPath, projectAPath), + Name = Path.GetFileNameWithoutExtension(projectAPath), ProjectGuid = new Guid("C95D800E-F016-4167-8E1B-1D3FF94CE2E2"), ProjectTypeGuid = new Guid("88152E7E-47E3-45C8-B5D3-DDB15B2F0435"), IsMainProject = true, }, new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectB", + FullPath = Path.Combine(TestRootPath, projectBPath), + Name = Path.GetFileNameWithoutExtension(projectBPath), ProjectGuid = new Guid("F3CEBCAB-98E5-4041-84DB-033C9682F340"), ProjectTypeGuid = new Guid("EEC9AD2B-9B7E-4581-864E-76A2BB607C3F"), IsMainProject = true, }, new SlnProject { - FullPath = GetTempFileName(), - Name = "ProjectC", + FullPath = Path.Combine(TestRootPath, projectCPath), + Name = Path.GetFileNameWithoutExtension(projectCPath), ProjectGuid = new Guid("0079D674-EC4D-4D09-9C4E-699D0D1B0F72"), ProjectTypeGuid = new Guid("7717E4E9-5443-401B-A964-55727AF96E0C"), IsMainProject = true, @@ -1032,12 +1062,13 @@ public void WithFoldersDoNotIgnoreMainProject() string[] solutionItems = new[] { Path.Combine(root, "SubFolder3", solutionItem1Name) }; string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); SlnFile slnFile = new SlnFile(); slnFile.AddProjects(projects, new Dictionary(), projects[1].FullPath); slnFile.AddSolutionItems(solutionItems); - slnFile.Save(solutionFilePath, true); + slnFile.Save(serializer, solutionFilePath, true); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -1090,12 +1121,13 @@ public void WithFoldersIgnoreMainProject() string[] solutionItems = new[] { Path.Combine(root, "SubFolder3", solutionItem1Name) }; string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); SlnFile slnFile = new SlnFile(); slnFile.AddProjects(projects, new Dictionary()); slnFile.AddSolutionItems(solutionItems); - slnFile.Save(solutionFilePath, true); + slnFile.Save(serializer, solutionFilePath, true); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -1157,12 +1189,13 @@ public void WithFoldersDoesNotCreateRootFolder(bool ignoreMainProject, bool coll string[] solutionItems = new[] { Path.Combine(root, "SubFolder3", solutionItem1Name) }; string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); SlnFile slnFile = new SlnFile(); slnFile.AddProjects(projects, new Dictionary(), ignoreMainProject ? null : projects[1].FullPath); slnFile.AddSolutionItems(solutionItems); - slnFile.Save(solutionFilePath, useFolders: true, collapseFolders: collapseFolders); + slnFile.Save(serializer, solutionFilePath, useFolders: true, collapseFolders: collapseFolders); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -1211,6 +1244,7 @@ public void WithFoldersDoesNotCreateRootFolder(bool ignoreMainProject, bool coll public void VisualStudioVersionIsWritten() { string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); SlnFile slnFile = new SlnFile { @@ -1218,18 +1252,15 @@ public void VisualStudioVersionIsWritten() SolutionGuid = new Guid("{6370DE27-36B7-44AE-B47A-1ECF4A6D740A}"), }; - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); File.ReadAllText(solutionFilePath).ShouldBe( - @"Microsoft Visual Studio Solution File, Format Version 12.00 + @" +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 1 VisualStudioVersion = 1.2.3.4 MinimumVisualStudioVersion = 10.0.40219.1 Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection @@ -1246,6 +1277,7 @@ public void Save_WithSolutionItemsAddedToSpecificFolder_SolutionItemsExistInSpec { // Arrange string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); var slnFile = new SlnFile() { @@ -1255,21 +1287,16 @@ public void Save_WithSolutionItemsAddedToSpecificFolder_SolutionItemsExistInSpec slnFile.AddSolutionItems("docs", new[] { Path.Combine(this.TestRootPath, "README.md") }); // Act - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); - // Assert - File.ReadAllText(solutionFilePath).ShouldBe( - @"Microsoft Visual Studio Solution File, Format Version 12.00 -Project(""{2150E333-8FDC-42A3-9474-1A3956D46DE8}"") = ""docs"", ""docs"", ""{B283EBC2-E01F-412D-9339-FD56EF114549}"" + const string ExpectedSolutionContents = @" +Microsoft Visual Studio Solution File, Format Version 12.00 +Project(""{2150E333-8FDC-42A3-9474-1A3956D46DE8}"") = ""docs"", ""docs"", ""{B283EBC2-E01F-412D-9339-FD56EF114549}"" ProjectSection(SolutionItems) = preProject README.md = README.md EndProjectSection EndProject Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection @@ -1277,8 +1304,10 @@ public void Save_WithSolutionItemsAddedToSpecificFolder_SolutionItemsExistInSpec SolutionGuid = {6370DE27-36B7-44AE-B47A-1ECF4A6D740A} EndGlobalSection EndGlobal -", - StringCompareShould.IgnoreLineEndings); +"; + + // Assert + File.ReadAllText(solutionFilePath).ShouldBe(ExpectedSolutionContents, StringCompareShould.IgnoreLineEndings); } [Fact] @@ -1305,10 +1334,10 @@ public void EmitWindowsWarningForProjectsOnMultipleDrives() SlnFile slnFile = new (); SlnProject[] projects = new[] { projectA, projectB }; string solutionFilePath = isWindowsPlatform ? @$"X:\{Path.GetRandomFileName()}" : $"/mnt/{Path.GetRandomFileName()}"; - StringBuilderTextWriter writer = new (new StringBuilder(), new List()); + ISolutionSerializer serializer = new MockSolutionSerializer(); slnFile.AddProjects(projects); - slnFile.Save(solutionFilePath, writer, useFolders: true, logger); + slnFile.Save(serializer, solutionFilePath, useFolders: true, logger); logger.Errors.Count.ShouldBe(0); @@ -1328,7 +1357,6 @@ public void DoNotEmitWarningForRootPath() { TestLogger logger = new (); SlnFile slnFile = new (); - StringBuilderTextWriter writer = new (new StringBuilder(), new List()); SlnProject project = new SlnProject { @@ -1341,8 +1369,10 @@ public void DoNotEmitWarningForRootPath() }; string solutionFilePath = Path.Combine(TestRootPath, "sample.sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); + slnFile.AddProjects([project]); - slnFile.Save(solutionFilePath, writer, useFolders: true, logger, collapseFolders: true); + slnFile.Save(serializer, solutionFilePath, useFolders: true, logger, collapseFolders: true); logger.Errors.Count.ShouldBe(0); logger.Warnings.Count.ShouldBe(0); @@ -1353,6 +1383,7 @@ public void Save_WithSolutionItemsAddedWithParentFolder_SolutionItemsNestedInPar { // Arrange string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); var slnFile = new SlnFile() { @@ -1380,26 +1411,24 @@ public void Save_WithSolutionItemsAddedWithParentFolder_SolutionItemsNestedInPar slnFile.AddProjects(new[] { project }); // Act - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); // Assert File.ReadAllText(solutionFilePath).ShouldBe( - @"Microsoft Visual Studio Solution File, Format Version 12.00 + @" +Microsoft Visual Studio Solution File, Format Version 12.00 Project(""{65815BD7-8B14-4E69-8328-D5C4ED3245BE}"") = ""ProjectA"", ""ProjectA.csproj"", ""{2ACFA184-2D17-4F80-A132-EC462B48A065}"" EndProject -Project(""{2150E333-8FDC-42A3-9474-1A3956D46DE8}"") = ""docs"", ""docs"", ""{24073434-9641-4234-A3E8-352E5E549B65}"" +Project(""{2150E333-8FDC-42A3-9474-1A3956D46DE8}"") = ""docs"", ""docs"", ""{24073434-9641-4234-A3E8-352E5E549B65}"" ProjectSection(SolutionItems) = preProject README.md = README.md EndProjectSection EndProject -Project(""{2150E333-8FDC-42A3-9474-1A3956D46DE8}"") = ""license"", ""license"", ""{9124D1F8-9153-40CC-BC94-3B2A3AA51E91}"" +Project(""{2150E333-8FDC-42A3-9474-1A3956D46DE8}"") = ""license"", ""license"", ""{9124D1F8-9153-40CC-BC94-3B2A3AA51E91}"" ProjectSection(SolutionItems) = preProject LICENSE.txt = LICENSE.txt EndProjectSection EndProject - GlobalSection(NestedProjects) = preSolution - {9124D1F8-9153-40CC-BC94-3B2A3AA51E91} = {24073434-9641-4234-A3E8-352E5E549B65} - EndGlobalSection Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1414,6 +1443,9 @@ public void Save_WithSolutionItemsAddedWithParentFolder_SolutionItemsNestedInPar GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {9124D1F8-9153-40CC-BC94-3B2A3AA51E91} = {24073434-9641-4234-A3E8-352E5E549B65} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6370DE27-36B7-44AE-B47A-1ECF4A6D740A} EndGlobalSection @@ -1428,12 +1460,15 @@ public void Save_WithSolutionItemsAddedWithParentFolder_SolutionItemsNestedInPar public void SlnProject_IsBuildable_ReflectedAsProjectConfigurationInSolutionIncludeInBuild(bool isBuildable) { string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); + + string projectFilePath = Path.GetRandomFileName(); SlnFile slnFile = new SlnFile(); SlnProject slnProject = new SlnProject { - FullPath = GetTempFileName(), - Name = "Project", + FullPath = Path.Combine(TestRootPath, projectFilePath), + Name = Path.GetFileNameWithoutExtension(projectFilePath), ProjectGuid = Guid.NewGuid(), ProjectTypeGuid = Guid.NewGuid(), Configurations = new[] { "Debug", "Release" }, @@ -1442,7 +1477,7 @@ public void SlnProject_IsBuildable_ReflectedAsProjectConfigurationInSolutionIncl }; slnFile.AddProjects(new[] { slnProject }); - slnFile.Save(solutionFilePath, useFolders: false); + slnFile.Save(serializer, solutionFilePath, useFolders: false); ValidateProjectInSolution( (slnProject, projectInSolution) => @@ -1475,11 +1510,13 @@ private string GetSolutionFilePath(Project[] projects) private void ValidateProjectInSolution(Action customValidator, SlnProject[] projects, bool useFolders) { string solutionFilePath = GetTempFileName(".sln"); + ISolutionSerializer serializer = SolutionSerializers.GetSerializerByMoniker(solutionFilePath); SlnFile slnFile = new SlnFile(); slnFile.AddProjects(projects); - slnFile.Save(solutionFilePath, useFolders); + slnFile.CreateSolutionDirectory(solutionFilePath); + slnFile.Save(serializer, solutionFilePath, useFolders); SolutionFile solutionFile = SolutionFile.Parse(solutionFilePath); @@ -1559,5 +1596,30 @@ private ProjectInSolution GetSolutionFolderByName(SolutionFile solutionFile, str return solutionFile.ProjectsInOrder.FirstOrDefault(i => i.ProjectName.Equals(name)); #endif } + + private class MockSolutionSerializer : ISolutionSerializer + { + public string Name => throw new NotImplementedException(); + + public ISerializerModelExtension CreateModelExtension() + { + throw new NotImplementedException(); + } + + public bool IsSupported(string moniker) + { + throw new NotImplementedException(); + } + + public Task OpenAsync(string moniker, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public Task SaveAsync(string moniker, SolutionModel model, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } } } \ No newline at end of file diff --git a/src/Microsoft.VisualStudio.SlnGen/Microsoft.VisualStudio.SlnGen.csproj b/src/Microsoft.VisualStudio.SlnGen/Microsoft.VisualStudio.SlnGen.csproj index c51c6cc..7e76b2d 100644 --- a/src/Microsoft.VisualStudio.SlnGen/Microsoft.VisualStudio.SlnGen.csproj +++ b/src/Microsoft.VisualStudio.SlnGen/Microsoft.VisualStudio.SlnGen.csproj @@ -1,58 +1,59 @@ - - - Exe - net472;net8.0;net9.0 - $(TargetFrameworks);net10.0 - $(AllowedOutputExtensionsInPackageBuildOutputFolder);.config - true - true - $(NoWarn);NU5128;MSB3270 - false - LatestMinor - true - false - - - - All - - - - - - - - - - - - - - - - - - - - - - - - <_MajorMinorBuildRuntimeFramework>$([System.Text.RegularExpressions.Regex]::Match(%(RuntimeFramework.Version), '^\d+\.\d+\.\d+')) - <_PreviousRuntimeFramework>%(RuntimeFramework.Version) - - - - - - - - - - - + + + Exe + net472;net8.0;net9.0 + $(TargetFrameworks);net10.0 + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.config + true + true + $(NoWarn);NU5128;MSB3270 + false + LatestMinor + true + false + + + + All + + + + + + + + + + + + + + + + + + + + + + + + + <_MajorMinorBuildRuntimeFramework>$([System.Text.RegularExpressions.Regex]::Match(%(RuntimeFramework.Version), '^\d+\.\d+\.\d+')) + <_PreviousRuntimeFramework>%(RuntimeFramework.Version) + + + + + + + + + + + diff --git a/src/Microsoft.VisualStudio.SlnGen/SlnFile.cs b/src/Microsoft.VisualStudio.SlnGen/SlnFile.cs index cb3fe98..1ade71a 100644 --- a/src/Microsoft.VisualStudio.SlnGen/SlnFile.cs +++ b/src/Microsoft.VisualStudio.SlnGen/SlnFile.cs @@ -3,13 +3,17 @@ // Licensed under the MIT license. using Microsoft.Build.Evaluation; +using Microsoft.VisualStudio.SolutionPersistence; +using Microsoft.VisualStudio.SolutionPersistence.Model; +using Microsoft.VisualStudio.SolutionPersistence.Serializer; +using Microsoft.VisualStudio.SolutionPersistence.Serializer.SlnV12; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; -using System.Text.RegularExpressions; +using System.Threading; namespace Microsoft.VisualStudio.SlnGen { @@ -18,50 +22,7 @@ namespace Microsoft.VisualStudio.SlnGen /// public sealed class SlnFile { - /// - /// The beginning of the line that ends a global section. - /// - private const string GlobalSectionEnd = "\tEndGlobalSection"; - - /// - /// The beginning of the line that starts the extensibility global section. - /// - private const string GlobalSectionStartExtensibilityGlobals = "\tGlobalSection(ExtensibilityGlobals)"; - - /// - /// The solution header. - /// - private const string Header = "Microsoft Visual Studio Solution File, Format Version {0}"; - - /// - /// The beginning of the line that ends project information. - /// - private const string ProjectSectionEnd = "EndProject"; - - /// - /// The beginning of the line that contains project information. - /// - private const string ProjectSectionStart = "Project(\""; - - /// - /// The beginning of the line that contains the solution GUID. - /// - private const string SectionSettingSolutionGuid = "\t\tSolutionGuid = "; - - /// - /// A regular expression used to parse the project section. - /// - private static readonly Regex GuidRegex = new (@"(?\{[0-9a-fA-F\-]+\})"); - - /// - /// The separator to split project information by. - /// - private static readonly string[] ProjectSectionSeparator = { "\", \"" }; - - /// - /// The file format version. - /// - private readonly string _fileFormatVersion; + private static readonly char[] DirectorySeparatorCharacters = new char[] { Path.DirectorySeparatorChar }; /// /// Gets the projects. @@ -73,20 +34,10 @@ public sealed class SlnFile /// private readonly Dictionary _solutionItems = new (); - /// - /// Initializes a new instance of the class. - /// - /// The file format version. - public SlnFile(string fileFormatVersion) - { - _fileFormatVersion = fileFormatVersion; - } - /// /// Initializes a new instance of the class. /// public SlnFile() - : this("12.00") { } @@ -156,6 +107,7 @@ public static (string solutionFileFullPath, int customProjectTypeGuidCount, int } var firstProjectName = firstProject.GetPropertyValueOrDefault(MSBuildPropertyNames.SlnGenProjectName, Path.GetFileName(firstProject.FullPath)); + string solutionFileName = Path.ChangeExtension(firstProjectName, "sln"); solutionFileFullPath = Path.Combine(solutionDirectoryFullPath!, solutionFileName); @@ -172,7 +124,7 @@ public static (string solutionFileFullPath, int customProjectTypeGuidCount, int } } - SlnFile solution = new SlnFile + SlnFile slnFile = new () { Platforms = arguments.GetPlatforms(), Configurations = arguments.GetConfigurations(), @@ -182,10 +134,10 @@ public static (string solutionFileFullPath, int customProjectTypeGuidCount, int { if (arguments.VisualStudioVersion.Version != null && Version.TryParse(arguments.VisualStudioVersion.Version, out Version version)) { - solution.VisualStudioVersion = version; + slnFile.VisualStudioVersion = version; } - if (solution.VisualStudioVersion == null) + if (slnFile.VisualStudioVersion == null) { string devEnvFullPath = arguments.GetDevEnvFullPath(Program.CurrentDevelopmentEnvironment.VisualStudio); @@ -193,18 +145,16 @@ public static (string solutionFileFullPath, int customProjectTypeGuidCount, int { FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(devEnvFullPath); - solution.VisualStudioVersion = new Version(fileVersionInfo.ProductMajorPart, fileVersionInfo.ProductMinorPart, fileVersionInfo.ProductBuildPart, fileVersionInfo.FilePrivatePart); + slnFile.VisualStudioVersion = new Version(fileVersionInfo.ProductMajorPart, fileVersionInfo.ProductMinorPart, fileVersionInfo.ProductBuildPart, fileVersionInfo.FilePrivatePart); } } } - if (TryParseExistingSolution(solutionFileFullPath, out Guid solutionGuid, out IReadOnlyDictionary projectGuidsByPath)) + if (TryParseExistingSolution(solutionFileFullPath, out Guid solutionGuid, out IReadOnlyDictionary projectGuidsByPath, out ISolutionSerializer serializer)) { logger.LogMessageNormal("Updating existing solution file and reusing Visual Studio cache"); - - solution.SolutionGuid = solutionGuid; - solution.ExistingProjectGuids = projectGuidsByPath; - + slnFile.SolutionGuid = solutionGuid; + slnFile.ExistingProjectGuids = projectGuidsByPath; arguments.LoadProjectsInVisualStudio = new[] { bool.TrueString }; } @@ -214,112 +164,79 @@ public static (string solutionFileFullPath, int customProjectTypeGuidCount, int isBuildable = bool.TrueString.Equals(isBuildableString, StringComparison.OrdinalIgnoreCase); } - solution.AddProjects(projectList, customProjectTypeGuids, arguments.IgnoreMainProject ? null : firstProject.FullPath, isBuildable); + slnFile.AddProjects(projectList, customProjectTypeGuids, arguments.IgnoreMainProject ? null : firstProject.FullPath, isBuildable); - solution.AddSolutionItems(solutionItems); + slnFile.AddSolutionItems(solutionItems); string slnGenFoldersPropertyValue = firstProject.GetPropertyValueOrDefault(MSBuildPropertyNames.SlnGenFolders, "false"); var enableFolders = arguments.EnableFolders(slnGenFoldersPropertyValue); if (!logger.HasLoggedErrors) { - solution.Save(solutionFileFullPath, enableFolders, logger, arguments.EnableCollapseFolders(), arguments.EnableAlwaysBuild()); + slnFile.CreateSolutionDirectory(solutionFileFullPath); + slnFile.Save(serializer, solutionFileFullPath, enableFolders, logger, arguments.EnableCollapseFolders(), arguments.EnableAlwaysBuild()); } - return (solutionFileFullPath, customProjectTypeGuids.Count, solutionItems.Count, solution.SolutionGuid); + return (solutionFileFullPath, customProjectTypeGuids.Count, solutionItems.Count, solutionGuid); } /// /// Attempts to read the existing GUID from a solution file if one exists. /// - /// The path to a solution file. + /// Path to the existing solution file. /// Receives the of the existing solution file if one is found, otherwise default(Guid). /// Receives the project GUIDs by their full paths. - /// true if the solution GUID was found, otherwise false. - public static bool TryParseExistingSolution(string path, out Guid solutionGuid, out IReadOnlyDictionary projectGuidsByPath) + /// Serializer which can reads and determines the appropriate solution model from the solution file (based on the moniker). + /// true if the solution file could be correctly parsed. + public static bool TryParseExistingSolution(string solutionFileFullPath, out Guid solutionGuid, out IReadOnlyDictionary projectGuidsByPath, out ISolutionSerializer serializer) { - solutionGuid = default; projectGuidsByPath = default; + solutionGuid = default; + serializer = SolutionSerializers.GetSerializerByMoniker(solutionFileFullPath); - bool foundSolutionGuid = false; - - Dictionary projectGuids = new Dictionary(StringComparer.OrdinalIgnoreCase); - - FileInfo fileInfo = new FileInfo(path); - + FileInfo fileInfo = new FileInfo(solutionFileFullPath); if (!fileInfo.Exists || fileInfo.Directory == null) { return false; } - using FileStream stream = File.OpenRead(path); - using StreamReader reader = new StreamReader(stream, Encoding.GetEncoding(0), detectEncodingFromByteOrderMarks: true); - - string line; - - while ((line = reader.ReadLine()) != null) + if (serializer is null) { - if (line.StartsWith(ProjectSectionStart)) - { - string[] projectDetails = line.Split(ProjectSectionSeparator, StringSplitOptions.RemoveEmptyEntries); - - if (projectDetails.Length == 3) - { - Match projectGuidMatch = GuidRegex.Match(projectDetails[2]); - - if (!projectGuidMatch.Groups["Guid"].Success) - { - continue; - } - - string projectGuidString = projectGuidMatch.Groups["Guid"].Value; - - Match projectTypeGuidMatch = GuidRegex.Match(projectDetails[0]); - - if (!projectTypeGuidMatch.Groups["Guid"].Success) - { - continue; - } - - if (!Guid.TryParse(projectGuidString, out Guid projectGuid) || !Guid.TryParse(projectTypeGuidMatch.Groups["Guid"].Value, out Guid projectTypeGuid)) - { - continue; - } + return false; + } - string projectPath = projectDetails[1].Trim().Trim('\"'); + bool foundSolutionGuid = false; - projectGuids[projectPath] = projectGuid; - } + try + { + SolutionModel existingSolution = serializer.OpenAsync(solutionFileFullPath, CancellationToken.None).Result; - while ((line = reader.ReadLine()) != null) - { - if (line.StartsWith(ProjectSectionEnd)) - { - break; - } - } + Dictionary projectGuids = new (StringComparer.OrdinalIgnoreCase); + foreach (SolutionProjectModel project in existingSolution.SolutionProjects) + { + projectGuids[project.FilePath] = project.Id; } - if (line != null && line.StartsWith(GlobalSectionStartExtensibilityGlobals)) + foreach (SolutionFolderModel folder in existingSolution.SolutionFolders) { - while ((line = reader.ReadLine()) != null) - { - if (line.StartsWith(SectionSettingSolutionGuid)) - { - string solutionGuidString = line.Substring(SectionSettingSolutionGuid.Length); - - foundSolutionGuid = Guid.TryParse(solutionGuidString, out solutionGuid); - } + projectGuids[GetSolutionFolderPathWithForwardSlashes(folder.Path)] = folder.Id; + } - if (line.StartsWith(GlobalSectionEnd)) - { - break; - } - } + IEnumerable existingSlnProperties = existingSolution.GetSlnProperties(); + SolutionPropertyBag extensibilityGlobals = existingSlnProperties.Where(x => x.Id == "ExtensibilityGlobals").FirstOrDefault(); + if (extensibilityGlobals is not null) + { + extensibilityGlobals.TryGetValue("SolutionGuid", out string solutionGuidStr); + foundSolutionGuid = Guid.TryParse(solutionGuidStr, out solutionGuid); } - } - projectGuidsByPath = projectGuids; + projectGuidsByPath = projectGuids; + } + catch (SolutionException) + { + // There was an unrecoverable syntax error reading the solution file. + return false; + } return foundSolutionGuid; } @@ -392,47 +309,49 @@ public void AddSolutionItems(Guid? parentFolderGuid, string folderPath, Guid fol } /// - /// Saves the Visual Studio solution to a file. + /// Creates the directory where the solution resides. /// - /// The full path to the file to write to. - /// Specifies if folders should be created. - /// A to use for logging. - /// An optional value indicating whether or not folders containing a single item should be collapsed into their parent folder. - /// An optional value indicating whether or not to always include the project in the build even if it has no matching configuration. - public void Save(string path, bool useFolders, ISlnGenLogger logger = null, bool collapseFolders = false, bool alwaysBuild = true) + /// A root path for the solution. + internal void CreateSolutionDirectory(string rootPath) { - string directoryName = Path.GetDirectoryName(path); + string directoryName = Path.GetDirectoryName(rootPath); if (!directoryName.IsNullOrWhiteSpace()) { Directory.CreateDirectory(directoryName!); } - - using FileStream fileStream = File.Create(path); - - using StreamWriter writer = new StreamWriter(fileStream, Encoding.UTF8); - - Save(path, writer, useFolders, logger, collapseFolders, alwaysBuild); } /// /// Saves the Visual Studio solution to a file. /// + /// Serializer which saves the solution model to the solution file (based on its moniker). /// A root path for the solution to make other paths relative to. - /// The to save the solution file to. /// Specifies if folders should be created. /// A to use for logging. /// An optional value indicating whether or not folders containing a single item should be collapsed into their parent folder. /// An optional value indicating whether or not to always include the project in the build even if it has no matching configuration. - internal void Save(string rootPath, TextWriter writer, bool useFolders, ISlnGenLogger logger = null, bool collapseFolders = false, bool alwaysBuild = true) + internal void Save(ISolutionSerializer serializer, string rootPath, bool useFolders, ISlnGenLogger logger = null, bool collapseFolders = false, bool alwaysBuild = true) { - writer.WriteLine(Header, _fileFormatVersion); + SolutionModel newSolution = new (); + + // Set UTF8 BOM encoding for .sln + if (serializer is ISolutionSerializer v12Serializer) + { + newSolution.SerializerExtension = v12Serializer.CreateModelExtension(new () + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), + }); + } if (VisualStudioVersion != null) { - writer.WriteLine($"# Visual Studio Version {VisualStudioVersion.Major}"); - writer.WriteLine($"VisualStudioVersion = {VisualStudioVersion}"); - writer.WriteLine($"MinimumVisualStudioVersion = {MinimumVisualStudioVersion}"); + newSolution.VisualStudioProperties.OpenWith = $"Visual Studio Version {VisualStudioVersion.Major}"; + newSolution.VisualStudioProperties.Version = VisualStudioVersion; + if (Version.TryParse(MinimumVisualStudioVersion, out var minimumVisualStudioVersion)) + { + newSolution.VisualStudioProperties.MinimumVersion = minimumVisualStudioVersion; + } } List sortedProjects = _projects.OrderBy(i => i.IsMainProject ? 0 : 1).ThenBy(i => i.FullPath).ToList(); @@ -445,8 +364,9 @@ internal void Save(string rootPath, TextWriter writer, bool useFolders, ISlnGenL project.ProjectGuid = existingProjectGuid; } - writer.WriteLine($@"Project(""{project.ProjectTypeGuid.ToSolutionString()}"") = ""{project.Name}"", ""{solutionPath}"", ""{project.ProjectGuid.ToSolutionString()}"""); - writer.WriteLine("EndProject"); + SolutionProjectModel projectModel = newSolution.AddProject(solutionPath, project.ProjectTypeGuid.ToSolutionString(), null); + projectModel.DisplayName = project.Name; + projectModel.Id = project.ProjectGuid; } SlnHierarchy hierarchy = null; @@ -466,9 +386,8 @@ internal void Save(string rootPath, TextWriter writer, bool useFolders, ISlnGenL { if (solutionItems.Value.SolutionItems.Any()) { - writer.WriteLine($@"Project(""{SlnFolder.FolderProjectTypeGuidString}"") = ""{solutionItems.Key}"", ""{solutionItems.Key}"", ""{solutionItems.Value.FolderGuid.ToSolutionString()}"" "); - WriteSolutionItemsProjectSection(rootPath, writer, solutionItems.Value.SolutionItems); - writer.WriteLine("EndProject"); + SolutionFolderModel newFolder = AddFolderToModel(newSolution, solutionItems.Key, solutionItems.Value.FolderGuid); + AddSolutionItemsToModel(newFolder, solutionItems.Value.SolutionItems, rootPath); } } @@ -476,14 +395,7 @@ internal void Save(string rootPath, TextWriter writer, bool useFolders, ISlnGenL var solutionItemsWithParents = _solutionItems.Where(x => x.Value.ParentFolderGuid.HasValue).ToArray(); if (solutionItemsWithParents.Length > 0) { - writer.WriteLine(@" GlobalSection(NestedProjects) = preSolution"); - - foreach (KeyValuePair solutionItem in solutionItemsWithParents) - { - writer.WriteLine($@" {solutionItem.Value.FolderGuid.ToSolutionString()} = {solutionItem.Value.ParentFolderGuid.Value.ToSolutionString()}"); - } - - writer.WriteLine(" EndGlobalSection"); + AddNestedProjectsToModel(newSolution, solutionItemsWithParents); } } @@ -523,27 +435,22 @@ internal void Save(string rootPath, TextWriter writer, bool useFolders, ISlnGenL // guard against root folder if (folder != hierarchy.RootFolder) { - writer.WriteLine($@"Project(""{folder.ProjectTypeGuidString}"") = ""{folder.Name}"", ""{projectSolutionPath}"", ""{folder.FolderGuid.ToSolutionString()}"""); + SolutionFolderModel newFolder = AddFolderToModel(newSolution, folder.Name, folder.FolderGuid); if (folder.SolutionItems.Count > 0) { - WriteSolutionItemsProjectSection(rootPath, writer, folder.SolutionItems); + AddSolutionItemsToModel(newFolder, folder.SolutionItems, rootPath); } - - writer.WriteLine("EndProject"); } else if (folder.SolutionItems.Count > 0) { // Special case for solution items in root folder - writer.WriteLine($@"Project(""{SlnFolder.FolderProjectTypeGuidString}"") = ""Solution Items"", ""Solution Items"", ""{{B283EBC2-E01F-412D-9339-FD56EF114549}}"" "); - WriteSolutionItemsProjectSection(rootPath, writer, folder.SolutionItems); - writer.WriteLine("EndProject"); + SolutionFolderModel newFolder = AddFolderToModel(newSolution, "Solution Items", new Guid("B283EBC2-E01F-412D-9339-FD56EF114549")); + AddSolutionItemsToModel(newFolder, folder.SolutionItems, rootPath); } } - } - writer.WriteLine("Global"); - - writer.WriteLine(" GlobalSection(SolutionConfigurationPlatforms) = preSolution"); + AddHierarchyNestedProjectsToModel(newSolution, hierarchy); + } HashSet solutionPlatforms = Platforms != null && Platforms.Any() ? new HashSet(GetValidSolutionPlatforms(Platforms), StringComparer.OrdinalIgnoreCase) @@ -553,23 +460,104 @@ internal void Save(string rootPath, TextWriter writer, bool useFolders, ISlnGenL ? new HashSet(Configurations, StringComparer.OrdinalIgnoreCase) : new HashSet(sortedProjects.SelectMany(i => i.Configurations).Where(i => !i.IsNullOrWhiteSpace()), StringComparer.OrdinalIgnoreCase); - foreach (string configuration in solutionConfigurations) + AddSolutionConfigurationPlatformsToModel(newSolution, solutionConfigurations, solutionPlatforms); + + bool hasSharedProject = AddProjectConfigurationPlatformsToModel(newSolution, sortedProjects, solutionConfigurations, solutionPlatforms, alwaysBuild); + + if (hasSharedProject) { - foreach (string platform in solutionPlatforms) + AddSharedMSBuildProjectFilesToModel(newSolution, sortedProjects, rootPath); + } + + AddSolutionGuidToModel(newSolution); + + serializer.SaveAsync(rootPath, newSolution, CancellationToken.None).Wait(); + } + + private static string GetSolutionFolderPathWithForwardSlashes(string path) + { + // SolutionModel::AddFolder expects paths to have leading, trailing and inner forward slashes + // https://github.com/microsoft/vs-solutionpersistence/blob/87ee8ea069662d55c336a9bd68fe4851d0384fa5/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionModel.cs#L171C1-L172C1 + return "/" + string.Join("/", GetPathWithDirectorySeparator(path).Split(DirectorySeparatorCharacters, StringSplitOptions.RemoveEmptyEntries)) + "/"; + } + + private static string GetPathWithDirectorySeparator(string path) => path.Replace('\\', '/'); + + private static SolutionFolderModel AddFolderToModel(SolutionModel newSolution, string solutionFolder, Guid folderGuid) + { + SolutionFolderModel solutionFolderModel = newSolution.AddFolder(GetSolutionFolderPathWithForwardSlashes(solutionFolder)); + solutionFolderModel.Id = folderGuid; + return solutionFolderModel; + } + + private static void AddSolutionItemsToModel(SolutionFolderModel newFolder, IEnumerable solutionItems, string rootPath) + { + SolutionPropertyBag slnProperties = new ("SolutionItems", scope: PropertiesScope.PreLoad); + foreach (string solutionItem in solutionItems + .Select(i => i.ToRelativePath(rootPath).ToSolutionPath()) + .Where(i => !string.IsNullOrWhiteSpace(i))) + { + slnProperties.Add(solutionItem, solutionItem); + } + + newFolder.AddSlnProperties(slnProperties); + } + + private static void AddNestedProjectsToModel(SolutionModel newSolution, KeyValuePair[] solutionItemsWithParents) + { + SolutionPropertyBag slnProperties = new ("NestedProjects", scope: PropertiesScope.PreLoad); + foreach (KeyValuePair solutionItem in solutionItemsWithParents) + { + slnProperties.Add(solutionItem.Value.FolderGuid.ToSolutionString(), solutionItem.Value.ParentFolderGuid.Value.ToSolutionString()); + } + + newSolution.AddSlnProperties(slnProperties); + } + + private static void AddHierarchyNestedProjectsToModel(SolutionModel newSolution, SlnHierarchy hierarchy) + { + var foldersWithParents = hierarchy.Folders.Where(i => i.Parent != null).ToArray(); + if (foldersWithParents.Length > 0) + { + SolutionPropertyBag slnProperties = new ("NestedProjects", scope: PropertiesScope.PreLoad); + foreach (SlnFolder folder in foldersWithParents) { - if (!string.IsNullOrWhiteSpace(configuration) && !string.IsNullOrWhiteSpace(platform)) + foreach (SlnProject project in folder.Projects) + { + slnProperties.Add(project.ProjectGuid.ToSolutionString(), folder.FolderGuid.ToSolutionString()); + } + + // guard against root folder + if (folder.Parent != hierarchy.RootFolder) { - writer.WriteLine($" {configuration}|{platform} = {configuration}|{platform}"); + slnProperties.Add(folder.FolderGuid.ToSolutionString(), folder.Parent.FolderGuid.ToSolutionString()); } } + + newSolution.AddSlnProperties(slnProperties); } + } - writer.WriteLine(" EndGlobalSection"); + private void AddSharedMSBuildProjectFilesToModel(SolutionModel newSolution, List sortedProjects, string rootPath) + { + SolutionPropertyBag slnProperties = new ("SharedMSBuildProjectFiles", scope: PropertiesScope.PreLoad); + foreach (SlnProject project in sortedProjects) + { + foreach (string sharedProjectItem in project.SharedProjectItems) + { + slnProperties.Add($"{sharedProjectItem.ToRelativePath(rootPath).ToSolutionPath()}*{project.ProjectGuid.ToSolutionString(uppercase: false).ToLowerInvariant()}*SharedItemsImports", $"{GetSharedProjectOptions(project)}"); + } + } - writer.WriteLine(" GlobalSection(ProjectConfigurationPlatforms) = postSolution"); + newSolution.AddSlnProperties(slnProperties); + } + private bool AddProjectConfigurationPlatformsToModel(SolutionModel newSolution, List sortedProjects, HashSet solutionConfigurations, HashSet solutionPlatforms, bool alwaysBuild) + { bool hasSharedProject = false; + SolutionPropertyBag slnProperties = new ("ProjectConfigurationPlatforms", scope: PropertiesScope.PostLoad); + foreach (SlnProject project in sortedProjects) { if (project.IsSharedProject) @@ -588,88 +576,51 @@ internal void Save(string rootPath, TextWriter writer, bool useFolders, ISlnGenL { bool foundPlatform = TryGetProjectSolutionPlatform(platform, project, out string projectSolutionPlatform, out string projectBuildPlatform); - writer.WriteLine($@" {projectGuid}.{configuration}|{platform}.ActiveCfg = {projectSolutionConfiguration}|{projectSolutionPlatform}"); + slnProperties.Add($"{projectGuid}.{configuration}|{platform}.ActiveCfg", $"{projectSolutionConfiguration}|{projectSolutionPlatform}"); if (foundPlatform && foundConfiguration && project.IsBuildable) { - writer.WriteLine($@" {projectGuid}.{configuration}|{platform}.Build.0 = {projectSolutionConfiguration}|{projectBuildPlatform}"); + slnProperties.Add($"{projectGuid}.{configuration}|{platform}.Build.0", $"{projectSolutionConfiguration}|{projectBuildPlatform}"); } if (project.IsDeployable) { - writer.WriteLine($@" {projectGuid}.{configuration}|{platform}.Deploy.0 = {projectSolutionConfiguration}|{projectSolutionPlatform}"); + slnProperties.Add($"{projectGuid}.{configuration}|{platform}.Deploy.0", $"{projectSolutionConfiguration}|{projectSolutionPlatform}"); } } } } - writer.WriteLine(" EndGlobalSection"); - - writer.WriteLine(" GlobalSection(SolutionProperties) = preSolution"); - writer.WriteLine(" HideSolutionNode = FALSE"); - writer.WriteLine(" EndGlobalSection"); - - if (hierarchy != null) - { - var foldersWithParents = hierarchy.Folders.Where(i => i.Parent != null).ToArray(); - if (foldersWithParents.Length > 0) - { - writer.WriteLine(@" GlobalSection(NestedProjects) = preSolution"); - - foreach (SlnFolder folder in foldersWithParents) - { - foreach (SlnProject project in folder.Projects) - { - writer.WriteLine($@" {project.ProjectGuid.ToSolutionString()} = {folder.FolderGuid.ToSolutionString()}"); - } - - // guard against root folder - if (folder.Parent != hierarchy.RootFolder) - { - writer.WriteLine($@" {folder.FolderGuid.ToSolutionString()} = {folder.Parent.FolderGuid.ToSolutionString()}"); - } - } - - writer.WriteLine(" EndGlobalSection"); - } - } + newSolution.AddSlnProperties(slnProperties); - writer.WriteLine(" GlobalSection(ExtensibilityGlobals) = postSolution"); - writer.WriteLine($" SolutionGuid = {SolutionGuid.ToSolutionString()}"); - writer.WriteLine(" EndGlobalSection"); + return hasSharedProject; + } - if (hasSharedProject) + private void AddSolutionConfigurationPlatformsToModel(SolutionModel newSolution, HashSet solutionConfigurations, HashSet solutionPlatforms) + { + SolutionPropertyBag slnProperties = new ("SolutionConfigurationPlatforms", scope: PropertiesScope.PreLoad); + foreach (string configuration in solutionConfigurations) { - writer.WriteLine(" GlobalSection(SharedMSBuildProjectFiles) = preSolution"); - - foreach (SlnProject project in sortedProjects) + foreach (string platform in solutionPlatforms) { - foreach (string sharedProjectItem in project.SharedProjectItems) + if (!string.IsNullOrWhiteSpace(configuration) && !string.IsNullOrWhiteSpace(platform)) { - writer.WriteLine($" {sharedProjectItem.ToRelativePath(rootPath).ToSolutionPath()}*{project.ProjectGuid.ToSolutionString(uppercase: false).ToLowerInvariant()}*SharedItemsImports = {GetSharedProjectOptions(project)}"); + slnProperties.Add($"{configuration}|{platform}", $"{configuration}|{platform}"); } } - - writer.WriteLine(" EndGlobalSection"); } - writer.WriteLine("EndGlobal"); + newSolution.AddSlnProperties(slnProperties); } - private static void WriteSolutionItemsProjectSection( - string rootPath, - TextWriter writer, - IEnumerable solutionItems) + private void AddSolutionGuidToModel(SolutionModel newSolution) { - writer.WriteLine(" ProjectSection(SolutionItems) = preProject"); - foreach (string solutionItem in solutionItems - .Select(i => i.ToRelativePath(rootPath).ToSolutionPath()) - .Where(i => !string.IsNullOrWhiteSpace(i))) + SolutionPropertyBag newExtensibilityGlobals = new ("ExtensibilityGlobals") { - writer.WriteLine($" {solutionItem} = {solutionItem}"); - } + { "SolutionGuid", SolutionGuid.ToString() }, + }; - writer.WriteLine(" EndProjectSection"); + newSolution.AddSlnProperties(newExtensibilityGlobals); } private string GetSharedProjectOptions(SlnProject project)