Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using Microsoft.AspNetCore.Razor.Utilities;

namespace Microsoft.AspNetCore.Razor.Language;

Expand Down Expand Up @@ -93,6 +94,23 @@ protected override string NormalizeAndEnsureValidPath(string path)

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

// On Windows, check to see if this is a rooted file path. If it is, just return it.
// This covers the following cases:
//
// 1. It is rooted within the project root. That's valid and we would have checked
// specifically for that case below.
// 2. It is rooted outside of the project root. That's invalid, and we don't want to
// concatenate it with the project root. That would potentially produce an invalid
// Windows path like 'C:/project/C:/other-project/some-file.cshtml'.
//
// Note that returning a path that is rooted outside of the project root will cause
// the GetItem(...) method to throw, but it could be overridden by a descendant file
// system.
if (PlatformInformation.IsWindows && PathUtilities.IsPathFullyQualified(path))
{
return normalizedPath;
}

// Check if the given path is an absolute path. It is absolute if...
//
// 1. It is a network share path and starts with a '//' (e.g. //server/some/network/folder) or...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal static async Task<RazorCodeDocument> GenerateCodeDocumentAsync(
{
var importSources = await GetImportSourcesAsync(document, projectEngine, cancellationToken).ConfigureAwait(false);
var tagHelpers = await document.Project.GetTagHelpersAsync(cancellationToken).ConfigureAwait(false);
var source = await document.GetSourceAsync(projectEngine, cancellationToken).ConfigureAwait(false);
var source = await document.GetSourceAsync(cancellationToken).ConfigureAwait(false);

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

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

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

using var importProjectItems = new PooledArrayBuilder<RazorProjectItem>();
projectEngine.CollectImports(projectItem, ref importProjectItems.AsRef());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,12 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem;

internal static class IDocumentSnapshotExtensions
{
public static async Task<RazorSourceDocument> GetSourceAsync(this IDocumentSnapshot document, RazorProjectEngine projectEngine, CancellationToken cancellationToken)
public static async Task<RazorSourceDocument> GetSourceAsync(
this IDocumentSnapshot document,
CancellationToken cancellationToken)
{
var projectItem = document is { FilePath: string filePath, FileKind: var fileKind }
? projectEngine.FileSystem.GetItem(filePath, fileKind)
: null;

var text = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);
var properties = RazorSourceDocumentProperties.Create(document.FilePath, projectItem?.RelativePhysicalPath);
var properties = RazorSourceDocumentProperties.Create(document.FilePath, document.TargetPath);
return RazorSourceDocument.Create(text, properties);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem.Legacy;
/// </remarks>
internal interface ILegacyDocumentSnapshot
{
string TargetPath { get; }
RazorFileKind FileKind { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,26 @@ private void ReleaseSemaphore(ProjectKey projectKey)
.ConfigureAwait(false);
watch.Stop();

// don't report success if the work was cancelled
// Don't report success if the work was cancelled
cancellationToken.ThrowIfCancellationRequested();

// Don't report success if the call failed.
// If the ImmutableArray that was returned is default, then the call failed.

if (tagHelpers.IsDefault)
{
_telemetryReporter.ReportEvent("taghelperresolve/end", Severity.Normal,
new("id", telemetryId),
new("result", "error"));

_logger.LogError($"""
Tag helper discovery failed.
Project: {projectSnapshot.FilePath}
""");

return (null, configuration);
}

_telemetryReporter.ReportEvent("taghelperresolve/end", Severity.Normal,
new("id", telemetryId),
new("ellapsedms", watch.ElapsedMilliseconds),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,16 @@ public void OnUnsubscribed(IVisualStudioDocumentTracker documentTracker)
private static ImmutableArray<RazorProjectItem> GetPhysicalImportItems(string filePath, ILegacyProjectSnapshot projectSnapshot)
{
var projectEngine = projectSnapshot.GetProjectEngine();
var documentSnapshot = projectSnapshot.GetDocument(filePath);
var projectItem = projectEngine.FileSystem.GetItem(filePath, documentSnapshot?.FileKind);

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

return projectEngine.GetImports(projectItem, static i => i is not DefaultImportProjectItem);
}

private void FileChangeTracker_Changed(object sender, FileChangeEventArgs args)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.Test.Common;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Serialization;
using Microsoft.CodeAnalysis.Razor.Utilities;
using Moq;

namespace Microsoft.AspNetCore.Razor.LanguageServer.ProjectSystem;
Expand Down Expand Up @@ -37,13 +39,19 @@ private static IRazorProjectInfoDriver CreateProjectInfoDriver()
return mock.Object;
}

public async Task AddDocumentToPotentialProjectsAsync(string textDocumentPath, CancellationToken cancellationToken)
public async Task AddDocumentToPotentialProjectsAsync(string filePath, CancellationToken cancellationToken)
{
var document = new DocumentSnapshotHandle(
textDocumentPath, textDocumentPath, FileKinds.GetFileKindFromPath(textDocumentPath));

foreach (var projectSnapshot in _projectManager.FindPotentialProjects(textDocumentPath))
foreach (var projectSnapshot in _projectManager.FindPotentialProjects(filePath))
{
var projectDirectory = FilePathNormalizer.GetNormalizedDirectoryName(projectSnapshot.FilePath);
var normalizedFilePath = FilePathNormalizer.Normalize(filePath);

var targetPath = normalizedFilePath.StartsWith(projectDirectory, FilePathComparison.Instance)
? normalizedFilePath[projectDirectory.Length..]
: normalizedFilePath;

var document = new DocumentSnapshotHandle(filePath, targetPath, FileKinds.GetFileKindFromPath(filePath));

var projectInfo = projectSnapshot.ToRazorProjectInfo();

projectInfo = projectInfo with
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ public static class Is
/// </summary>
public const string FreeBSD = nameof(FreeBSD);

/// <summary>
/// Only execute if the current operating system platform is Unix-based.
/// </summary>
public const string AnyUnix = nameof(AnyUnix);

public static class Not
{
/// <summary>
Expand All @@ -142,6 +147,12 @@ public static class Not
/// Only execute if the current operating system platform is not FreeBSD.
/// </summary>
public const string FreeBSD = $"!{nameof(FreeBSD)}";

/// <summary>
/// Only execute if the current operating system platform is not Unix-based.
/// </summary>
public const string AnyUnix = $"!{nameof(AnyUnix)}";

}
}

Expand All @@ -157,6 +168,9 @@ private static FrozenDictionary<string, Func<bool>> CreateConditionMap()
Add(Is.Linux, static () => PlatformInformation.IsLinux);
Add(Is.MacOS, static () => PlatformInformation.IsMacOS);
Add(Is.FreeBSD, static () => PlatformInformation.IsFreeBSD);
Add(Is.AnyUnix, static () => PlatformInformation.IsLinux ||
PlatformInformation.IsMacOS ||
PlatformInformation.IsFreeBSD);

return map.ToFrozenDictionary();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,84 @@ public void GetExtension_Span(string path, string expected)
Assert.Equal(!string.IsNullOrEmpty(expected), PathUtilities.HasExtension(path.AsSpan()));
}

// The tests below are derived from the .NET Runtime:
// - https://github.com/dotnet/runtime/blob/91195a7948a16c769ccaf7fd8ca84b1d210f6841/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/IO/Path.IsPathFullyQualified.cs

[Fact]
public static void IsPathFullyQualified_NullArgument()
{
Assert.Throws<ArgumentNullException>(() => PathUtilities.IsPathFullyQualified(null!));
}

[Fact]
public static void IsPathFullyQualified_Empty()
{
Assert.False(PathUtilities.IsPathFullyQualified(""));
Assert.False(PathUtilities.IsPathFullyQualified(ReadOnlySpan<char>.Empty));
}

[ConditionalTheory(Is.Windows)]
[InlineData("/")]
[InlineData(@"\")]
[InlineData(".")]
[InlineData("C:")]
[InlineData("C:foo.txt")]
public static void IsPathFullyQualified_Windows_Invalid(string path)
{
Assert.False(PathUtilities.IsPathFullyQualified(path));
Assert.False(PathUtilities.IsPathFullyQualified(path.AsSpan()));
}

[ConditionalTheory(Is.Windows)]
[InlineData(@"\\")]
[InlineData(@"\\\")]
[InlineData(@"\\Server")]
[InlineData(@"\\Server\Foo.txt")]
[InlineData(@"\\Server\Share\Foo.txt")]
[InlineData(@"\\Server\Share\Test\Foo.txt")]
[InlineData(@"C:\")]
[InlineData(@"C:\foo1")]
[InlineData(@"C:\\")]
[InlineData(@"C:\\foo2")]
[InlineData(@"C:/")]
[InlineData(@"C:/foo1")]
[InlineData(@"C://")]
[InlineData(@"C://foo2")]
public static void IsPathFullyQualified_Windows_Valid(string path)
{
Assert.True(PathUtilities.IsPathFullyQualified(path));
Assert.True(PathUtilities.IsPathFullyQualified(path.AsSpan()));
}

[ConditionalTheory(Is.AnyUnix)]
[InlineData(@"\")]
[InlineData(@"\\")]
[InlineData(".")]
[InlineData("./foo.txt")]
[InlineData("..")]
[InlineData("../foo.txt")]
[InlineData(@"C:")]
[InlineData(@"C:/")]
[InlineData(@"C://")]
public static void IsPathFullyQualified_Unix_Invalid(string path)
{
Assert.False(PathUtilities.IsPathFullyQualified(path));
Assert.False(PathUtilities.IsPathFullyQualified(path.AsSpan()));
}

[ConditionalTheory(Is.AnyUnix)]
[InlineData("/")]
[InlineData("/foo.txt")]
[InlineData("/..")]
[InlineData("//")]
[InlineData("//foo.txt")]
[InlineData("//..")]
public static void IsPathFullyQualified_Unix_Valid(string path)
{
Assert.True(PathUtilities.IsPathFullyQualified(path));
Assert.True(PathUtilities.IsPathFullyQualified(path.AsSpan()));
}

private static void AssertEqual(ReadOnlySpan<char> expected, ReadOnlySpan<char> actual)
{
if (!actual.SequenceEqual(expected))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public static ReadOnlySpan<char> GetExtension(ReadOnlySpan<char> path)
{
return i != length - 1
? path[i..length]
: [];
: [];
}

if (IsDirectorySeparator(ch))
Expand Down Expand Up @@ -70,5 +70,95 @@ public static bool HasExtension(ReadOnlySpan<char> path)
private static bool IsDirectorySeparator(char ch)
=> ch == Path.DirectorySeparatorChar ||
(PlatformInformation.IsWindows && ch == Path.AltDirectorySeparatorChar);

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsValidDriveChar(char value)
=> (uint)((value | 0x20) - 'a') <= (uint)('z' - 'a');
#endif

public static bool IsPathFullyQualified(string path)
{
ArgHelper.ThrowIfNull(path);

return IsPathFullyQualified(path.AsSpan());
}

public static bool IsPathFullyQualified(ReadOnlySpan<char> path)
{
#if NET
return Path.IsPathFullyQualified(path);
#else
if (PlatformInformation.IsWindows)
{
// Derived from .NET Runtime:
// - https://github.com/dotnet/runtime/blob/c7c961a330395152e5ec4000032cd3204ceb4a10/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L250-L274

if (path.Length < 2)
{
// It isn't fixed, it must be relative. There is no way to specify a fixed
// path with one character (or less).
return false;
}

if (IsDirectorySeparator(path[0]))
{
// There is no valid way to specify a relative path with two initial slashes or
// \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\
return path[1] == '?' || IsDirectorySeparator(path[1]);
}

// The only way to specify a fixed path that doesn't begin with two slashes
// is the drive, colon, slash format- i.e. C:\
return (path.Length >= 3)
&& (path[1] == Path.VolumeSeparatorChar)
&& IsDirectorySeparator(path[2])
// To match old behavior we'll check the drive character for validity as the path is technically
// not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream.
&& IsValidDriveChar(path[0]);
}
else
{
// Derived from .NET Runtime:
// - https://github.com/dotnet/runtime/blob/c7c961a330395152e5ec4000032cd3204ceb4a10/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L77-L82

// This is much simpler than Windows where paths can be rooted, but not fully qualified (such as Drive Relative)
// As long as the path is rooted in Unix it doesn't use the current directory and therefore is fully qualified.
return IsPathRooted(path);
}
#endif
}

public static bool IsPathRooted(string path)
{
#if NET
return Path.IsPathRooted(path);
#else
return IsPathRooted(path.AsSpan());
#endif
}

public static bool IsPathRooted(ReadOnlySpan<char> path)
{
#if NET
return Path.IsPathRooted(path);

#else
if (PlatformInformation.IsWindows)
{
// Derived from .NET Runtime
// - https://github.com/dotnet/runtime/blob/850c0ab4519b904a28f2d67abdaba1ac78c955ff/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs#L271-L276

var length = path.Length;
return (length >= 1 && IsDirectorySeparator(path[0]))
|| (length >= 2 && IsValidDriveChar(path[0]) && path[1] == Path.VolumeSeparatorChar);
}
else
{
// Derived from .NET Runtime
// - https://github.com/dotnet/runtime/blob/850c0ab4519b904a28f2d67abdaba1ac78c955ff/src/libraries/System.Private.CoreLib/src/System/IO/Path.Unix.cs#L132-L135

return path.StartsWith(Path.DirectorySeparatorChar);
}
#endif
}
}
Loading
Loading