Create new project to allow hosting the RazorProjectHost with a Roslyn workspace and minimal dependencies

This is the first part of a larger simplification, but wanted to limit the code moves for now.
This commit is contained in:
David Wengier 2023-04-04 12:02:01 +10:00 коммит произвёл David Wengier
Родитель 7e7d499920
Коммит c2af8ff1c8
8 изменённых файлов: 548 добавлений и 0 удалений

Просмотреть файл

@ -172,6 +172,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.ExternalAccess.OmniSharp", "src\Razor\src\Microsoft.AspNetCore.Razor.ExternalAccess.OmniSharp\Microsoft.AspNetCore.Razor.ExternalAccess.OmniSharp.csproj", "{3E2B6DF5-524F-4909-8A66-7F8C6383620A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace", "src\Razor\src\Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace\Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.csproj", "{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.ProjectEngineHost", "src\Razor\src\Microsoft.AspNetCore.Razor.ProjectEngineHost\Microsoft.AspNetCore.Razor.ProjectEngineHost.csproj", "{2FB4801C-A083-4F08-A4FB-C4910985DE31}"
EndProject
Global
@ -718,6 +720,22 @@ Global
{3E2B6DF5-524F-4909-8A66-7F8C6383620A}.Release|Any CPU.Build.0 = Release|Any CPU
{3E2B6DF5-524F-4909-8A66-7F8C6383620A}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU
{3E2B6DF5-524F-4909-8A66-7F8C6383620A}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.Release|Any CPU.Build.0 = Release|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.DebugNoVSIX|Any CPU.ActiveCfg = Debug|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.DebugNoVSIX|Any CPU.Build.0 = Debug|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.Release|Any CPU.Build.0 = Release|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.ReleaseNoVSIX|Any CPU.ActiveCfg = Release|Any CPU
{2FB4801C-A083-4F08-A4FB-C4910985DE31}.ReleaseNoVSIX|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -797,6 +815,8 @@ Global
{A9F9B5E5-C5C2-4860-BE56-038C70ADBAC9} = {FB7C870E-A173-4F75-BE63-4EF39C79A759}
{7400A168-2552-49C7-93E3-D4DAA90C216F} = {C2C98051-0F39-47F2-80B6-E72B29159F2C}
{3E2B6DF5-524F-4909-8A66-7F8C6383620A} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED}
{2223B8FD-D98A-47BE-94A9-6A3A6B8557B8} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED}
{2FB4801C-A083-4F08-A4FB-C4910985DE31} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0035341D-175A-4D05-95E6-F1C2785A1E26}

Просмотреть файл

@ -0,0 +1,93 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
using System.Diagnostics;
namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace;
// Copied from https://github.com/dotnet/project-system/blob/e4db47666e0a49f6c38e701f8630dbc31380fb64/src/Microsoft.VisualStudio.ProjectSystem.Managed/Threading/Tasks/CancellationSeries.cs
internal sealed class CancellationSeries : IDisposable
{
private CancellationTokenSource? _cts = new();
private readonly CancellationToken _superToken;
/// <summary>
/// Initializes a new instance of <see cref="CancellationSeries"/>.
/// </summary>
/// <param name="token">An optional cancellation token that, when cancelled, cancels the last
/// issued token and causes any subsequent tokens to be issued in a cancelled state.</param>
public CancellationSeries(CancellationToken token)
{
_superToken = token;
}
/// <summary>
/// Creates the next <see cref="CancellationToken"/> in the series, ensuring the last issued
/// token (if any) is cancelled first.
/// </summary>
/// <param name="token">An optional cancellation token that, when cancelled, cancels the
/// returned token.</param>
/// <returns>
/// A cancellation token that will be cancelled when either:
/// <list type="bullet">
/// <item><see cref="CreateNext"/> is called again</item>
/// <item>The token passed to this method (if any) is cancelled</item>
/// <item>The token passed to the constructor (if any) is cancelled</item>
/// <item><see cref="Dispose"/> is called</item>
/// </list>
/// </returns>
/// <exception cref="ObjectDisposedException">This object has been disposed.</exception>
public CancellationToken CreateNext(CancellationToken token)
{
var nextSource = CancellationTokenSource.CreateLinkedTokenSource(token, _superToken);
// Obtain the token before exchange, as otherwise the CTS may be cancelled before
// we request the Token, which will result in an ObjectDisposedException.
// This way we would return a cancelled token, which is reasonable.
var nextToken = nextSource.Token;
var priorSource = Interlocked.Exchange(ref _cts, nextSource);
if (priorSource is null)
{
nextSource.Dispose();
throw new ObjectDisposedException(nameof(CancellationSeries));
}
try
{
priorSource.Cancel();
}
finally
{
// A registered action on the token may throw, which would surface here.
// Ensure we always dispose the prior CTS.
priorSource.Dispose();
}
return nextToken;
}
public void Dispose()
{
var source = Interlocked.Exchange(ref _cts, null);
if (source is null)
{
// Already disposed
return;
}
try
{
source.Cancel();
}
finally
{
source.Dispose();
}
}
}

