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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<!-- Put repo-specific PackageVersion items in this group. -->
<PackageVersion Include="IsExternalInit" Version="1.0.3" />
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.0" />
<PackageVersion Include="Microsoft.IO.Redist" Version="6.0.1" />
<PackageVersion Include="System.Memory" Version="4.5.5" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.5.4" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
<PackageReference Include="System.Threading.Tasks.Extensions" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
</ItemGroup>

<ItemGroup>
<!-- PublicAPI entries in all TargetFrameworks -->
<AdditionalFiles Include="PublicAPI/PublicAPI.Shipped.txt" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();

Expand Down Expand Up @@ -112,6 +119,162 @@ internal string ToXmlString()
return this.Document.OuterXml;
}

private void ExpandGlobPatternsInElement(XmlElement element, string baseDirectory, HashSet<string> 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<XmlNode> children = element.ChildNodes.Cast<XmlNode>().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<string> 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<XmlElement> 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<string> allResolvedPaths)
{
Matcher matcher = new(StringComparison.OrdinalIgnoreCase, preserveFilterOrder: true);
_ = matcher.AddInclude(pattern);

IEnumerable<string> globResults = matcher.GetResultsInFullPath(baseDirectory);
List<string> 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<string> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading