diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs index a1b1ba4b487..70356d7d1fa 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs @@ -1,8 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Razor.Language; @@ -21,6 +25,10 @@ public sealed record class RazorConfiguration( LanguageServerFlags: null, UseConsolidatedMvcViews: true); + private Checksum? _checksum; + internal Checksum Checksum + => _checksum ?? InterlockedOperations.Initialize(ref _checksum, CalculateChecksum()); + public bool Equals(RazorConfiguration? other) => other is not null && LanguageVersion == other.LanguageVersion && @@ -39,4 +47,33 @@ public override int GetHashCode() hash.Add(LanguageServerFlags); return hash; } + + internal void AppendChecksum(Checksum.Builder builder) + { + builder.AppendData(LanguageVersion.Major); + builder.AppendData(LanguageVersion.Minor); + builder.AppendData(ConfigurationName); + builder.AppendData(UseConsolidatedMvcViews); + + if (LanguageServerFlags is null) + { + builder.AppendNull(); + } + else + { + builder.AppendData(LanguageServerFlags.ForceRuntimeCodeGeneration); + } + + foreach (var extension in Extensions) + { + builder.AppendData(extension.ExtensionName); + } + } + + private Checksum CalculateChecksum() + { + var builder = new Checksum.Builder(); + AppendChecksum(builder); + return builder.FreeAndGetChecksum(); + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs new file mode 100644 index 00000000000..b6a612b952a --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.Work.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; + +public abstract partial class RazorWorkspaceListenerBase +{ + internal abstract record Work(ProjectId ProjectId); + internal sealed record UpdateWork(ProjectId ProjectId) : Work(ProjectId); + internal sealed record RemovalWork(ProjectId ProjectId, string IntermediateOutputPath) : Work(ProjectId); +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs index 7ab3ecf2a4b..1a1c0e4952c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorWorkspaceListenerBase.cs @@ -3,19 +3,24 @@ using System.Collections.Immutable; using System.Diagnostics; +using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; -public abstract class RazorWorkspaceListenerBase : IDisposable +public abstract partial class RazorWorkspaceListenerBase : IDisposable { private static readonly TimeSpan s_debounceTime = TimeSpan.FromMilliseconds(500); private readonly CancellationTokenSource _disposeTokenSource = new(); private readonly ILogger _logger; private readonly AsyncBatchingWorkQueue _workQueue; + private readonly CompilationTagHelperResolver _tagHelperResolver = new(NoOpTelemetryReporter.Instance); + + // Only modified in the batching work queue so no need to lock for mutation + private readonly Dictionary _projectChecksums = new(); // Use an immutable dictionary for ImmutableInterlocked operations. The value isn't checked, just // the existance of the key so work is only done for projects with dynamic files. @@ -23,11 +28,6 @@ public abstract class RazorWorkspaceListenerBase : IDisposable private Stream? _stream; private Workspace? _workspace; - private bool _disposed; - - internal record Work(ProjectId ProjectId); - internal record UpdateWork(ProjectId ProjectId) : Work(ProjectId); - internal record RemovalWork(ProjectId ProjectId, string IntermediateOutputPath) : Work(ProjectId); private protected RazorWorkspaceListenerBase(ILogger logger) { @@ -42,22 +42,21 @@ void IDisposable.Dispose() if (_workspace is not null) { _workspace.WorkspaceChanged -= Workspace_WorkspaceChanged; + _workspace = null; } - if (_disposed) + if (_disposeTokenSource.IsCancellationRequested) { _logger.LogInformation("Disposal was called twice"); return; } - _disposed = true; _logger.LogInformation("Tearing down named pipe for pid {pid}", Process.GetCurrentProcess().Id); _disposeTokenSource.Cancel(); _disposeTokenSource.Dispose(); _stream?.Dispose(); - _stream = null; } public void NotifyDynamicFile(ProjectId projectId) @@ -93,7 +92,7 @@ private protected void EnsureInitialized(Workspace workspace, Func creat } // Early check for disposal just to reduce any work further - if (_disposed) + if (_disposeTokenSource.IsCancellationRequested) { return; } @@ -173,7 +172,7 @@ private void Workspace_WorkspaceChanged(object? sender, WorkspaceChangeEventArgs // void EnqueueUpdate(Project? project) { - if (_disposed || + if (_disposeTokenSource.IsCancellationRequested || project is not { Language: LanguageNames.CSharp @@ -195,7 +194,7 @@ void RemoveProject(Project project) { // Remove project is called from Workspace.Changed, while other notifications of _projectsWithDynamicFile // are handled with NotifyDynamicFile. Use ImmutableInterlocked here to be sure the updates happen - // in a thread safe manner since those are not assumed to be the same thread. + // in a thread safe manner since those are not assumed to be the same thread. if (ImmutableInterlocked.TryRemove(ref _projectsWithDynamicFile, project.Id, out var _)) { var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath); @@ -219,50 +218,47 @@ void RemoveProject(Project project) /// private protected async virtual ValueTask ProcessWorkAsync(ImmutableArray work, CancellationToken cancellationToken) { - // Capture as locals here. Cancellation of the work queue still need to propogate. The cancellation - // token itself represents the work queue halting, but this will help avoid any assumptions about nullability of locals - // through the use in this function. - var stream = _stream; - var solution = _workspace?.CurrentSolution; - - cancellationToken.ThrowIfCancellationRequested(); - - // Early bail check for if we are disposed or somewhere in the middle of disposal - if (_disposed || stream is null || solution is null) + if (cancellationToken.IsCancellationRequested) { _logger.LogTrace("Skipping work due to disposal"); return; } + var stream = _stream.AssumeNotNull(); + var solution = _workspace.AssumeNotNull().CurrentSolution; + await CheckConnectionAsync(stream, cancellationToken).ConfigureAwait(false); - await ProcessWorkCoreAsync(work, stream, solution, _logger, cancellationToken).ConfigureAwait(false); + await ProcessWorkCoreAsync(work, stream, solution, cancellationToken).ConfigureAwait(false); } - private static async Task ProcessWorkCoreAsync(ImmutableArray work, Stream stream, Solution solution, ILogger logger, CancellationToken cancellationToken) + private async Task ProcessWorkCoreAsync(ImmutableArray work, Stream stream, Solution solution, CancellationToken cancellationToken) { foreach (var unit in work) { try { - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return; + } if (unit is RemovalWork removalWork) { - await ReportRemovalAsync(stream, removalWork, logger, cancellationToken).ConfigureAwait(false); + await ReportRemovalAsync(stream, removalWork, _logger, cancellationToken).ConfigureAwait(false); } var project = solution.GetProject(unit.ProjectId); if (project is null) { - logger.LogTrace("Project {projectId} is not in workspace", unit.ProjectId); + _logger.LogTrace("Project {projectId} is not in workspace", unit.ProjectId); continue; } - await ReportUpdateProjectAsync(stream, project, logger, cancellationToken).ConfigureAwait(false); + await ReportUpdateProjectAsync(stream, project, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { - logger.LogError(ex, "Encountered exception while processing unit: {message}", ex.Message); + _logger.LogError(ex, "Encountered exception while processing unit: {message}", ex.Message); } } @@ -272,22 +268,37 @@ private static async Task ProcessWorkCoreAsync(ImmutableArray work, Stream } catch (Exception ex) { - logger.LogError(ex, "Encountered error flusingh stream"); + _logger.LogError(ex, "Encountered error flushing stream"); } } - private static async Task ReportUpdateProjectAsync(Stream stream, Project project, ILogger logger, CancellationToken cancellationToken) + private async Task ReportUpdateProjectAsync(Stream stream, Project project, CancellationToken cancellationToken) { - logger.LogTrace("Serializing information for {projectId}", project.Id); - var projectInfo = await RazorProjectInfoFactory.ConvertAsync(project, logger, cancellationToken).ConfigureAwait(false); - if (projectInfo is null) + _logger.LogTrace("Serializing information for {projectId}", project.Id); + var projectPath = Path.GetDirectoryName(project.FilePath); + if (projectPath is null) { - logger.LogTrace("Skipped writing data for {projectId}", project.Id); + _logger.LogInformation("projectPath is null, skip update for {projectId}", project.Id); return; } - stream.WriteProjectInfoAction(ProjectInfoAction.Update); - await stream.WriteProjectInfoAsync(projectInfo, cancellationToken).ConfigureAwait(false); + var checksum = _projectChecksums.GetOrAdd(project.Id, static _ => null); + var projectEngine = RazorProjectInfoHelpers.GetProjectEngine(project, projectPath); + var tagHelpers = await _tagHelperResolver.GetTagHelpersAsync(project, projectEngine, cancellationToken).ConfigureAwait(false); + var projectInfo = RazorProjectInfoHelpers.TryConvert(project, projectPath, tagHelpers); + if (projectInfo is not null) + { + if (checksum == projectInfo.Checksum) + { + _logger.LogInformation("Checksum for {projectId} did not change. Skipped sending update", project.Id); + return; + } + + _projectChecksums[project.Id] = projectInfo.Checksum; + + stream.WriteProjectInfoAction(ProjectInfoAction.Update); + await stream.WriteProjectInfoAsync(projectInfo, cancellationToken).ConfigureAwait(false); + } } private static Task ReportRemovalAsync(Stream stream, RemovalWork unit, ILogger logger, CancellationToken cancellationToken) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectExtensions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectExtensions.cs new file mode 100644 index 00000000000..5bff1fb7f44 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +#if !NET +using System; +#endif + +using System.Diagnostics; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Utilities; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor; + +internal static class ProjectExtensions +{ + public static ProjectKey ToProjectKey(this Project project) + { + var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(project.CompilationOutputInfo.AssemblyPath); + return new(intermediateOutputPath); + } + + /// + /// Returns if this matches the given . + /// + public static bool Matches(this ProjectKey projectKey, Project project) + { + // In order to perform this check, we are relying on the fact that Id will always end with a '/', + // because it is guaranteed to be normalized. However, CompilationOutputInfo.AssemblyPath will + // contain the assembly file name, which AreDirectoryPathsEquivalent will shave off before comparing. + // So, AreDirectoryPathsEquivalent will return true when Id is "C:/my/project/path/" + // and the assembly path is "C:\my\project\path\assembly.dll" + + Debug.Assert(projectKey.Id.EndsWith('/'), $"This method can't be called if {nameof(projectKey.Id)} is not a normalized directory path."); + + return FilePathNormalizer.AreDirectoryPathsEquivalent(projectKey.Id, project.CompilationOutputInfo.AssemblyPath); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs index b4c2c9548fb..b775aea7491 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/ProjectWorkspaceState.cs @@ -5,6 +5,7 @@ using System.Collections.Immutable; using System.Linq; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis.CSharp; using Microsoft.Extensions.Internal; @@ -54,4 +55,13 @@ public override int GetHashCode() return hash.CombinedHash; } + + internal void AppendChecksum(Checksum.Builder builder) + { + builder.AppendData((int)CSharpLanguageVersion); + foreach (var tagHelper in TagHelpers) + { + builder.AppendData(tagHelper.Checksum); + } + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs index 86158c6ceb9..d38ddcd1d11 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/ProjectSystem/RazorProjectInfo.cs @@ -1,30 +1,16 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -using System; -using System.Buffers; using System.Collections.Immutable; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MessagePack; -using MessagePack.Resolvers; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Serialization; -using Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; +using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; -using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Razor.ProjectSystem; internal sealed record class RazorProjectInfo { - private static readonly MessagePackSerializerOptions s_options = MessagePackSerializerOptions.Standard - .WithResolver(CompositeResolver.Create( - RazorProjectInfoResolver.Instance, - StandardResolver.Instance)); - public ProjectKey ProjectKey { get; init; } public string FilePath { get; init; } public RazorConfiguration Configuration { get; init; } @@ -33,6 +19,10 @@ internal sealed record class RazorProjectInfo public ProjectWorkspaceState ProjectWorkspaceState { get; init; } public ImmutableArray Documents { get; init; } + private Checksum? _checksum; + internal Checksum Checksum + => _checksum ?? InterlockedOperations.Initialize(ref _checksum, ComputeChecksum()); + public RazorProjectInfo( ProjectKey projectKey, string filePath, @@ -51,46 +41,29 @@ public RazorProjectInfo( Documents = documents.NullToEmpty(); } - public bool Equals(RazorProjectInfo? other) - => other is not null && - ProjectKey == other.ProjectKey && - FilePath == other.FilePath && - Configuration.Equals(other.Configuration) && - RootNamespace == other.RootNamespace && - DisplayName == other.DisplayName && - ProjectWorkspaceState.Equals(other.ProjectWorkspaceState) && - Documents.SequenceEqual(other.Documents); + public bool Equals(RazorConfiguration? other) + => other is not null && Checksum.Equals(other.Checksum); public override int GetHashCode() - { - var hash = HashCodeCombiner.Start(); + => Checksum.GetHashCode(); - hash.Add(ProjectKey); - hash.Add(FilePath); - hash.Add(Configuration); - hash.Add(RootNamespace); - hash.Add(DisplayName); - hash.Add(ProjectWorkspaceState); - hash.Add(Documents); - - return hash.CombinedHash; - } - - public byte[] Serialize() - => MessagePackSerializer.Serialize(this, s_options); - - public void SerializeTo(IBufferWriter bufferWriter) - => MessagePackSerializer.Serialize(bufferWriter, this, s_options); + private Checksum ComputeChecksum() + { + var builder = new Checksum.Builder(); - public void SerializeTo(Stream stream) - => MessagePackSerializer.Serialize(stream, this, s_options); + builder.AppendData(FilePath); + builder.AppendData(ProjectKey.Id); + builder.AppendData(DisplayName); + builder.AppendData(RootNamespace); - public static RazorProjectInfo? DeserializeFrom(ReadOnlyMemory buffer) - => MessagePackSerializer.Deserialize(buffer, s_options); + Configuration.AppendChecksum(builder); + foreach (var document in Documents) + { + document.AppendChecksum(builder); + } - public static RazorProjectInfo? DeserializeFrom(Stream stream) - => MessagePackSerializer.Deserialize(stream, s_options); + ProjectWorkspaceState.AppendChecksum(builder); - public static ValueTask DeserializeFromAsync(Stream stream, CancellationToken cancellationToken) - => MessagePackSerializer.DeserializeAsync(stream, s_options, cancellationToken); + return builder.FreeAndGetChecksum(); + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoFactory.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs similarity index 68% rename from src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoFactory.cs rename to src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs index c99a25ec283..ff0a4dc7ab6 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace/RazorProjectInfoFactory.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/RazorProjectInfoHelpers.cs @@ -1,9 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. +using System; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -using System.Runtime.InteropServices; +using System.IO; +using System.Threading; +using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.ProjectEngineHost; @@ -15,60 +18,43 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Razor; -using Microsoft.Extensions.Logging; -namespace Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace; +namespace Microsoft.AspNetCore.Razor; -internal static class RazorProjectInfoFactory +internal static class RazorProjectInfoHelpers { - private static readonly StringComparison s_stringComparison; - - static RazorProjectInfoFactory() - { - s_stringComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - } - - public static async Task ConvertAsync(Project project, ILogger? logger, CancellationToken cancellationToken) + public static RazorProjectInfo? TryConvert( + Project project, + string projectPath, + ImmutableArray tagHelpers) { - var projectPath = Path.GetDirectoryName(project.FilePath); - if (projectPath is null) - { - logger?.LogInformation("projectPath is null, skip conversion for {projectId}", project.Id); - return null; - } - - var intermediateOutputPath = Path.GetDirectoryName(project.CompilationOutputInfo.AssemblyPath); - if (intermediateOutputPath is null) - { - logger?.LogInformation("intermediatePath is null, skip conversion for {projectId}", project.Id); - return null; - } - - // 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.Length == 0) { - if (project.DocumentIds.Count == 0) - { - logger?.LogInformation("No razor documents for {projectId}", project.Id); - } - else - { - logger?.LogTrace("No documents in {projectId}", project.Id); - } - return null; } + var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; + var (razorConfiguration, rootNamespace) = ComputeRazorConfigurationOptions(options); + var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; + var projectWorkspaceState = ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); - var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; - var configuration = ComputeRazorConfigurationOptions(options, logger, out var defaultNamespace); + return new RazorProjectInfo( + projectKey: project.ToProjectKey(), + filePath: project.FilePath.AssumeNotNull(), + razorConfiguration, + rootNamespace, + displayName: project.Name, + projectWorkspaceState, + documents); + } + public static async Task GetWorkspaceStateAsync(Project project, RazorConfiguration configuration, string? defaultNamespace, string projectPath, CancellationToken cancellationToken) + { + var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; var fileSystem = RazorProjectFileSystem.Create(projectPath); var defaultConfigure = (RazorProjectEngineBuilder builder) => @@ -92,19 +78,35 @@ static RazorProjectInfoFactory() var resolver = new CompilationTagHelperResolver(NoOpTelemetryReporter.Instance); var tagHelpers = await resolver.GetTagHelpersAsync(project, engine, cancellationToken).ConfigureAwait(false); - var projectWorkspaceState = ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); + return ProjectWorkspaceState.Create(tagHelpers, csharpLanguageVersion); + } - return new RazorProjectInfo( - projectKey: new ProjectKey(intermediateOutputPath), - filePath: project.FilePath!, - configuration: configuration, - rootNamespace: defaultNamespace, - displayName: project.Name, - projectWorkspaceState: projectWorkspaceState, - documents: documents); + public static RazorProjectEngine GetProjectEngine(Project project, string projectPath) + { + var options = project.AnalyzerOptions.AnalyzerConfigOptionsProvider; + var (configuration, rootNamespace) = ComputeRazorConfigurationOptions(options); + var csharpLanguageVersion = (project.ParseOptions as CSharpParseOptions)?.LanguageVersion ?? LanguageVersion.Default; + var fileSystem = RazorProjectFileSystem.Create(projectPath); + var defaultConfigure = (RazorProjectEngineBuilder builder) => + { + if (rootNamespace is not null) + { + builder.SetRootNamespace(rootNamespace); + } + + builder.SetCSharpLanguageVersion(csharpLanguageVersion); + builder.SetSupportLocalizedComponentNames(); // ProjectState in MS.CA.Razor.Workspaces does this, so I'm doing it too! + }; + + var engineFactory = ProjectEngineFactories.DefaultProvider.GetFactory(configuration); + + return engineFactory.Create( + configuration, + fileSystem, + configure: defaultConfigure); } - private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options, ILogger? logger, out string defaultNamespace) + public static (RazorConfiguration razorConfiguration, string rootNamespace) ComputeRazorConfigurationOptions(AnalyzerConfigOptionsProvider options) { // See RazorSourceGenerator.RazorProviders.cs @@ -119,18 +121,17 @@ private static RazorConfiguration ComputeRazorConfigurationOptions(AnalyzerConfi if (!globalOptions.TryGetValue("build_property.RazorLangVersion", out var razorLanguageVersionString) || !RazorLanguageVersion.TryParse(razorLanguageVersionString, out var razorLanguageVersion)) { - logger?.LogTrace("Using default of latest language version"); razorLanguageVersion = RazorLanguageVersion.Latest; } var razorConfiguration = new RazorConfiguration(razorLanguageVersion, configurationName, Extensions: [], UseConsolidatedMvcViews: true); - defaultNamespace = rootNamespace ?? "ASP"; // TODO: Source generator does this. Do we want it? + rootNamespace ??= "ASP"; // TODO: Source generator does this. Do we want it? - return razorConfiguration; + return (razorConfiguration, rootNamespace); } - internal static ImmutableArray GetDocuments(Project project, string projectPath) + public static ImmutableArray GetDocuments(Project project, string projectPath) { using var documents = new PooledArrayBuilder(); @@ -168,7 +169,7 @@ internal static ImmutableArray GetDocuments(Project proj private static string GetTargetPath(string documentFilePath, string normalizedProjectPath) { var targetFilePath = FilePathNormalizer.Normalize(documentFilePath); - if (targetFilePath.StartsWith(normalizedProjectPath, s_stringComparison)) + if (targetFilePath.StartsWith(normalizedProjectPath, FilePathComparison.Instance)) { // Make relative targetFilePath = documentFilePath[normalizedProjectPath.Length..]; @@ -182,14 +183,14 @@ private static string GetTargetPath(string documentFilePath, string normalizedPr private static bool TryGetFileKind(string filePath, [NotNullWhen(true)] out string? fileKind) { - var extension = Path.GetExtension(filePath.AsSpan()); + var extension = Path.GetExtension(filePath); - if (extension.Equals(".cshtml", s_stringComparison)) + if (extension.Equals(".cshtml", FilePathComparison.Instance)) { fileKind = FileKinds.Legacy; return true; } - else if (extension.Equals(".razor", s_stringComparison)) + else if (extension.Equals(".razor", FilePathComparison.Instance)) { fileKind = FileKinds.GetComponentFileKindFromFilePath(filePath); return true; @@ -213,11 +214,9 @@ private static bool TryGetRazorFileName(string? filePath, [NotNullWhen(true)] ou const string generatedRazorExtension = $".razor{suffix}"; const string generatedCshtmlExtension = $".cshtml{suffix}"; - var path = filePath.AsSpan(); - // Generated files have a path like: virtualcsharp-razor:///e:/Scratch/RazorInConsole/Goo.cshtml__virtual.cs - if (path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && - (path.EndsWith(generatedRazorExtension, s_stringComparison) || path.EndsWith(generatedCshtmlExtension, s_stringComparison))) + if (filePath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + (filePath.EndsWith(generatedRazorExtension, FilePathComparison.Instance) || filePath.EndsWith(generatedCshtmlExtension, FilePathComparison.Instance))) { // Go through the file path normalizer because it also does Uri decoding, and we're converting from a Uri to a path // but "new Uri(filePath).LocalPath" seems wasteful diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs index 9b4bafb1f27..f848b283680 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/DocumentSnapshotHandle.cs @@ -1,6 +1,16 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. +using Microsoft.AspNetCore.Razor.Utilities; + namespace Microsoft.AspNetCore.Razor.Serialization; -internal record DocumentSnapshotHandle(string FilePath, string TargetPath, string FileKind); +internal record DocumentSnapshotHandle(string FilePath, string TargetPath, string FileKind) +{ + internal void AppendChecksum(Checksum.Builder builder) + { + builder.AppendData(FilePath); + builder.AppendData(TargetPath); + builder.AppendData(FileKind); + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs new file mode 100644 index 00000000000..d708d6500bb --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Serialization/RazorMessagePackSerializer.cs @@ -0,0 +1,42 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Buffers; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using MessagePack; +using MessagePack.Resolvers; +using Microsoft.AspNetCore.Razor.Serialization.MessagePack.Resolvers; + +namespace Microsoft.AspNetCore.Razor.Serialization; + +internal static class RazorMessagePackSerializer +{ + private static readonly MessagePackSerializerOptions s_options = MessagePackSerializerOptions.Standard + .WithResolver(CompositeResolver.Create( + RazorProjectInfoResolver.Instance, + StandardResolver.Instance)); + + public static byte[] Serialize(T instance) + => MessagePackSerializer.Serialize(instance, s_options); + + public static void SerializeTo(T instance, IBufferWriter bufferWriter) + => MessagePackSerializer.Serialize(bufferWriter, instance, s_options); + + public static void SerializeTo(T instance, Stream stream) + => MessagePackSerializer.Serialize(stream, instance, s_options); + + public static T? DeserializeFrom(ReadOnlyMemory buffer) + => MessagePackSerializer.Deserialize(buffer, s_options); + + public static T? DeserializeFrom(ReadOnlySequence buffer) + => MessagePackSerializer.Deserialize(buffer, s_options); + + public static T? DeserializeFrom(Stream stream) + => MessagePackSerializer.Deserialize(stream, s_options); + + public static ValueTask DeserializeFromAsync(Stream stream, CancellationToken cancellationToken) + => MessagePackSerializer.DeserializeAsync(stream, s_options, cancellationToken); +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs index 81abc7a2a16..855306bca98 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.ProjectEngineHost/Utilities/StreamExtensions.NetCore.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Serialization; using System; using System.Buffers; using System.Diagnostics; @@ -73,9 +74,16 @@ public static Task ReadProjectInfoRemovalAsync(this Stream stream, Cance return stream.ReadStringAsync(encoding: null, cancellationToken); } + public static async Task WriteWithSizeAsync(this Stream stream, T value, CancellationToken cancellationToken) + { + var bytes = RazorMessagePackSerializer.Serialize(value); + WriteSize(stream, bytes.Length); + await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); + } + public static async Task WriteProjectInfoAsync(this Stream stream, RazorProjectInfo projectInfo, CancellationToken cancellationToken) { - var bytes = projectInfo.Serialize(); + var bytes = RazorMessagePackSerializer.Serialize(projectInfo); WriteSize(stream, bytes.Length); await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); } @@ -90,7 +98,7 @@ public static async Task WriteProjectInfoAsync(this Stream stream, RazorProjectI // The array may be larger than the bytes read so make sure to trim accordingly. var projectInfoMemory = projectInfoBytes.AsMemory(0, sizeToRead); - return RazorProjectInfo.DeserializeFrom(projectInfoMemory); + return RazorMessagePackSerializer.DeserializeFrom(projectInfoMemory); } public static void WriteSize(this Stream stream, int length) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs index 34b050477a0..f27ab8f9a04 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/Extensions.cs @@ -1,15 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -#if !NET -using System; -#endif - -using System.Diagnostics; using Microsoft.AspNetCore.Razor; -using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Serialization; -using Microsoft.AspNetCore.Razor.Utilities; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem; @@ -17,26 +10,4 @@ internal static class Extensions { public static DocumentSnapshotHandle ToHandle(this IDocumentSnapshot snapshot) => new(snapshot.FilePath.AssumeNotNull(), snapshot.TargetPath.AssumeNotNull(), snapshot.FileKind.AssumeNotNull()); - - public static ProjectKey ToProjectKey(this Project project) - { - var intermediateOutputPath = FilePathNormalizer.GetNormalizedDirectoryName(project.CompilationOutputInfo.AssemblyPath); - return new(intermediateOutputPath); - } - - /// - /// Returns if this matches the given . - /// - public static bool Matches(this ProjectKey projectKey, Project project) - { - // In order to perform this check, we are relying on the fact that Id will always end with a '/', - // because it is guaranteed to be normalized. However, CompilationOutputInfo.AssemblyPath will - // contain the assembly file name, which AreDirectoryPathsEquivalent will shave off before comparing. - // So, AreDirectoryPathsEquivalent will return true when Id is "C:/my/project/path/" - // and the assembly path is "C:\my\project\path\assembly.dll" - - Debug.Assert(projectKey.Id.EndsWith('/'), $"This method can't be called if {nameof(projectKey.Id)} is not a normalized directory path."); - - return FilePathNormalizer.AreDirectoryPathsEquivalent(projectKey.Id, project.CompilationOutputInfo.AssemblyPath); - } } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs index 09130e8cb44..2803a39d311 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackProjectManager.cs @@ -5,6 +5,7 @@ using System.ComponentModel.Composition; using System.IO; using System.Threading; +using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.ProjectSystem; using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.AspNetCore.Razor.Utilities; diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs index c1e9de5988f..55ba43f16f7 100644 --- a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/VsSolutionUpdatesProjectSnapshotChangeTrigger.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Runtime.InteropServices; using System.Threading; +using Microsoft.AspNetCore.Razor; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.VisualStudio.Razor.Extensions; diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs index 465d490de1f..a56d2cf705a 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorProjectInfoSerializerTest.cs @@ -21,7 +21,7 @@ public void GeneratedDocument() .WithFilePath("virtualcsharp-razor:///e:/Scratch/RazorInConsole/Goo.cshtml__virtual.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -35,7 +35,7 @@ public void AdditionalDocument() project = workspace.CurrentSolution.GetProject(project.Id)!; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -53,7 +53,7 @@ public void AdditionalAndGeneratedDocument() .WithFilePath("virtualcsharp-razor:///e:/Scratch/RazorInConsole/Another.cshtml__virtual.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -71,7 +71,7 @@ public void AdditionalNonRazorAndGeneratedDocument() .WithFilePath("virtualcsharp-razor:///e:/Scratch/RazorInConsole/Another.cshtml__virtual.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Single(documents); } @@ -85,7 +85,7 @@ public void NormalDocument() .WithFilePath("e:/Scratch/RazorInConsole/Goo.cs") .Project; - var documents = RazorProjectInfoFactory.GetDocuments(project, "temp"); + var documents = RazorProjectInfoHelpers.GetDocuments(project, "temp"); Assert.Empty(documents); } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs index 8fe48a38dce..d57248e9043 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ExternalAccess.RoslynWorkspace.Test/RazorWorkspaceListenerTest.cs @@ -256,6 +256,57 @@ public async Task TestSerialization() Assert.Equal(intermediateDirectory, await readerStream.ReadProjectInfoRemovalAsync(CancellationToken.None)); } + [Fact] + public async Task CSharpDocumentAdded_DoesNotUpdate() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + listener.NotifyDynamicFile(project.Id); + + await listener.WaitForDebounceAsync(); + + Assert.Equal(1, listener.SerializeCalls[project.Id]); + Assert.Equal(1, listener.WorkspaceChangedEvents); + + var document = project.AddDocument("TestDocument", "class TestDocument { }"); + Assert.True(workspace.TryApplyChanges(document.Project.Solution)); + + // We can't wait for debounce here, because it won't happen, but if we don't wait for _something_ we won't know + // if the test fails, so a delay is annoyingly necessary. + await Task.Delay(500); + + Assert.Equal(2, listener.WorkspaceChangedEvents); + Assert.Equal(1, listener.SerializeCalls[project.Id]); + } + + [Fact] + public async Task RazorFileAdded_DoesUpdate() + { + using var workspace = new AdhocWorkspace(CodeAnalysis.Host.Mef.MefHostServices.DefaultHost); + + using var listener = new TestRazorWorkspaceListener(); + listener.EnsureInitialized(workspace); + + var project = workspace.AddProject("TestProject", LanguageNames.CSharp); + listener.NotifyDynamicFile(project.Id); + + await listener.WaitForDebounceAsync(); + + Assert.Equal(1, listener.SerializeCalls[project.Id]); + Assert.Equal(1, listener.WorkspaceChangedEvents); + + workspace.AddDocument(DocumentInfo.Create(DocumentId.CreateNewId(project.Id), @"Page.razor", filePath: @"C:\test\Page.razor")); + + await listener.WaitForDebounceAsync(); + + Assert.Equal(2, listener.WorkspaceChangedEvents); + Assert.Equal(2, listener.SerializeCalls[project.Id]); + } + private class TestRazorWorkspaceListener : RazorWorkspaceListenerBase { private ConcurrentDictionary _serializeCalls = new(); @@ -265,6 +316,7 @@ private class TestRazorWorkspaceListener : RazorWorkspaceListenerBase public ConcurrentDictionary SerializeCalls => _serializeCalls; public ConcurrentDictionary RemoveCalls => _removeCalls; + public int WorkspaceChangedEvents { get; private set; } public TestRazorWorkspaceListener() : base(NullLoggerFactory.Instance.CreateLogger("")) @@ -274,6 +326,7 @@ public TestRazorWorkspaceListener() public void EnsureInitialized(Workspace workspace) { EnsureInitialized(workspace, static () => Stream.Null); + workspace.WorkspaceChanged += (s, a) => { WorkspaceChangedEvents++; }; } private protected override ValueTask ProcessWorkAsync(ImmutableArray work, CancellationToken cancellationToken) diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/ProjectInfoChecksumTests.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/ProjectInfoChecksumTests.cs new file mode 100644 index 00000000000..66cfd0297ac --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/ProjectInfoChecksumTests.cs @@ -0,0 +1,97 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.ProjectSystem; +using Microsoft.AspNetCore.Razor.Serialization; +using Microsoft.AspNetCore.Razor.Test.Common; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.ProjectEngineHost.Test; + +public class ProjectInfoChecksumTests +{ + [Fact] + public void CheckSame() + { + var info1 = CreateInfo(); + var info2 = CreateInfo(); + + Assert.Equal(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_ProjectKey() + { + var info1 = CreateInfo(); + var info2 = info1 with { ProjectKey = new ProjectKey("Test2") }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_FilePath() + { + var info1 = CreateInfo(); + var info2 = info1 with { FilePath = @"C:\test\test2.csproj" }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_RootNamespace() + { + var info1 = CreateInfo(); + var info2 = info1 with { RootNamespace = "TestNamespace2" }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_DisplayName() + { + var info1 = CreateInfo(); + var info2 = info1 with { DisplayName = "Test2 (tfm)" }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_Configuration() + { + var info1 = CreateInfo(); + var info2 = info1 with { Configuration = new RazorConfiguration(RazorLanguageVersion.Latest, "TestConfiguration2", []) }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_ProjectWorkspaceState() + { + var info1 = CreateInfo(); + var info2 = info1 with { ProjectWorkspaceState = ProjectWorkspaceState.Create(RazorTestResources.BlazorServerAppTagHelpers, CodeAnalysis.CSharp.LanguageVersion.CSharp10) }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + [Fact] + public void Change_Documents() + { + var info1 = CreateInfo(); + var info2 = info1 with { Documents = info1.Documents.Add(new DocumentSnapshotHandle(@"C:\test\home.razor", @"C:\test\lib\net8.0", FileKinds.Component)) }; + + Assert.NotEqual(info1.Checksum, info2.Checksum); + } + + RazorProjectInfo CreateInfo() + { + return new RazorProjectInfo( + new ProjectKey("Test"), + @"C:\test\test.csproj", + new RazorConfiguration(RazorLanguageVersion.Latest, "TestConfiguration", []), + "TestNamespace", + "Test (tfm)", + ProjectWorkspaceState.Create(RazorTestResources.BlazorServerAppTagHelpers, CodeAnalysis.CSharp.LanguageVersion.Latest), + []); + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/RazorConfigurationChecksumTests.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/RazorConfigurationChecksumTests.cs new file mode 100644 index 00000000000..67845df4c5c --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/RazorConfigurationChecksumTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.ProjectEngineHost.Test; + +public class RazorConfigurationChecksumTests +{ + [Fact] + public void CheckSame() + { + var config1 = GetConfiguration(); + var config2 = GetConfiguration(); + + Assert.Equal(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_RazorLanguageVersion() + { + var config1 = GetConfiguration(); + var config2 = config1 with { LanguageVersion = RazorLanguageVersion.Version_2_1 }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_ConfigurationName() + { + var config1 = GetConfiguration(); + var config2 = config1 with { ConfigurationName = "Configuration2" }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_Extensions() + { + var config1 = GetConfiguration(); + var config2 = config1 with { Extensions = config1.Extensions.Add(new RazorExtension("TestExtension2")) }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + [Fact] + public void Change_UseConsolidatedMvcViews() + { + var config1 = GetConfiguration(); + var config2 = config1 with { UseConsolidatedMvcViews = !config1.UseConsolidatedMvcViews }; + + Assert.NotEqual(config1.Checksum, config2.Checksum); + } + + private RazorConfiguration GetConfiguration() + { + return new RazorConfiguration( + RazorLanguageVersion.Latest, + "Configuration", + [new RazorExtension("TestExtension")], + new LanguageServerFlags(ForceRuntimeCodeGeneration: true), + UseConsolidatedMvcViews: false); + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs index 8895137d798..bbaecd1749f 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.ProjectEngineHost.Test/StreamExtensionTests.NetCore.cs @@ -88,7 +88,7 @@ public async Task SerializeProjectInfo() projectWorkspaceState, [new DocumentSnapshotHandle(@"C:\test\document.razor", @"document.razor", FileKinds.Component)]); - var bytesToSerialize = projectInfo.Serialize(); + var bytesToSerialize = RazorMessagePackSerializer.Serialize(projectInfo); await stream.WriteProjectInfoAsync(projectInfo, default);