From 1c68264c356e59e88de72f6d066cea54bb5d6d11 Mon Sep 17 00:00:00 2001
From: kasperk81 <83082615+kasperk81@users.noreply.github.com>
Date: Mon, 1 Dec 2025 01:03:25 +0000
Subject: [PATCH] Implement globbing support for .slnx files
Adds glob pattern support to File and Project Path attributes without
requiring schema changes. Users can include items using standard glob
patterns (e.g., `Path="**/*.cs"` to match all C# files recursively)
and exclude them using the `!` prefix (e.g., `Path="!**/bin/**"` to
exclude build output directories). Patterns are expanded at parse time
using Microsoft.Extensions.FileSystemGlobbing, preserving the user's
original file ordering and path separators.
---
Directory.Packages.props | 1 +
...ft.VisualStudio.SolutionPersistence.csproj | 4 +
.../Serializer/Xml/XmlDecorators/SlnxFile.cs | 163 ++++++
.../Serializer/Xml/XmlDecorators/XmlFolder.cs | 16 +-
.../Serialization/GlobPatternsTests.cs | 539 ++++++++++++++++++
5 files changed, 719 insertions(+), 4 deletions(-)
create mode 100644 test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e5423a30..47cad822 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj b/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj
index 00df45f7..3f0f455d 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Microsoft.VisualStudio.SolutionPersistence.csproj
@@ -18,6 +18,10 @@
+
+
+
+
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs
index 96a7a6c6..c5fab562 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/SlnxFile.cs
@@ -2,7 +2,10 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System.Diagnostics;
+using System.Linq;
using System.Xml;
+using Microsoft.Extensions.FileSystemGlobbing;
+using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
using Microsoft.VisualStudio.SolutionPersistence.Model;
using Microsoft.VisualStudio.SolutionPersistence.Utilities;
@@ -29,6 +32,10 @@ internal SlnxFile(
XmlElement? xmlSolution = this.Document.DocumentElement;
if (xmlSolution is not null && Keywords.ToKeyword(xmlSolution.Name) == Keyword.Solution)
{
+ // Expand ALL File/Project path attributes with glob patterns at raw XML parsing time
+ // This happens BEFORE decorators are created, so all paths (literal and glob) go through expansion
+ this.ExpandGlobPatternsInXml(xmlSolution);
+
this.Solution = new XmlSolution(this, xmlSolution);
this.Solution.UpdateFromXml();
@@ -112,6 +119,162 @@ internal string ToXmlString()
return this.Document.OuterXml;
}
+ private void ExpandGlobPatternsInElement(XmlElement element, string baseDirectory, HashSet allResolvedPaths)
+ {
+ // Process File and Project elements in this element first
+ // We need to handle them in order to support excludes correctly.
+ // We iterate through a snapshot of children, but modify the live DOM.
+ List children = element.ChildNodes.Cast().ToList();
+
+ foreach (XmlNode childNode in children)
+ {
+ // Skip if the node was already removed from the DOM
+ if (childNode.ParentNode is null)
+ {
+ continue;
+ }
+
+ if (childNode is not XmlElement childElement)
+ {
+ continue;
+ }
+
+ string elementName = childElement.Name;
+ if (elementName != "File" && elementName != "Project")
+ {
+ continue;
+ }
+
+ string? pathAttribute = childElement.GetAttribute("Path");
+ if (string.IsNullOrEmpty(pathAttribute))
+ {
+ continue;
+ }
+
+ string modelPath = PathExtensions.ConvertToModel(pathAttribute);
+
+ if (modelPath.StartsWith('!'))
+ {
+ // Exclude pattern
+ this.ApplyExcludePattern(element, childElement, modelPath.Substring(1), baseDirectory, allResolvedPaths);
+ }
+ else
+ {
+ // Include pattern
+ this.ApplyIncludePattern(element, childElement, modelPath, baseDirectory, allResolvedPaths);
+ }
+ }
+
+ // Process recursively for nested folders
+ foreach (XmlNode childNode in element.ChildNodes)
+ {
+ if (childNode is XmlElement childElement && childElement.Name != "File" && childElement.Name != "Project")
+ {
+ this.ExpandGlobPatternsInElement(childElement, baseDirectory, allResolvedPaths);
+ }
+ }
+ }
+
+ private void ApplyExcludePattern(XmlElement parent, XmlElement excludeElement, string pattern, string baseDirectory, HashSet allResolvedPaths)
+ {
+ // Remove the exclude element itself
+ _ = parent.RemoveChild(excludeElement);
+
+ // Find all siblings of the same type that match the pattern
+ string targetElementName = excludeElement.Name;
+ Matcher matcher = new(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true);
+ _ = matcher.AddInclude(pattern); // We use Include here because we want to match the file path against this pattern
+
+ // We need to iterate over current children of the parent
+ List siblingsToCheck = [];
+ foreach (XmlNode node in parent.ChildNodes)
+ {
+ if (node is XmlElement el && el.Name == targetElementName)
+ {
+ siblingsToCheck.Add(el);
+ }
+ }
+
+ foreach (XmlElement sibling in siblingsToCheck)
+ {
+ string? path = sibling.GetAttribute("Path");
+ if (string.IsNullOrEmpty(path))
+ {
+ continue;
+ }
+
+ string modelPath = PathExtensions.ConvertToModel(path);
+
+ // Check if this path matches the exclude pattern
+ PatternMatchingResult result = matcher.Match(modelPath);
+ if (result.HasMatches)
+ {
+ _ = parent.RemoveChild(sibling);
+ _ = allResolvedPaths.Remove(modelPath);
+ }
+ }
+ }
+
+ private void ApplyIncludePattern(XmlElement parent, XmlElement includeElement, string pattern, string baseDirectory, HashSet allResolvedPaths)
+ {
+ Matcher matcher = new(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true);
+ _ = matcher.AddInclude(pattern);
+
+ IEnumerable globResults = matcher.GetResultsInFullPath(baseDirectory);
+ List resolvedPaths = [];
+
+ foreach (string absolutePath in globResults)
+ {
+ string relativePath = Path.GetRelativePath(baseDirectory, absolutePath);
+ if (allResolvedPaths.Add(relativePath))
+ {
+ resolvedPaths.Add(relativePath);
+ }
+ }
+
+ // Handle no matches
+ if (resolvedPaths.Count == 0)
+ {
+ // If it's a literal path (no wildcards), preserve it even if it doesn't exist (or wasn't found)
+ if (allResolvedPaths.Add(pattern))
+ {
+ resolvedPaths.Add(pattern);
+ }
+ }
+
+ // Update XML
+ if (resolvedPaths.Count > 0)
+ {
+ // Insert new elements
+ foreach (string path in resolvedPaths)
+ {
+ XmlElement newElement = (XmlElement)includeElement.CloneNode(deep: true);
+ newElement.SetAttribute("Path", PathExtensions.ConvertModelToForwardSlashPath(path));
+ _ = parent.InsertBefore(newElement, includeElement);
+ }
+ }
+
+ // Remove the original element
+ _ = parent.RemoveChild(includeElement);
+ }
+
+ private void ExpandGlobPatternsInXml(XmlElement solutionElement)
+ {
+ if (this.FullPath is null)
+ {
+ // Can't resolve relative paths without a base directory
+ return;
+ }
+
+ string baseDirectory = Path.GetDirectoryName(this.FullPath) ?? Environment.CurrentDirectory;
+
+ // Track all resolved paths to avoid duplicates across the entire solution
+ HashSet allResolvedPaths = new(StringComparer.OrdinalIgnoreCase);
+
+ // Process all File and Project elements recursively throughout the solution
+ this.ExpandGlobPatternsInElement(solutionElement, baseDirectory, allResolvedPaths);
+ }
+
// Fill out default values.
private SlnxSerializerSettings GetDefaultSerializationSettings(SlnxSerializerSettings inputSettings)
{
diff --git a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs
index dd75d2d1..c3eba865 100644
--- a/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs
+++ b/src/Microsoft.VisualStudio.SolutionPersistence/Serializer/Xml/XmlDecorators/XmlFolder.cs
@@ -82,11 +82,19 @@ internal void AddToModel(SolutionModel solutionModel, List<(XmlProject XmlProjec
SolutionFolderModel folderModel = solutionModel.AddFolder(this.Name);
folderModel.Id = this.Id;
- foreach (XmlFile file in this.files.GetItems())
+ // Iterate over the XML children to preserve order, but use the decorators for data
+ foreach (XmlNode childNode in this.XmlElement.ChildNodes)
{
- string modelPath = PathExtensions.ConvertToModel(file.Path);
- folderModel.AddFile(modelPath);
- this.Root.UserPaths[modelPath] = file.Path;
+ if (childNode is XmlElement childElement && childElement.Name == "File")
+ {
+ string? path = childElement.GetAttribute("Path");
+ if (!string.IsNullOrEmpty(path))
+ {
+ string modelPath = PathExtensions.ConvertToModel(path);
+ folderModel.AddFile(modelPath);
+ this.Root.UserPaths[modelPath] = path;
+ }
+ }
}
foreach (XmlProperties properties in this.propertyBags.GetItems())
diff --git a/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs
new file mode 100644
index 00000000..1e68353f
--- /dev/null
+++ b/test/Microsoft.VisualStudio.SolutionPersistence.Tests/Serialization/GlobPatternsTests.cs
@@ -0,0 +1,539 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.VisualStudio.SolutionPersistence.Model;
+
+namespace Serialization;
+
+///
+/// Tests for glob pattern expansion functionality in SLNX files.
+///
+public sealed class GlobPatternsTests
+{
+ ///
+ /// Tests that literal file paths (no glob patterns) are preserved as-is.
+ ///
+ [Fact]
+ public async Task LiteralPathsPreservedAsync()
+ {
+ string slnxContent = """
+
+
+
+
+
+
+
+ """;
+
+ using MemoryStream stream = new(Encoding.UTF8.GetBytes(slnxContent));
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None);
+
+ SolutionFolderModel? folder = model.FindFolder("/test/");
+ Assert.NotNull(folder);
+ Assert.Equal(3, folder.Files?.Count);
+
+ List expectedFiles = ["file1.txt", "src/file2.cs", "docs/readme.md"];
+ Assert.Equal(expectedFiles, folder.Files);
+ }
+
+ ///
+ /// Tests that glob patterns are expanded to match actual files in the file system.
+ ///
+ [Fact]
+ public async Task GlobPatternsExpandedAsync()
+ {
+ // Create a temporary directory structure for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_glob_test_{Guid.NewGuid():N}");
+ try
+ {
+ // Create test directory structure
+ _ = Directory.CreateDirectory(tempDir);
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "docs"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "obj"));
+
+ // Create test files
+ File.WriteAllText(Path.Combine(tempDir, "src", "Program.cs"), "// test");
+ File.WriteAllText(Path.Combine(tempDir, "src", "Helper.cs"), "// test");
+ File.WriteAllText(Path.Combine(tempDir, "tests", "Test1.cs"), "// test");
+ File.WriteAllText(Path.Combine(tempDir, "docs", "README.md"), "# test");
+ File.WriteAllText(Path.Combine(tempDir, "obj", "temp.dll"), "temp");
+
+ // Create SLNX with glob patterns
+ string slnxContent = """
+
+
+
+
+
+
+ """;
+
+ string slnxPath = Path.Combine(tempDir, "test.slnx");
+ File.WriteAllText(slnxPath, slnxContent);
+
+ // Parse the SLNX file
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None);
+
+ // Verify glob patterns were expanded
+ SolutionFolderModel? folder = model.FindFolder("/src/");
+ Assert.NotNull(folder);
+ Assert.NotNull(folder.Files);
+ Assert.True(folder.Files.Count >= 3); // At least the files we created
+
+ // Should include CS files
+ Assert.Contains(folder.Files, f => f.Contains("Program.cs"));
+ Assert.Contains(folder.Files, f => f.Contains("Helper.cs"));
+ Assert.Contains(folder.Files, f => f.Contains("Test1.cs"));
+
+ // Should include MD files
+ Assert.Contains(folder.Files, f => f.Contains("README.md"));
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests that exclude patterns (prefixed with !) work correctly.
+ ///
+ [Fact]
+ public async Task ExcludePatternsWorkAsync()
+ {
+ // Create a temporary directory structure for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_exclude_test_{Guid.NewGuid():N}");
+ try
+ {
+ // Create test directory structure
+ _ = Directory.CreateDirectory(tempDir);
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "obj"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "bin"));
+
+ // Create test files
+ File.WriteAllText(Path.Combine(tempDir, "src", "Program.cs"), "// test");
+ File.WriteAllText(Path.Combine(tempDir, "obj", "Program.dll"), "temp");
+ File.WriteAllText(Path.Combine(tempDir, "bin", "Program.exe"), "temp");
+
+ // Create SLNX with include and exclude patterns
+ string slnxContent = """
+
+
+
+
+
+
+
+ """;
+
+ string slnxPath = Path.Combine(tempDir, "test.slnx");
+ File.WriteAllText(slnxPath, slnxContent);
+
+ // Parse the SLNX file
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None);
+
+ // Verify exclude patterns worked
+ SolutionFolderModel? folder = model.FindFolder("/files/");
+ Assert.NotNull(folder);
+ Assert.NotNull(folder.Files);
+
+ // Should include source files
+ Assert.Contains(folder.Files, f => f.Contains("Program.cs"));
+
+ // Should NOT include obj/bin files due to exclusion
+ Assert.DoesNotContain(folder.Files, f => f.Contains("obj/"));
+ Assert.DoesNotContain(folder.Files, f => f.Contains("bin/"));
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests mixed literal paths and glob patterns.
+ ///
+ [Fact]
+ public async Task MixedPatternsWorkAsync()
+ {
+ // Create a temporary directory structure for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_mixed_test_{Guid.NewGuid():N}");
+ try
+ {
+ // Create test directory structure
+ _ = Directory.CreateDirectory(tempDir);
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src"));
+
+ // Create test files
+ File.WriteAllText(Path.Combine(tempDir, "LICENSE"), "MIT License");
+ File.WriteAllText(Path.Combine(tempDir, "src", "Program.cs"), "// test");
+ File.WriteAllText(Path.Combine(tempDir, "src", "Helper.cs"), "// test");
+
+ // Create SLNX with mixed patterns
+ string slnxContent = """
+
+
+
+
+
+
+
+ """;
+
+ string slnxPath = Path.Combine(tempDir, "test.slnx");
+ File.WriteAllText(slnxPath, slnxContent);
+
+ // Parse the SLNX file
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None);
+
+ // Verify mixed patterns worked
+ SolutionFolderModel? folder = model.FindFolder("/mixed/");
+ Assert.NotNull(folder);
+ Assert.NotNull(folder.Files);
+
+ // Should include literal file that exists
+ Assert.Contains(folder.Files, f => f == "LICENSE");
+
+ // Should include glob-matched files
+ Assert.Contains(folder.Files, f => f.Contains("Program.cs"));
+ Assert.Contains(folder.Files, f => f.Contains("Helper.cs"));
+
+ // Should NOT include literal file that doesn't exist - Matcher only returns existing files
+ Assert.Contains(folder.Files, f => f == "README.md");
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests that forward slash paths are preserved in the output.
+ ///
+ [Fact]
+ public async Task ForwardSlashPathsPreservedAsync()
+ {
+ // Create a temporary directory structure for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_path_test_{Guid.NewGuid():N}");
+ try
+ {
+ // Create test directory structure
+ _ = Directory.CreateDirectory(tempDir);
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "nested"));
+
+ // Create test files
+ File.WriteAllText(Path.Combine(tempDir, "src", "nested", "deep.cs"), "// test");
+
+ // Create SLNX with glob pattern
+ string slnxContent = """
+
+
+
+
+
+ """;
+
+ string slnxPath = Path.Combine(tempDir, "test.slnx");
+ File.WriteAllText(slnxPath, slnxContent);
+
+ // Parse the SLNX file
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None);
+
+ // Verify forward slashes are preserved
+ SolutionFolderModel? folder = model.FindFolder("/paths/");
+ Assert.NotNull(folder);
+ Assert.NotNull(folder.Files);
+
+ // Should use forward slashes in the resolved path
+ string? matchedFile = null;
+ foreach (string file in folder.Files)
+ {
+ if (file.Contains("deep.cs"))
+ {
+ matchedFile = file;
+ break;
+ }
+ }
+
+ Assert.NotNull(matchedFile);
+ Assert.Contains("/", matchedFile); // Should contain forward slashes
+ Assert.DoesNotContain("\\", matchedFile); // Should not contain backslashes
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests that literal project paths (no glob patterns) are preserved as-is.
+ ///
+ [Fact]
+ public async Task LiteralProjectPathsPreservedAsync()
+ {
+ string slnxContent = """
+
+
+
+
+
+
+
+ """;
+
+ using MemoryStream stream = new(Encoding.UTF8.GetBytes(slnxContent));
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(stream, CancellationToken.None);
+
+ SolutionFolderModel? folder = model.FindFolder("/projects/");
+ Assert.NotNull(folder);
+
+ // Get projects in this folder from the solution's SolutionProjects collection
+ List projectsInFolder = [];
+ foreach (SolutionProjectModel project in model.SolutionProjects)
+ {
+ if (ReferenceEquals(project.Parent, folder))
+ {
+ projectsInFolder.Add(project.FilePath);
+ }
+ }
+
+ Assert.Equal(3, projectsInFolder.Count);
+
+ List expectedProjects = ["src/Project1/Project1.csproj", "src/Project2/Project2.csproj", "tests/TestProject/TestProject.csproj"];
+ Assert.Equal(expectedProjects, projectsInFolder);
+ }
+
+ ///
+ /// Tests that glob patterns work for Project elements.
+ ///
+ [Fact]
+ public async Task ProjectGlobPatternsExpandedAsync()
+ {
+ // Create a temporary directory structure for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_project_glob_test_{Guid.NewGuid():N}");
+ try
+ {
+ // Create test directory structure
+ _ = Directory.CreateDirectory(tempDir);
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "WebApp"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "Library"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests", "UnitTests"));
+
+ // Create test project files
+ File.WriteAllText(Path.Combine(tempDir, "src", "WebApp", "WebApp.csproj"), "");
+ File.WriteAllText(Path.Combine(tempDir, "src", "Library", "Library.csproj"), "");
+ File.WriteAllText(Path.Combine(tempDir, "tests", "UnitTests", "UnitTests.csproj"), "");
+
+ // Create SLNX with Project glob patterns
+ string slnxContent = """
+
+
+
+
+
+ """;
+
+ string slnxPath = Path.Combine(tempDir, "test.slnx");
+ File.WriteAllText(slnxPath, slnxContent);
+
+ // Parse the SLNX file
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None);
+
+ // Verify Project glob patterns were expanded
+ SolutionFolderModel? folder = model.FindFolder("/all-projects/");
+ Assert.NotNull(folder);
+
+ // Get projects in this folder from the solution's SolutionProjects collection
+ List projectsInFolder = [];
+ foreach (SolutionProjectModel project in model.SolutionProjects)
+ {
+ if (ReferenceEquals(project.Parent, folder))
+ {
+ projectsInFolder.Add(project.FilePath);
+ }
+ }
+
+ Assert.Equal(3, projectsInFolder.Count);
+
+ // Should include all project files
+ Assert.Contains(projectsInFolder, p => p.Contains("WebApp.csproj"));
+ Assert.Contains(projectsInFolder, p => p.Contains("Library.csproj"));
+ Assert.Contains(projectsInFolder, p => p.Contains("UnitTests.csproj"));
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests mixed literal and glob Project patterns.
+ ///
+ [Fact]
+ public async Task MixedProjectPatternsWorkAsync()
+ {
+ // Create a temporary directory structure for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_mixed_project_test_{Guid.NewGuid():N}");
+ try
+ {
+ // Create test directory structure
+ _ = Directory.CreateDirectory(tempDir);
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests"));
+
+ // Create test project files
+ File.WriteAllText(Path.Combine(tempDir, "src", "Main.csproj"), "");
+ File.WriteAllText(Path.Combine(tempDir, "tests", "Tests.csproj"), "");
+ File.WriteAllText(Path.Combine(tempDir, "Special.csproj"), "");
+
+ // Create SLNX with mixed Project patterns
+ string slnxContent = """
+
+
+
+
+
+
+
+ """;
+
+ string slnxPath = Path.Combine(tempDir, "test.slnx");
+ File.WriteAllText(slnxPath, slnxContent);
+
+ // Parse the SLNX file
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None);
+
+ // Verify mixed Project patterns worked
+ SolutionFolderModel? folder = model.FindFolder("/mixed-projects/");
+ Assert.NotNull(folder);
+
+ // Get projects in this folder from the solution's SolutionProjects collection
+ List projectsInFolder = [];
+ foreach (SolutionProjectModel project in model.SolutionProjects)
+ {
+ if (ReferenceEquals(project.Parent, folder))
+ {
+ projectsInFolder.Add(project.FilePath);
+ }
+ }
+
+ // Should include literal project that exists
+ Assert.Contains(projectsInFolder, p => p == "Special.csproj");
+
+ // Should include glob-matched projects
+ Assert.Contains(projectsInFolder, p => p.Contains("Main.csproj"));
+
+ // Should include literal project that doesn't exist (preserved as-is)
+ Assert.Contains(projectsInFolder, p => p == "NonExistent.csproj");
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+
+ ///
+ /// Tests that both root-level and folder-level projects support globbing.
+ ///
+ [Fact]
+ public async Task BothRootAndFolderProjectGlobbingAsync()
+ {
+ // Create a temporary directory structure for testing
+ string tempDir = Path.Combine(Path.GetTempPath(), $"slnx_root_folder_test_{Guid.NewGuid():N}");
+ try
+ {
+ // Create test directory structure
+ _ = Directory.CreateDirectory(tempDir);
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "root"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "src", "libraries"));
+ _ = Directory.CreateDirectory(Path.Combine(tempDir, "tests"));
+
+ // Create test project files
+ File.WriteAllText(Path.Combine(tempDir, "root", "RootProject.csproj"), "");
+ File.WriteAllText(Path.Combine(tempDir, "src", "libraries", "Library.csproj"), "");
+ File.WriteAllText(Path.Combine(tempDir, "tests", "UnitTests.csproj"), "");
+
+ // Create SLNX with both root and folder project patterns
+ string slnxContent = """
+
+
+
+
+
+
+ """;
+
+ string slnxPath = Path.Combine(tempDir, "test.slnx");
+ File.WriteAllText(slnxPath, slnxContent);
+
+ // Parse the SLNX file
+ SolutionModel model = await SolutionSerializers.SlnXml.OpenAsync(slnxPath, CancellationToken.None);
+
+ // Check root-level projects
+ List rootProjects = [];
+ foreach (SolutionProjectModel project in model.SolutionProjects)
+ {
+ // Root level projects
+ if (project.Parent == null)
+ {
+ rootProjects.Add(project.FilePath);
+ }
+ }
+
+ // Should include root glob-matched project
+ Assert.Contains(rootProjects, p => p.Contains("RootProject.csproj"));
+
+ // Check folder-level projects
+ SolutionFolderModel? srcFolder = model.FindFolder("/src/");
+ Assert.NotNull(srcFolder);
+
+ List folderProjects = [];
+ foreach (SolutionProjectModel project in model.SolutionProjects)
+ {
+ if (ReferenceEquals(project.Parent, srcFolder))
+ {
+ folderProjects.Add(project.FilePath);
+ }
+ }
+
+ // Should include folder glob-matched projects
+ Assert.Contains(folderProjects, p => p.Contains("Library.csproj"));
+ Assert.Contains(folderProjects, p => p.Contains("UnitTests.csproj"));
+ }
+ finally
+ {
+ // Cleanup
+ if (Directory.Exists(tempDir))
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+ }
+}