diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/NotFoundProjectItem.cs b/src/Microsoft.AspNetCore.Razor.Evolution/NotFoundProjectItem.cs new file mode 100644 index 0000000000..c81c626244 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Evolution/NotFoundProjectItem.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.AspNetCore.Razor.Evolution +{ + /// + /// A that does not exist. + /// + public class NotFoundProjectItem : RazorProjectItem + { + /// + /// Initializes a new instance of . + /// + /// The base path. + /// The path. + public NotFoundProjectItem(string basePath, string path) + { + BasePath = basePath; + Path = path; + } + + /// + public override string BasePath { get; } + + /// + public override string Path { get; } + + /// + public override bool Exists => false; + + /// + public override string PhysicalPath => throw new NotSupportedException(); + + /// + public override Stream Read() => throw new NotSupportedException(); + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Properties/Resources.Designer.cs index 9c4f6c57a9..f9e00ec9df 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Properties/Resources.Designer.cs @@ -170,6 +170,22 @@ namespace Microsoft.AspNetCore.Razor.Evolution return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperAssemblyCouldNotBeResolved"), p0); } + /// + /// Path must begin with a forward slash '/'. + /// + internal static string RazorProject_PathMustStartWithForwardSlash + { + get { return GetString("RazorProject_PathMustStartWithForwardSlash"); } + } + + /// + /// Path must begin with a forward slash '/'. + /// + internal static string FormatRazorProject_PathMustStartWithForwardSlash() + { + return GetString("RazorProject_PathMustStartWithForwardSlash"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/RazorProject.cs b/src/Microsoft.AspNetCore.Razor.Evolution/RazorProject.cs index 98d3a386f7..c93124ba74 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/RazorProject.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/RazorProject.cs @@ -1,7 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; +using System.Diagnostics; +using System.Text; namespace Microsoft.AspNetCore.Razor.Evolution { @@ -16,5 +19,82 @@ namespace Microsoft.AspNetCore.Razor.Evolution /// The base path. /// The sequence of . public abstract IEnumerable EnumerateItems(string basePath); + + /// + /// Gets a for the specified path. + /// + /// The path. + /// The . + public abstract RazorProjectItem GetItem(string path); + + /// + /// Gets the sequence of files named that are applicable to the specified path. + /// + /// The path of a project item. + /// The file name to seek. + /// A sequence of applicable instances. + /// + /// This method returns paths starting from the directory of and + /// traverses to the project root. + /// e.g. + /// /Views/Home/View.cshtml -> [ /Views/Home/FileName.cshtml, /Views/FileName.cshtml, /FileName.cshtml ] + /// + public virtual IEnumerable FindHierarchicalItems(string path, string fileName) + { + EnsureValidPath(path); + if (string.IsNullOrEmpty(fileName)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(fileName)); + } + + Debug.Assert(!string.IsNullOrEmpty(path)); + if (path.Length == 1) + { + yield break; + } + + StringBuilder builder; + var fileNameIndex = path.LastIndexOf('/'); + var length = path.Length; + Debug.Assert(fileNameIndex != -1); + if (string.Compare(path, fileNameIndex + 1, fileName, 0, fileName.Length) == 0) + { + // If the specified path is for the file hierarchy being constructed, then the first file that applies + // to it is in a parent directory. + builder = new StringBuilder(path, 0, fileNameIndex, fileNameIndex + fileName.Length); + length = fileNameIndex; + } + else + { + builder = new StringBuilder(path); + } + + var index = length; + while (index > 0 && (index = path.LastIndexOf('/', index - 1)) != -1) + { + builder.Length = index + 1; + builder.Append(fileName); + + var itemPath = builder.ToString(); + yield return GetItem(itemPath); + } + } + + /// + /// Performs validation for paths passed to methods of . + /// + /// The path to validate. + protected virtual void EnsureValidPath(string path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(path)); + } + + if (path[0] != '/') + { + throw new ArgumentException(Resources.RazorProject_PathMustStartWithForwardSlash, nameof(path)); + } + } } -} \ No newline at end of file +} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/RazorProjectItem.cs b/src/Microsoft.AspNetCore.Razor.Evolution/RazorProjectItem.cs index 108bc8c498..24368a7d86 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/RazorProjectItem.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/RazorProjectItem.cs @@ -33,6 +33,11 @@ namespace Microsoft.AspNetCore.Razor.Evolution /// The . public abstract Stream Read(); + /// + /// Gets a value that determines if the file exists. + /// + public abstract bool Exists { get; } + /// /// The root relative path of the item. /// diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Resources.resx b/src/Microsoft.AspNetCore.Razor.Evolution/Resources.resx index 0b6711b754..045870c0e4 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/Resources.resx +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Resources.resx @@ -147,4 +147,7 @@ The assembly '{0}' could not be resolved or contains no tag helpers. + + Path must begin with a forward slash '/'. + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorProjectItemTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorProjectItemTest.cs index 26b830968a..ed45f6ea82 100644 --- a/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorProjectItemTest.cs +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorProjectItemTest.cs @@ -124,6 +124,8 @@ namespace Microsoft.AspNetCore.Razor.Evolution } } + public override bool Exists => true; + public override Stream Read() { throw new NotImplementedException(); diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorProjectTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorProjectTest.cs new file mode 100644 index 0000000000..714af3d0c3 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/RazorProjectTest.cs @@ -0,0 +1,223 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Evolution +{ + public class RazorProjectTest + { + [Theory] + [InlineData(null)] + [InlineData("")] + public void EnsureValidPath_ThrowsIfPathIsNullOrEmpty(string path) + { + // Arrange + var project = new TestRazorProject(new Dictionary()); + + // Act and Assert + ExceptionAssert.ThrowsArgumentNullOrEmptyString(() => project.EnsureValidPath(path), "path"); + } + + [Theory] + [InlineData("foo")] + [InlineData("~/foo")] + [InlineData("\\foo")] + public void EnsureValidPath_ThrowsIfPathDoesNotStartWithForwardSlash(string path) + { + // Arrange + var project = new TestRazorProject(new Dictionary()); + + // Act and Assert + ExceptionAssert.ThrowsArgument( + () => project.EnsureValidPath(path), + "path", + "Path must begin with a forward slash '/'."); + } + + [Fact] + public void FindHierarchicalItems_ReturnsEmptySequenceIfPathIsAtRoot() + { + // Arrange + var project = new TestRazorProject(new Dictionary()); + + // Act + var result = project.FindHierarchicalItems("/", "File.cshtml"); + + // Assert + Assert.Empty(result); + } + + [Theory] + [InlineData("_ViewStart.cshtml")] + [InlineData("_ViewImports.cshtml")] + public void FindHierarchicalItems_ReturnsItemsForPath(string fileName) + { + // Arrange + var path = "/Views/Home/Index.cshtml"; + var items = new Dictionary + { + { $"/{fileName}", CreateProjectItem($"/{fileName}") }, + { $"/Views/{fileName}", CreateProjectItem($"/Views/{fileName}") }, + { $"/Views/Home/{fileName}", CreateProjectItem($"/Views/Home/{fileName}") }, + }; + var project = new TestRazorProject(items); + + // Act + var result = project.FindHierarchicalItems(path, $"{fileName}"); + + // Assert + Assert.Collection( + result, + item => Assert.Equal($"/Views/Home/{fileName}", item.Path), + item => Assert.Equal($"/Views/{fileName}", item.Path), + item => Assert.Equal($"/{fileName}", item.Path)); + } + + [Fact] + public void FindHierarchicalItems_ReturnsItemsForPathAtRoot() + { + // Arrange + var path = "/Index.cshtml"; + var items = new Dictionary + { + { "/File.cshtml", CreateProjectItem("/File.cshtml") }, + }; + var project = new TestRazorProject(items); + + // Act + var result = project.FindHierarchicalItems(path, "File.cshtml"); + + // Assert + Assert.Collection( + result, + item => Assert.Equal("/File.cshtml", item.Path)); + } + + [Fact] + public void FindHierarchicalItems_DoesNotIncludePassedInItem() + { + // Arrange + var path = "/Areas/MyArea/Views/Home/File.cshtml"; + var items = new Dictionary + { + { "/Areas/MyArea/Views/Home/File.cshtml", CreateProjectItem("/Areas/MyArea/Views/Home/File.cshtml") }, + { "/Areas/MyArea/Views/File.cshtml", CreateProjectItem("/Areas/MyArea/Views/File.cshtml") }, + { "/Areas/MyArea/File.cshtml", CreateProjectItem("/Areas/MyArea/File.cshtml") }, + { "/Areas/File.cshtml", CreateProjectItem("/Areas/File.cshtml") }, + { "/File.cshtml", CreateProjectItem("/File.cshtml") }, + }; + var project = new TestRazorProject(items); + + // Act + var result = project.FindHierarchicalItems(path, "File.cshtml"); + + // Assert + Assert.Collection( + result, + item => Assert.Equal("/Areas/MyArea/Views/File.cshtml", item.Path), + item => Assert.Equal("/Areas/MyArea/File.cshtml", item.Path), + item => Assert.Equal("/Areas/File.cshtml", item.Path), + item => Assert.Equal("/File.cshtml", item.Path)); + } + + [Fact] + public void FindHierarchicalItems_ReturnsEmptySequenceIfPassedInItemWithFileNameIsAtRoot() + { + // Arrange + var path = "/File.cshtml"; + var items = new Dictionary + { + { "/File.cshtml", CreateProjectItem("/File.cshtml") }, + }; + var project = new TestRazorProject(items); + + // Act + var result = project.FindHierarchicalItems(path, "File.cshtml"); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void FindHierarchicalItems_IncludesNonExistentFiles() + { + // Arrange + var path = "/Areas/MyArea/Views/Home/Test.cshtml"; + var items = new Dictionary + { + { "/Areas/MyArea/File.cshtml", CreateProjectItem("/Areas/MyArea/File.cshtml") }, + { "/File.cshtml", CreateProjectItem("/File.cshtml") }, + }; + var project = new TestRazorProject(items); + + // Act + var result = project.FindHierarchicalItems(path, "File.cshtml"); + + // Assert + Assert.Collection( + result, + item => + { + Assert.Equal("/Areas/MyArea/Views/Home/File.cshtml", item.Path); + Assert.False(item.Exists); + }, + item => + { + Assert.Equal("/Areas/MyArea/Views/File.cshtml", item.Path); + Assert.False(item.Exists); + }, + item => + { + Assert.Equal("/Areas/MyArea/File.cshtml", item.Path); + Assert.True(item.Exists); + }, + item => + { + Assert.Equal("/Areas/File.cshtml", item.Path); + Assert.False(item.Exists); + }, + item => + { + Assert.Equal("/File.cshtml", item.Path); + Assert.True(item.Exists); + }); + } + + private RazorProjectItem CreateProjectItem(string path) + { + var projectItem = new Mock(); + projectItem.SetupGet(f => f.Path).Returns(path); + projectItem.SetupGet(f => f.Exists).Returns(true); + return projectItem.Object; + } + + private class TestRazorProject : RazorProject + { + private readonly Dictionary _items; + + public TestRazorProject(Dictionary items) + { + _items = items; + } + + public override IEnumerable EnumerateItems(string basePath) => throw new NotImplementedException(); + + public override RazorProjectItem GetItem(string path) + { + if (!_items.TryGetValue(path, out var item)) + { + item = new NotFoundProjectItem("", path); + } + + return item; + } + + public new void EnsureValidPath(string path) => base.EnsureValidPath(path); + } + } +}