Просмотреть файл

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="$(MicrosoftCodeAnalysisCSharpPackageVersion)" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="$(MicrosoftCodeAnalysisWorkspacesCommonPackageVersion)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\Compiler\Microsoft.CodeAnalysis.Razor\src\Microsoft.CodeAnalysis.Razor.csproj" />
<ProjectReference Include="..\Microsoft.AspNetCore.Razor.ProjectEngineHost\Microsoft.AspNetCore.Razor.ProjectEngineHost.csproj" />
</ItemGroup>
</Project>

Просмотреть файл

@ -0,0 +1 @@
#nullable enable

Просмотреть файл

@ -0,0 +1 @@
#nullable enable

Просмотреть файл

@ -0,0 +1,215 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.ProjectEngineHost;
using Microsoft.AspNetCore.Razor.ProjectEngineHost.Serialization;
using Microsoft.AspNetCore.Razor.Telemetry;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Razor;
using Newtonsoft.Json;
namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace;
internal static class RazorProjectJsonSerializer
{
private static readonly JsonSerializer s_serializer;
private static readonly EmptyProjectEngineFactory s_fallbackProjectEngineFactory;
private static readonly StringComparison s_stringComparison;
static RazorProjectJsonSerializer()
{
s_serializer = new JsonSerializer()
{
Formatting = Formatting.Indented
};
s_serializer.Converters.RegisterProjectSerializerConverters();
s_fallbackProjectEngineFactory = new EmptyProjectEngineFactory();
s_stringComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase;
}
public static async Task SerializeAsync(Project project, string projectRazorJsonFileName, CancellationToken cancellationToken)
{
var projectPath = Path.GetDirectoryName(project.FilePath);
if (projectPath is null)
{
return;
}
var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath);
if (intermediateOutputPath is null)
{
return;
}
// First, lets get the documents, because if there aren't any, we can skip out early
var documents = GetDocuments(project, projectPath);
// Not a razor project
if (documents.Count == 0)
{
return;
}
var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default;
var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider;
var configuration = ComputeRazorConfigurationOptions(options, out var defaultNamespace);
var fileSystem = RazorProjectFileSystem.Create(projectPath);
var defaultConfigure = (RazorProjectEngineBuilder builder) =>
{
if (defaultNamespace is not null)
{
builder.SetRootNamespace(defaultNamespace);
}
builder.SetCSharpLanguageVersion(csharpLanguageVersion);
builder.SetSupportLocalizedComponentNames(); // ProjectState in MS.CA.Razor.Workspaces does this, so I'm doing it too!
};
var engine = DefaultProjectEngineFactory.Create(
configuration,
fileSystem: fileSystem,
configure: defaultConfigure,
fallback: s_fallbackProjectEngineFactory,
factories: ProjectEngineFactories.Factories);
var resolver = new CompilationTagHelperResolver(NoOpTelemetryReporter.Instance);
var tagHelpers = await resolver.GetTagHelpersAsync(project, engine, cancellationToken).ConfigureAwait(false);
var projectWorkspaceState = new ProjectWorkspaceState(
tagHelpers: tagHelpers.Descriptors!,
csharpLanguageVersion: csharpLanguageVersion);
var jsonFilePath = Path.Combine(intermediateOutputPath, projectRazorJsonFileName);
var projectRazorJson = new ProjectRazorJson(
serializedOriginFilePath: jsonFilePath,
filePath: project.FilePath!,
configuration: configuration,
rootNamespace: defaultNamespace,
projectWorkspaceState: projectWorkspaceState,
documents: documents);
WriteJsonFile(jsonFilePath, projectRazorJson);
}
private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, out string defaultNamespace)
{
// See RazorSourceGenerator.RazorProviders.cs
var globalOptions = options.GlobalOptions;
globalOptions.TryGetValue("build_property.RazorConfiguration", out var configurationName);
configurationName ??= "MVC-3.0"; // TODO: Source generator uses "default" here??
globalOptions.TryGetValue("build_property.RootNamespace", out var rootNamespace);
if (!globalOptions.TryGetValue("build_property.RazorLangVersion", out var razorLanguageVersionString) ||
!RazorLanguageVersion.TryParse(razorLanguageVersionString, out var razorLanguageVersion))
{
razorLanguageVersion = RazorLanguageVersion.Latest;
}
var razorConfiguration = RazorConfiguration.Create(razorLanguageVersion, configurationName, Enumerable.Empty<RazorExtension>(), useConsolidatedMvcViews: true);
defaultNamespace = rootNamespace ?? "ASP"; // TODO: Source generator does this. Do we want it?
return razorConfiguration;
}
private static void WriteJsonFile(string publishFilePath, ProjectRazorJson projectRazorJson)
{
// We need to avoid having an incomplete file at any point, but our
// project configuration is large enough that it will be written as multiple operations.
var tempFilePath = string.Concat(publishFilePath, ".temp");
var tempFileInfo = new FileInfo(tempFilePath);
if (tempFileInfo.Exists)
{
// This could be caused by failures during serialization or early process termination.
tempFileInfo.Delete();
}
// This needs to be in explicit brackets because the operation needs to be completed
// by the time we move the temp file into its place
using (var writer = tempFileInfo.CreateText())
{
s_serializer.Serialize(writer, projectRazorJson);
}
var fileInfo = new FileInfo(publishFilePath);
if (fileInfo.Exists)
{
fileInfo.Delete();
}
File.Move(tempFileInfo.FullName, publishFilePath);
}
private static IReadOnlyList<DocumentSnapshotHandle> GetDocuments(Project project, string projectPath)
{
var documents = new List<DocumentSnapshotHandle>(project.DocumentIds.Count);
var normalizedProjectPath = FilePathNormalizer.NormalizeDirectory(projectPath);
// We go through additional documents, because that's where the razor files will be
// We could alternatively go through the Documents and look for our virtual C# documents, that the dynamic file info
// would have added
foreach (var document in project.AdditionalDocuments)
{
if (document.FilePath is not null &&
TryGetFileKind(document.FilePath, out var kind))
{
documents.Add(new DocumentSnapshotHandle(document.FilePath, GetTargetPath(document.FilePath, normalizedProjectPath), kind));
}
}
return documents;
}
private static string GetTargetPath(string documentFilePath, string normalizedProjectPath)
{
var targetFilePath = FilePathNormalizer.Normalize(documentFilePath);
if (targetFilePath.StartsWith(normalizedProjectPath, s_stringComparison))
{
// Make relative
targetFilePath = documentFilePath.Substring(normalizedProjectPath.Length);
}
// Representing all of our host documents with a re-normalized target path to workaround GetRelatedDocument limitations.
var normalizedTargetFilePath = targetFilePath.Replace('/', '\\').TrimStart('\\');
return normalizedTargetFilePath;
}
private static bool TryGetFileKind(string? filePath, [NotNullWhen(true)] out string? fileKind)
{
var extension = Path.GetExtension(filePath);
if (string.Equals(extension, ".cshtml", s_stringComparison))
{
fileKind = FileKinds.Legacy;
return true;
}
else if (string.Equals(extension, ".razor", s_stringComparison))
{
fileKind = FileKinds.GetComponentFileKindFromFilePath(filePath);
return true;
}
fileKind = null;
return false;
}
}

