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();