Skip to content

Commit 761db5d

Browse files
Tooling: Don't throw exceptions when generating code for file rooted outside of project (#11864)
Fixes [AzDO 2477693](https://devdiv.visualstudio.com/DevDiv/_queries/edit/2477693/?queryId=25fb5e55-8826-41b9-849a-30c1cf2d9f51) It is a perfectly valid scenario to use a file rooted outside of the project containing it. For example, a NuGet package might add Razor files as content along with "TargetPath" metadata to a project. However, recent changes broke this scenario. The incorrect change was to start calling `RazorProjectFileSystem.GetItem(...)` with the physical file path of a document. This is almost never correct because `DefaultRazorProjectFileSystem.GetItem(...)` will throw if the path passed in is rooted outside of the project root. Instead, it's more correct to pass in the document's target path, which is essentially a "logical path" to the file under the project root. When the target path is passed in, `DefaultRazorProjectFileSystem.GetItem(...)` will return a file path under the project root, which is what is needed by tooling to compute the applicable import file paths. I've fixed this issue comprehensively by correcting three other issues that I found along the way. 1. `DefaultRazorProjecFileSystem.NormalizeAndEnsureValidPath(...)` shouldn't join two rooted paths. Instead, if the path passed is rooted outside of the project root, it should just return the path. This was noticed because a rooted path was being passed in when it shouldn't have been. 2. `ProjectStateUpdate` shouldn't throw when trying to send "success" telemetry on a failure. 3. Ensure that tooling calls `RazorProjectFileSystem.GetItem(...)` with a document's target path rather than its physical file path. ---- CI Build: https://dev.azure.com/dnceng/internal/_build/results?buildId=2710729&view=results Test Insertion: https://dev.azure.com/devdiv/DevDiv/_git/VS/pullrequest/637065
2 parents 6352a32 + 719324c commit 761db5d

File tree

11 files changed

+282
-20
lines changed

11 files changed

+282
-20
lines changed

src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorProjectFileSystem.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.Diagnostics;
77
using System.IO;
8+
using Microsoft.AspNetCore.Razor.Utilities;
89

910
namespace Microsoft.AspNetCore.Razor.Language;
1011

@@ -93,6 +94,23 @@ protected override string NormalizeAndEnsureValidPath(string path)
9394

9495
var normalizedPath = path.Replace('\\', '/');
9596

97+
// On Windows, check to see if this is a rooted file path. If it is, just return it.
98+
// This covers the following cases:
99+
//
100+
// 1. It is rooted within the project root. That's valid and we would have checked
101+
// specifically for that case below.
102+
// 2. It is rooted outside of the project root. That's invalid, and we don't want to
103+
// concatenate it with the project root. That would potentially produce an invalid
104+
// Windows path like 'C:/project/C:/other-project/some-file.cshtml'.
105+
//
106+
// Note that returning a path that is rooted outside of the project root will cause
107+
// the GetItem(...) method to throw, but it could be overridden by a descendant file
108+
// system.
109+
if (PlatformInformation.IsWindows && PathUtilities.IsPathFullyQualified(path))
110+
{
111+
return normalizedPath;
112+
}
113+
96114
// Check if the given path is an absolute path. It is absolute if...
97115
//
98116
// 1. It is a network share path and starts with a '//' (e.g. //server/some/network/folder) or...

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/CompilationHelpers.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ internal static async Task<RazorCodeDocument> GenerateCodeDocumentAsync(
2020
{
2121
var importSources = await GetImportSourcesAsync(document, projectEngine, cancellationToken).ConfigureAwait(false);
2222
var tagHelpers = await document.Project.GetTagHelpersAsync(cancellationToken).ConfigureAwait(false);
23-
var source = await document.GetSourceAsync(projectEngine, cancellationToken).ConfigureAwait(false);
23+
var source = await document.GetSourceAsync(cancellationToken).ConfigureAwait(false);
2424

2525
var generator = new CodeDocumentGenerator(projectEngine, compilerOptions);
2626
return generator.Generate(source, document.FileKind, importSources, tagHelpers, cancellationToken);
@@ -33,15 +33,18 @@ internal static async Task<RazorCodeDocument> GenerateDesignTimeCodeDocumentAsyn
3333
{
3434
var importSources = await GetImportSourcesAsync(document, projectEngine, cancellationToken).ConfigureAwait(false);
3535
var tagHelpers = await document.Project.GetTagHelpersAsync(cancellationToken).ConfigureAwait(false);
36-
var source = await document.GetSourceAsync(projectEngine, cancellationToken).ConfigureAwait(false);
36+
var source = await document.GetSourceAsync(cancellationToken).ConfigureAwait(false);
3737

3838
var generator = new CodeDocumentGenerator(projectEngine, RazorCompilerOptions.None);
3939
return generator.GenerateDesignTime(source, document.FileKind, importSources, tagHelpers, cancellationToken);
4040
}
4141

4242
private static async Task<ImmutableArray<RazorSourceDocument>> GetImportSourcesAsync(IDocumentSnapshot document, RazorProjectEngine projectEngine, CancellationToken cancellationToken)
4343
{
44-
var projectItem = projectEngine.FileSystem.GetItem(document.FilePath, document.FileKind);
44+
// We don't use document.FilePath when calling into GetItem(...) because
45+
// it could be rooted outside of the project root. document.TargetPath should
46+
// represent the logical relative path within the project root.
47+
var projectItem = projectEngine.FileSystem.GetItem(document.TargetPath, document.FileKind);
4548

4649
using var importProjectItems = new PooledArrayBuilder<RazorProjectItem>();
4750
projectEngine.CollectImports(projectItem, ref importProjectItems.AsRef());

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/IDocumentSnapshotExtensions.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,12 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem;
1111

1212
internal static class IDocumentSnapshotExtensions
1313
{
14-
public static async Task<RazorSourceDocument> GetSourceAsync(this IDocumentSnapshot document, RazorProjectEngine projectEngine, CancellationToken cancellationToken)
14+
public static async Task<RazorSourceDocument> GetSourceAsync(
15+
this IDocumentSnapshot document,
16+
CancellationToken cancellationToken)
1517
{
16-
var projectItem = document is { FilePath: string filePath, FileKind: var fileKind }
17-
? projectEngine.FileSystem.GetItem(filePath, fileKind)
18-
: null;
19-
2018
var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
21-
var properties = RazorSourceDocumentProperties.Create(document.FilePath, projectItem?.RelativePhysicalPath);
19+
var properties = RazorSourceDocumentProperties.Create(document.FilePath, document.TargetPath);
2220
return RazorSourceDocument.Create(text, properties);
2321
}
2422

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Legacy/ILegacyDocumentSnapshot.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem.Legacy;
1313
/// </remarks>
1414
internal interface ILegacyDocumentSnapshot
1515
{
16+
string TargetPath { get; }
1617
RazorFileKind FileKind { get; }
1718
}

src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Discovery/ProjectStateUpdater.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,9 +311,26 @@ private void ReleaseSemaphore(ProjectKey projectKey)
311311
.ConfigureAwait(false);
312312
watch.Stop();
313313

314-
// don't report success if the work was cancelled
314+
// Don't report success if the work was cancelled
315315
cancellationToken.ThrowIfCancellationRequested();
316316

317+
// Don't report success if the call failed.
318+
// If the ImmutableArray that was returned is default, then the call failed.
319+
320+
if (tagHelpers.IsDefault)
321+
{
322+
_telemetryReporter.ReportEvent("taghelperresolve/end", Severity.Normal,
323+
new("id", telemetryId),
324+
new("result", "error"));
325+
326+
_logger.LogError($"""
327+
Tag helper discovery failed.
328+
Project: {projectSnapshot.FilePath}
329+
""");
330+
331+
return (null, configuration);
332+
}
333+
317334
_telemetryReporter.ReportEvent("taghelperresolve/end", Severity.Normal,
318335
new("id", telemetryId),
319336
new("ellapsedms", watch.ElapsedMilliseconds),

src/Razor/src/Microsoft.VisualStudio.LegacyEditor.Razor/ImportDocumentManager.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,10 +97,16 @@ public void OnUnsubscribed(IVisualStudioDocumentTracker documentTracker)
9797
private static ImmutableArray<RazorProjectItem> GetPhysicalImportItems(string filePath, ILegacyProjectSnapshot projectSnapshot)
9898
{
9999
var projectEngine = projectSnapshot.GetProjectEngine();
100-
var documentSnapshot = projectSnapshot.GetDocument(filePath);
101-
var projectItem = projectEngine.FileSystem.GetItem(filePath, documentSnapshot?.FileKind);
102100

103-
return projectEngine.GetImports(projectItem, static i => i.PhysicalPath is not null);
101+
// If we can get the document, use it's target path to find the project item
102+
// to avoid GetItem(...) throwing an exception if the file path is rooted outside
103+
// of the project. If we can't get the document, we'll just go ahead and use
104+
// the file path, since it's probably OK.
105+
var projectItem = projectSnapshot.GetDocument(filePath) is { } document
106+
? projectEngine.FileSystem.GetItem(document.TargetPath, document.FileKind)
107+
: projectEngine.FileSystem.GetItem(filePath, fileKind: null);
108+
109+
return projectEngine.GetImports(projectItem, static i => i is not DefaultImportProjectItem);
104110
}
105111

106112
private void FileChangeTracker_Changed(object sender, FileChangeEventArgs args)

src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/TestRazorProjectService.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
using Microsoft.AspNetCore.Razor.Language;
77
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
88
using Microsoft.AspNetCore.Razor.Test.Common;
9+
using Microsoft.CodeAnalysis.Razor;
910
using Microsoft.CodeAnalysis.Razor.Logging;
1011
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
1112
using Microsoft.CodeAnalysis.Razor.Serialization;
13+
using Microsoft.CodeAnalysis.Razor.Utilities;
1214
using Moq;
1315

1416
namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem;
@@ -37,13 +39,19 @@ private static IRazorProjectInfoDriver CreateProjectInfoDriver()
3739
return mock.Object;
3840
}
3941

40-
public async Task AddDocumentToPotentialProjectsAsync(string textDocumentPath, CancellationToken cancellationToken)
42+
public async Task AddDocumentToPotentialProjectsAsync(string filePath, CancellationToken cancellationToken)
4143
{
42-
var document = new DocumentSnapshotHandle(
43-
textDocumentPath, textDocumentPath, FileKinds.GetFileKindFromPath(textDocumentPath));
44-
45-
foreach (var projectSnapshot in _projectManager.FindPotentialProjects(textDocumentPath))
44+
foreach (var projectSnapshot in _projectManager.FindPotentialProjects(filePath))
4645
{
46+
var projectDirectory = FilePathNormalizer.GetNormalizedDirectoryName(projectSnapshot.FilePath);
47+
var normalizedFilePath = FilePathNormalizer.Normalize(filePath);
48+
49+
var targetPath = normalizedFilePath.StartsWith(projectDirectory, FilePathComparison.Instance)
50+
? normalizedFilePath[projectDirectory.Length..]
51+
: normalizedFilePath;
52+
53+
var document = new DocumentSnapshotHandle(filePath, targetPath, FileKinds.GetFileKindFromPath(filePath));
54+
4755
var projectInfo = projectSnapshot.ToRazorProjectInfo();
4856

4957
projectInfo = projectInfo with

src/Shared/Microsoft.AspNetCore.Razor.Test.Common/ConditionalFactAttribute.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ public static class Is
121121
/// </summary>
122122
public const string FreeBSD = nameof(FreeBSD);
123123

124+
/// <summary>
125+
/// Only execute if the current operating system platform is Unix-based.
126+
/// </summary>
127+
public const string AnyUnix = nameof(AnyUnix);
128+
124129
public static class Not
125130
{
126131
/// <summary>
@@ -142,6 +147,12 @@ public static class Not
142147
/// Only execute if the current operating system platform is not FreeBSD.
143148
/// </summary>
144149
public const string FreeBSD = $"!{nameof(FreeBSD)}";
150+
151+
/// <summary>
152+
/// Only execute if the current operating system platform is not Unix-based.
153+
/// </summary>
154+
public const string AnyUnix = $"!{nameof(AnyUnix)}";
155+
145156
}
146157
}
147158

@@ -157,6 +168,9 @@ private static FrozenDictionary<string, Func<bool>> CreateConditionMap()
157168
Add(Is.Linux, static () => PlatformInformation.IsLinux);
158169
Add(Is.MacOS, static () => PlatformInformation.IsMacOS);
159170
Add(Is.FreeBSD, static () => PlatformInformation.IsFreeBSD);
171+
Add(Is.AnyUnix, static () => PlatformInformation.IsLinux ||
172+
PlatformInformation.IsMacOS ||
173+
PlatformInformation.IsFreeBSD);
160174

161175
return map.ToFrozenDictionary();
162176

src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared.Test/PathUtilitiesTests.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,84 @@ public void GetExtension_Span(string path, string expected)
4242
Assert.Equal(!string.IsNullOrEmpty(expected), PathUtilities.HasExtension(path.AsSpan()));
4343
}
4444

45+
// The tests below are derived from the .NET Runtime:
46+
// - https://github.com/dotnet/runtime/blob/91195a7948a16c769ccaf7fd8ca84b1d210f6841/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/IO/Path.IsPathFullyQualified.cs
47+
48+
[Fact]
49+
public static void IsPathFullyQualified_NullArgument()
50+
{
51+
Assert.Throws<ArgumentNullException>(() => PathUtilities.IsPathFullyQualified(null!));
52+
}
53+
54+
[Fact]
55+
public static void IsPathFullyQualified_Empty()
56+
{
57+
Assert.False(PathUtilities.IsPathFullyQualified(""));
58+
Assert.False(PathUtilities.IsPathFullyQualified(ReadOnlySpan<char>.Empty));
59+
}
60+
61+
[ConditionalTheory(Is.Windows)]
62+
[InlineData("/")]
63+
[InlineData(@"\")]
64+
[InlineData(".")]
65+
[InlineData("C:")]
66+
[InlineData("C:foo.txt")]
67+
public static void IsPathFullyQualified_Windows_Invalid(string path)
68+
{
69+
Assert.False(PathUtilities.IsPathFullyQualified(path));
70+
Assert.False(PathUtilities.IsPathFullyQualified(path.AsSpan()));
71+
}
72+
73+
[ConditionalTheory(Is.Windows)]
74+
[InlineData(@"\\")]
75+
[InlineData(@"\\\")]
76+
[InlineData(@"\\Server")]
77+
[InlineData(@"\\Server\Foo.txt")]
78+
[InlineData(@"\\Server\Share\Foo.txt")]
79+
[InlineData(@"\\Server\Share\Test\Foo.txt")]
80+
[InlineData(@"C:\")]
81+
[InlineData(@"C:\foo1")]
82+
[InlineData(@"C:\\")]
83+
[InlineData(@"C:\\foo2")]
84+
[InlineData(@"C:/")]
85+
[InlineData(@"C:/foo1")]
86+
[InlineData(@"C://")]
87+
[InlineData(@"C://foo2")]
88+
public static void IsPathFullyQualified_Windows_Valid(string path)
89+
{
90+
Assert.True(PathUtilities.IsPathFullyQualified(path));
91+
Assert.True(PathUtilities.IsPathFullyQualified(path.AsSpan()));
92+
}
93+
94+
[ConditionalTheory(Is.AnyUnix)]
95+
[InlineData(@"\")]
96+
[InlineData(@"\\")]
97+
[InlineData(".")]
98+
[InlineData("./foo.txt")]
99+
[InlineData("..")]
100+
[InlineData("../foo.txt")]
101+
[InlineData(@"C:")]
102+
[InlineData(@"C:/")]
103+
[InlineData(@"C://")]
104+
public static void IsPathFullyQualified_Unix_Invalid(string path)
105+
{
106+
Assert.False(PathUtilities.IsPathFullyQualified(path));
107+
Assert.False(PathUtilities.IsPathFullyQualified(path.AsSpan()));
108+
}
109+
110+
[ConditionalTheory(Is.AnyUnix)]
111+
[InlineData("/")]
112+
[InlineData("/foo.txt")]
113+
[InlineData("/..")]
114+
[InlineData("//")]
115+
[InlineData("//foo.txt")]
116+
[InlineData("//..")]
117+
public static void IsPathFullyQualified_Unix_Valid(string path)
118+
{
119+
Assert.True(PathUtilities.IsPathFullyQualified(path));
120+
Assert.True(PathUtilities.IsPathFullyQualified(path.AsSpan()));
121+
}
122+
45123
private static void AssertEqual(ReadOnlySpan<char> expected, ReadOnlySpan<char> actual)
46124
{
47125
if (!actual.SequenceEqual(expected))

src/Shared/Microsoft.AspNetCore.Razor.Utilities.Shared/PathUtilities.cs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public static ReadOnlySpan<char> GetExtension(ReadOnlySpan<char> path)
3636
{
3737
return i != length - 1
3838
? path[i..length]
39-
: [];
39+
: [];
4040
}
4141

4242
if (IsDirectorySeparator(ch))
@@ -70,5 +70,95 @@ public static bool HasExtension(ReadOnlySpan<char> path)
7070
private static bool IsDirectorySeparator(char ch)
7171
=> ch == Path.DirectorySeparatorChar ||
7272
(PlatformInformation.IsWindows && ch == Path.AltDirectorySeparatorChar);
73+
74+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
75+
private static bool IsValidDriveChar(char value)
76+
=> (uint)((value | 0x20) - 'a') <= (uint)('z' - 'a');
77+
#endif
78+
79+
public static bool IsPathFullyQualified(string path)
80+
{
81+
ArgHelper.ThrowIfNull(path);
82+
83+
return IsPathFullyQualified(path.AsSpan());
84+
}
85+
86+
public static bool IsPathFullyQualified(ReadOnlySpan<char> path)
87+
{
88+
#if NET
89+
return Path.IsPathFullyQualified(path);
90+
#else
91+
if (PlatformInformation.IsWindows)
92+
{
93+
// Derived from .NET Runtime:
94+
// - https://github.com/dotnet/runtime/blob/c7c961a330395152e5ec4000032cd3204ceb4a10/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L250-L274
95+
96+
if (path.Length < 2)
97+
{
98+
// It isn't fixed, it must be relative. There is no way to specify a fixed
99+
// path with one character (or less).
100+
return false;
101+
}
102+
103+
if (IsDirectorySeparator(path[0]))
104+
{
105+
// There is no valid way to specify a relative path with two initial slashes or
106+
// \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\
107+
return path[1] == '?' || IsDirectorySeparator(path[1]);
108+
}
109+
110+
// The only way to specify a fixed path that doesn't begin with two slashes
111+
// is the drive, colon, slash format- i.e. C:\
112+
return (path.Length >= 3)
113+
&& (path[1] == Path.VolumeSeparatorChar)
114+
&& IsDirectorySeparator(path[2])
115+
// To match old behavior we'll check the drive character for validity as the path is technically
116+
// not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream.
117+
&& IsValidDriveChar(path[0]);
118+
}
119+
else
120+
{
121+
// Derived from .NET Runtime:
122+
// - https://github.com/dotnet/runtime/blob/c7c961a330395152e5ec4000032cd3204ceb4a10/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L77-L82
123+
124+
// This is much simpler than Windows where paths can be rooted, but not fully qualified (such as Drive Relative)
125+
// As long as the path is rooted in Unix it doesn't use the current directory and therefore is fully qualified.
126+
return IsPathRooted(path);
127+
}
128+
#endif
129+
}
130+
131+
public static bool IsPathRooted(string path)
132+
{
133+
#if NET
134+
return Path.IsPathRooted(path);
135+
#else
136+
return IsPathRooted(path.AsSpan());
137+
#endif
138+
}
139+
140+
public static bool IsPathRooted(ReadOnlySpan<char> path)
141+
{
142+
#if NET
143+
return Path.IsPathRooted(path);
144+
145+
#else
146+
if (PlatformInformation.IsWindows)
147+
{
148+
// Derived from .NET Runtime
149+
// - https://github.com/dotnet/runtime/blob/850c0ab4519b904a28f2d67abdaba1ac78c955ff/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs#L271-L276
150+
151+
var length = path.Length;
152+
return (length >= 1 && IsDirectorySeparator(path[0]))
153+
|| (length >= 2 && IsValidDriveChar(path[0]) && path[1] == Path.VolumeSeparatorChar);
154+
}
155+
else
156+
{
157+
// Derived from .NET Runtime
158+
// - https://github.com/dotnet/runtime/blob/850c0ab4519b904a28f2d67abdaba1ac78c955ff/src/libraries/System.Private.CoreLib/src/System/IO/Path.Unix.cs#L132-L135
159+
160+
return path.StartsWith(Path.DirectorySeparatorChar);
161+
}
73162
#endif
74163
}
164+
}

0 commit comments

Comments
 (0)