From e30a076ee2e8709fc16e73e6e3c9300d851f1ee1 Mon Sep 17 00:00:00 2001 From: gumbarros Date: Sun, 29 Mar 2026 17:35:36 -0300 Subject: [PATCH 1/2] Add support for `Order` attribute in `.slnx` files for projects and folders. This closes #131. --- .../Model/SolutionItemModel.cs | 22 +++++++++++++ .../PublicAPI/PublicAPI.Unshipped.txt | 4 ++- .../Serializer/Xml/Keywords.cs | 2 ++ .../Serializer/Xml/Slnx.xsd | 4 ++- .../Xml/XmlDecorators/XmlDecorator.cs | 20 +++++++++++ .../Serializer/Xml/XmlDecorators/XmlFolder.cs | 13 ++++++++ .../XmlDecorators/XmlProject.ApplyModel.cs | 6 ++++ .../Xml/XmlDecorators/XmlProject.cs | 7 ++++ .../Serialization/Folders.cs | 21 ++++++++++++ .../Serialization/InvalidSolutions.cs | 21 ++++++++++++ .../Serialization/Project.cs | 33 +++++++++++++++++++ 11 files changed, 151 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs index 13b5d9c4..db252415 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs @@ -10,6 +10,7 @@ public abstract class SolutionItemModel : PropertyContainerModel { private Guid? id; private Guid? defaultId; + private int? order; private protected SolutionItemModel(SolutionModel solutionModel, SolutionFolderModel? parent) { @@ -30,6 +31,7 @@ private protected SolutionItemModel(SolutionModel solutionModel, SolutionItemMod this.Solution = solutionModel; this.id = itemModel.id; this.defaultId = itemModel.defaultId; + this.order = itemModel.order; // This is a shallow copy of the parent, it needs to be swapped out to finish the deep copy. // But we can't find the new parent until all copy constructors have been called. @@ -77,6 +79,26 @@ public Guid Id /// public bool IsDefaultId => this.id is null; + /// + /// Gets or sets the item order used by .slnx files. + /// + /// + /// When set, this must be a non-negative integer. + /// + public int? Order + { + get => this.order; + set + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(Order)); + } + + this.order = value; + } + } + /// /// Gets the display name of the item. If there is a filename it will be used, otherwise the actual display name. /// diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt b/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt index d34f9569..ee001891 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt @@ -34,4 +34,6 @@ Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType.UnsupportedVe Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.ErrorType.get -> Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType? Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.ErrorType.init -> void Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void -Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, System.Exception! inner, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void \ No newline at end of file +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, System.Exception! inner, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionItemModel.Order.get -> int? +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionItemModel.Order.set -> void diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs index 63c95801..d3b3b69b 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs @@ -26,6 +26,7 @@ internal enum Keyword Id, Name, Path, + Order, Type, DefaultStartup, DisplayName, @@ -80,6 +81,7 @@ static Keywords() new(nameof(Keyword.Id), Keyword.Id), new(nameof(Keyword.Name), Keyword.Name), new(nameof(Keyword.Path), Keyword.Path), + new(nameof(Keyword.Order), Keyword.Order), new(nameof(Keyword.Type), Keyword.Type), new(nameof(Keyword.DefaultStartup), Keyword.DefaultStartup), new(nameof(Keyword.DisplayName), Keyword.DisplayName), diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd index 399bf6ed..ec5f44d7 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd @@ -51,6 +51,7 @@ + @@ -64,6 +65,7 @@ + @@ -115,4 +117,4 @@ - \ No newline at end of file + diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs index 2ed5eb37..65d4ece2 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs @@ -124,6 +124,26 @@ internal Guid GetXmlAttributeGuid(Keyword keyword, Guid defaultValue = default) internal void UpdateXmlAttributeGuid(Keyword keyword, Guid value) => this.UpdateXmlAttribute(keyword, isDefault: value == Guid.Empty, value, guid => guid.ToString()); + internal int? GetXmlAttributeInt(Keyword keyword) + { + string? value = this.GetXmlAttribute(keyword); + if (value.IsNullOrEmpty()) + { + return null; + } + + if (int.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int intValue) && + intValue >= 0) + { + return intValue; + } + + throw new FormatException($"Attribute '{keyword.ToXmlString()}' must be a non-negative integer."); + } + + internal void UpdateXmlAttributeInt(Keyword keyword, int? value) => + this.UpdateXmlAttribute(keyword, isDefault: value is null, value.GetValueOrDefault(), v => v.ToString(System.Globalization.CultureInfo.InvariantCulture)); + internal bool GetXmlAttributeBool(Keyword keyword, bool defaultValue = false) => bool.TryParse(this.GetXmlAttribute(keyword), out bool boolValue) ? boolValue : defaultValue; diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs index dd75d2d1..92a276d2 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs @@ -28,6 +28,12 @@ internal Guid Id set => this.UpdateXmlAttributeGuid(Keyword.Id, value); } + internal int? Order + { + get => this.GetXmlAttributeInt(Keyword.Order); + set => this.UpdateXmlAttributeInt(Keyword.Order, value); + } + #if DEBUG internal override string DebugDisplay => $"{base.DebugDisplay} FolderProjects={this.folderProjects} Files={this.files}"; @@ -81,6 +87,7 @@ internal void AddToModel(SolutionModel solutionModel, List<(XmlProject XmlProjec { SolutionFolderModel folderModel = solutionModel.AddFolder(this.Name); folderModel.Id = this.Id; + folderModel.Order = this.Order; foreach (XmlFile file in this.files.GetItems()) { @@ -121,6 +128,12 @@ internal bool ApplyModelToXml(SolutionFolderModel modelFolder) modified = true; } + if (this.Order != modelFolder.Order) + { + this.Order = modelFolder.Order; + modified = true; + } + // Files modified |= this.ApplyModelItemsToXml( itemRefs: modelFolder.Files?.ToList(this.Root.ConvertToUserPath), diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs index aeceffa7..17c19aa6 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs @@ -37,6 +37,12 @@ internal bool ApplyModelToXml(SolutionProjectModel modelProject) modified = true; } + if (this.Order != modelProject.Order) + { + this.Order = modelProject.Order; + modified = true; + } + // BuildDependencies modified |= this.ApplyModelItemsToXml( itemRefs: modelProject.Dependencies?.ToList(dependencyProject => this.Root.ConvertToUserPath(dependencyProject.FilePath)), diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs index d32ce5c6..81c45da1 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs @@ -29,6 +29,12 @@ internal Guid Id set => this.UpdateXmlAttributeGuid(Keyword.Id, value); } + internal int? Order + { + get => this.GetXmlAttributeInt(Keyword.Order); + set => this.UpdateXmlAttributeInt(Keyword.Order, value); + } + internal string? DisplayName { get => this.GetXmlAttribute(Keyword.DisplayName); @@ -117,6 +123,7 @@ internal SolutionProjectModel AddToModel(SolutionModel solution) folder: parentFolder); projectModel.Id = this.Id; + projectModel.Order = this.Order; projectModel.DisplayName = this.DisplayName; foreach (ConfigurationRule configurationRule in this.configurationRules.ToModel()) diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs index a9d72984..0ce50134 100644 --- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs @@ -222,4 +222,25 @@ public void ChangeFolderName() Assert.Equal("/A/Nested/Deep/", folderNestedA.Path); Assert.Equal("/C/Nested/Deep/", folderNestedB.Path); } + + /// + /// Ensures folder order is preserved when round-tripping .slnx. + /// + [Fact] + public async Task RoundTripFolderOrderAsync() + { + SolutionModel solution = new SolutionModel(); + SolutionFolderModel folderA = solution.AddFolder("/A/"); + SolutionFolderModel folderB = solution.AddFolder("/B/"); + + folderA.Order = 20; + folderB.Order = 10; + + (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution); + + Assert.Contains("Folder Name=\"/A/\" Order=\"20\"", contents.FullString); + Assert.Contains("Folder Name=\"/B/\" Order=\"10\"", contents.FullString); + Assert.Equal(20, reserializedSolution.FindFolder("/A/")!.Order); + Assert.Equal(10, reserializedSolution.FindFolder("/B/")!.Order); + } } diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs index c06fa099..77f1bb1a 100644 --- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs @@ -209,6 +209,27 @@ public async Task InvalidSlnxVersionAsync() Assert.Equal(2, ex.Column); } + /// + /// Ensure .slnx rejects non-numeric Order attributes. + /// + [Fact] + public async Task InvalidOrderAttributeAsync() + { + const string invalidSlnx = """ + + + +"""; + + using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(invalidSlnx)); + SolutionException ex = await Assert.ThrowsAsync( + async () => _ = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None)); + + Assert.Contains("Order", ex.Message); + Assert.Equal(2, ex.Line); + Assert.Equal(4, ex.Column); + } + /// /// The legacy sln solution parser would ignore duplicate folder guids. /// Ensure this behavior is maintained. diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs index 0173f4e5..2bfe8b4c 100644 --- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs @@ -110,4 +110,37 @@ public async Task DefaultStartupAsync() Assert.Equal("DefaultStartup.csproj", firstProject.FilePath); Assert.Equal(firstItem, firstProject); } + + /// + /// Ensures project order is preserved when round-tripping .slnx. + /// + [Fact] + public async Task RoundTripProjectOrderAsync() + { + SolutionModel solution = new SolutionModel(); + SolutionProjectModel projectA = solution.AddProject("A.csproj"); + SolutionProjectModel projectB = solution.AddProject("B.csproj"); + + projectA.Order = 2; + projectB.Order = 1; + + (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution); + + Assert.Contains("Project Path=\"A.csproj\" Order=\"2\"", contents.FullString); + Assert.Contains("Project Path=\"B.csproj\" Order=\"1\"", contents.FullString); + Assert.Equal(2, reserializedSolution.FindProject("A.csproj")!.Order); + Assert.Equal(1, reserializedSolution.FindProject("B.csproj")!.Order); + } + + /// + /// Ensures order cannot be set to a negative value. + /// + [Fact] + public void RejectsNegativeProjectOrder() + { + SolutionModel solution = new SolutionModel(); + SolutionProjectModel project = solution.AddProject("A.csproj"); + + Assert.Throws(() => project.Order = -1); + } } From e8437aad9dc70bc463a7016606c7fb24a88a1022 Mon Sep 17 00:00:00 2001 From: gumbarros Date: Sat, 15 Aug 2026 16:30:26 -0300 Subject: [PATCH 2/2] Use `SolutionSortOrder` instead of `Order` prop. --- .../Model/SolutionItemModel.cs | 22 ------ .../Model/SolutionModel.cs | 10 +++ .../Model/SolutionSortOrder.cs | 20 +++++ .../PublicAPI/PublicAPI.Unshipped.txt | 7 +- .../Serializer/Xml/Keywords.cs | 6 +- .../Serializer/Xml/Slnx.xsd | 3 +- .../Xml/XmlDecorators/ItemRefList`1.cs | 2 + .../XmlDecorators/XmlContainer.ApplyModel.cs | 13 +++- .../Xml/XmlDecorators/XmlDecorator.cs | 20 ----- .../Serializer/Xml/XmlDecorators/XmlFolder.cs | 32 ++++---- .../XmlDecorators/XmlProject.ApplyModel.cs | 6 -- .../Xml/XmlDecorators/XmlProject.cs | 7 -- .../XmlDecorators/XmlSolution.ApplyModel.cs | 6 ++ .../Xml/XmlDecorators/XmlSolution.cs | 50 ++++++++++-- .../Serialization/Folders.cs | 21 ----- .../Serialization/InvalidSolutions.cs | 21 ----- .../Serialization/Project.cs | 33 -------- .../Serialization/SortOrder.cs | 76 +++++++++++++++++++ 18 files changed, 197 insertions(+), 158 deletions(-) create mode 100644 src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionSortOrder.cs create mode 100644 test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/SortOrder.cs diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs index db252415..13b5d9c4 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionItemModel.cs @@ -10,7 +10,6 @@ public abstract class SolutionItemModel : PropertyContainerModel { private Guid? id; private Guid? defaultId; - private int? order; private protected SolutionItemModel(SolutionModel solutionModel, SolutionFolderModel? parent) { @@ -31,7 +30,6 @@ private protected SolutionItemModel(SolutionModel solutionModel, SolutionItemMod this.Solution = solutionModel; this.id = itemModel.id; this.defaultId = itemModel.defaultId; - this.order = itemModel.order; // This is a shallow copy of the parent, it needs to be swapped out to finish the deep copy. // But we can't find the new parent until all copy constructors have been called. @@ -79,26 +77,6 @@ public Guid Id /// public bool IsDefaultId => this.id is null; - /// - /// Gets or sets the item order used by .slnx files. - /// - /// - /// When set, this must be a non-negative integer. - /// - public int? Order - { - get => this.order; - set - { - if (value < 0) - { - throw new ArgumentOutOfRangeException(nameof(Order)); - } - - this.order = value; - } - } - /// /// Gets the display name of the item. If there is a filename it will be used, otherwise the actual display name. /// diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionModel.cs index b1bf4906..a4da969e 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionModel.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionModel.cs @@ -88,6 +88,7 @@ public SolutionModel(SolutionModel solutionModel) } this.Description = solutionModel.Description; + this.SortOrder = solutionModel.SortOrder; this.solutionBuildTypes = [.. solutionModel.solutionBuildTypes]; this.solutionPlatforms = [.. solutionModel.solutionPlatforms]; this.projectTypes = [.. solutionModel.projectTypes]; @@ -111,6 +112,15 @@ public SolutionModel(SolutionModel solutionModel) /// public string? Description { get; set; } + /// + /// Gets or sets the order in which solution items are written to a .slnx file. + /// + /// + /// The default is , which groups items by type and sorts them alphabetically. + /// Set to to preserve the order the items appear in the solution file. + /// + public SolutionSortOrder SortOrder { get; set; } + /// /// Gets the list of solution items in the solution. /// This is all of the solution folders and projects in the solution. diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionSortOrder.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionSortOrder.cs new file mode 100644 index 00000000..76dfecdb --- /dev/null +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Model/SolutionSortOrder.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.VisualStudio.SolutionPersistence.Model; + +/// +/// Specifies how solution items are ordered when serializing a .slnx file. +/// +public enum SolutionSortOrder +{ + /// + /// Items are grouped by type and sorted alphabetically. This is the default. + /// + Alphabetical = 0, + + /// + /// Items are written in document order, i.e. the order the elements appear in the solution file. + /// + Document = 1, +} diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt b/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt index ee001891..2c0f9ea8 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt @@ -35,5 +35,8 @@ Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.ErrorType.get Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.ErrorType.init -> void Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void Microsoft.VisualStudio.SolutionPersistence.Model.SolutionException.SolutionException(string! message, System.Exception! inner, Microsoft.VisualStudio.SolutionPersistence.Model.SolutionErrorType errorType) -> void -Microsoft.VisualStudio.SolutionPersistence.Model.SolutionItemModel.Order.get -> int? -Microsoft.VisualStudio.SolutionPersistence.Model.SolutionItemModel.Order.set -> void +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionModel.SortOrder.get -> Microsoft.VisualStudio.SolutionPersistence.Model.SolutionSortOrder +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionModel.SortOrder.set -> void +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionSortOrder +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionSortOrder.Alphabetical = 0 -> Microsoft.VisualStudio.SolutionPersistence.Model.SolutionSortOrder +Microsoft.VisualStudio.SolutionPersistence.Model.SolutionSortOrder.Document = 1 -> Microsoft.VisualStudio.SolutionPersistence.Model.SolutionSortOrder diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs index d3b3b69b..f65cd107 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Keywords.cs @@ -16,6 +16,7 @@ internal enum Keyword // Solution properties Description, Version, + Sort, // Solution sections Configurations, @@ -26,7 +27,6 @@ internal enum Keyword Id, Name, Path, - Order, Type, DefaultStartup, DisplayName, @@ -65,6 +65,8 @@ internal static class Keywords internal const string XmlTrue = "true"; internal const string XmlFalse = "false"; + internal const string SortDocument = "Document"; + private static readonly string[] KeywordToString; private static readonly Lictionary StringToKeyword; @@ -75,13 +77,13 @@ static Keywords() new(nameof(Keyword.Solution), Keyword.Solution), new(nameof(Keyword.Description), Keyword.Description), new(nameof(Keyword.Version), Keyword.Version), + new(nameof(Keyword.Sort), Keyword.Sort), new(nameof(Keyword.Configurations), Keyword.Configurations), new(nameof(Keyword.Folder), Keyword.Folder), new(nameof(Keyword.Project), Keyword.Project), new(nameof(Keyword.Id), Keyword.Id), new(nameof(Keyword.Name), Keyword.Name), new(nameof(Keyword.Path), Keyword.Path), - new(nameof(Keyword.Order), Keyword.Order), new(nameof(Keyword.Type), Keyword.Type), new(nameof(Keyword.DefaultStartup), Keyword.DefaultStartup), new(nameof(Keyword.DisplayName), Keyword.DisplayName), diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd index ec5f44d7..e6caf5f2 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd @@ -9,6 +9,7 @@ + @@ -51,7 +52,6 @@ - @@ -65,7 +65,6 @@ - diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/ItemRefList`1.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/ItemRefList`1.cs index 36c75fea..a9456bf3 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/ItemRefList`1.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/ItemRefList`1.cs @@ -48,6 +48,8 @@ internal readonly void Add(T item) internal readonly T? FirstOrDefault() => this.items.Count > 0 ? this.items[0] : null; + internal readonly bool TryGet(string itemRef, [NotNullWhen(true)] out T? item) => this.items.TryGetValue(itemRef, out item); + // Finds the item that would be immediately after the given item ref. internal readonly bool TryFindNext(string itemRef, out T? item) { diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs index d5543ae2..fb76f793 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlContainer.ApplyModel.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Xml; +using Microsoft.VisualStudio.SolutionPersistence.Model; using Microsoft.VisualStudio.SolutionPersistence.Utilities; namespace Microsoft.VisualStudio.SolutionPersistence.Serializer.Xml.XmlDecorators; @@ -121,11 +122,19 @@ internal bool ApplyModelItemsToXml( } // Add new elements that aren't already in the XML. - modelItems.Sort(decoratorItems.IgnoreCase ? ComparisonOrdinalIgnoreCase : ComparisonOrdinal); + bool documentOrder = this.Root.Solution?.SortOrder == SolutionSortOrder.Document; + if (!documentOrder) + { + modelItems.Sort(decoratorItems.IgnoreCase ? ComparisonOrdinalIgnoreCase : ComparisonOrdinal); + } + foreach ((string itemRef, TModelItem modelItem) in modelItems) { // Find position to insert before based on general areas and alphabetical order. - XmlDecorator? insertBefore = decoratorItems.TryFindNext(itemRef, out TDecorator? insertBeforeLocal) ? insertBeforeLocal : this.FindNextDecorator(); + // In document order mode new items are appended to the end of their area in model order. + XmlDecorator? insertBefore = documentOrder + ? this.FindNextDecorator() + : (decoratorItems.TryFindNext(itemRef, out TDecorator? insertBeforeLocal) ? insertBeforeLocal : this.FindNextDecorator()); TDecorator newDecorator = (TDecorator)this.CreateAndAddChild(decoratorElementName, itemRef, insertBefore); _ = applyModelToXml?.Invoke(newDecorator, modelItem); diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs index 65d4ece2..2ed5eb37 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlDecorator.cs @@ -124,26 +124,6 @@ internal Guid GetXmlAttributeGuid(Keyword keyword, Guid defaultValue = default) internal void UpdateXmlAttributeGuid(Keyword keyword, Guid value) => this.UpdateXmlAttribute(keyword, isDefault: value == Guid.Empty, value, guid => guid.ToString()); - internal int? GetXmlAttributeInt(Keyword keyword) - { - string? value = this.GetXmlAttribute(keyword); - if (value.IsNullOrEmpty()) - { - return null; - } - - if (int.TryParse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out int intValue) && - intValue >= 0) - { - return intValue; - } - - throw new FormatException($"Attribute '{keyword.ToXmlString()}' must be a non-negative integer."); - } - - internal void UpdateXmlAttributeInt(Keyword keyword, int? value) => - this.UpdateXmlAttribute(keyword, isDefault: value is null, value.GetValueOrDefault(), v => v.ToString(System.Globalization.CultureInfo.InvariantCulture)); - internal bool GetXmlAttributeBool(Keyword keyword, bool defaultValue = false) => bool.TryParse(this.GetXmlAttribute(keyword), out bool boolValue) ? boolValue : defaultValue; diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs index 92a276d2..cbcd4941 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs @@ -28,12 +28,6 @@ internal Guid Id set => this.UpdateXmlAttributeGuid(Keyword.Id, value); } - internal int? Order - { - get => this.GetXmlAttributeInt(Keyword.Order); - set => this.UpdateXmlAttributeInt(Keyword.Order, value); - } - #if DEBUG internal override string DebugDisplay => $"{base.DebugDisplay} FolderProjects={this.folderProjects} Files={this.files}"; @@ -87,7 +81,6 @@ internal void AddToModel(SolutionModel solutionModel, List<(XmlProject XmlProjec { SolutionFolderModel folderModel = solutionModel.AddFolder(this.Name); folderModel.Id = this.Id; - folderModel.Order = this.Order; foreach (XmlFile file in this.files.GetItems()) { @@ -101,9 +94,24 @@ internal void AddToModel(SolutionModel solutionModel, List<(XmlProject XmlProjec properties.AddToModel(folderModel); } - foreach (XmlProject project in this.folderProjects.GetItems()) + if (this.xmlSolution.SortOrder == SolutionSortOrder.Document) + { + // Preserve the order the elements appear in the document. + foreach (XmlElement childElement in this.XmlElement.ChildElements()) + { + if (Keywords.ToKeyword(childElement.Name) == Keyword.Project && + this.folderProjects.TryGet(childElement.GetAttribute(Keyword.Path.ToXmlString()).Trim(), out XmlProject? project)) + { + newProjects.Add((project, project.AddToModel(solutionModel))); + } + } + } + else { - newProjects.Add((project, project.AddToModel(solutionModel))); + foreach (XmlProject project in this.folderProjects.GetItems()) + { + newProjects.Add((project, project.AddToModel(solutionModel))); + } } } catch (Exception ex) when (SolutionException.ShouldWrap(ex)) @@ -128,12 +136,6 @@ internal bool ApplyModelToXml(SolutionFolderModel modelFolder) modified = true; } - if (this.Order != modelFolder.Order) - { - this.Order = modelFolder.Order; - modified = true; - } - // Files modified |= this.ApplyModelItemsToXml( itemRefs: modelFolder.Files?.ToList(this.Root.ConvertToUserPath), diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs index 17c19aa6..aeceffa7 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.ApplyModel.cs @@ -37,12 +37,6 @@ internal bool ApplyModelToXml(SolutionProjectModel modelProject) modified = true; } - if (this.Order != modelProject.Order) - { - this.Order = modelProject.Order; - modified = true; - } - // BuildDependencies modified |= this.ApplyModelItemsToXml( itemRefs: modelProject.Dependencies?.ToList(dependencyProject => this.Root.ConvertToUserPath(dependencyProject.FilePath)), diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs index 81c45da1..d32ce5c6 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlProject.cs @@ -29,12 +29,6 @@ internal Guid Id set => this.UpdateXmlAttributeGuid(Keyword.Id, value); } - internal int? Order - { - get => this.GetXmlAttributeInt(Keyword.Order); - set => this.UpdateXmlAttributeInt(Keyword.Order, value); - } - internal string? DisplayName { get => this.GetXmlAttribute(Keyword.DisplayName); @@ -123,7 +117,6 @@ internal SolutionProjectModel AddToModel(SolutionModel solution) folder: parentFolder); projectModel.Id = this.Id; - projectModel.Order = this.Order; projectModel.DisplayName = this.DisplayName; foreach (ConfigurationRule configurationRule in this.configurationRules.ToModel()) diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs index 09b857e2..6046958f 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.ApplyModel.cs @@ -24,6 +24,12 @@ internal bool ApplyModelToXml(SolutionModel modelSolution) modified = true; } + if (this.SortOrder != modelSolution.SortOrder) + { + this.SortOrder = modelSolution.SortOrder; + modified = true; + } + // Configurations // Use the item ref logic to allow only a single "Configurations" element, and use string.Empty as the item ref. modified |= this.ApplyModelItemsToXml( diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.cs index cb998ddc..e7c22e59 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlSolution.cs @@ -28,6 +28,18 @@ internal string? Version set => this.UpdateXmlAttribute(Keyword.Version, value); } + internal string? Sort + { + get => this.GetXmlAttribute(Keyword.Sort); + set => this.UpdateXmlAttribute(Keyword.Sort, value); + } + + internal SolutionSortOrder SortOrder + { + get => StringComparer.OrdinalIgnoreCase.Equals(this.Sort, Keywords.SortDocument) ? SolutionSortOrder.Document : SolutionSortOrder.Alphabetical; + set => this.Sort = value == SolutionSortOrder.Document ? Keywords.SortDocument : null; + } + #if DEBUG internal override string DebugDisplay => $"{base.DebugDisplay} RootProjects={this.rootProjects} Folders={this.folders}"; @@ -109,20 +121,48 @@ internal SolutionModel ToModel() { StringTable = this.Root.StringTable, Description = this.Description, + SortOrder = this.SortOrder, // Project types are loaded earlier when parsing the XML since they are needed to resolve projects. ProjectTypes = this.Root.ProjectTypes.ProjectTypes, }; List<(XmlProject, SolutionProjectModel)> newProjects = new List<(XmlProject, SolutionProjectModel)>(this.rootProjects.ItemsCount); - foreach (XmlProject project in this.rootProjects.GetItems()) + if (this.SortOrder == SolutionSortOrder.Document) { - newProjects.Add((project, project.AddToModel(solutionModel))); + // Preserve the order the elements appear in the document. + foreach (XmlElement childElement in this.XmlElement.ChildElements()) + { + switch (Keywords.ToKeyword(childElement.Name)) + { + case Keyword.Project: + if (this.rootProjects.TryGet(childElement.GetAttribute(Keyword.Path.ToXmlString()).Trim(), out XmlProject? project)) + { + newProjects.Add((project, project.AddToModel(solutionModel))); + } + + break; + case Keyword.Folder: + if (this.folders.TryGet(childElement.GetAttribute(Keyword.Name.ToXmlString()).Trim(), out XmlFolder? folder)) + { + folder.AddToModel(solutionModel, newProjects); + } + + break; + } + } } - - foreach (XmlFolder folder in this.folders.GetItems()) + else { - folder.AddToModel(solutionModel, newProjects); + foreach (XmlProject project in this.rootProjects.GetItems()) + { + newProjects.Add((project, project.AddToModel(solutionModel))); + } + + foreach (XmlFolder folder in this.folders.GetItems()) + { + folder.AddToModel(solutionModel, newProjects); + } } // Dependencies need to be added after all the projects are loaded. diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs index 0ce50134..a9d72984 100644 --- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Folders.cs @@ -222,25 +222,4 @@ public void ChangeFolderName() Assert.Equal("/A/Nested/Deep/", folderNestedA.Path); Assert.Equal("/C/Nested/Deep/", folderNestedB.Path); } - - /// - /// Ensures folder order is preserved when round-tripping .slnx. - /// - [Fact] - public async Task RoundTripFolderOrderAsync() - { - SolutionModel solution = new SolutionModel(); - SolutionFolderModel folderA = solution.AddFolder("/A/"); - SolutionFolderModel folderB = solution.AddFolder("/B/"); - - folderA.Order = 20; - folderB.Order = 10; - - (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution); - - Assert.Contains("Folder Name=\"/A/\" Order=\"20\"", contents.FullString); - Assert.Contains("Folder Name=\"/B/\" Order=\"10\"", contents.FullString); - Assert.Equal(20, reserializedSolution.FindFolder("/A/")!.Order); - Assert.Equal(10, reserializedSolution.FindFolder("/B/")!.Order); - } } diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs index 77f1bb1a..c06fa099 100644 --- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/InvalidSolutions.cs @@ -209,27 +209,6 @@ public async Task InvalidSlnxVersionAsync() Assert.Equal(2, ex.Column); } - /// - /// Ensure .slnx rejects non-numeric Order attributes. - /// - [Fact] - public async Task InvalidOrderAttributeAsync() - { - const string invalidSlnx = """ - - - -"""; - - using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(invalidSlnx)); - SolutionException ex = await Assert.ThrowsAsync( - async () => _ = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None)); - - Assert.Contains("Order", ex.Message); - Assert.Equal(2, ex.Line); - Assert.Equal(4, ex.Column); - } - /// /// The legacy sln solution parser would ignore duplicate folder guids. /// Ensure this behavior is maintained. diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs index 2bfe8b4c..0173f4e5 100644 --- a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/Project.cs @@ -110,37 +110,4 @@ public async Task DefaultStartupAsync() Assert.Equal("DefaultStartup.csproj", firstProject.FilePath); Assert.Equal(firstItem, firstProject); } - - /// - /// Ensures project order is preserved when round-tripping .slnx. - /// - [Fact] - public async Task RoundTripProjectOrderAsync() - { - SolutionModel solution = new SolutionModel(); - SolutionProjectModel projectA = solution.AddProject("A.csproj"); - SolutionProjectModel projectB = solution.AddProject("B.csproj"); - - projectA.Order = 2; - projectB.Order = 1; - - (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution); - - Assert.Contains("Project Path=\"A.csproj\" Order=\"2\"", contents.FullString); - Assert.Contains("Project Path=\"B.csproj\" Order=\"1\"", contents.FullString); - Assert.Equal(2, reserializedSolution.FindProject("A.csproj")!.Order); - Assert.Equal(1, reserializedSolution.FindProject("B.csproj")!.Order); - } - - /// - /// Ensures order cannot be set to a negative value. - /// - [Fact] - public void RejectsNegativeProjectOrder() - { - SolutionModel solution = new SolutionModel(); - SolutionProjectModel project = solution.AddProject("A.csproj"); - - Assert.Throws(() => project.Order = -1); - } } diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/SortOrder.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/SortOrder.cs new file mode 100644 index 00000000..fbd3a1f1 --- /dev/null +++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/SortOrder.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Serialization; + +/// +/// Tests related to . +/// +public class SortOrder +{ + /// + /// Ensures is written to the file and round-trips. + /// + [Fact] + public async Task DocumentRoundTripsAsync() + { + SolutionModel solution = new SolutionModel { SortOrder = SolutionSortOrder.Document }; + _ = solution.AddProject("A.csproj"); + + (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution); + + Assert.Contains("Sort=\"Document\"", contents.FullString); + Assert.Equal(SolutionSortOrder.Document, reserializedSolution.SortOrder); + } + + /// + /// Ensures is omitted from the file and round-trips. + /// + [Fact] + public async Task AlphabeticalIsOmittedAsync() + { + SolutionModel solution = new SolutionModel(); + _ = solution.AddProject("A.csproj"); + + (SolutionModel reserializedSolution, FileContents contents) = await SaveAndReopenModelAsync(SolutionSerializers.SlnXml, solution); + + Assert.DoesNotContain("Sort=", contents.FullString); + Assert.Equal(SolutionSortOrder.Alphabetical, reserializedSolution.SortOrder); + } + + /// + /// Ensures an unrecognized Sort value is treated as . + /// + [Fact] + public async Task UnknownSortValueIsAlphabeticalAsync() + { + const string slnx = """ + + + +"""; + + using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(slnx)); + SolutionModel solution = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None); + + Assert.Equal(SolutionSortOrder.Alphabetical, solution.SortOrder); + } + + /// + /// Ensures the Sort value is parsed case-insensitively. + /// + [Fact] + public async Task SortValueIsCaseInsensitiveAsync() + { + const string slnx = """ + + + +"""; + + using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(slnx)); + SolutionModel solution = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None); + + Assert.Equal(SolutionSortOrder.Document, solution.SortOrder); + } +}