Просмотреть файл

@ -0,0 +1,137 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
using System.Runtime.InteropServices;
using Microsoft.CodeAnalysis;
namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace;
public class RazorWorkspaceListener : IDisposable
{
private static readonly TimeSpan s_debounceTime = TimeSpan.FromMilliseconds(100);
private string? _projectRazorJsonFileName;
private readonly Dictionary<string, TaskDelayScheduler> _workQueues;
private readonly object _gate = new();
public RazorWorkspaceListener()
{
var comparer = RuntimeInformation.IsOSPlatform(OSPlatform.Linux)
? StringComparer.Ordinal
: StringComparer.OrdinalIgnoreCase;
_workQueues = new Dictionary<string, TaskDelayScheduler>(comparer);
}
public void EnsureInitialized(Workspace workspace, string projectRazorJsonFileName)
{
// Make sure we don't hook up the event handler multiple times
if (_projectRazorJsonFileName is not null)
{
return;
}
_projectRazorJsonFileName = projectRazorJsonFileName;
workspace.WorkspaceChanged += Workspace_WorkspaceChanged;
}
private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs e)
{
switch (e.Kind)
{
case WorkspaceChangeKind.SolutionAdded:
case WorkspaceChangeKind.SolutionChanged:
case WorkspaceChangeKind.SolutionReloaded:
foreach (var project in e.NewSolution.Projects)
{
EnqueueUpdate(project);
}
break;
case WorkspaceChangeKind.ProjectRemoved:
RemoveProject(e.OldSolution.GetProject(e.ProjectId));
break;
case WorkspaceChangeKind.ProjectAdded:
case WorkspaceChangeKind.ProjectChanged:
case WorkspaceChangeKind.ProjectReloaded:
case WorkspaceChangeKind.DocumentAdded:
case WorkspaceChangeKind.DocumentRemoved:
case WorkspaceChangeKind.DocumentReloaded:
case WorkspaceChangeKind.DocumentChanged:
case WorkspaceChangeKind.AdditionalDocumentAdded:
case WorkspaceChangeKind.AdditionalDocumentRemoved:
case WorkspaceChangeKind.AdditionalDocumentReloaded:
case WorkspaceChangeKind.AdditionalDocumentChanged:
case WorkspaceChangeKind.DocumentInfoChanged:
case WorkspaceChangeKind.AnalyzerConfigDocumentAdded:
case WorkspaceChangeKind.AnalyzerConfigDocumentRemoved:
case WorkspaceChangeKind.AnalyzerConfigDocumentReloaded:
case WorkspaceChangeKind.AnalyzerConfigDocumentChanged:
EnqueueUpdate(e.NewSolution.GetProject(e.ProjectId));
break;
case WorkspaceChangeKind.SolutionCleared:
case WorkspaceChangeKind.SolutionRemoved:
foreach (var project in e.OldSolution.Projects)
{
RemoveProject(project);
}
break;
default:
break;
}
}
private void RemoveProject(Project? project)
{
if (project?.FilePath is null)
{
return;
}
TaskDelayScheduler? scheduler;
lock (_gate)
{
if (_workQueues.TryGetValue(project.FilePath, out scheduler))
{
_workQueues.Remove(project.FilePath);
}
}
scheduler?.Dispose();
}
private void EnqueueUpdate(Project? project)
{
if (_projectRazorJsonFileName is null ||
project is not
{
FilePath: not null,
Language: LanguageNames.CSharp
})
{
return;
}
TaskDelayScheduler? scheduler;
lock (_gate)
{
if (!_workQueues.TryGetValue(project.FilePath, out scheduler))
{
scheduler = new TaskDelayScheduler(s_debounceTime, CancellationToken.None);
}
}
scheduler.ScheduleAsyncTask(ct => RazorProjectJsonSerializer.SerializeAsync(project, _projectRazorJsonFileName, ct), CancellationToken.None);
}
public void Dispose()
{
lock (_gate)
{
foreach (var kvp in _workQueues)
{
kvp.Value.Dispose();
}
}
}
}

