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 356bec30ae8..bf9148cd6b3 100644 --- a/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs +++ b/src/TUnit.Core.SourceGenerator/CodeGenerators/Equality/PreventCompilationTriggerOnEveryKeystrokeComparer.cs @@ -1,7 +1,14 @@ -using Microsoft.CodeAnalysis; +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) @@ -26,7 +33,10 @@ 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.ExternalReferences, y.ExternalReferences) + && ReferencesEqual(x.DirectiveReferences, y.DirectiveReferences); } public int GetHashCode(Compilation obj) @@ -36,4 +46,33 @@ public int GetHashCode(Compilation obj) 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 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..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,9 +25,14 @@ public bool Equals(DynamicTestModel? other) return true; } + // 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 && MethodName == other.MethodName - && IsStatic == other.IsStatic; + && IsStatic == other.IsStatic + && FilePath == other.FilePath + && LineNumber == other.LineNumber; } public override bool Equals(object? obj) @@ -45,6 +47,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..2bd2c4f274a --- /dev/null +++ b/tests/TUnit.SourceGenerator.IncrementalTests/InfrastructureGeneratorIncrementalTests.cs @@ -0,0 +1,112 @@ +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()); + } + + [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]; + + TestHelper.AssertRunReason(runResult, InfrastructureGenerator.ExtractAssemblyInfoStep, reason, 0); + } +}