Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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.
Expand Down Expand Up @@ -77,6 +79,26 @@ public Guid Id
/// </summary>
public bool IsDefaultId => this.id is null;

/// <summary>
/// Gets or sets the item order used by .slnx files.
/// </summary>
/// <remarks>
/// When set, this must be a non-negative integer.
/// </remarks>
public int? Order
{
get => this.order;
set
{
if (value < 0)
{
throw new ArgumentOutOfRangeException(nameof(Order));
}

this.order = value;
}
}

/// <summary>
/// Gets the display name of the item. If there is a filename it will be used, otherwise the actual display name.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal enum Keyword
Id,
Name,
Path,
Order,
Type,
DefaultStartup,
DisplayName,
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<xs:group ref="PropertiesGroup" minOccurs="0" maxOccurs="unbounded"/>
</xs:choice>
<xs:attribute name="Name" type="xs:string" use="required"/>
<xs:attribute name="Order" type="xs:nonNegativeInteger" />
</xs:complexType>

<xs:complexType name="Project">
Expand All @@ -64,6 +65,7 @@
<xs:group ref="PropertiesGroup" minOccurs="0" maxOccurs="unbounded"/>
</xs:choice>
<xs:attribute name="Path" type="xs:string" use="required"/>
<xs:attribute name="Order" type="xs:nonNegativeInteger" />
<xs:attribute name="Type" type="xs:string" />
<xs:attribute name="DisplayName" type="xs:string" />
</xs:complexType>
Expand Down Expand Up @@ -115,4 +117,4 @@
</xs:element>
</xs:choice>
</xs:group>
</xs:schema>
</xs:schema>
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}";
Expand Down Expand Up @@ -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())
{
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,25 @@ public void ChangeFolderName()
Assert.Equal("/A/Nested/Deep/", folderNestedA.Path);
Assert.Equal("/C/Nested/Deep/", folderNestedB.Path);
}

/// <summary>
/// Ensures folder order is preserved when round-tripping .slnx.
/// </summary>
[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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,27 @@ public async Task InvalidSlnxVersionAsync()
Assert.Equal(2, ex.Column);
}

/// <summary>
/// Ensure .slnx rejects non-numeric Order attributes.
/// </summary>
[Fact]
public async Task InvalidOrderAttributeAsync()
{
const string invalidSlnx = """
<Solution>
<Project Path="App.csproj" Order="abc" />
</Solution>
""";

using MemoryStream stream = new MemoryStream(Encoding.UTF8.GetBytes(invalidSlnx));
SolutionException ex = await Assert.ThrowsAsync<SolutionException>(
async () => _ = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None));

Assert.Contains("Order", ex.Message);
Assert.Equal(2, ex.Line);
Assert.Equal(4, ex.Column);
}

/// <summary>
/// The legacy sln solution parser would ignore duplicate folder guids.
/// Ensure this behavior is maintained.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,37 @@ public async Task DefaultStartupAsync()
Assert.Equal("DefaultStartup.csproj", firstProject.FilePath);
Assert.Equal(firstItem, firstProject);
}

/// <summary>
/// Ensures project order is preserved when round-tripping .slnx.
/// </summary>
[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);
}

/// <summary>
/// Ensures order cannot be set to a negative value.
/// </summary>
[Fact]
public void RejectsNegativeProjectOrder()
{
SolutionModel solution = new SolutionModel();
SolutionProjectModel project = solution.AddProject("A.csproj");

Assert.Throws<ArgumentOutOfRangeException>(() => project.Order = -1);
}
}