From ee0d347c1cdc74d3a4d067b7794c3e6df4a476c0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:25:17 +0100 Subject: [PATCH 1/3] fix(source-gen): compare every emitted field in hook/dynamic-test models and refresh infrastructure on reference changes HookModel and DynamicTestModel equality ignored fields that feed the generated output (FilePath, LineNumber, return type, parameters, attributes, ...), so the incremental pipeline kept stale generated files after edits. The infrastructure generator's compilation comparer ignored references, so adding or removing a reference never refreshed TUnitInfrastructure.g.cs. Co-Authored-By: Claude --- ...pilationTriggerOnEveryKeystrokeComparer.cs | 60 +++++++++++++- .../CodeGenerators/InfrastructureGenerator.cs | 3 + .../Generators/HookMetadataGenerator.cs | 9 +++ .../Models/Extracted/DynamicTestModel.cs | 12 ++- .../Models/Extracted/HookModel.cs | 32 +++++++- .../DynamicTestsGeneratorIncrementalTests.cs | 19 +++++ .../HookMetadataGeneratorIncrementalTests.cs | 80 +++++++++++++++++++ ...InfrastructureGeneratorIncrementalTests.cs | 75 +++++++++++++++++ 8 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 tests/TUnit.SourceGenerator.IncrementalTests/HookMetadataGeneratorIncrementalTests.cs create mode 100644 tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs diff --git a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs index 356bec30ae8..899eece96de 100644 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs +++ b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs @@ -1,7 +1,13 @@ -using Microsoft.CodeAnalysis; +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 or swapping a reference does not. +/// public class PreventCompilationTriggerOnEveryKeystrokeComparer : IEqualityComparer { public bool Equals(Compilation? x, Compilation? y) @@ -26,7 +32,9 @@ public bool Equals(Compilation? x, Compilation? y) return false; } - return x.Language == y.Language && x.AssemblyName == y.AssemblyName; + return x.Language == y.Language + && x.AssemblyName == y.AssemblyName + && ReferencesEqual(x.References, y.References); } public int GetHashCode(Compilation obj) @@ -36,4 +44,52 @@ public int GetHashCode(Compilation obj) return (obj.Language.GetHashCode() * 397) ^ (obj.AssemblyName != null ? obj.AssemblyName.GetHashCode() : 0); } } + + private static bool ReferencesEqual(IEnumerable x, IEnumerable y) + { + using var xEnumerator = x.GetEnumerator(); + using var yEnumerator = y.GetEnumerator(); + + while (true) + { + var xHasNext = xEnumerator.MoveNext(); + var yHasNext = yEnumerator.MoveNext(); + + if (xHasNext != yHasNext) + { + return false; + } + + if (!xHasNext) + { + return true; + } + + if (!ReferenceEqual(xEnumerator.Current, yEnumerator.Current)) + { + return false; + } + } + } + + private static bool ReferenceEqual(MetadataReference x, MetadataReference y) + { + if (ReferenceEquals(x, y)) + { + return true; + } + + // In the IDE a project reference is a CompilationReference that is recreated whenever the + // referenced project is edited. Comparing it by identity would rerun the reference walk on + // every keystroke in that project, so compare the referenced assembly name instead. + if (x is CompilationReference xCompilation && y is CompilationReference yCompilation) + { + return xCompilation.Compilation.AssemblyName == yCompilation.Compilation.AssemblyName + && xCompilation.Properties.Equals(yCompilation.Properties); + } + + // Hosts reuse PortableExecutableReference instances for unchanged files, so a different + // instance means the reference was added, removed or rebuilt. + return false; + } } diff --git a/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs b/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs index b494c7cff01..a9746741288 100644 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/CodeGenerators/InfrastructureGenerator.cs @@ -46,6 +46,8 @@ public class InfrastructureGenerator : IIncrementalGenerator "TUnit_DynamicTestRegistration", ]; + public const string ExtractAssemblyInfoStep = "ExtractAssemblyInfo"; + public void Initialize(IncrementalGeneratorInitializationContext context) { var enabledProvider = context.AnalyzerConfigOptionsProvider @@ -60,6 +62,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var assemblyInfoProvider = context.CompilationProvider .WithComparer(new PreventCompilationTriggerOnEveryKeystrokeComparer()) .Select((compilation, _) => ExtractAssemblyInfo(compilation)) + .WithTrackingName(ExtractAssemblyInfoStep) .Combine(enabledProvider); context.RegisterSourceOutput(assemblyInfoProvider, (sourceContext, data) => diff --git a/src/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs b/src/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs index 524d4d73b1b..917e20a3d5d 100644 --- a/src/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/Generators/HookMetadataGenerator.cs @@ -16,6 +16,11 @@ namespace TUnit.Core.SourceGenerator.Generators; [Generator] public class HookMetadataGenerator : IIncrementalGenerator { + public const string ExtractBeforeHooks = "ExtractBeforeHooks"; + public const string ExtractAfterHooks = "ExtractAfterHooks"; + public const string ExtractBeforeEveryHooks = "ExtractBeforeEveryHooks"; + public const string ExtractAfterEveryHooks = "ExtractAfterEveryHooks"; + public void Initialize(IncrementalGeneratorInitializationContext context) { var enabledProvider = context.AnalyzerConfigOptionsProvider @@ -31,6 +36,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) predicate: static (node, _) => node is MethodDeclarationSyntax, transform: static (ctx, _) => ExtractHookModel(ctx, "Before")) .Where(static m => m is not null) + .WithTrackingName(ExtractBeforeHooks) .Combine(enabledProvider); var afterHooks = context.SyntaxProvider @@ -39,6 +45,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) predicate: static (node, _) => node is MethodDeclarationSyntax, transform: static (ctx, _) => ExtractHookModel(ctx, "After")) .Where(static m => m is not null) + .WithTrackingName(ExtractAfterHooks) .Combine(enabledProvider); var beforeEveryHooks = context.SyntaxProvider @@ -47,6 +54,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) predicate: static (node, _) => node is MethodDeclarationSyntax, transform: static (ctx, _) => ExtractHookModel(ctx, "BeforeEvery")) .Where(static m => m is not null) + .WithTrackingName(ExtractBeforeEveryHooks) .Combine(enabledProvider); var afterEveryHooks = context.SyntaxProvider @@ -55,6 +63,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) predicate: static (node, _) => node is MethodDeclarationSyntax, transform: static (ctx, _) => ExtractHookModel(ctx, "AfterEvery")) .Where(static m => m is not null) + .WithTrackingName(ExtractAfterEveryHooks) .Combine(enabledProvider); context.RegisterSourceOutput(beforeHooks, GenerateHookFile); diff --git a/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs b/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs index 0b87357f08f..2f34a135b9d 100644 --- a/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs +++ b/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs @@ -28,9 +28,17 @@ public bool Equals(DynamicTestModel? other) return true; } + // FilePath and LineNumber feed both the hint name and the generated body, so they + // must be compared along with everything else that is emitted. return FullyQualifiedTypeName == other.FullyQualifiedTypeName + && MinimalTypeName == other.MinimalTypeName + && Namespace == other.Namespace && MethodName == other.MethodName - && IsStatic == other.IsStatic; + && IsStatic == other.IsStatic + && IsAsync == other.IsAsync + && ReturnType == other.ReturnType + && FilePath == other.FilePath + && LineNumber == other.LineNumber; } public override bool Equals(object? obj) @@ -45,6 +53,8 @@ public override int GetHashCode() var hash = FullyQualifiedTypeName.GetHashCode(); hash = (hash * 397) ^ MethodName.GetHashCode(); hash = (hash * 397) ^ IsStatic.GetHashCode(); + hash = (hash * 397) ^ FilePath.GetHashCode(); + hash = (hash * 397) ^ LineNumber; return hash; } } diff --git a/src/TUnit.Core.SourceGenerator/Models/Extracted/HookModel.cs b/src/TUnit.Core.SourceGenerator/Models/Extracted/HookModel.cs index 8d19b7cf52e..f0aeb69e5a4 100644 --- a/src/TUnit.Core.SourceGenerator/Models/Extracted/HookModel.cs +++ b/src/TUnit.Core.SourceGenerator/Models/Extracted/HookModel.cs @@ -58,13 +58,37 @@ public bool Equals(HookModel? other) return true; } + // Every field feeds the generated hook file (hint name, delegate body, or the hook + // object's metadata such as FilePath/LineNumber), so every field must be compared. + // Otherwise the incremental pipeline treats a changed hook as unchanged and the IDE + // keeps showing stale generated output. return FullyQualifiedTypeName == other.FullyQualifiedTypeName + && MinimalTypeName == other.MinimalTypeName + && Namespace == other.Namespace + && AssemblyName == other.AssemblyName && MethodName == other.MethodName + && FilePath == other.FilePath + && LineNumber == other.LineNumber && HookKind == other.HookKind && HookType == other.HookType && Order == other.Order + && HookExecutorTypeName == other.HookExecutorTypeName + && IsStatic == other.IsStatic + && IsAsync == other.IsAsync + && ReturnsVoid == other.ReturnsVoid + && ReturnType == other.ReturnType && ParameterCount == other.ParameterCount - && IsStatic == other.IsStatic; + && HasCancellationTokenOnly == other.HasCancellationTokenOnly + && HasContextOnly == other.HasContextOnly + && HasContextAndCancellationToken == other.HasContextAndCancellationToken + && FirstParameterTypeName == other.FirstParameterTypeName + && Parameters.Equals(other.Parameters) + && ClassIsGenericType == other.ClassIsGenericType + && ClassIsOpenGeneric == other.ClassIsOpenGeneric + && ClassTypeParameters.Equals(other.ClassTypeParameters) + && MethodInfoExpression == other.MethodInfoExpression + && HookAttribute.Equals(other.HookAttribute) + && MethodAttributes.Equals(other.MethodAttributes); } public override bool Equals(object? obj) @@ -78,11 +102,17 @@ public override int GetHashCode() { var hash = FullyQualifiedTypeName.GetHashCode(); hash = (hash * 397) ^ MethodName.GetHashCode(); + hash = (hash * 397) ^ FilePath.GetHashCode(); + hash = (hash * 397) ^ LineNumber; hash = (hash * 397) ^ HookKind.GetHashCode(); hash = (hash * 397) ^ HookType.GetHashCode(); hash = (hash * 397) ^ Order; hash = (hash * 397) ^ ParameterCount; hash = (hash * 397) ^ IsStatic.GetHashCode(); + hash = (hash * 397) ^ IsAsync.GetHashCode(); + hash = (hash * 397) ^ ReturnType.GetHashCode(); + hash = (hash * 397) ^ Parameters.GetHashCode(); + hash = (hash * 397) ^ MethodAttributes.GetHashCode(); return hash; } } diff --git a/tests/TUnit.SourceGenerator.IncrementalTests/DynamicTestsGeneratorIncrementalTests.cs b/tests/TUnit.SourceGenerator.IncrementalTests/DynamicTestsGeneratorIncrementalTests.cs index bd807bb810c..dc27055539b 100644 --- a/tests/TUnit.SourceGenerator.IncrementalTests/DynamicTestsGeneratorIncrementalTests.cs +++ b/tests/TUnit.SourceGenerator.IncrementalTests/DynamicTestsGeneratorIncrementalTests.cs @@ -84,6 +84,25 @@ public void SecondBuildDynamicTests(DynamicTestBuilderContext context) AssertRunReasons(driver2, IncrementalGeneratorRunReasons.New, 1); } + [Fact] + public void MoveDynamicMethodToDifferentLine_ShouldRegenerate() + { + var syntaxTree = CSharpSyntaxTree.ParseText(DefaultConverter, CSharpParseOptions.Default); + var compilation1 = Fixture.CreateLibrary(syntaxTree); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReasons(driver1, IncrementalGeneratorRunReasons.New); + + // The [CallerLineNumber] captured by DynamicTestBuilder feeds the hint name and the + // generated DynamicTestBuilderContext, so shifting the method must regenerate. + var compilation2 = compilation1.ReplaceSyntaxTree( + syntaxTree, + CSharpSyntaxTree.ParseText(DefaultConverter.Replace("public class DynamicTestArgumentsTests", "\n\npublic class DynamicTestArgumentsTests"), CSharpParseOptions.Default)); + + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReasons(driver2, IncrementalGeneratorRunReasons.Modified); + } + private static void AssertRunReasons( GeneratorDriver driver, IncrementalGeneratorRunReasons reasons, diff --git a/tests/TUnit.SourceGenerator.IncrementalTests/HookMetadataGeneratorIncrementalTests.cs b/tests/TUnit.SourceGenerator.IncrementalTests/HookMetadataGeneratorIncrementalTests.cs new file mode 100644 index 00000000000..20fb7c53fd1 --- /dev/null +++ b/tests/TUnit.SourceGenerator.IncrementalTests/HookMetadataGeneratorIncrementalTests.cs @@ -0,0 +1,80 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TUnit.Core.SourceGenerator.Generators; + +namespace TUnit.Assertions.SourceGenerator.IncrementalTests; + +public class HookMetadataGeneratorIncrementalTests +{ + private const string DefaultSource = + """ + using System.Threading.Tasks; + using TUnit.Core; + + public class HookTests + { + [Before(HookType.Test)] + public void Setup() + { + } + } + """; + + [Fact] + public void AddUnrelatedType_ShouldNotRegenerate() + { + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReason(driver1, IncrementalStepRunReason.New); + + var compilation2 = compilation1.AddSyntaxTrees(CSharpSyntaxTree.ParseText("struct MyValue {}")); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReason(driver2, IncrementalStepRunReason.Cached); + } + + [Fact] + public void MoveHookToDifferentLine_ShouldRegenerate() + { + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReason(driver1, IncrementalStepRunReason.New); + + // Same hook shifted down two lines: the emitted LineNumber must follow it. + var compilation2 = compilation1.ReplaceSyntaxTree( + compilation1.SyntaxTrees.Single(), + CSharpSyntaxTree.ParseText(DefaultSource.Replace("public class HookTests", "\n\npublic class HookTests"), CSharpParseOptions.Default)); + + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReason(driver2, IncrementalStepRunReason.Modified); + } + + [Fact] + public void MakeHookAsync_ShouldRegenerate() + { + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReason(driver1, IncrementalStepRunReason.New); + + var compilation2 = TestHelper.ReplaceMethodDeclaration(compilation1, "Setup", + """ + [Before(HookType.Test)] + public async Task Setup() + { + await Task.Yield(); + } + """); + + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReason(driver2, IncrementalStepRunReason.Modified); + } + + private static void AssertRunReason(GeneratorDriver driver, IncrementalStepRunReason reason) + { + var runResult = driver.GetRunResult().Results[0]; + + TestHelper.AssertRunReason(runResult, HookMetadataGenerator.ExtractBeforeHooks, reason, 0); + } +} diff --git a/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs b/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs new file mode 100644 index 00000000000..99ef85ee077 --- /dev/null +++ b/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs @@ -0,0 +1,75 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using TUnit.Core.SourceGenerator.CodeGenerators; + +namespace TUnit.Assertions.SourceGenerator.IncrementalTests; + +public class InfrastructureGeneratorIncrementalTests +{ + private const string DefaultSource = + """ + using TUnit.Core; + + public class Tests + { + [Test] + public void Test1() + { + } + } + """; + + [Fact] + public void EditSource_ShouldNotRegenerate() + { + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReason(driver1, IncrementalStepRunReason.New); + + var compilation2 = compilation1.AddSyntaxTrees(CSharpSyntaxTree.ParseText("struct MyValue {}")); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReason(driver2, IncrementalStepRunReason.Cached); + + var compilation3 = TestHelper.ReplaceMethodDeclaration(compilation1, "Test1", + """ + [Test] + public void Test1() + { + var x = 1; + } + """); + var driver3 = driver2.RunGenerators(compilation3); + AssertRunReason(driver3, IncrementalStepRunReason.Cached); + } + + [Fact] + public void AddReference_ShouldRegenerate() + { + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReason(driver1, IncrementalStepRunReason.New); + + // A library that references TUnit.Core must be pre-loaded by TUnitInfrastructure.g.cs. + var library = CSharpCompilation.Create( + "OtherTestLibrary", + [CSharpSyntaxTree.ParseText("namespace OtherTestLibrary { public class SharedHooks { [TUnit.Core.Before(TUnit.Core.HookType.Assembly)] public static void Setup() { } } }")], + compilation1.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var compilation2 = compilation1.AddReferences(library.ToMetadataReference()); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReason(driver2, IncrementalStepRunReason.Modified); + + var generated = driver2.GetRunResult().GeneratedTrees.Single(t => t.FilePath.EndsWith("TUnitInfrastructure.g.cs")); + Xunit.Assert.Contains("global::OtherTestLibrary.SharedHooks", generated.ToString()); + } + + private static void AssertRunReason(GeneratorDriver driver, IncrementalStepRunReason reason) + { + var runResult = driver.GetRunResult().Results[0]; + + TestHelper.AssertRunReason(runResult, InfrastructureGenerator.ExtractAssemblyInfoStep, reason, 0); + } +} From ff413f95b3e4b5d165c62f77abef750586319f45 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:45:38 +0100 Subject: [PATCH 2/3] fix(source-gen): rerun infrastructure extraction when a project reference is edited Compare metadata references by identity only. An IDE project reference is a new CompilationReference after each edit to the referenced project, and the old name-based comparison kept a stale typeof() in TUnitInfrastructure.g.cs when that edit renamed or removed the selected public type. AssemblyInfoModel equality still keeps the output cached when the extracted model does not change. Also remove DynamicTestModel.Namespace, IsAsync and ReturnType. The generator never emitted them, so they only caused needless regeneration. Co-Authored-By: Claude --- .../CodeGenerators/DynamicTestsGenerator.cs | 3 -- ...pilationTriggerOnEveryKeystrokeComparer.cs | 30 ++++----------- .../Models/Extracted/DynamicTestModel.cs | 10 +---- ...InfrastructureGeneratorIncrementalTests.cs | 37 +++++++++++++++++++ 4 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/CodeGenerators/DynamicTestsGenerator.cs b/src/TUnit.Core.SourceGenerator/CodeGenerators/DynamicTestsGenerator.cs index d2c2cd989b6..70defd07d4b 100644 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/DynamicTestsGenerator.cs +++ b/src/TUnit.Core.SourceGenerator/CodeGenerators/DynamicTestsGenerator.cs @@ -73,11 +73,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { FullyQualifiedTypeName = containingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), MinimalTypeName = containingType.Name, - Namespace = containingType.ContainingNamespace?.ToDisplayString() ?? string.Empty, MethodName = methodSymbol.Name, IsStatic = methodSymbol.IsStatic, - IsAsync = methodSymbol.IsAsync, - ReturnType = methodSymbol.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), FilePath = filePath, LineNumber = lineNumber }; diff --git a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs index 899eece96de..77eccd0c03e 100644 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs +++ b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs @@ -6,7 +6,7 @@ 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 or swapping a reference does not. +/// downstream reference walk is skipped. Adding, removing, rebuilding or editing a reference does not. /// public class PreventCompilationTriggerOnEveryKeystrokeComparer : IEqualityComparer { @@ -65,31 +65,15 @@ private static bool ReferencesEqual(IEnumerable x, IEnumerabl return true; } - if (!ReferenceEqual(xEnumerator.Current, yEnumerator.Current)) + // 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(xEnumerator.Current, yEnumerator.Current)) { return false; } } } - - private static bool ReferenceEqual(MetadataReference x, MetadataReference y) - { - if (ReferenceEquals(x, y)) - { - return true; - } - - // In the IDE a project reference is a CompilationReference that is recreated whenever the - // referenced project is edited. Comparing it by identity would rerun the reference walk on - // every keystroke in that project, so compare the referenced assembly name instead. - if (x is CompilationReference xCompilation && y is CompilationReference yCompilation) - { - return xCompilation.Compilation.AssemblyName == yCompilation.Compilation.AssemblyName - && xCompilation.Properties.Equals(yCompilation.Properties); - } - - // Hosts reuse PortableExecutableReference instances for unchanged files, so a different - // instance means the reference was added, removed or rebuilt. - return false; - } } diff --git a/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs b/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs index 2f34a135b9d..03eafecfed4 100644 --- a/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs +++ b/src/TUnit.Core.SourceGenerator/Models/Extracted/DynamicTestModel.cs @@ -8,11 +8,8 @@ public sealed class DynamicTestModel : IEquatable { public required string FullyQualifiedTypeName { get; init; } public required string MinimalTypeName { get; init; } - public required string Namespace { get; init; } public required string MethodName { get; init; } public required bool IsStatic { get; init; } - public required bool IsAsync { get; init; } - public required string ReturnType { get; init; } public required string FilePath { get; init; } public required int LineNumber { get; init; } @@ -28,15 +25,12 @@ public bool Equals(DynamicTestModel? other) return true; } - // FilePath and LineNumber feed both the hint name and the generated body, so they - // must be compared along with everything else that is emitted. + // Every field feeds the generated source (FilePath and LineNumber also feed the hint name), + // so all of them must be compared. return FullyQualifiedTypeName == other.FullyQualifiedTypeName && MinimalTypeName == other.MinimalTypeName - && Namespace == other.Namespace && MethodName == other.MethodName && IsStatic == other.IsStatic - && IsAsync == other.IsAsync - && ReturnType == other.ReturnType && FilePath == other.FilePath && LineNumber == other.LineNumber; } diff --git a/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs b/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs index 99ef85ee077..2bd2c4f274a 100644 --- a/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs +++ b/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs @@ -66,6 +66,43 @@ public void AddReference_ShouldRegenerate() Xunit.Assert.Contains("global::OtherTestLibrary.SharedHooks", generated.ToString()); } + [Fact] + public void EditProjectReference_ShouldRegenerate() + { + var compilation1 = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(DefaultSource, CSharpParseOptions.Default)); + + var library1 = CreateHookLibrary(compilation1, "SharedHooks"); + compilation1 = compilation1.AddReferences(library1.ToMetadataReference()); + + var driver1 = TestHelper.GenerateTracked(compilation1); + AssertRunReason(driver1, IncrementalStepRunReason.New); + + // In the IDE, editing a referenced project replaces its CompilationReference while the + // assembly name stays the same. Renaming the only public type must reach the output, + // otherwise TUnitInfrastructure.g.cs keeps a typeof() to a type that no longer exists. + var library2 = CreateHookLibrary(compilation1, "RenamedHooks"); + var compilation2 = compilation1.ReplaceReference(compilation1.References.Last(), library2.ToMetadataReference()); + var driver2 = driver1.RunGenerators(compilation2); + AssertRunReason(driver2, IncrementalStepRunReason.Modified); + + var generated = driver2.GetRunResult().GeneratedTrees.Single(t => t.FilePath.EndsWith("TUnitInfrastructure.g.cs")).ToString(); + Xunit.Assert.Contains("global::OtherTestLibrary.RenamedHooks", generated); + Xunit.Assert.DoesNotContain("global::OtherTestLibrary.SharedHooks", generated); + + // An edit that does not change the extracted model reruns extraction but keeps the output cached. + var library3 = CreateHookLibrary(compilation1, "RenamedHooks"); + var compilation3 = compilation2.ReplaceReference(compilation2.References.Last(), library3.ToMetadataReference()); + var driver3 = driver2.RunGenerators(compilation3); + AssertRunReason(driver3, IncrementalStepRunReason.Unchanged); + } + + private static CSharpCompilation CreateHookLibrary(Compilation consumer, string typeName) => + CSharpCompilation.Create( + "OtherTestLibrary", + [CSharpSyntaxTree.ParseText($"namespace OtherTestLibrary {{ public class {typeName} {{ [TUnit.Core.Before(TUnit.Core.HookType.Assembly)] public static void Setup() {{ }} }} }}")], + consumer.References.Where(r => r is not CompilationReference), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + private static void AssertRunReason(GeneratorDriver driver, IncrementalStepRunReason reason) { var runResult = driver.GetRunResult().Results[0]; From 24f8ff64ccbea73033377c865766e7a7ec99c6e7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:21:44 +0100 Subject: [PATCH 3/3] perf(source-gen): compare compilation references without enumerator allocations Compare ExternalReferences and DirectiveReferences as immutable arrays, with a same-array fast path and a length check before the per-reference identity walk. --- ...pilationTriggerOnEveryKeystrokeComparer.cs | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs index 77eccd0c03e..bf9148cd6b3 100644 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs +++ b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; namespace TUnit.Core.SourceGenerator.CodeGenerators.Equality; @@ -34,7 +35,8 @@ public bool Equals(Compilation? x, Compilation? y) return x.Language == y.Language && x.AssemblyName == y.AssemblyName - && ReferencesEqual(x.References, y.References); + && ReferencesEqual(x.ExternalReferences, y.ExternalReferences) + && ReferencesEqual(x.DirectiveReferences, y.DirectiveReferences); } public int GetHashCode(Compilation obj) @@ -45,35 +47,32 @@ public int GetHashCode(Compilation obj) } } - private static bool ReferencesEqual(IEnumerable x, IEnumerable y) + private static bool ReferencesEqual(ImmutableArray x, ImmutableArray y) { - using var xEnumerator = x.GetEnumerator(); - using var yEnumerator = y.GetEnumerator(); - - while (true) + // Syntax-only edits usually keep the same backing array, so this avoids the element walk. + if (x == y) { - var xHasNext = xEnumerator.MoveNext(); - var yHasNext = yEnumerator.MoveNext(); - - if (xHasNext != yHasNext) - { - return false; - } + return true; + } - if (!xHasNext) - { - 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(xEnumerator.Current, yEnumerator.Current)) + if (!ReferenceEquals(x[i], y[i])) { return false; } } + + return true; } }