diff --git a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs deleted file mode 100644 index bf9148cd6b3..00000000000 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; - -namespace TUnit.Core.SourceGenerator.CodeGenerators.Equality; - -/// -/// Treats two compilations as equal when they would produce the same reference-derived output: -/// same language, assembly name and metadata references. Syntax-only edits (ordinary keystrokes) -/// keep the same instances, so they compare equal and the -/// downstream reference walk is skipped. Adding, removing, rebuilding or editing a reference does not. -/// -public class PreventCompilationTriggerOnEveryKeystrokeComparer : IEqualityComparer -{ - public bool Equals(Compilation? x, Compilation? y) - { - if (ReferenceEquals(x, y)) - { - return true; - } - - if (x is null) - { - return false; - } - - if (y is null) - { - return false; - } - - if (x.GetType() != y.GetType()) - { - return false; - } - - return x.Language == y.Language - && x.AssemblyName == y.AssemblyName - && ReferencesEqual(x.ExternalReferences, y.ExternalReferences) - && ReferencesEqual(x.DirectiveReferences, y.DirectiveReferences); - } - - public int GetHashCode(Compilation obj) - { - unchecked - { - return (obj.Language.GetHashCode() * 397) ^ (obj.AssemblyName != null ? obj.AssemblyName.GetHashCode() : 0); - } - } - - private static bool ReferencesEqual(ImmutableArray x, ImmutableArray y) - { - // Syntax-only edits usually keep the same backing array, so this avoids the element walk. - if (x == y) - { - return true; - } - - if (x.Length != y.Length) - { - return false; - } - - for (var i = 0; i < x.Length; i++) - { - // Hosts reuse reference instances while the referenced file or project is unchanged. - // A new instance means the reference was added, rebuilt or (for an IDE project - // reference) its source was edited, which can change the types and dependencies the - // reference walk selects. Rerun extraction then; AssemblyInfoModel equality keeps the - // generated source cached when the extracted model is unchanged. - if (!ReferenceEquals(x[i], y[i])) - { - return false; - } - } - - return true; - } -} diff --git a/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs b/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs index a9746741288..ff310d78f3f 100644 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs @@ -1,5 +1,5 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using TUnit.Core.SourceGenerator.CodeGenerators.Equality; using TUnit.Core.SourceGenerator.Models; using TUnit.Core.SourceGenerator.Models.Extracted; @@ -57,11 +57,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); }); - // Extract assembly names as primitives in the transform step - // This enables proper incremental caching + // Extract assembly names as primitives in the transform step. AssemblyInfoModel equality + // keeps the generated source cached across keystrokes. No custom Compilation comparer here: + // when a comparer reports "equal", the input node keeps the OLD compilation in its table, + // pinning it (trees, bound state) until references change. The reference walk is memoized + // per driver instead, so syntax-only edits still skip it. + var memo = new AssemblyInfoMemo(); var assemblyInfoProvider = context.CompilationProvider - .WithComparer(new PreventCompilationTriggerOnEveryKeystrokeComparer()) - .Select((compilation, _) => ExtractAssemblyInfo(compilation)) + .Select((compilation, _) => memo.GetAssemblyInfo(compilation)) .WithTrackingName(ExtractAssemblyInfoStep) .Combine(enabledProvider); @@ -77,13 +80,110 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } + /// + /// Single-slot memo of the reference walk, created per and so owned by one + /// generator driver. The walk costs several milliseconds per run on a real test project (about + /// 6.5 ms for TUnit.TestProject's 211 references), and in the IDE it would otherwise run on every + /// keystroke. + /// + /// The key is every compilation input the walk reads except source: + /// (compared with + /// ==, which compares the backing array by reference; syntax-only edits reuse it, while any + /// reference change produces a new one), the instance and the + /// assembly name. Scripts are never memoized. The one source dependency, a source type shadowing a + /// selected type, is re-checked on every hit. + /// + /// + /// The slot only ever describes the latest compilation this driver saw: every miss replaces or + /// clears it. It holds that compilation's reference list and options (which the live compilation + /// holds anyway), never a or syntax tree, and it dies with the driver. + /// + /// + internal sealed class AssemblyInfoMemo + { + // Racy by design: concurrent runs may both miss, both extract and both store. Entries are + // immutable and each equals a fresh extraction for its key, so the only cost is duplicate work. + private volatile Entry? _last; + + public AssemblyInfoModel GetAssemblyInfo(Compilation compilation) + { + // Script submissions read inputs outside the key (#r directive references, the previous + // submission), so they always extract fresh. + if (compilation.ScriptCompilationInfo is not null || !compilation.DirectiveReferences.IsEmpty) + { + _last = null; + return ExtractAssemblyInfo(compilation, out _); + } + + var last = _last; + if (last is not null + && last.References == compilation.ExternalReferences + && ReferenceEquals(last.Options, compilation.Options) + && last.AssemblyName == compilation.AssemblyName + && !IsAnyShadowedBySource(compilation, last.SelectedMetadataNames)) + { + return last.Model; + } + + var model = ExtractAssemblyInfo(compilation, out var selectedMetadataNames); + _last = selectedMetadataNames is null + ? null + : new Entry(compilation.ExternalReferences, compilation.Options, compilation.AssemblyName, model, selectedMetadataNames); + return model; + } + + private sealed class Entry( + ImmutableArray references, + CompilationOptions options, + string? assemblyName, + AssemblyInfoModel model, + string[] selectedMetadataNames) + { + public ImmutableArray References { get; } = references; + + public CompilationOptions Options { get; } = options; + + public string? AssemblyName { get; } = assemblyName; + + public AssemblyInfoModel Model { get; } = model; + + public string[] SelectedMetadataNames { get; } = selectedMetadataNames; + } + } + + private static bool IsAnyShadowedBySource(Compilation compilation, string[] selectedMetadataNames) + { + var sourceAssembly = compilation.Assembly; + foreach (var metadataName in selectedMetadataNames) + { + if (sourceAssembly.GetTypeByMetadataName(metadataName) is not null) + { + return true; + } + } + + return false; + } + /// /// Extracts all needed data as primitives in the transform step. /// This enables proper incremental caching - the model contains only strings. + /// + /// The result is memoized by across syntax-only edits, so any + /// dependency on source must be reported through (as the + /// shadowing check does) so that reuse can re-check it. + /// /// - private static AssemblyInfoModel ExtractAssemblyInfo(Compilation compilation) + /// The compilation to inspect. + /// + /// Metadata names of the selected types, or when a source type shadowed a + /// candidate, in which case the result depends on source and must not be memoized. + /// + private static AssemblyInfoModel ExtractAssemblyInfo(Compilation compilation, out string[]? selectedMetadataNames) { var assembliesToLoad = new List(); + var metadataNames = new List(); + var shadowedBySource = false; // Find TUnit.Core assembly - only assemblies referencing this can contain tests var tunitCoreAssembly = FindTUnitCoreAssembly(compilation); @@ -122,14 +222,17 @@ private static AssemblyInfoModel ExtractAssemblyInfo(Compilation compilation) { if (ShouldLoadAssembly(assembly, compilation)) { - var publicType = GetFirstUniquePublicType(assembly, compilation); + var publicType = GetFirstUniquePublicType(assembly, compilation, out var metadataName, ref shadowedBySource); if (publicType != null) { assembliesToLoad.Add(publicType); + metadataNames.Add(metadataName!); } } } + selectedMetadataNames = shadowedBySource ? null : [.. metadataNames]; + return new AssemblyInfoModel { AssemblyName = compilation.Assembly.Name, @@ -276,7 +379,11 @@ private static bool IsLoadableAtRuntime(IAssemblySymbol assembly, Compilation co /// Gets the first public type from an assembly that can be uniquely resolved by the compilation. /// This avoids CS0433 errors when multiple assemblies define types with the same fully-qualified name. /// - private static string? GetFirstUniquePublicType(IAssemblySymbol assembly, Compilation compilation) + private static string? GetFirstUniquePublicType( + IAssemblySymbol assembly, + Compilation compilation, + out string? selectedMetadataName, + ref bool shadowedBySource) { foreach (var type in GetPublicTypesRecursive(assembly.GlobalNamespace)) { @@ -293,10 +400,12 @@ private static bool IsLoadableAtRuntime(IAssemblySymbol assembly, Compilation co // GetTypeByMetadataName returns null when the type name is ambiguous if (SymbolEqualityComparer.Default.Equals(resolvedType, type)) { + selectedMetadataName = metadataName; return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); } // null or different type = ambiguous, try next type + shadowedBySource |= IsFromSource(resolvedType, compilation); } // Fallback: try generic types if no non-generic unique type was found @@ -327,13 +436,23 @@ private static bool IsLoadableAtRuntime(IAssemblySymbol assembly, Compilation co typeName = typeName.Substring(0, genericStart) + openGenericSuffix; } + selectedMetadataName = metadataName; return typeName; } + + shadowedBySource |= IsFromSource(resolvedType, compilation); } + selectedMetadataName = null; return null; // No unique type found, skip this assembly } + // GetTypeByMetadataName returns the compilation's own (source) type when one exists, hiding + // any referenced type with the same metadata name. + private static bool IsFromSource(INamedTypeSymbol? resolvedType, Compilation compilation) => + resolvedType is not null + && SymbolEqualityComparer.Default.Equals(resolvedType.ContainingAssembly, compilation.Assembly); + /// /// Gets the full metadata name for a type (e.g., "Namespace.OuterClass+NestedClass"). /// This is the format expected by Compilation.GetTypeByMetadataName(). diff --git a/src/TUnit.Core.SourceGenerator/Properties/AssemblyInfo.cs b/src/TUnit.Core.SourceGenerator/Properties/AssemblyInfo.cs new file mode 100644 index 00000000000..ddb03cd92b2 --- /dev/null +++ b/src/TUnit.Core.SourceGenerator/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("TUnit.SourceGenerator.IncrementalTests")] diff --git a/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs b/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs index 2bd2c4f274a..cc67303266c 100644 --- a/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs +++ b/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using TUnit.Core.SourceGenerator.CodeGenerators; @@ -27,9 +28,12 @@ public void EditSource_ShouldNotRegenerate() var driver1 = TestHelper.GenerateTracked(compilation1); AssertRunReason(driver1, IncrementalStepRunReason.New); + // Syntax-only edits rerun the cheap Select, whose model is unchanged, so the generated source + // stays cached. Memo_SyntaxOnlyEdits_ReuseModel checks that the reference walk is skipped. var compilation2 = compilation1.AddSyntaxTrees(CSharpSyntaxTree.ParseText("struct MyValue {}")); var driver2 = driver1.RunGenerators(compilation2); - AssertRunReason(driver2, IncrementalStepRunReason.Cached); + AssertRunReason(driver2, IncrementalStepRunReason.Unchanged); + TestHelper.AssertSourceOutputsCached(driver2.GetRunResult().Results[0]); var compilation3 = TestHelper.ReplaceMethodDeclaration(compilation1, "Test1", """ @@ -40,9 +44,52 @@ public void Test1() } """); var driver3 = driver2.RunGenerators(compilation3); - AssertRunReason(driver3, IncrementalStepRunReason.Cached); + AssertRunReason(driver3, IncrementalStepRunReason.Unchanged); + TestHelper.AssertSourceOutputsCached(driver3.GetRunResult().Results[0]); } + [Fact] + public void EditSource_DoesNotRetainPreviousCompilation() + { + var (driver, firstCompilation, latestCompilation) = RunSourceEdits(10); + + for (var i = 0; i < 3; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + // A comparer that reports the new compilation as equal makes the input node keep the old + // one, pinning its syntax trees and bound state for as long as the driver lives. + Xunit.Assert.False(firstCompilation.IsAlive, "The generator driver kept the first compilation alive after syntax-only edits."); + GC.KeepAlive(driver); + GC.KeepAlive(latestCompilation); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static (GeneratorDriver Driver, WeakReference FirstCompilation, Compilation LatestCompilation) RunSourceEdits(int edits) + { + Compilation compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + var firstCompilation = new WeakReference(compilation); + + var driver = CSharpGeneratorDriver + .Create([new InfrastructureGenerator().AsSourceGenerator()]) + .RunGenerators(compilation); + + for (var i = 0; i < edits; i++) + { + compilation = compilation.ReplaceSyntaxTree( + compilation.SyntaxTrees.First(), + CSharpSyntaxTree.ParseText(DefaultSource + $"\npublic class Edit{i} {{ }}", CSharpParseOptions.Default)); + driver = driver.RunGenerators(compilation); + } + + return (driver, firstCompilation, compilation); + } + + private static object? GetExtractedModel(GeneratorDriver driver) => + driver.GetRunResult().Results[0].TrackedSteps[InfrastructureGenerator.ExtractAssemblyInfoStep][0].Outputs[0].Value; + [Fact] public void AddReference_ShouldRegenerate() { @@ -96,6 +143,161 @@ public void EditProjectReference_ShouldRegenerate() AssertRunReason(driver3, IncrementalStepRunReason.Unchanged); } + private const string ShadowingSource = "namespace OtherTestLibrary { public class FirstHooks { } }"; + + [Fact] + public void FreshDriver_SameReferences_DoesNotShareMemo() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + + // The memo belongs to one driver, so an unrelated driver over the same reference list + // extracts fresh instead of reusing another driver's result. + var first = GetExtractedModel(TestHelper.GenerateTracked(compilation)); + var second = GetExtractedModel(TestHelper.GenerateTracked(compilation)); + Xunit.Assert.NotSame(first, second); + Xunit.Assert.Equal(first, second); + } + + [Fact] + public void EditSource_SourceSensitiveSelectionIsNotReused() + { + var baseCompilation = CreateWithTwoTypeLibrary(); + var shadowedCompilation = baseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(ShadowingSource)); + + // The shadowed run must not memoize its (source-dependent) choice for later compilations. + var driver1 = TestHelper.GenerateTracked(shadowedCompilation); + Xunit.Assert.Contains("typeof(global::OtherTestLibrary.SecondHooks)", GenerateInfrastructure(driver1)); + + var driver2 = driver1.RunGenerators(baseCompilation); + AssertRunReason(driver2, IncrementalStepRunReason.Modified); + Xunit.Assert.Contains("typeof(global::OtherTestLibrary.FirstHooks)", GenerateInfrastructure(driver2)); + } + + [Fact] + public void EditSource_ShadowingSelectedType_ShouldRegenerate() + { + var compilation1 = CreateWithTwoTypeLibrary(); + var driver1 = TestHelper.GenerateTracked(compilation1); + Xunit.Assert.Contains("typeof(global::OtherTestLibrary.FirstHooks)", GenerateInfrastructure(driver1)); + + var compilation2 = compilation1.AddSyntaxTrees(CSharpSyntaxTree.ParseText(ShadowingSource)); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReason(driver2, IncrementalStepRunReason.Modified); + Xunit.Assert.Contains("typeof(global::OtherTestLibrary.SecondHooks)", GenerateInfrastructure(driver2)); + + var driver3 = driver2.RunGenerators(compilation1); + AssertRunReason(driver3, IncrementalStepRunReason.Modified); + Xunit.Assert.Contains("typeof(global::OtherTestLibrary.FirstHooks)", GenerateInfrastructure(driver3)); + } + + // The driver keeps the previous output instance whenever a rerun produces an equal model, so + // whether the walk was skipped is only observable on the memo itself: it returns the same + // instance on a hit and a new one after re-extracting. + + [Fact] + public void Memo_SyntaxOnlyEdits_ReuseModel() + { + var memo = new InfrastructureGenerator.AssemblyInfoMemo(); + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + var model1 = memo.GetAssemblyInfo(compilation1); + + var compilation2 = compilation1.AddSyntaxTrees(CSharpSyntaxTree.ParseText("struct MyValue {}")); + Xunit.Assert.Same(model1, memo.GetAssemblyInfo(compilation2)); + + var compilation3 = TestHelper.ReplaceMethodDeclaration(compilation1, "Test1", + """ + [Test] + public void Test1() + { + var x = 1; + } + """); + Xunit.Assert.Same(model1, memo.GetAssemblyInfo(compilation3)); + } + + [Fact] + public void Memo_ReferenceChange_ReExtracts() + { + var memo = new InfrastructureGenerator.AssemblyInfoMemo(); + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + var model1 = memo.GetAssemblyInfo(compilation1); + + var compilation2 = compilation1.AddReferences(CreateHookLibrary(compilation1, "SharedHooks").ToMetadataReference()); + var model2 = memo.GetAssemblyInfo(compilation2); + Xunit.Assert.NotSame(model1, model2); + Xunit.Assert.NotEqual(model1, model2); + } + + [Fact] + public void Memo_OptionsOrAssemblyNameChange_ReExtracts() + { + var memo = new InfrastructureGenerator.AssemblyInfoMemo(); + var baseCompilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + var baseModel = memo.GetAssemblyInfo(baseCompilation); + + // Same reference list, but options can change how references bind to assembly symbols, + // so a new options instance must not reuse the model extracted under the old one. + var withComparer = baseCompilation.WithOptions( + baseCompilation.Options.WithAssemblyIdentityComparer(DesktopAssemblyIdentityComparer.Default)); + Xunit.Assert.True(baseCompilation.ExternalReferences == withComparer.ExternalReferences); + var comparerModel = memo.GetAssemblyInfo(withComparer); + Xunit.Assert.NotSame(baseModel, comparerModel); + Xunit.Assert.Equal(baseModel, comparerModel); + + var withImportOptions = baseCompilation.WithOptions( + baseCompilation.Options.WithMetadataImportOptions(MetadataImportOptions.All)); + var importModel = memo.GetAssemblyInfo(withImportOptions); + Xunit.Assert.NotSame(comparerModel, importModel); + + // Syntax-only edits under the new options reuse the memo again. + Xunit.Assert.Same(importModel, memo.GetAssemblyInfo( + withImportOptions.AddSyntaxTrees(CSharpSyntaxTree.ParseText("struct MyValue {}")))); + + var renamed = withImportOptions.WithAssemblyName("Renamed"); + Xunit.Assert.True(withImportOptions.ExternalReferences == renamed.ExternalReferences); + var renamedModel = memo.GetAssemblyInfo(renamed); + Xunit.Assert.NotSame(importModel, renamedModel); + Xunit.Assert.Equal("Renamed", renamedModel.AssemblyName); + } + + [Fact] + public void Memo_ScriptCompilation_IsNeverMemoized() + { + var memo = new InfrastructureGenerator.AssemblyInfoMemo(); + var baseCompilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + var baseModel = memo.GetAssemblyInfo(baseCompilation); + + // Script submissions read inputs outside the memo key (#r directives, the previous + // submission and the internal ReferencesSupersedeLowerVersions option), so they always + // extract fresh. + var script = CSharpCompilation.CreateScriptCompilation( + baseCompilation.AssemblyName!, + CSharpSyntaxTree.ParseText("var x = 1;", CSharpParseOptions.Default.WithKind(SourceCodeKind.Script)), + baseCompilation.ExternalReferences); + Xunit.Assert.True(baseCompilation.ExternalReferences == script.ExternalReferences); + + var first = memo.GetAssemblyInfo(script); + var second = memo.GetAssemblyInfo(script.ReplaceSyntaxTree( + script.SyntaxTrees.Single(), + CSharpSyntaxTree.ParseText("var y = 2;", CSharpParseOptions.Default.WithKind(SourceCodeKind.Script)))); + Xunit.Assert.NotSame(baseModel, first); + Xunit.Assert.NotSame(first, second); + } + + private static CSharpCompilation CreateWithTwoTypeLibrary() + { + var consumer = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + var library = CSharpCompilation.Create( + "OtherTestLibrary", + [CSharpSyntaxTree.ParseText("namespace OtherTestLibrary { public class FirstHooks { [TUnit.Core.Before(TUnit.Core.HookType.Assembly)] public static void Setup() { } } public class SecondHooks { } }")], + consumer.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + return consumer.AddReferences(library.ToMetadataReference()); + } + + private static string GenerateInfrastructure(GeneratorDriver driver) => + driver.GetRunResult().GeneratedTrees.Single(t => t.FilePath.EndsWith("TUnitInfrastructure.g.cs")).ToString(); + private static CSharpCompilation CreateHookLibrary(Compilation consumer, string typeName) => CSharpCompilation.Create( "OtherTestLibrary",