From a9b8bed2cace989c938fd783f0460105d017328a Mon Sep 17 00:00:00 2001 From: Pranav K Date: Mon, 10 Jan 2022 20:35:21 -0800 Subject: [PATCH] Better caching for RazorSourceGenerator when SuppressRazorSourceGenerator changes (#23358) In VisualStudio SuppressRazorSourceGenerator changes when it's being invoked by the EnC and tooling. In our current implementation, while we no-op the operation, when SuppressRazorSourceGenerator=true, the code also evicts the valid previously cached results when SuppressRazorSourceGenerator=false by returning null values, causing us do work the next time its false. Contributes to dotnet/aspnetcore#32867 --- .../IncrementalValueProviderExtensions.cs | 25 +- .../RazorSourceGenerationOptions.cs | 24 +- .../RazorSourceGenerator.RazorProviders.cs | 4 +- .../SourceGenerators/RazorSourceGenerator.cs | 110 +++----- .../RazorSourceGeneratorComparer.cs | 38 +++ .../RazorSourceGenerationOptionsTest.cs | 231 ++++++++++++++++ .../RazorSourceGeneratorComparerTest.cs | 120 +++++++++ .../RazorSourceGeneratorTests.cs | 251 ++++++++++++++++++ 8 files changed, 706 insertions(+), 97 deletions(-) create mode 100644 src/RazorSdk/SourceGenerators/RazorSourceGeneratorComparer.cs create mode 100644 src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGenerationOptionsTest.cs create mode 100644 src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorComparerTest.cs diff --git a/src/RazorSdk/SourceGenerators/IncrementalValueProviderExtensions.cs b/src/RazorSdk/SourceGenerators/IncrementalValueProviderExtensions.cs index cd4d7aa188ef..871c4dd22594 100644 --- a/src/RazorSdk/SourceGenerators/IncrementalValueProviderExtensions.cs +++ b/src/RazorSdk/SourceGenerators/IncrementalValueProviderExtensions.cs @@ -1,4 +1,3 @@ - // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. @@ -10,6 +9,30 @@ namespace Microsoft.NET.Sdk.Razor.SourceGenerators { internal static class IncrementalValuesProviderExtensions { + /// + /// Adds a comparer used to determine if an incremental steps needs to be re-run accounting for . + /// + /// In VS, design time builds are executed with set to true. In this case, RSG can safely + /// allow previously cached results to be used, while no-oping in the step that adds sources to the context. This allows source generator caches from being evicted + /// when the value of this property flip-flips during a hot-reload / EnC session. + /// + /// + internal static IncrementalValueProvider<(T Left, RazorSourceGenerationOptions Right)> WithRazorSourceGeneratorComparer( + this IncrementalValueProvider<(T Left, RazorSourceGenerationOptions Right)> source, + Func? equals = null) + where T : notnull + { + return source.WithComparer(new RazorSourceGeneratorComparer(equals)); + } + + internal static IncrementalValuesProvider<(T Left, RazorSourceGenerationOptions Right)> WithRazorSourceGeneratorComparer( + this IncrementalValuesProvider<(T Left, RazorSourceGenerationOptions Right)> source, + Func? equals = null) + where T : notnull + { + return source.WithComparer(new RazorSourceGeneratorComparer(equals)); + } + internal static IncrementalValueProvider WithLambdaComparer(this IncrementalValueProvider source, Func equal, Func getHashCode) { var comparer = new LambdaComparer(equal, getHashCode); diff --git a/src/RazorSdk/SourceGenerators/RazorSourceGenerationOptions.cs b/src/RazorSdk/SourceGenerators/RazorSourceGenerationOptions.cs index a7fff98d2382..9c6adae7cba6 100644 --- a/src/RazorSdk/SourceGenerators/RazorSourceGenerationOptions.cs +++ b/src/RazorSdk/SourceGenerators/RazorSourceGenerationOptions.cs @@ -14,19 +14,10 @@ internal class RazorSourceGenerationOptions : IEquatable - /// Gets a flag that determines if the source generator waits for the debugger to attach. - /// - /// To configure this using MSBuild, use the _RazorSourceGeneratorDebug property. - /// For instance dotnet msbuild /p:_RazorSourceGeneratorDebug=true - /// - /// - public bool WaitForDebugger { get; set; } = false; - /// /// Gets a flag that determines if generated Razor views and Pages includes the RazorSourceChecksumAttribute. /// - public bool GenerateMetadataSourceChecksumAttributes { get; set; } = false; + public bool GenerateMetadataSourceChecksumAttributes { get; set; } /// /// Gets a flag that determines if the source generator should no-op. @@ -36,7 +27,7 @@ internal class RazorSourceGenerationOptions : IEquatable /// - public bool SuppressRazorSourceGenerator { get; set; } = false; + public bool SuppressRazorSourceGenerator { get; set; } /// /// Gets the CSharp language version currently used by the compilation. @@ -46,14 +37,17 @@ internal class RazorSourceGenerationOptions : IEquatable /// Gets a flag that determines if localized component names should be supported.. /// - public bool SupportLocalizedComponentNames { get; set; } = false; + public bool SupportLocalizedComponentNames { get; set; } public bool Equals(RazorSourceGenerationOptions other) + => SuppressRazorSourceGenerator == other.SuppressRazorSourceGenerator && EqualsIgnoringSupression(other); + + public bool EqualsIgnoringSupression(RazorSourceGenerationOptions other) { - return RootNamespace == other.RootNamespace && - Configuration == other.Configuration && + return + RootNamespace == other.RootNamespace && + Configuration.Equals(other.Configuration) && GenerateMetadataSourceChecksumAttributes == other.GenerateMetadataSourceChecksumAttributes && - SuppressRazorSourceGenerator == other.SuppressRazorSourceGenerator && CSharpLanguageVersion == other.CSharpLanguageVersion && SupportLocalizedComponentNames == other.SupportLocalizedComponentNames; } diff --git a/src/RazorSdk/SourceGenerators/RazorSourceGenerator.RazorProviders.cs b/src/RazorSdk/SourceGenerators/RazorSourceGenerator.RazorProviders.cs index d1bbf12a007d..fa92f117924b 100644 --- a/src/RazorSdk/SourceGenerators/RazorSourceGenerator.RazorProviders.cs +++ b/src/RazorSdk/SourceGenerators/RazorSourceGenerator.RazorProviders.cs @@ -24,7 +24,6 @@ private static (RazorSourceGenerationOptions?, Diagnostic?) ComputeRazorSourceGe globalOptions.TryGetValue("build_property.RazorConfiguration", out var configurationName); globalOptions.TryGetValue("build_property.RootNamespace", out var rootNamespace); globalOptions.TryGetValue("build_property.SupportLocalizedComponentNames", out var supportLocalizedComponentNames); - globalOptions.TryGetValue("build_property._RazorSourceGeneratorDebug", out var waitForDebugger); globalOptions.TryGetValue("build_property.SuppressRazorSourceGenerator", out var suppressRazorSourceGenerator); globalOptions.TryGetValue("build_property.GenerateRazorMetadataSourceChecksumAttributes", out var generateMetadataSourceChecksumAttributes); @@ -41,10 +40,9 @@ private static (RazorSourceGenerationOptions?, Diagnostic?) ComputeRazorSourceGe var razorConfiguration = RazorConfiguration.Create(razorLanguageVersion, configurationName ?? "default", System.Linq.Enumerable.Empty(), true); - var razorSourceGenerationOptions = new RazorSourceGenerationOptions() + var razorSourceGenerationOptions = new RazorSourceGenerationOptions { Configuration = razorConfiguration, - WaitForDebugger = waitForDebugger == "true", SuppressRazorSourceGenerator = suppressRazorSourceGenerator == "true", GenerateMetadataSourceChecksumAttributes = generateMetadataSourceChecksumAttributes == "true", RootNamespace = rootNamespace ?? "ASP", diff --git a/src/RazorSdk/SourceGenerators/RazorSourceGenerator.cs b/src/RazorSdk/SourceGenerators/RazorSourceGenerator.cs index e1fe1292a870..0c3f981d5419 100644 --- a/src/RazorSdk/SourceGenerators/RazorSourceGenerator.cs +++ b/src/RazorSdk/SourceGenerators/RazorSourceGenerator.cs @@ -22,7 +22,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var razorSourceGeneratorOptionsWithDiagnostics = context.AnalyzerConfigOptionsProvider .Combine(context.ParseOptionsProvider) .Select(ComputeRazorSourceGeneratorOptions); - var razorSourceGeneratorOptions = razorSourceGeneratorOptionsWithDiagnostics.ReportDiagnostics(context); + + var razorSourceGeneratorOptions = razorSourceGeneratorOptionsWithDiagnostics + .ReportDiagnostics(context); var sourceItemsWithDiagnostics = context.AdditionalTextsProvider .Where(static (file) => file.Path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) || file.Path.EndsWith(".cshtml", StringComparison.OrdinalIgnoreCase)) @@ -52,23 +54,18 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return false; }); - var componentFiles = sourceItems.Where(static file => file.FilePath.EndsWith(".razor", StringComparison.OrdinalIgnoreCase)); + var componentFiles = sourceItems.Where( + static file => file.FilePath.EndsWith(".razor", StringComparison.OrdinalIgnoreCase) && !file.FilePath.EndsWith("_Imports.razor", StringComparison.OrdinalIgnoreCase)); var generatedDeclarationCode = componentFiles .Combine(importFiles.Collect()) .Combine(razorSourceGeneratorOptions) + .WithRazorSourceGeneratorComparer() .Select(static (pair, _) => { - var ((sourceItem, importFiles), razorSourceGeneratorOptions) = pair; RazorSourceGeneratorEventSource.Log.GenerateDeclarationCodeStart(sourceItem.FilePath); - if (razorSourceGeneratorOptions.SuppressRazorSourceGenerator) - { - RazorSourceGeneratorEventSource.Log.GenerateDeclarationCodeStop(sourceItem.FilePath); - return null; - } - var projectEngine = GetDeclarationProjectEngine(sourceItem, importFiles, razorSourceGeneratorOptions); var codeGen = projectEngine.Process(sourceItem); @@ -96,17 +93,12 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var tagHelpersFromCompilation = context.CompilationProvider .Combine(generatedDeclarationSyntaxTrees.Collect()) .Combine(razorSourceGeneratorOptions) + .WithRazorSourceGeneratorComparer() .Select(static (pair, _) => { RazorSourceGeneratorEventSource.Log.DiscoverTagHelpersFromCompilationStart(); - var ((compilation, generatedDeclarationSyntaxTrees), razorSourceGeneratorOptions) = pair; - - if (razorSourceGeneratorOptions.SuppressRazorSourceGenerator) - { - RazorSourceGeneratorEventSource.Log.DiscoverTagHelpersFromCompilationStop(); - return ImmutableArray.Empty; - } + var ((compilation, generatedDeclarationSyntaxTrees), _) = pair; var tagHelperFeature = new StaticCompilationTagHelperFeature(); var discoveryProjectEngine = GetDiscoveryProjectEngine(compilation.References.ToImmutableArray(), tagHelperFeature); @@ -116,65 +108,33 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tagHelperFeature.Compilation = compilationWithDeclarations; tagHelperFeature.TargetAssembly = compilationWithDeclarations.Assembly; - var result = (IList)tagHelperFeature.GetDescriptors(); + var result = tagHelperFeature.GetDescriptors().ToImmutableHashSet(); RazorSourceGeneratorEventSource.Log.DiscoverTagHelpersFromCompilationStop(); return result; }) - .WithLambdaComparer(static (a, b) => - { - if (a.Count != b.Count) - { - return false; - } - - for (var i = 0; i < a.Count; i++) - { - if (!a[i].Equals(b[i])) - { - return false; - } - } - - return true; - }, getHashCode: static a => a.Count); + .WithLambdaComparer(static (a, b) => a.SetEquals(b), static item => item.Count.GetHashCode()); var tagHelpersFromReferences = context.CompilationProvider - .Combine(razorSourceGeneratorOptions) .Combine(hasRazorFiles) - .WithLambdaComparer(static (a, b) => + .Combine(razorSourceGeneratorOptions) + .WithRazorSourceGeneratorComparer(static (a, b) => { - var ((compilationA, razorSourceGeneratorOptionsA), hasRazorFilesA) = a; - var ((compilationB, razorSourceGeneratorOptionsB), hasRazorFilesB) = b; + var (compilationA, _) = a; + var (compilationB, hasRazorFilesB) = b; - if (!compilationA.References.SequenceEqual(compilationB.References)) + if (!hasRazorFilesB) { - return false; + // If there aren't any razor files, we can use previously cached results + return true; } - if (razorSourceGeneratorOptionsA != razorSourceGeneratorOptionsB) - { - return false; - } - - return hasRazorFilesA == hasRazorFilesB; - }, - static item => - { - // we'll use the number of references as a hashcode. - var ((compilationA, razorSourceGeneratorOptionsA), hasRazorFilesA) = item; - return compilationA.References.GetHashCode(); + return compilationA.References.SequenceEqual(compilationB.References); }) .Select(static (pair, _) => { RazorSourceGeneratorEventSource.Log.DiscoverTagHelpersFromReferencesStart(); - var ((compilation, razorSourceGeneratorOptions), hasRazorFiles) = pair; - - if (razorSourceGeneratorOptions.SuppressRazorSourceGenerator) - { - RazorSourceGeneratorEventSource.Log.DiscoverTagHelpersFromReferencesStop(); - return ImmutableArray.Empty; - } + var ((compilation, hasRazorFiles), _) = pair; if (!hasRazorFiles) { @@ -213,7 +173,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } var allTagHelpers = new TagHelperDescriptor[count]; - tagHelpersFromCompilation.CopyTo(allTagHelpers, 0); + var i = 0; + foreach (var item in tagHelpersFromCompilation) + { + allTagHelpers[i++] = item; + } tagHelpersFromReferences.CopyTo(allTagHelpers, tagHelpersFromCompilation.Count); return allTagHelpers; @@ -222,20 +186,15 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var generatedOutput = sourceItems .Combine(importFiles.Collect()) .Combine(allTagHelpers) - .Combine(razorSourceGeneratorOptions) .Combine(context.ParseOptionsProvider) + .Combine(razorSourceGeneratorOptions) + .WithRazorSourceGeneratorComparer() .Select(static (pair, _) => { - var ((((sourceItem, imports), allTagHelpers), razorSourceGeneratorOptions), parserOptions) = pair; + var ((((sourceItem, imports), allTagHelpers), parserOptions), razorSourceGeneratorOptions) = pair; RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStart(sourceItem.FilePath); - if (razorSourceGeneratorOptions.SuppressRazorSourceGenerator) - { - RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStop(sourceItem.FilePath); - return default; - } - // Add a generated suffix so tools, such as coverlet, consider the file to be generated var hintName = GetIdentifierFromPath(sourceItem.RelativePhysicalPath) + ".g.cs"; @@ -249,12 +208,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }) .WithLambdaComparer(static (a, b) => { - if (a.hintName is null) - { - // Source generator is suppressed. - return false; - } - if (a.csharpDocument.Diagnostics.Count > 0 || b.csharpDocument.Diagnostics.Count > 0) { // if there are any diagnostics, treat the documents as unequal and force RegisterSourceOutput to be called uncached. @@ -264,16 +217,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return string.Equals(a.csharpDocument.GeneratedCode, b.csharpDocument.GeneratedCode, StringComparison.Ordinal); }, static a => StringComparer.Ordinal.GetHashCode(a.csharpDocument)); - context.RegisterSourceOutput(generatedOutput, static (context, pair) => + context.RegisterSourceOutput(generatedOutput.Combine(razorSourceGeneratorOptions), static (context, pair) => { - var (hintName, csharpDocument) = pair; - RazorSourceGeneratorEventSource.Log.AddSyntaxTrees(hintName); - if (hintName is null) + var ((hintName, csharpDocument), razorSourceGeneratorOptions) = pair; + + if (razorSourceGeneratorOptions.SuppressRazorSourceGenerator) { // Source generator is suppressed. return; } + RazorSourceGeneratorEventSource.Log.AddSyntaxTrees(hintName); for (var i = 0; i < csharpDocument.Diagnostics.Count; i++) { var razorDiagnostic = csharpDocument.Diagnostics[i]; diff --git a/src/RazorSdk/SourceGenerators/RazorSourceGeneratorComparer.cs b/src/RazorSdk/SourceGenerators/RazorSourceGeneratorComparer.cs new file mode 100644 index 000000000000..e8ebf216532a --- /dev/null +++ b/src/RazorSdk/SourceGenerators/RazorSourceGeneratorComparer.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; + +namespace Microsoft.NET.Sdk.Razor.SourceGenerators +{ + /// + /// A comparer used to determine if an incremental steps needs to be re-run accounting for . + /// + /// In VS, design time builds are executed with set to true. In this case, RSG can safely + /// allow previously cached results to be used, while no-oping in the step that adds sources to the context. This allows source generator caches from being evicted + /// when the value of this property flip-flips during a hot-reload / EnC session. + /// + /// + internal sealed class RazorSourceGeneratorComparer : IEqualityComparer<(T Left, RazorSourceGenerationOptions Right)> where T : notnull + { + private readonly Func _equals; + public RazorSourceGeneratorComparer(Func? equals = null) + { + _equals = equals ?? EqualityComparer.Default.Equals; + } + + public bool Equals((T Left, RazorSourceGenerationOptions Right) x, (T Left, RazorSourceGenerationOptions Right) y) + { + if (y.Right.SuppressRazorSourceGenerator) + { + // If source generation is suppressed, we can always use previously cached results. + return true; + } + + return _equals(x.Left, y.Left) && x.Right.EqualsIgnoringSupression(y.Right); + } + + public int GetHashCode((T Left, RazorSourceGenerationOptions Right) obj) => obj.Left.GetHashCode(); + } +} diff --git a/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGenerationOptionsTest.cs b/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGenerationOptionsTest.cs new file mode 100644 index 000000000000..b8ab16ea7400 --- /dev/null +++ b/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGenerationOptionsTest.cs @@ -0,0 +1,231 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Microsoft.NET.Sdk.Razor.SourceGenerators +{ + public class RazorSourceGenerationOptionsTest + { + [Fact] + public void Equals_ReturnsFalse_IfConfigurationChanged() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Default, + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Create(RazorLanguageVersion.Latest, "3.1", Enumerable.Empty()), + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.False(equals); + } + + [Fact] + public void Equals_ReturnsFalse_IfLanguageChanged() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + CSharpLanguageVersion = LanguageVersion.CSharp10, + Configuration = RazorConfiguration.Default, + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Default, + CSharpLanguageVersion = LanguageVersion.CSharp9, + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.False(equals); + } + + [Fact] + public void Equals_ReturnsFalse_IfGenerateMetadataSourceChecksumAttributesChanged() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + CSharpLanguageVersion = LanguageVersion.CSharp10, + Configuration = RazorConfiguration.Default, + GenerateMetadataSourceChecksumAttributes = false, + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Default, + CSharpLanguageVersion = LanguageVersion.CSharp9, + GenerateMetadataSourceChecksumAttributes = true, + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.False(equals); + } + + [Fact] + public void Equals_ReturnsFalse_IfRootNamespaceChanged() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + CSharpLanguageVersion = LanguageVersion.Latest, + Configuration = RazorConfiguration.Default, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Initial", + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Default, + CSharpLanguageVersion = LanguageVersion.Latest, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Different", + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.False(equals); + } + + [Fact] + public void Equals_ReturnsFalse_IfSupportLocalizedComponentNameChanged() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + CSharpLanguageVersion = LanguageVersion.Latest, + Configuration = RazorConfiguration.Default, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = false, + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Default, + CSharpLanguageVersion = LanguageVersion.Latest, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = true, + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.False(equals); + } + + [Fact] + public void Equals_ReturnsFalse_IfSuppressRazorSourceGeneratorChanged() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + CSharpLanguageVersion = LanguageVersion.Latest, + Configuration = RazorConfiguration.Default, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = true, + SuppressRazorSourceGenerator = true, + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Default, + CSharpLanguageVersion = LanguageVersion.Latest, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = true, + SuppressRazorSourceGenerator = false, + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.False(equals); + } + + [Fact] + public void Equals_ReturnsTrue_IfValuesAreUnchanged() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + CSharpLanguageVersion = LanguageVersion.Latest, + Configuration = RazorConfiguration.Default, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = true, + SuppressRazorSourceGenerator = true, + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Default, + CSharpLanguageVersion = LanguageVersion.Latest, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = true, + SuppressRazorSourceGenerator = true, + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.True(equals); + } + + [Fact] + public void Equals_ReturnsTrue_IfRazorConfigurationAreDifferentInstancesButEqualValues() + { + // Arrange + var options1 = new RazorSourceGenerationOptions + { + CSharpLanguageVersion = LanguageVersion.Latest, + Configuration = RazorConfiguration.Create(RazorLanguageVersion.Parse("6.0"), "Default", Enumerable.Empty(), useConsolidatedMvcViews: true), + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = true, + SuppressRazorSourceGenerator = true, + }; + + var options2 = new RazorSourceGenerationOptions + { + Configuration = RazorConfiguration.Create(RazorLanguageVersion.Parse("6.0"), "Default", Enumerable.Empty(), useConsolidatedMvcViews: true), + CSharpLanguageVersion = LanguageVersion.Latest, + GenerateMetadataSourceChecksumAttributes = true, + RootNamespace = "Asp", + SupportLocalizedComponentNames = true, + SuppressRazorSourceGenerator = true, + }; + + // Act + var equals = options1.Equals(options2); + + // Assert + Assert.True(equals); + } + } +} diff --git a/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorComparerTest.cs b/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorComparerTest.cs new file mode 100644 index 000000000000..2408a440b3b9 --- /dev/null +++ b/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorComparerTest.cs @@ -0,0 +1,120 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Xunit; + +namespace Microsoft.NET.Sdk.Razor.SourceGenerators.Tests +{ + public class RazorSourceGeneratorComparerTest + { + [Fact] + public void Equals_ReturnsTrue_IfSuppressRazorSourceGeneratorOnRightIsTrue() + { + // Arrange + var left = (new ComparableType(), new RazorSourceGenerationOptions()); + var right = (new ComparableType(), new RazorSourceGenerationOptions { SuppressRazorSourceGenerator = true }); + + var razorComparer = new RazorSourceGeneratorComparer(); + + // Act + var result = razorComparer.Equals(left, right); + + // Assert + Assert.True(result); + Assert.False(right.Item1.EqualsCalled); + } + + [Fact] + public void Equals_ComparesWithoutSuppressRazorSourceGenerator() + { + // Arrange + var left = (new ComparableType(), new RazorSourceGenerationOptions { SuppressRazorSourceGenerator = true /* This is ignored */}); + var right = (new ComparableType(), new RazorSourceGenerationOptions()); + + var razorComparer = new RazorSourceGeneratorComparer(); + + // Act + var result = razorComparer.Equals(left, right); + + // Assert + Assert.True(result); + Assert.True(left.Item1.EqualsCalled); + } + + [Fact] + public void Equals_UsesProvidedComparer() + { + // Arrange + var left = (new ComparableType(), new RazorSourceGenerationOptions { SuppressRazorSourceGenerator = true /* This is ignored */}); + var right = (new ComparableType(), new RazorSourceGenerationOptions()); + var invoked = false; + + var razorComparer = new RazorSourceGeneratorComparer((a, b) => + { + invoked = true; + return true; + }); + + // Act + var result = razorComparer.Equals(left, right); + + // Assert + Assert.True(result); + Assert.False(left.Item1.EqualsCalled); + Assert.True(invoked); + } + + [Fact] + public void Equals_ReturnsFalse_IfItemComparersReturnFalse() + { + // Arrange + var left = (new ComparableType(), new RazorSourceGenerationOptions()); + var right = (new ComparableType { Value = "Different" }, new RazorSourceGenerationOptions()); + + var razorComparer = new RazorSourceGeneratorComparer(); + + // Act + var result = razorComparer.Equals(left, right); + + // Assert + Assert.False(result); + Assert.True(left.Item1.EqualsCalled); + } + + [Fact] + public void GetHashCode_ReturnsValueFromItem() + { + // Arrange + var item = (new ComparableType(), new RazorSourceGenerationOptions()); + + var razorComparer = new RazorSourceGeneratorComparer(); + + // Act + var result = razorComparer.GetHashCode(item); + + // Assert + Assert.Equal(42, result); + } + + private class ComparableType : IEquatable + { + public string Value { get; set; } = "Some value"; + + public bool EqualsCalled = false; + + public bool Equals(ComparableType other) + { + EqualsCalled = true; + return Value.Equals(other.Value); + } + + public override int GetHashCode() => 42; + + public override bool Equals(object obj) => obj is ComparableType other && Equals(other); + } + } +} diff --git a/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs b/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs index 7971c330ad84..8354ce799792 100644 --- a/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs +++ b/src/Tests/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs @@ -1002,6 +1002,257 @@ @using Microsoft.AspNetCore.Components.Web }); } + [Fact] + public async Task SourceGenerator_UsesPreviouslyCachedResults_IfSuppressRazorSourceGeneratorIsTrue_AndWasPreviouslyFalse() + { + // When SuppressRazorSourceGenerator=true, we can safely re-use previously cached results instead of + // re-running steps and producing no-op results. + // Arrange + using var eventListener = new RazorEventListener(); + var project = CreateTestProject(new() + { + ["Pages/Index.razor"] = "

Hello world

", + ["Pages/Counter.razor"] = +@" +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +

Counter

+", + }); + + var compilation = await project.GetCompilationAsync(); + TestAnalyzerConfigOptionsProvider? testOptionsProvider = null; + var (driver, additionalTexts) = await GetDriverWithAdditionalTextAsync(project, optionsProvider => + { + testOptionsProvider = optionsProvider; + optionsProvider.TestGlobalOptions["build_property.SuppressRazorSourceGenerator"] = "false"; + }); + + var result = RunGenerator(compilation!, ref driver); + Assert.Empty(result.Diagnostics); + Assert.Collection( + result.GeneratedSources, + sourceResult => + { + Assert.Contains("public partial class Index", sourceResult.SourceText.ToString()); + }, + sourceResult => + { + var sourceText = sourceResult.SourceText.ToString(); + Assert.Contains("public partial class Counter", sourceText); + Assert.Contains("__builder.AddAttribute(2, \"onclick\", Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this,", sourceText); + }); + + var updatedOptionsProvider = new TestAnalyzerConfigOptionsProvider(); + foreach (var option in testOptionsProvider!.AdditionalTextOptions) + { + updatedOptionsProvider.AdditionalTextOptions[option.Key] = option.Value; + } + + foreach (var option in testOptionsProvider!.TestGlobalOptions.Options) + { + updatedOptionsProvider.TestGlobalOptions[option.Key] = option.Value; + } + + updatedOptionsProvider.TestGlobalOptions["build_property.SuppressRazorSourceGenerator"] = "true"; + + driver = driver.WithUpdatedAnalyzerConfigOptions(updatedOptionsProvider); + eventListener.Events.Clear(); + result = RunGenerator(compilation!, ref driver); + + Assert.Empty(result.GeneratedSources); + Assert.Collection(eventListener.Events, + e => Assert.Equal("ComputeRazorSourceGeneratorOptions", e.EventName)); + } + + [Fact] + public async Task SourceGenerator_UsesPreviouslyCachedResults_IfSuppressRazorSourceGeneratorIsTrue_AndWasPreviouslyTrue() + { + // When SuppressRazorSourceGenerator=true, we can safely re-use previously cached results instead of + // re-running steps and producing no-op results. + // Arrange + using var eventListener = new RazorEventListener(); + var project = CreateTestProject(new() + { + ["Pages/Index.razor"] = "

Hello world

", + ["Pages/Counter.razor"] = +@" +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +

Counter

+", + }); + + var compilation = await project.GetCompilationAsync(); + TestAnalyzerConfigOptionsProvider? testOptionsProvider = null; + var (driver, additionalTexts) = await GetDriverWithAdditionalTextAsync(project, optionsProvider => + { + testOptionsProvider = optionsProvider; + optionsProvider.TestGlobalOptions["build_property.SuppressRazorSourceGenerator"] = "true"; + }); + + var result = RunGenerator(compilation!, ref driver); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.GeneratedSources); + + var updatedOptionsProvider = new TestAnalyzerConfigOptionsProvider(); + foreach (var option in testOptionsProvider!.AdditionalTextOptions) + { + updatedOptionsProvider.AdditionalTextOptions[option.Key] = option.Value; + } + + foreach (var option in testOptionsProvider!.TestGlobalOptions.Options) + { + updatedOptionsProvider.TestGlobalOptions[option.Key] = option.Value; + } + + updatedOptionsProvider.TestGlobalOptions["build_property.SuppressRazorSourceGenerator"] = "true"; + + driver = driver.WithUpdatedAnalyzerConfigOptions(updatedOptionsProvider); + + eventListener.Events.Clear(); + result = RunGenerator(compilation!, ref driver); + + Assert.Empty(result.GeneratedSources); + Assert.Collection(eventListener.Events, + e => Assert.Equal("ComputeRazorSourceGeneratorOptions", e.EventName)); + } + + [Fact] + public async Task SourceGenerator_UsesPreviouslyCachedResults_BetweenSuppressRazorSourceGeneratorsChanges() + { + // When SuppressRazorSourceGenerator=true, we can safely re-use previously cached results instead of + // re-running steps and producing no-op results. + // This test verifies if the suppression changes from false -> true -> false, we can continue using + // results from previously cached results. + // Arrange + using var eventListener = new RazorEventListener(); + var project = CreateTestProject(new() + { + ["Pages/Index.razor"] = "

Hello world

", + ["Pages/Counter.razor"] = +@" +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Web +

Counter

+", + }); + + var compilation = await project.GetCompilationAsync(); + TestAnalyzerConfigOptionsProvider? testOptionsProvider = null; + var (driver, additionalTexts) = await GetDriverWithAdditionalTextAsync(project, optionsProvider => + { + testOptionsProvider = optionsProvider; + optionsProvider.TestGlobalOptions["build_property.SuppressRazorSourceGenerator"] = "false"; + }); + + var result = RunGenerator(compilation!, ref driver); + Assert.Empty(result.Diagnostics); + Assert.Equal(2, result.GeneratedSources.Length); + + var indexText = additionalTexts.First(f => f.Path == "Pages/Index.razor"); + + for (var i = 0; i < 10; i++) + { + // Step 2 + var updatedOptionsProvider = new TestAnalyzerConfigOptionsProvider(); + foreach (var option in testOptionsProvider!.AdditionalTextOptions) + { + updatedOptionsProvider.AdditionalTextOptions[option.Key] = option.Value; + } + + foreach (var option in testOptionsProvider!.TestGlobalOptions.Options) + { + updatedOptionsProvider.TestGlobalOptions[option.Key] = option.Value; + } + + updatedOptionsProvider.TestGlobalOptions["build_property.SuppressRazorSourceGenerator"] = "true"; + + driver = driver.WithUpdatedAnalyzerConfigOptions(updatedOptionsProvider); + eventListener.Events.Clear(); + result = RunGenerator(compilation!, ref driver); + + Assert.Empty(result.GeneratedSources); + + // Step 3 + updatedOptionsProvider = new TestAnalyzerConfigOptionsProvider(); + foreach (var option in testOptionsProvider!.AdditionalTextOptions) + { + updatedOptionsProvider.AdditionalTextOptions[option.Key] = option.Value; + } + + foreach (var option in testOptionsProvider!.TestGlobalOptions.Options) + { + updatedOptionsProvider.TestGlobalOptions[option.Key] = option.Value; + } + + updatedOptionsProvider.TestGlobalOptions["build_property.SuppressRazorSourceGenerator"] = "false"; + driver = driver.WithUpdatedAnalyzerConfigOptions(updatedOptionsProvider); + + // Simulate text change + var updatedText = new TestAdditionalText("Pages/Index.razor", SourceText.From($"

Hello world {(i + 1)}

", Encoding.UTF8)); + driver = driver.ReplaceAdditionalText(indexText, updatedText); + indexText = updatedText; + + eventListener.Events.Clear(); + result = RunGenerator(compilation!, ref driver); + + Assert.Collection( + result.GeneratedSources, + sourceResult => + { + Assert.Contains($"Hello world {(i + 1)}", sourceResult.SourceText.ToString()); + }, + sourceResult => + { + var sourceText = sourceResult.SourceText.ToString(); + Assert.Contains("public partial class Counter", sourceText); + // Regression test for https://github.com/dotnet/aspnetcore/issues/36116. Verify that @onclick is resolved as a component, and not as a regular attribute + Assert.Contains("__builder.AddAttribute(2, \"onclick\", Microsoft.AspNetCore.Components.EventCallback.Factory.Create(this,", sourceText); + }); + + Assert.Collection( + eventListener.Events, + e => Assert.Equal("ComputeRazorSourceGeneratorOptions", e.EventName), + e => + { + Assert.Equal("GenerateDeclarationCodeStart", e.EventName); + var file = Assert.Single(e.Payload); + Assert.Equal("/Pages/Index.razor", file); + }, + e => + { + Assert.Equal("GenerateDeclarationCodeStop", e.EventName); + var file = Assert.Single(e.Payload); + Assert.Equal("/Pages/Index.razor", file); + }, + e => + { + Assert.Equal("RazorCodeGenerateStart", e.EventName); + var file = Assert.Single(e.Payload); + Assert.Equal("/Pages/Index.razor", file); + }, + e => + { + Assert.Equal("RazorCodeGenerateStop", e.EventName); + var file = Assert.Single(e.Payload); + Assert.Equal("/Pages/Index.razor", file); + }, + e => + { + Assert.Equal("AddSyntaxTrees", e.EventName); + var file = Assert.Single(e.Payload); + Assert.Equal("Pages_Index_razor.g.cs", file); + }, + e => + { + Assert.Equal("AddSyntaxTrees", e.EventName); + var file = Assert.Single(e.Payload); + Assert.Equal("Pages_Counter_razor.g.cs", file); + }); + } + } + private static async ValueTask GetDriverAsync(Project project) { var (driver, _) = await GetDriverWithAdditionalTextAsync(project);