Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
using Microsoft.CodeAnalysis;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;

namespace TUnit.Core.SourceGenerator.CodeGenerators.Equality;

/// <summary>
/// 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 <see cref="MetadataReference"/> instances, so they compare equal and the
/// downstream reference walk is skipped. Adding, removing, rebuilding or editing a reference does not.
/// </summary>
public class PreventCompilationTriggerOnEveryKeystrokeComparer : IEqualityComparer<Compilation>
{
public bool Equals(Compilation? x, Compilation? y)
Expand All @@ -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)
Expand All @@ -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<MetadataReference> x, ImmutableArray<MetadataReference> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public class InfrastructureGenerator : IIncrementalGenerator
"TUnit_DynamicTestRegistration",
];

public const string ExtractAssemblyInfoStep = "ExtractAssemblyInfo";

public void Initialize(IncrementalGeneratorInitializationContext context)
{
var enabledProvider = context.AnalyzerConfigOptionsProvider
Expand All @@ -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) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,8 @@ public sealed class DynamicTestModel : IEquatable<DynamicTestModel>
{
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; }

Expand All @@ -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)
Expand All @@ -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;
}
}
Expand Down
32 changes: 31 additions & 1 deletion src/TUnit.Core.SourceGenerator/Models/Extracted/HookModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DynamicTestsGenerator>(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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HookMetadataGenerator>(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<HookMetadataGenerator>(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<HookMetadataGenerator>(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);
}
}
Loading
Loading