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); + } }