Просмотреть файл

@ -0,0 +1,64 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace;
// Copied from https://github.com/dotnet/project-system/blob/e4db47666e0a49f6c38e701f8630dbc31380fb64/src/Microsoft.VisualStudio.ProjectSystem.Managed/Threading/Tasks/TaskDelayScheduler.cs
// (with changes to remove JTF concepts)
/// <summary>
/// Helper class which allows a task to be scheduled to run after some delay, but if a new task
/// is scheduled before the delay runs out, the previous task is cancelled.
/// </summary>
internal sealed class TaskDelayScheduler
{
private readonly TimeSpan _taskDelayTime;
private readonly CancellationSeries _cancellationSeries;
/// <summary>
/// Creates an instance of the TaskDelayScheduler. If an originalSourceToken is passed, it will be linked to the PendingUpdateTokenSource so
/// that cancelling that token will also flow through and cancel a pending update.
/// </summary>
public TaskDelayScheduler(TimeSpan taskDelayTime, CancellationToken originalSourceToken)
{
_taskDelayTime = taskDelayTime;
_cancellationSeries = new CancellationSeries(originalSourceToken);
}
public void ScheduleAsyncTask(Func<CancellationToken, Task> operation, CancellationToken token)
{
var nextToken = _cancellationSeries.CreateNext(token);
_ = Task.Run(async () =>
{
if (nextToken.IsCancellationRequested)
{
return;
}
try
{
await Task.Delay(_taskDelayTime, nextToken);
}
catch (OperationCanceledException)
{
return;
}
if (nextToken.IsCancellationRequested)
{
return;
}
await operation(nextToken);
});
}
/// <summary>
/// Cancels any pending tasks and disposes this object.
/// </summary>
public void Dispose()
{
_cancellationSeries.Dispose();
}
}