diff --git a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs
index 63169e7b4f..d14e3dc45e 100644
--- a/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs
+++ b/src/Microsoft.AspNet.Mvc.TagHelpers/Internal/GlobbingUrlBuilder.cs
@@ -20,11 +20,13 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
{
private static readonly char[] PatternSeparator = new[] { ',' };
+ private static readonly PathComparer DefaultPathComparer = new PathComparer();
+
private readonly FileProviderGlobbingDirectory _baseGlobbingDirectory;
// Internal for testing
internal GlobbingUrlBuilder() { }
-
+
///
/// Creates a new .
///
@@ -95,7 +97,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
{
return Enumerable.Empty();
}
-
+
if (Cache != null)
{
var cacheKey = $"{nameof(GlobbingUrlBuilder)}-inc:{include}-exc:{exclude}";
@@ -127,7 +129,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
var matches = matcher.Execute(_baseGlobbingDirectory);
- return matches.Files.Select(ResolveMatchedPath);
+ return matches.Files.Select(ResolveMatchedPath)
+ .OrderBy(path => path, DefaultPathComparer);
}
private string ResolveMatchedPath(string matchedPath)
@@ -137,6 +140,72 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
return RequestPathBase.Add(relativePath).ToString();
}
+ private class PathComparer : IComparer
+ {
+ public int Compare(string x, string y)
+ {
+ // < 0 = x < y
+ // > 0 = x > y
+
+ if (string.Equals(x, y, StringComparison.Ordinal))
+ {
+ return 0;
+ }
+
+ if (string.IsNullOrEmpty(x) || string.IsNullOrEmpty(y))
+ {
+ return string.Compare(x, y, StringComparison.Ordinal);
+ }
+
+ var xExtIndex = x.LastIndexOf('.');
+ var yExtIndex = y.LastIndexOf('.');
+
+ // Ensure extension index is in the last segment, i.e. in the file name
+ var xSlashIndex = x.LastIndexOf('/');
+ var ySlashIndex = y.LastIndexOf('/');
+ xExtIndex = xExtIndex > xSlashIndex ? xExtIndex : -1;
+ yExtIndex = yExtIndex > ySlashIndex ? yExtIndex : -1;
+
+ // Get paths without their extensions, if they have one
+ var xNoExt = xExtIndex >= 0 ? x.Substring(0, xExtIndex) : x;
+ var yNoExt = yExtIndex >= 0 ? y.Substring(0, yExtIndex) : y;
+
+ if (string.Equals(xNoExt, yNoExt, StringComparison.Ordinal))
+ {
+ // Only extension differs so just compare the extension
+ var xExt = xExtIndex >= 0 ? x.Substring(xExtIndex) : string.Empty;
+ var yExt = yExtIndex >= 0 ? y.Substring(yExtIndex) : string.Empty;
+
+ return string.Compare(xExt, yExt, StringComparison.Ordinal);
+ }
+
+ var xSegments = xNoExt.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
+ var ySegments = yNoExt.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
+
+ if (xSegments.Length != ySegments.Length)
+ {
+ // Different path depths so shallower path wins
+ return xSegments.Length.CompareTo(ySegments.Length);
+ }
+
+ // Depth is the same so compare each segment
+ for (int i = 0; i < xSegments.Length; i++)
+ {
+ var xSegment = xSegments[i];
+ var ySegment = ySegments[i];
+
+ var xToY = string.Compare(xSegment, ySegment, StringComparison.Ordinal);
+ if (xToY != 0)
+ {
+ return xToY;
+ }
+ }
+
+ // Should't get here, but if we do, hey, they're the same :)
+ return 0;
+ }
+ }
+
private static string TrimLeadingSlash(string value)
{
var result = value;
diff --git a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs
index 62bc4925c4..d2d448de7d 100644
--- a/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs
+++ b/test/Microsoft.AspNet.Mvc.TagHelpers.Test/Internal/GlobbingUrlBuilderTest.cs
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using Microsoft.AspNet.FileProviders;
using Microsoft.AspNet.Http;
@@ -51,6 +52,179 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
url => Assert.Equal("/blank.css", url));
}
+ public static TheoryData OrdersGlobbedMatchResultsCorrectly_Data
+ {
+ get
+ {
+ return new TheoryData
+ {
+ {
+ /* staticUrl */ "/site.css",
+ /* dirStructure */ new FileNode(null, new [] {
+ new FileNode("B", new [] {
+ new FileNode("a.css"),
+ new FileNode("b.css"),
+ new FileNode("ba.css"),
+ new FileNode("b", new [] {
+ new FileNode("a.css")
+ })
+ }),
+ new FileNode("A", new [] {
+ new FileNode("c.css"),
+ new FileNode("d.css")
+ }),
+ new FileNode("a.css")
+ }),
+ /* expectedPaths */ new []
+ {
+ "/site.css",
+ "/a.css",
+ "/A/c.css", "/A/d.css",
+ "/B/a.css", "/B/b.css", "/B/ba.css",
+ "/B/b/a.css"
+ }
+ },
+ {
+ /* staticUrl */ "/site.css",
+ /* dirStructure */ new FileNode(null, new [] {
+
+ new FileNode("A", new [] {
+ new FileNode("c.css"),
+ new FileNode("d.css")
+ }),
+ new FileNode("_A", new [] {
+ new FileNode("1.css"),
+ new FileNode("2.css")
+ }),
+ new FileNode("__A", new [] {
+ new FileNode("1.css"),
+ new FileNode("_1.css")
+ })
+ }),
+ /* expectedPaths */ new []
+ {
+ "/site.css",
+ "/A/c.css", "/A/d.css",
+ "/_A/1.css", "/_A/2.css",
+ "/__A/1.css", "/__A/_1.css"
+ }
+ },
+ {
+ /* staticUrl */ "/site.css",
+ /* dirStructure */ new FileNode(null, new [] {
+ new FileNode("A", new [] {
+ new FileNode("a.b.css"),
+ new FileNode("a-b.css"),
+ new FileNode("a_b.css"),
+ new FileNode("a.css"),
+ new FileNode("a.c.css")
+ })
+ }),
+ /* expectedPaths */ new []
+ {
+ "/site.css",
+ "/A/a.css", "/A/a-b.css", "/A/a.b.css", "/A/a.c.css", "/A/a_b.css"
+ }
+ },
+ {
+ /* staticUrl */ "/site.css",
+ /* dirStructure */ new FileNode(null, new [] {
+ new FileNode("B", new [] {
+ new FileNode("a.bss"),
+ new FileNode("a.css")
+ }),
+ new FileNode("A", new [] {
+ new FileNode("a.css"),
+ new FileNode("a.bss")
+ })
+ }),
+ /* expectedPaths */ new []
+ {
+ "/site.css",
+ "/A/a.bss", "/A/a.css",
+ "/B/a.bss", "/B/a.css"
+ }
+ },
+ {
+ /* staticUrl */ "/site.css",
+ /* dirStructure */ new FileNode(null, new [] {
+ new FileNode("B", new [] {
+ new FileNode("site2.css"),
+ new FileNode("site11.css")
+ }),
+ new FileNode("A", new [] {
+ new FileNode("site2.css"),
+ new FileNode("site11.css")
+ })
+ }),
+ /* expectedPaths */ new []
+ {
+ "/site.css",
+ "/A/site11.css", "/A/site2.css",
+ "/B/site11.css", "/B/site2.css"
+ }
+ },
+ {
+ /* staticUrl */ "/site.css",
+ /* dirStructure */ new FileNode(null, new [] {
+ new FileNode("B", new [] {
+ new FileNode("site"),
+ new FileNode("site.css")
+ }),
+ new FileNode("A", new [] {
+ new FileNode("site.css"),
+ new FileNode("site")
+ })
+ }),
+ /* expectedPaths */ new []
+ {
+ "/site.css",
+ "/A/site", "/A/site.css",
+ "/B/site", "/B/site.css"
+ }
+ },
+ {
+ /* staticUrl */ "/site.css",
+ /* dirStructure */ new FileNode(null, new [] {
+ new FileNode("B.B", new [] {
+ new FileNode("site"),
+ new FileNode("site.css")
+ }),
+ new FileNode("A.A", new [] {
+ new FileNode("site.css"),
+ new FileNode("site")
+ })
+ }),
+ /* expectedPaths */ new []
+ {
+ "/site.css",
+ "/A.A/site", "/A.A/site.css",
+ "/B.B/site", "/B.B/site.css"
+ }
+ }
+ };
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(OrdersGlobbedMatchResultsCorrectly_Data))]
+ public void OrdersGlobbedMatchResultsCorrectly(string staticUrl, FileNode dirStructure, string[] expectedPaths)
+ {
+ // Arrange
+ var fileProvider = MakeFileProvider(dirStructure);
+ IMemoryCache cache = null;
+ var requestPathBase = PathString.Empty;
+ var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase);
+
+ // Act
+ var urlList = globbingUrlBuilder.BuildUrlList(staticUrl, "**/*.*", excludePattern: null);
+
+ // Assert
+ var collectionAssertions = expectedPaths.Select>(expected =>
+ actual => Assert.Equal(expected, actual));
+ Assert.Collection(urlList, collectionAssertions.ToArray());
+ }
+
[Theory]
[InlineData("/sub")]
[InlineData("/sub/again")]
@@ -70,8 +244,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
// Assert
Assert.Collection(urlList,
- url => Assert.Equal($"{pathBase}/site.css", url),
- url => Assert.Equal($"{pathBase}/blank.css", url));
+ url => Assert.Equal($"{pathBase}/blank.css", url),
+ url => Assert.Equal($"{pathBase}/site.css", url));
}
[Fact]
@@ -79,7 +253,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
{
// Arrange
var fileProvider = MakeFileProvider();
- var cache = MakeCache(new List { "/site.css", "/blank.css" });
+ var cache = MakeCache(new List { "/blank.css", "/site.css" });
var requestPathBase = PathString.Empty;
var globbingUrlBuilder = new GlobbingUrlBuilder(fileProvider, cache, requestPathBase);
@@ -91,8 +265,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
// Assert
Assert.Collection(urlList,
- url => Assert.Equal("/site.css", url),
- url => Assert.Equal("/blank.css", url));
+ url => Assert.Equal("/blank.css", url),
+ url => Assert.Equal("/site.css", url));
}
[Fact]
@@ -128,8 +302,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
// Assert
Assert.Collection(urlList,
- url => Assert.Equal("/site.css", url),
- url => Assert.Equal("/blank.css", url));
+ url => Assert.Equal("/blank.css", url),
+ url => Assert.Equal("/site.css", url));
cacheSetContext.VerifyAll();
Mock.Get(cache).VerifyAll();
}
@@ -187,13 +361,71 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
Assert.Collection(excludePatterns, pattern => Assert.Equal($"{leadingSlash}**/*.min.css", pattern));
}
- private static IFileInfo MakeFileInfo(string name)
+ public class FileNode
+ {
+ public FileNode(string name)
+ {
+ Name = name;
+ }
+
+ public FileNode(string name, IList children)
+ {
+ Name = name;
+ Children = children;
+ }
+
+ public string Name { get; }
+
+ public IList Children { get; }
+
+ public bool IsDirectory => Children != null && Children.Any();
+ }
+
+ private static IFileInfo MakeFileInfo(string name, bool isDirectory = false)
{
var fileInfo = new Mock();
fileInfo.Setup(f => f.Name).Returns(name);
+ fileInfo.Setup(f => f.IsDirectory).Returns(isDirectory);
return fileInfo.Object;
}
+ private static IFileProvider MakeFileProvider(FileNode rootNode)
+ {
+ if (rootNode.Children == null || !rootNode.Children.Any())
+ {
+ throw new ArgumentException(nameof(rootNode));
+ }
+
+ var fileProvider = new Mock(MockBehavior.Strict);
+ fileProvider.Setup(fp => fp.GetDirectoryContents(string.Empty))
+ .Returns(MakeDirectoryContents(rootNode, fileProvider));
+
+ return fileProvider.Object;
+ }
+
+ private static IDirectoryContents MakeDirectoryContents(FileNode fileNode, Mock fileProviderMock)
+ {
+ var children = new List();
+
+ foreach (var node in fileNode.Children)
+ {
+ children.Add(MakeFileInfo(node.Name, node.IsDirectory));
+ if (node.IsDirectory)
+ {
+ var subPath = fileNode.Name != null
+ ? (fileNode.Name + "/" + node.Name)
+ : node.Name;
+ fileProviderMock.Setup(fp => fp.GetDirectoryContents(subPath))
+ .Returns(MakeDirectoryContents(node, fileProviderMock));
+ }
+ }
+
+ var directoryContents = new Mock();
+ directoryContents.Setup(dc => dc.GetEnumerator()).Returns(children.GetEnumerator());
+
+ return directoryContents.Object;
+ }
+
private static IDirectoryContents MakeDirectoryContents(params string[] fileNames)
{
var files = fileNames.Select(name => MakeFileInfo(name));
@@ -215,7 +447,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers.Internal
.Returns(directoryContents);
return fileProvider.Object;
}
-
+
private static IMemoryCache MakeCache(object result = null)
{
var cache = new Mock();