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);
+ }
+ }
+}