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 d34f9569..2c0f9ea8 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Microsoft.VisualStudio.SolutionPersistence/PublicAPI/PublicAPI.Unshipped.txt @@ -34,4 +34,9 @@ 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.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 63c95801..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, @@ -64,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; @@ -74,6 +77,7 @@ 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), diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd index 399bf6ed..e6caf5f2 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/Slnx.xsd @@ -9,6 +9,7 @@ + @@ -115,4 +116,4 @@ - \ No newline at end of file + 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/XmlFolder.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs index dd75d2d1..cbcd4941 100644 --- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs +++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs @@ -94,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) { - newProjects.Add((project, project.AddToModel(solutionModel))); + // 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 + { + foreach (XmlProject project in this.folderProjects.GetItems()) + { + newProjects.Add((project, project.AddToModel(solutionModel))); + } } } catch (Exception ex) when (SolutionException.ShouldWrap(ex)) 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/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); + } +}