diff --git a/README.md b/README.md
index 70411c07bc..938320af94 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,7 @@ See the [documentation](https://tunit.dev/docs/getting-started/attributes) for m
| `TUnit.Core` | Shared test library components without an execution engine |
| `TUnit.Engine` | Execution engine for test projects |
| `TUnit.Assertions` | Standalone assertions — works with other test frameworks too |
+| `TUnit.Assertions.Should` | Optional FluentAssertions-style `value.Should().BeEqualTo(...)` syntax over `TUnit.Assertions` (beta) |
| `TUnit.Playwright` | Playwright integration with automatic browser lifecycle management |
## Migrating from xUnit, NUnit, or MSTest?
diff --git a/SharedTestHelpers/AnalyzerTestCompatibility.cs b/SharedTestHelpers/AnalyzerTestCompatibility.cs
new file mode 100644
index 0000000000..57470524d0
--- /dev/null
+++ b/SharedTestHelpers/AnalyzerTestCompatibility.cs
@@ -0,0 +1,63 @@
+using System;
+using System.IO;
+using System.Reflection;
+
+namespace TUnit.Tests.Shared;
+
+///
+/// Shared resolution helper for the analyzer-test framework's reference-assembly mismatch.
+///
+///
+///
+/// The Microsoft.CodeAnalysis.Testing 1.1.2 framework targets net9.0 reference assemblies. Loading
+/// TUnit's net10.0 builds via typeof(...).Assembly.Location raises CS1705 (referenced
+/// assembly has a higher System.Runtime version), which the verifier suppresses via
+/// CompilerDiagnostics = None. The suppression silently breaks symbol resolution for every
+/// extension method (IsEqualTo, Throws, etc.) in the test compilation — analyzers find nothing
+/// to flag and tests report "expected 1 diagnostic, actual 0" with no compiler errors visible.
+///
+///
+/// The fix is to load the netstandard2.0 builds (compatible with both Net90 ref assemblies and the
+/// test runtime). Each analyzer test csproj copies the relevant netstandard2.0 build into the test
+/// bin via a <None Include="..." Link="X.netstandard2.0.dll" CopyToOutputDirectory="...">
+/// item; this helper resolves that copy and fails fast if it is missing. Falling back to the
+/// runtime assembly path would silently reintroduce the CS1705/symbol-resolution failure this
+/// helper exists to avoid.
+///
+///
+/// Linked into multiple test projects via <Compile Include="..\SharedTestHelpers\...">;
+/// see TUnit.Assertions.Should.SourceGenerator for the same pattern with
+/// CovarianceHelper/EquatableArray.
+///
+///
+internal static class AnalyzerTestCompatibility
+{
+ public static string GetCompatibleDllPath(string assemblyName, Assembly fallback)
+ {
+ var ns20Path = Path.Combine(AppContext.BaseDirectory, $"{assemblyName}.netstandard2.0.dll");
+ if (!File.Exists(ns20Path))
+ {
+ throw new FileNotFoundException(
+ $"netstandard2.0 build of {assemblyName} not found at '{ns20Path}'. " +
+ "Run 'dotnet build' before running analyzer tests.",
+ ns20Path);
+ }
+
+ return ns20Path;
+ }
+
+ public static string GetSystemTextJson9DllPath()
+ {
+ const string fileName = "System.Text.Json.9.0.dll";
+ var path = Path.Combine(AppContext.BaseDirectory, fileName);
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException(
+ $"System.Text.Json 9.0 reference not found at '{path}'. " +
+ "Run 'dotnet build' before running analyzer tests.",
+ path);
+ }
+
+ return path;
+ }
+}
diff --git a/TUnit.Analyzers.Tests/AnalyzerTestHelpers.cs b/TUnit.Analyzers.Tests/AnalyzerTestHelpers.cs
index cfc1a476b4..2c7c8faf8f 100644
--- a/TUnit.Analyzers.Tests/AnalyzerTestHelpers.cs
+++ b/TUnit.Analyzers.Tests/AnalyzerTestHelpers.cs
@@ -34,9 +34,12 @@ public static CSharpAnalyzerTest CreateAnalyzerTest<
csTest.TestState.AdditionalReferences
.AddRange(
[
- MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
+#if NET8_0
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetSystemTextJson9DllPath()),
+#endif
MetadataReference.CreateFromFile(typeof(CircuitState).Assembly.Location),
- MetadataReference.CreateFromFile(typeof(ProjectReferenceEnum).Assembly.Location)
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.TestProject.Library", typeof(ProjectReferenceEnum).Assembly))
]
);
@@ -145,9 +148,12 @@ public static CSharpSuppressorTest CreateSuppresso
test.TestState.AdditionalReferences
.AddRange([
- MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
+#if NET8_0
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetSystemTextJson9DllPath()),
+#endif
MetadataReference.CreateFromFile(typeof(CircuitState).Assembly.Location),
- MetadataReference.CreateFromFile(typeof(ProjectReferenceEnum).Assembly.Location)
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.TestProject.Library", typeof(ProjectReferenceEnum).Assembly))
]);
return test;
diff --git a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj
index 0f0db63bd6..bcabd116ef 100644
--- a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj
+++ b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj
@@ -12,6 +12,7 @@
+
@@ -22,6 +23,32 @@
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
index eca856d259..4dbe1b840a 100644
--- a/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
+++ b/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
@@ -32,17 +32,21 @@ public static Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, param
///
public static async Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, Action configureTest, params DiagnosticResult[] expected)
{
+ // Microsoft.Bcl.AsyncInterfaces is required by TUnit.Core.netstandard2.0.dll's typeforward
+ // of IAsyncEnumerable; without it, tests that use IAsyncEnumerable in their source see
+ // CS0012/CS0508 alongside the expected analyzer diagnostic.
var test = new Test
{
TestCode = source,
- ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net90
+ .AddPackages([new PackageIdentity("Microsoft.Bcl.AsyncInterfaces", "9.0.0")]),
TestState =
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
typeof(CircuitState).Assembly.Location,
- typeof(ProjectReferenceEnum).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.TestProject.Library", typeof(ProjectReferenceEnum).Assembly),
},
},
};
diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
index a15d99f96b..a368c17244 100644
--- a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
+++ b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
@@ -47,7 +47,7 @@ params DiagnosticResult[] expected
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
},
}
};
@@ -97,7 +97,7 @@ Action configureTest
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
},
},
CodeActionValidationMode = CodeActionValidationMode.SemanticStructure,
diff --git a/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
index f08a2d3984..30e3034b02 100644
--- a/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
+++ b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
@@ -20,5 +20,16 @@
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
index 08543e0cd5..6c277f1fc8 100644
--- a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
@@ -33,12 +33,13 @@ public static async Task VerifyAnalyzerAsync([StringSyntax("c#")] string source,
var test = new Test
{
TestCode = source,
- ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net90
+ .AddPackages([new PackageIdentity("Microsoft.Bcl.AsyncInterfaces", "9.0.0")]),
TestState =
{
AdditionalReferences =
{
- typeof(TUnit.Core.TUnitAttribute).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnit.Core.TUnitAttribute).Assembly),
},
},
};
diff --git a/TUnit.Assertions.Analyzers.CodeFixers.Tests/TUnit.Assertions.Analyzers.CodeFixers.Tests.csproj b/TUnit.Assertions.Analyzers.CodeFixers.Tests/TUnit.Assertions.Analyzers.CodeFixers.Tests.csproj
index c1d0a9af32..f0e477243c 100644
--- a/TUnit.Assertions.Analyzers.CodeFixers.Tests/TUnit.Assertions.Analyzers.CodeFixers.Tests.csproj
+++ b/TUnit.Assertions.Analyzers.CodeFixers.Tests/TUnit.Assertions.Analyzers.CodeFixers.Tests.csproj
@@ -5,6 +5,24 @@
+
+
+
+
+
+
+
diff --git a/TUnit.Assertions.Analyzers.CodeFixers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs b/TUnit.Assertions.Analyzers.CodeFixers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
index 2c8ee11dc2..c05da31c84 100644
--- a/TUnit.Assertions.Analyzers.CodeFixers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
+++ b/TUnit.Assertions.Analyzers.CodeFixers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
@@ -60,8 +60,8 @@ params DiagnosticResult[] expected
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
- typeof(Assert).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly),
},
},
};
@@ -102,8 +102,8 @@ public static async Task VerifyCodeFixAsync(
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
- typeof(Assert).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly),
},
},
CodeActionValidationMode = CodeActionValidationMode.SemanticStructure,
diff --git a/TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs b/TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs
index 58efd18d48..e7fd49d332 100644
--- a/TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs
+++ b/TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs
@@ -32,8 +32,12 @@ public static CSharpAnalyzerTest CreateAnalyzerTest<
csTest.TestState.AdditionalReferences
.AddRange(
[
- MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
- MetadataReference.CreateFromFile(typeof(Assert).Assembly.Location),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly)),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions.Should", typeof(TUnit.Assertions.Should.ShouldExtensions).Assembly)),
+#if NET8_0
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetSystemTextJson9DllPath()),
+#endif
]
);
@@ -139,8 +143,12 @@ public static CSharpSuppressorTest CreateSuppresso
test.TestState.AdditionalReferences
.AddRange([
- MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
- MetadataReference.CreateFromFile(typeof(Assert).Assembly.Location),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly)),
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions.Should", typeof(TUnit.Assertions.Should.ShouldExtensions).Assembly)),
+#if NET8_0
+ MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetSystemTextJson9DllPath()),
+#endif
]);
return test;
@@ -152,9 +160,7 @@ private static ReferenceAssemblies GetReferenceAssemblies()
return ReferenceAssemblies.NetFramework.Net472.Default;
#elif NET8_0
return ReferenceAssemblies.Net.Net80;
-#elif NET9_0
- return ReferenceAssemblies.Net.Net90;
-#elif NET10_0_OR_GREATER
+#elif NET9_0_OR_GREATER
return ReferenceAssemblies.Net.Net90;
#else
return ReferenceAssemblies.Net.Net80; // Default fallback
diff --git a/TUnit.Assertions.Analyzers.Tests/ShouldAnalyzerTests.cs b/TUnit.Assertions.Analyzers.Tests/ShouldAnalyzerTests.cs
new file mode 100644
index 0000000000..9683f3aaf8
--- /dev/null
+++ b/TUnit.Assertions.Analyzers.Tests/ShouldAnalyzerTests.cs
@@ -0,0 +1,133 @@
+using AwaitVerifier = TUnit.Assertions.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+using MixVerifier = TUnit.Assertions.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+
+namespace TUnit.Assertions.Analyzers.Tests;
+
+public class ShouldAnalyzerTests
+{
+ [Test]
+ public async Task Should_chain_not_awaited_is_flagged()
+ {
+ await AwaitVerifier
+ .VerifyAnalyzerAsync(
+ """
+ using System.Threading.Tasks;
+ using TUnit.Assertions.Should;
+ using TUnit.Assertions.Should.Extensions;
+
+ public class MyClass
+ {
+ public async Task MyTest()
+ {
+ var one = 1;
+ {|#0:one.Should()|}.BeEqualTo(1);
+ }
+ }
+ """,
+
+ AwaitVerifier.Diagnostic(Rules.AwaitAssertion)
+ .WithLocation(0)
+ );
+ }
+
+ [Test]
+ public async Task Should_chain_awaited_is_clean()
+ {
+ await AwaitVerifier
+ .VerifyAnalyzerAsync(
+ """
+ using System.Threading.Tasks;
+ using TUnit.Assertions.Should;
+ using TUnit.Assertions.Should.Extensions;
+
+ public class MyClass
+ {
+ public async Task MyTest()
+ {
+ var one = 1;
+ await one.Should().BeEqualTo(1);
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Mixed_And_Or_in_Should_chain_is_flagged()
+ {
+ await MixVerifier
+ .VerifyAnalyzerAsync(
+ """
+ using System.Threading.Tasks;
+ using TUnit.Assertions.Should;
+ using TUnit.Assertions.Should.Extensions;
+
+ public class MyClass
+ {
+ public async Task MyTest()
+ {
+ var one = 1;
+ {|#0:await one.Should().BeEqualTo(1).And.BeEqualTo(1).Or.BeEqualTo(2)|};
+ }
+ }
+ """,
+
+ MixVerifier.Diagnostic(Rules.MixAndOrConditionsAssertion)
+ .WithLocation(0)
+ );
+ }
+
+ [Test]
+ public async Task Pure_And_in_Should_chain_is_clean()
+ {
+ await MixVerifier
+ .VerifyAnalyzerAsync(
+ """
+ using System.Threading.Tasks;
+ using TUnit.Assertions.Should;
+ using TUnit.Assertions.Should.Extensions;
+
+ public class MyClass
+ {
+ public async Task MyTest()
+ {
+ var one = 1;
+ await one.Should().BeEqualTo(1).And.NotBeEqualTo(7);
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Unrelated_Should_extension_in_other_namespace_is_not_flagged()
+ {
+ // Confirms the analyzer's TUnit-namespace check rules out unrelated `Should()`
+ // extensions (e.g. FluentAssertions, custom user libraries) from triggering
+ // TUnitAssertions0002.
+ await AwaitVerifier
+ .VerifyAnalyzerAsync(
+ """
+ using System.Threading.Tasks;
+
+ namespace OtherLibrary
+ {
+ public class Wrapper { public Wrapper Should() => this; public void BeFoo() { } }
+ public static class WrapperExtensions
+ {
+ public static Wrapper Should(this object value) => new Wrapper();
+ }
+ }
+
+ public class MyClass
+ {
+ public async Task MyTest()
+ {
+ var one = 1;
+ OtherLibrary.WrapperExtensions.Should(one).BeFoo();
+ }
+ }
+ """
+ );
+ }
+}
diff --git a/TUnit.Assertions.Analyzers.Tests/TUnit.Assertions.Analyzers.Tests.csproj b/TUnit.Assertions.Analyzers.Tests/TUnit.Assertions.Analyzers.Tests.csproj
index 04b80de846..6eb1edc2f8 100644
--- a/TUnit.Assertions.Analyzers.Tests/TUnit.Assertions.Analyzers.Tests.csproj
+++ b/TUnit.Assertions.Analyzers.Tests/TUnit.Assertions.Analyzers.Tests.csproj
@@ -4,6 +4,37 @@
+
+
+
+
+
+
+
+
+
+
@@ -12,6 +43,7 @@
+
diff --git a/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
index ea4bd75c56..a9be2616c4 100644
--- a/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
+++ b/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
@@ -3,6 +3,8 @@
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
+using TUnit.Assertions;
+using TUnit.Core;
namespace TUnit.Assertions.Analyzers.Tests.Verifiers;
@@ -45,8 +47,12 @@ public static async Task VerifyAnalyzerAsync([StringSyntax("c#-test")] string so
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
- typeof(Assert).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly),
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions.Should", typeof(TUnit.Assertions.Should.ShouldExtensions).Assembly),
+#if NET8_0
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetSystemTextJson9DllPath(),
+#endif
},
},
CompilerDiagnostics = CompilerDiagnostics.None
diff --git a/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs b/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
index 8567c9ad6f..e239af0e26 100644
--- a/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
+++ b/TUnit.Assertions.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
@@ -4,6 +4,8 @@
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
+using TUnit.Assertions;
+using TUnit.Core;
namespace TUnit.Assertions.Analyzers.Tests.Verifiers;
@@ -38,8 +40,8 @@ params DiagnosticResult[] expected
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
- typeof(Assert).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly),
},
},
};
@@ -72,8 +74,8 @@ public static async Task VerifyCodeFixAsync(
{
AdditionalReferences =
{
- typeof(TUnitAttribute).Assembly.Location,
- typeof(Assert).Assembly.Location,
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
+ TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly),
},
},
};
diff --git a/TUnit.Assertions.Analyzers/AwaitAssertionAnalyzer.cs b/TUnit.Assertions.Analyzers/AwaitAssertionAnalyzer.cs
index 9a134b30dd..d6d4f3a656 100644
--- a/TUnit.Assertions.Analyzers/AwaitAssertionAnalyzer.cs
+++ b/TUnit.Assertions.Analyzers/AwaitAssertionAnalyzer.cs
@@ -7,8 +7,8 @@
namespace TUnit.Assertions.Analyzers;
///
-/// A sample analyzer that reports the company name being used in class declarations.
-/// Traverses through the Syntax Tree and checks the name (identifier) of each class node.
+/// Reports Assert.That(...) / value.Should() assertion entries that aren't awaited
+/// (which silently no-op) and Assert.Multiple() calls without a using declaration.
///
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AwaitAssertionAnalyzer : ConcurrentDiagnosticAnalyzer
@@ -37,7 +37,8 @@ private void AnalyzeOperation(OperationAnalysisContext context)
CheckMultipleInvocation(context, invocationOperation);
}
- if (fullyQualifiedNonGenericMethodName is "global::TUnit.Assertions.Assert.That")
+ if (fullyQualifiedNonGenericMethodName is "global::TUnit.Assertions.Assert.That"
+ or "global::TUnit.Assertions.Should.ShouldExtensions.Should")
{
CheckAssertInvocation(context, invocationOperation);
}
@@ -67,6 +68,16 @@ private static void CheckMultipleInvocation(OperationAnalysisContext context, II
);
}
+ // Walks the syntactic parent chain. Stops at IBlockOperation/IDelegateCreationOperation as
+ // negative answers — that means the invocation was used as a statement / lambda body without
+ // an enclosing await. This catches the common `value.Should();` mistake but produces a false
+ // positive for split-variable patterns:
+ // var src = Assert.That(value); // ← walk hits the declaration's block before any await
+ // await src.IsEqualTo(...); // even though the chain IS awaited here
+ // The same applies to `var src = value.Should(); await src.X();`. Both Assert.That and
+ // Should() share this limitation by design — fixing requires usage-site dataflow analysis,
+ // which is significant complexity for a niche style. Left as a known imprecision so users who
+ // hit it know it's not their code.
private static bool IsAwaited(IInvocationOperation invocationOperation)
{
var parent = invocationOperation.Parent;
diff --git a/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs b/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs
index 50b70698bb..5024822c1b 100644
--- a/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs
+++ b/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs
@@ -3,6 +3,7 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
+using TUnit.Assertions.Analyzers.Extensions;
namespace TUnit.Assertions.Analyzers;
@@ -81,6 +82,11 @@ private bool IsNullabilityWarning(string diagnosticId)
};
}
+ // Statement-order match only — not control-flow aware. An assertion inside an `if (cond)` or
+ // `try`/`catch` branch suppresses warnings on subsequent uses even when the assertion may not
+ // have run on every path. Accepting that imprecision keeps the analyzer cheap; the alternative
+ // (full dataflow analysis via Roslyn's IFlowAnalysis) is significant complexity for a niche
+ // false-suppression case. See AwaitAssertionAnalyzer for the symmetric awaitedness check.
private bool WasAssertedNotNull(
ExpressionSyntax targetExpression,
SemanticModel semanticModel,
@@ -140,37 +146,31 @@ private bool IsNotNullAssertion(
SemanticModel semanticModel,
CancellationToken cancellationToken)
{
- // Pattern: await Assert.That(variable).IsNotNull()
- // or: await Assert.That(variable).Contains("test").And.IsNotNull()
- // or: Assert.That(variable).IsNotNull().GetAwaiter().GetResult()
+ // Patterns recognised:
+ // await Assert.That(variable).IsNotNull()
+ // await Assert.That(variable).Contains("test").And.IsNotNull()
+ // Assert.That(variable).IsNotNull().GetAwaiter().GetResult()
+ // await variable.Should().NotBeNull()
+ // await variable.Should().Contain("test").And.NotBeNull()
var invocations = statement.DescendantNodes().OfType();
foreach (var invocation in invocations)
{
- // Check if this is a call to IsNotNull()
- if (invocation.Expression is not MemberAccessExpressionSyntax { Name.Identifier.Text: "IsNotNull" })
+ if (invocation.Expression is not MemberAccessExpressionSyntax { Name.Identifier.Text: var calledName })
{
continue;
}
- // Walk up the expression chain to find Assert.That() call
- var assertThatCall = FindAssertThatInChain(invocation);
- if (assertThatCall is null)
+ ExpressionSyntax? targetArgument = calledName switch
{
- continue;
- }
-
- // Get the argument to Assert.That()
- if (assertThatCall.ArgumentList.Arguments.Count != 1)
- {
- continue;
- }
-
- var argument = assertThatCall.ArgumentList.Arguments[0].Expression;
+ "IsNotNull" => GetAssertThatArgument(invocation, semanticModel, cancellationToken),
+ "NotBeNull" => GetShouldReceiver(invocation, semanticModel, cancellationToken),
+ _ => null,
+ };
- // Check if the argument matches the target expression
- if (ExpressionsMatch(argument, targetExpression, semanticModel, cancellationToken))
+ if (targetArgument is not null
+ && ExpressionsMatch(targetArgument, targetExpression, semanticModel, cancellationToken))
{
return true;
}
@@ -179,6 +179,48 @@ private bool IsNotNullAssertion(
return false;
}
+ private static ExpressionSyntax? GetAssertThatArgument(
+ InvocationExpressionSyntax invocation,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken)
+ {
+ var assertThatCall = FindAssertThatInChain(invocation);
+ if (assertThatCall is null
+ || assertThatCall.ArgumentList.Arguments.Count != 1
+ || !IsTUnitMethod(assertThatCall, semanticModel, cancellationToken, "global::TUnit.Assertions.Assert.That"))
+ {
+ return null;
+ }
+
+ return assertThatCall.ArgumentList.Arguments[0].Expression;
+ }
+
+ private static ExpressionSyntax? GetShouldReceiver(
+ InvocationExpressionSyntax invocation,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken)
+ {
+ var shouldCall = FindShouldInChain(invocation);
+ if (shouldCall is null
+ || !IsTUnitMethod(shouldCall, semanticModel, cancellationToken, "global::TUnit.Assertions.Should.ShouldExtensions.Should"))
+ {
+ return null;
+ }
+
+ // Should is an extension method — its receiver is the value being asserted.
+ return shouldCall.Expression is MemberAccessExpressionSyntax memberAccess
+ ? memberAccess.Expression
+ : null;
+ }
+
+ private static bool IsTUnitMethod(
+ InvocationExpressionSyntax invocation,
+ SemanticModel semanticModel,
+ CancellationToken cancellationToken,
+ string fullyQualifiedNonGenericName)
+ => semanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is IMethodSymbol symbol
+ && symbol.GloballyQualifiedNonGeneric() == fullyQualifiedNonGenericName;
+
private bool ExpressionsMatch(
ExpressionSyntax assertArgument,
ExpressionSyntax targetExpression,
@@ -215,31 +257,44 @@ private bool SymbolsMatch(
return symbol1 is not null && SymbolEqualityComparer.Default.Equals(symbol1, symbol2);
}
- private InvocationExpressionSyntax? FindAssertThatInChain(InvocationExpressionSyntax invocation)
+ private static InvocationExpressionSyntax? FindAssertThatInChain(InvocationExpressionSyntax invocation)
+ => FindInvocationInChain(invocation, identifierName: "That", parentName: "Assert");
+
+ // Should() is an extension method, so its receiver is the asserted value (any expression).
+ // parentName MUST stay null — constraining it would break the suppressor for user-defined
+ // assertion entry points and for Should() reached via using-aliases / namespace imports.
+ private static InvocationExpressionSyntax? FindShouldInChain(InvocationExpressionSyntax invocation)
+ => FindInvocationInChain(invocation, identifierName: "Should", parentName: null);
+
+ ///
+ /// Walks up an expression chain looking for an invocation whose member-access name is
+ /// . When is non-null the
+ /// invocation must also be of the form {parentName}.{identifierName}(...); for
+ /// extension methods (Should) the receiver is arbitrary so parentName is null.
+ ///
+ private static InvocationExpressionSyntax? FindInvocationInChain(
+ InvocationExpressionSyntax invocation,
+ string identifierName,
+ string? parentName)
{
- // Walk up the expression chain looking for Assert.That()
var current = invocation.Expression;
while (current is not null)
{
if (current is InvocationExpressionSyntax invocationExpr)
{
- // Check if this is Assert.That()
- if (invocationExpr.Expression is MemberAccessExpressionSyntax
- {
- Name.Identifier.Text: "That",
- Expression: IdentifierNameSyntax { Identifier.Text: "Assert" }
- })
+ if (invocationExpr.Expression is MemberAccessExpressionSyntax memberExpr
+ && memberExpr.Name.Identifier.Text == identifierName
+ && (parentName is null
+ || (memberExpr.Expression is IdentifierNameSyntax id && id.Identifier.Text == parentName)))
{
return invocationExpr;
}
- // Continue walking up from this invocation
current = invocationExpr.Expression;
}
else if (current is MemberAccessExpressionSyntax memberAccess)
{
- // Move to the expression being accessed
current = memberAccess.Expression;
}
else
diff --git a/TUnit.Assertions.Analyzers/MixAndOrOperatorsAnalyzer.cs b/TUnit.Assertions.Analyzers/MixAndOrOperatorsAnalyzer.cs
index b468e9a81a..96b52bbd2d 100644
--- a/TUnit.Assertions.Analyzers/MixAndOrOperatorsAnalyzer.cs
+++ b/TUnit.Assertions.Analyzers/MixAndOrOperatorsAnalyzer.cs
@@ -7,8 +7,9 @@
namespace TUnit.Assertions.Analyzers;
///
-/// A sample analyzer that reports the company name being used in class declarations.
-/// Traverses through the Syntax Tree and checks the name (identifier) of each class node.
+/// Reports awaited assertion chains that mix .And and .Or combinators without
+/// explicit grouping — the runtime throws MixedAndOrAssertionsException, this surfaces it
+/// at compile time. Covers both Assert.That and value.Should() entry points.
///
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class MixAndOrOperatorsAnalyzer : ConcurrentDiagnosticAnalyzer
@@ -28,12 +29,14 @@ private static void AnalyzeOperation(OperationAnalysisContext context)
return;
}
- // Check if the awaited type implements IAssertionSource or inherits from Assertion
+ // Check if the awaited type implements IAssertionSource/IShouldSource or
+ // inherits from Assertion. ShouldAssertion is covered by the IShouldSource branch
+ // (it implements that interface), so it doesn't need a separate base-type check.
var awaitedType = awaitOperation.Operation.Type;
var isAssertionSource = awaitedType?.AllInterfaces.Any(x =>
- x.GloballyQualifiedNonGeneric() is "global::TUnit.Assertions.Core.IAssertionSource") == true;
- var isAssertion = awaitedType?.BaseType != null &&
- IsAssertionType(awaitedType.BaseType);
+ x.GloballyQualifiedNonGeneric() is "global::TUnit.Assertions.Core.IAssertionSource"
+ or "global::TUnit.Assertions.Should.Core.IShouldSource") == true;
+ var isAssertion = awaitedType?.BaseType != null && IsAssertionType(awaitedType.BaseType);
if (!isAssertionSource && !isAssertion)
{
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/GlobalSetup.cs b/TUnit.Assertions.Should.SourceGenerator.Tests/GlobalSetup.cs
new file mode 100644
index 0000000000..35a1207a39
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/GlobalSetup.cs
@@ -0,0 +1,12 @@
+using DiffEngine;
+
+namespace TUnit.Assertions.Should.SourceGenerator.Tests;
+
+public class GlobalSetup
+{
+ [Before(TestSession)]
+ public static void SetUp()
+ {
+ DiffRunner.Disabled = true;
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/GlobalUsings.cs b/TUnit.Assertions.Should.SourceGenerator.Tests/GlobalUsings.cs
new file mode 100644
index 0000000000..5a50de2ef6
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/GlobalUsings.cs
@@ -0,0 +1,3 @@
+global using VerifyTUnit;
+global using VerifyTests;
+global using static VerifyTUnit.Verifier;
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/NameConjugatorTests.cs b/TUnit.Assertions.Should.SourceGenerator.Tests/NameConjugatorTests.cs
new file mode 100644
index 0000000000..9adcc81acb
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/NameConjugatorTests.cs
@@ -0,0 +1,54 @@
+namespace TUnit.Assertions.Should.SourceGenerator.Tests;
+
+///
+/// Direct unit tests for the pure-function . The end-to-end
+/// generator tests in exercise the same rules
+/// indirectly, but only for the rules each test snippet happens to hit; these cases lock
+/// in the full conjugation table — including the -es drops that surfaced as a bug
+/// during PR review.
+///
+public class NameConjugatorTests
+{
+ [Test]
+ [Arguments("IsEqualTo", "BeEqualTo")]
+ [Arguments("IsZero", "BeZero")]
+ [Arguments("IsEmpty", "BeEmpty")]
+ [Arguments("IsNotNull", "NotBeNull")]
+ [Arguments("IsNotEqualTo", "NotBeEqualTo")]
+ [Arguments("IsNotEmpty", "NotBeEmpty")]
+ [Arguments("HasNotBeenCalled", "NotHaveBeenCalled")]
+ [Arguments("HasCount", "HaveCount")]
+ [Arguments("HasFiles", "HaveFiles")]
+ [Arguments("DoesNotContain", "NotContain")]
+ [Arguments("DoesNotMatch", "NotMatch")]
+ [Arguments("DoesMatch", "Match")]
+ [Arguments("Contains", "Contain")]
+ [Arguments("StartsWith", "StartWith")]
+ [Arguments("EndsWith", "EndWith")]
+ [Arguments("Throws", "Throw")]
+ [Arguments("Matches", "Match")]
+ [Arguments("MatchesRegex", "MatchRegex")]
+ [Arguments("Washes", "Wash")]
+ [Arguments("Catches", "Catch")]
+ [Arguments("Fixes", "Fix")]
+ [Arguments("Buzzes", "Buzz")]
+ [Arguments("Normalizes", "Normalize")]
+ [Arguments("Analyzes", "Analyze")]
+ [Arguments("Authorizes", "Authorize")]
+ [Arguments("Goes", "Go")]
+ [Arguments("Passes", "Pass")]
+ [Arguments("Writes", "Write")]
+ [Arguments("Applies", "Apply")]
+ [Arguments("Tries", "Try")]
+ [Arguments("Dies", "Die")]
+ [Arguments("Ties", "Tie")]
+ [Arguments("Pass", "Pass")]
+ [Arguments("Issue", "Issue")]
+ [Arguments("Is", "Be")]
+ [Arguments("Has", "Have")]
+ [Arguments("", "")]
+ public async Task Conjugate_returns_expected(string input, string expected)
+ {
+ await Assert.That(NameConjugator.Conjugate(input)).IsEqualTo(expected);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet10_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..b3f7d6a0d2
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet10_0.verified.txt
@@ -0,0 +1,40 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldMyBetweenExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeBetween(this global::TUnit.Assertions.Should.Core.IShouldSource source, TValue min, TValue max, [global::System.Runtime.CompilerServices.CallerArgumentExpression("min")] string? minExpression = null, [global::System.Runtime.CompilerServices.CallerArgumentExpression("max")] string? maxExpression = null)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeBetween(");
+ var __added = false;
+ if (minExpression is not null)
+ {
+ if (__added) innerContext.ExpressionBuilder.Append(", ");
+ innerContext.ExpressionBuilder.Append(minExpression);
+ __added = true;
+ }
+ if (maxExpression is not null)
+ {
+ if (__added) innerContext.ExpressionBuilder.Append(", ");
+ innerContext.ExpressionBuilder.Append(maxExpression);
+ __added = true;
+ }
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.MyBetweenAssertion(innerContext, min, max);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet8_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..b3f7d6a0d2
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet8_0.verified.txt
@@ -0,0 +1,40 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldMyBetweenExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeBetween(this global::TUnit.Assertions.Should.Core.IShouldSource source, TValue min, TValue max, [global::System.Runtime.CompilerServices.CallerArgumentExpression("min")] string? minExpression = null, [global::System.Runtime.CompilerServices.CallerArgumentExpression("max")] string? maxExpression = null)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeBetween(");
+ var __added = false;
+ if (minExpression is not null)
+ {
+ if (__added) innerContext.ExpressionBuilder.Append(", ");
+ innerContext.ExpressionBuilder.Append(minExpression);
+ __added = true;
+ }
+ if (maxExpression is not null)
+ {
+ if (__added) innerContext.ExpressionBuilder.Append(", ");
+ innerContext.ExpressionBuilder.Append(maxExpression);
+ __added = true;
+ }
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.MyBetweenAssertion(innerContext, min, max);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet9_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..b3f7d6a0d2
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.CallerArgumentExpression_attribute_is_propagated.DotNet9_0.verified.txt
@@ -0,0 +1,40 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldMyBetweenExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeBetween(this global::TUnit.Assertions.Should.Core.IShouldSource source, TValue min, TValue max, [global::System.Runtime.CompilerServices.CallerArgumentExpression("min")] string? minExpression = null, [global::System.Runtime.CompilerServices.CallerArgumentExpression("max")] string? maxExpression = null)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeBetween(");
+ var __added = false;
+ if (minExpression is not null)
+ {
+ if (__added) innerContext.ExpressionBuilder.Append(", ");
+ innerContext.ExpressionBuilder.Append(minExpression);
+ __added = true;
+ }
+ if (maxExpression is not null)
+ {
+ if (__added) innerContext.ExpressionBuilder.Append(", ");
+ innerContext.ExpressionBuilder.Append(maxExpression);
+ __added = true;
+ }
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.MyBetweenAssertion(innerContext, min, max);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet10_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..e9e994dc97
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet10_0.verified.txt
@@ -0,0 +1,28 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldMyEqualsExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeEqualTo(this global::TUnit.Assertions.Should.Core.IShouldSource source, TValue expected, [global::System.Runtime.CompilerServices.CallerArgumentExpression("expected")] string? expectedExpression = null)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeEqualTo(");
+ innerContext.ExpressionBuilder.Append(expectedExpression);
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.MyEqualsAssertion(innerContext, expected);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet8_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..e9e994dc97
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet8_0.verified.txt
@@ -0,0 +1,28 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldMyEqualsExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeEqualTo(this global::TUnit.Assertions.Should.Core.IShouldSource source, TValue expected, [global::System.Runtime.CompilerServices.CallerArgumentExpression("expected")] string? expectedExpression = null)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeEqualTo(");
+ innerContext.ExpressionBuilder.Append(expectedExpression);
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.MyEqualsAssertion(innerContext, expected);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet9_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..e9e994dc97
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.GenericAssertionExtension_emits_method_generic_param.DotNet9_0.verified.txt
@@ -0,0 +1,28 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldMyEqualsExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeEqualTo(this global::TUnit.Assertions.Should.Core.IShouldSource source, TValue expected, [global::System.Runtime.CompilerServices.CallerArgumentExpression("expected")] string? expectedExpression = null)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeEqualTo(");
+ innerContext.ExpressionBuilder.Append(expectedExpression);
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.MyEqualsAssertion(innerContext, expected);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet10_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..4ed689d27c
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet10_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldNotEmptyExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion NotBeEmpty(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".NotBeEmpty(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.NotEmptyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet8_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..4ed689d27c
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet8_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldNotEmptyExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion NotBeEmpty(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".NotBeEmpty(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.NotEmptyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet9_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..4ed689d27c
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.IsNot_prefix_conjugates_to_NotBe.DotNet9_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldNotEmptyExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion NotBeEmpty(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".NotBeEmpty(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.NotEmptyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet10_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..d77d861405
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet10_0.verified.txt
@@ -0,0 +1,28 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldLegacyExtensions
+{
+
+ [global::System.Obsolete("Use IsModern instead.")]
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeLegacy(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeLegacy(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.LegacyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet8_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..d77d861405
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet8_0.verified.txt
@@ -0,0 +1,28 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldLegacyExtensions
+{
+
+ [global::System.Obsolete("Use IsModern instead.")]
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeLegacy(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeLegacy(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.LegacyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet9_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..d77d861405
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Obsolete_attribute_is_forwarded.DotNet9_0.verified.txt
@@ -0,0 +1,28 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldLegacyExtensions
+{
+
+ [global::System.Obsolete("Use IsModern instead.")]
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeLegacy(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeLegacy(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.LegacyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet10_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..ac426aeaba
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet10_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldOddExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeAnOddNumber(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeAnOddNumber(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.OddAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet8_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..ac426aeaba
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet8_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldOddExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeAnOddNumber(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeAnOddNumber(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.OddAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet9_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..ac426aeaba
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.ShouldNameAttribute_overrides_conjugation.DotNet9_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldOddExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeAnOddNumber(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeAnOddNumber(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.OddAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet10_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..ef26605920
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet10_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldStringIsEmptyExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeEmpty(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeEmpty(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.StringIsEmptyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet8_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..ef26605920
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet8_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldStringIsEmptyExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeEmpty(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeEmpty(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.StringIsEmptyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet9_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..ef26605920
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.SimpleAssertionExtension_emits_conjugated_extension_method.DotNet9_0.verified.txt
@@ -0,0 +1,27 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Should.Core;
+
+namespace TUnit.Assertions.Should.Extensions;
+
+public static partial class ShouldStringIsEmptyExtensions
+{
+
+ public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeEmpty(this global::TUnit.Assertions.Should.Core.IShouldSource source)
+ {
+ var innerContext = source.Context;
+ innerContext.ExpressionBuilder.Append(".BeEmpty(");
+ innerContext.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.StringIsEmptyAssertion(innerContext);
+ var __tunit_should_because = source.ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(innerContext, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet10_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..669ace7528
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet10_0.verified.txt
@@ -0,0 +1,25 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+
+namespace MyNamespace;
+
+partial class ShouldDerivedSource
+{
+
+ public global::TUnit.Assertions.Should.Core.ShouldAssertion BeReady()
+ {
+ Context.ExpressionBuilder.Append(".BeReady(");
+ Context.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.BaseWrappedAssertion(Context);
+ var __tunit_should_because = ((global::TUnit.Assertions.Should.Core.IShouldSource)this).ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(Context, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet8_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..669ace7528
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet8_0.verified.txt
@@ -0,0 +1,25 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+
+namespace MyNamespace;
+
+partial class ShouldDerivedSource
+{
+
+ public global::TUnit.Assertions.Should.Core.ShouldAssertion BeReady()
+ {
+ Context.ExpressionBuilder.Append(".BeReady(");
+ Context.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.BaseWrappedAssertion(Context);
+ var __tunit_should_because = ((global::TUnit.Assertions.Should.Core.IShouldSource)this).ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(Context, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet9_0.verified.txt b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..669ace7528
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.Wrapper_generation_deduplicates_overridden_instance_methods.DotNet9_0.verified.txt
@@ -0,0 +1,25 @@
+//
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using TUnit.Assertions.Core;
+
+namespace MyNamespace;
+
+partial class ShouldDerivedSource
+{
+
+ public global::TUnit.Assertions.Should.Core.ShouldAssertion BeReady()
+ {
+ Context.ExpressionBuilder.Append(".BeReady(");
+ Context.ExpressionBuilder.Append(")");
+ var inner = new global::MyNamespace.BaseWrappedAssertion(Context);
+ var __tunit_should_because = ((global::TUnit.Assertions.Should.Core.IShouldSource)this).ConsumeBecauseMessage();
+ if (__tunit_should_because is not null)
+ {
+ inner.Because(__tunit_should_because);
+ }
+ return new global::TUnit.Assertions.Should.Core.ShouldAssertion(Context, inner);
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.cs b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.cs
new file mode 100644
index 0000000000..693db3a3c7
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/ShouldExtensionGeneratorTests.cs
@@ -0,0 +1,311 @@
+using System.Collections.Immutable;
+using System.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using TUnit.Assertions.Core;
+
+namespace TUnit.Assertions.Should.SourceGenerator.Tests;
+
+///
+/// Locks in the key behaviours of by compiling small
+/// inline snippets, running the generator, and asserting on the emitted source. Failing tests
+/// here mean the Should-flavored API surface has shifted in a user-visible way — any change
+/// is intentional and should be reflected in the assertions below.
+///
+///
+/// The generator scans for extension methods on ; the test
+/// snippets declare those methods explicitly because TUnit.Assertions.SourceGenerator
+/// (which would normally synthesise them from [AssertionExtension]) doesn't run inside
+/// the test's in-memory compilation.
+///
+public class ShouldExtensionGeneratorTests
+{
+ [Test]
+ public async Task SimpleAssertionExtension_emits_conjugated_extension_method()
+ {
+ var output = await RunGenerator("""
+ using TUnit.Assertions.Core;
+
+ namespace MyNamespace;
+
+ public class StringIsEmptyAssertion : Assertion
+ {
+ public StringIsEmptyAssertion(AssertionContext context) : base(context) { }
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ => Task.FromResult(AssertionResult.Passed);
+ protected override string GetExpectation() => "to be empty";
+ }
+
+ public static class StringIsEmptyExtensions
+ {
+ public static StringIsEmptyAssertion IsEmpty(this IAssertionSource source)
+ => new(source.Context);
+ }
+ """);
+
+ // Conjugation: IsEmpty -> BeEmpty (Is* -> Be* rule).
+ await Assert.That(output).Contains("BeEmpty");
+ // Should-flavored surface — first param is IShouldSource, return is ShouldAssertion.
+ await Assert.That(output).Contains("this global::TUnit.Assertions.Should.Core.IShouldSource source");
+ await Assert.That(output).Contains("global::TUnit.Assertions.Should.Core.ShouldAssertion");
+ // Inner assertion is constructed with the existing TUnit.Assertions class.
+ await Assert.That(output).Contains("new global::MyNamespace.StringIsEmptyAssertion(innerContext)");
+ }
+
+ [Test]
+ public async Task GenericAssertionExtension_emits_method_generic_param()
+ {
+ var output = await RunGenerator("""
+ using TUnit.Assertions.Core;
+ using System.Runtime.CompilerServices;
+
+ namespace MyNamespace;
+
+ public class MyEqualsAssertion : Assertion
+ {
+ public MyEqualsAssertion(AssertionContext context, TValue expected) : base(context) { }
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ => Task.FromResult(AssertionResult.Passed);
+ protected override string GetExpectation() => "to be equal";
+ }
+
+ public static class MyEqualsExtensions
+ {
+ public static MyEqualsAssertion IsEqualTo(
+ this IAssertionSource source,
+ TValue expected,
+ [CallerArgumentExpression(nameof(expected))] string? expectedExpression = null)
+ => new(source.Context, expected);
+ }
+ """);
+
+ await Assert.That(output).Contains("BeEqualTo");
+ await Assert.That(output).Contains("IShouldSource");
+ await Assert.That(output).Contains("TValue expected");
+ }
+
+ [Test]
+ public async Task ShouldNameAttribute_overrides_conjugation()
+ {
+ var output = await RunGenerator("""
+ using TUnit.Assertions.Core;
+ using TUnit.Assertions.Should.Attributes;
+
+ namespace MyNamespace;
+
+ [ShouldName("BeAnOddNumber")]
+ public class OddAssertion : Assertion
+ {
+ public OddAssertion(AssertionContext context) : base(context) { }
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ => Task.FromResult(AssertionResult.Passed);
+ protected override string GetExpectation() => "to be odd";
+ }
+
+ public static class OddExtensions
+ {
+ public static OddAssertion IsOdd(this IAssertionSource source)
+ => new(source.Context);
+ }
+ """);
+
+ await Assert.That(output).Contains("BeAnOddNumber");
+ // Default conjugation would have produced "BeOdd" — verify it didn't sneak in alongside.
+ await Assert.That(output).DoesNotContain("public static global::TUnit.Assertions.Should.Core.ShouldAssertion BeOdd(");
+ }
+
+ [Test]
+ public async Task Obsolete_attribute_is_forwarded()
+ {
+ var output = await RunGenerator("""
+ using System;
+ using TUnit.Assertions.Core;
+
+ namespace MyNamespace;
+
+ public class LegacyAssertion : Assertion
+ {
+ public LegacyAssertion(AssertionContext context) : base(context) { }
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ => Task.FromResult(AssertionResult.Passed);
+ protected override string GetExpectation() => "to be legacy";
+ }
+
+ public static class LegacyExtensions
+ {
+ [Obsolete("Use IsModern instead.")]
+ public static LegacyAssertion IsLegacy(this IAssertionSource source)
+ => new(source.Context);
+ }
+ """);
+
+ await Assert.That(output).Contains("Obsolete");
+ await Assert.That(output).Contains("Use IsModern instead.");
+ }
+
+ [Test]
+ public async Task Wrapper_generation_deduplicates_overridden_instance_methods()
+ {
+ var output = await RunGenerator("""
+ using TUnit.Assertions.Core;
+ using TUnit.Assertions.Should.Attributes;
+ using TUnit.Assertions.Should.Core;
+
+ namespace MyNamespace;
+
+ public class BaseWrappedAssertion : Assertion
+ {
+ public BaseWrappedAssertion(AssertionContext context) : base(context) { }
+ public virtual BaseWrappedAssertion IsReady() => new(Context);
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ => Task.FromResult(AssertionResult.Passed);
+ protected override string GetExpectation() => "to be ready";
+ }
+
+ public sealed class DerivedWrappedAssertion : BaseWrappedAssertion
+ {
+ public DerivedWrappedAssertion(AssertionContext context) : base(context) { }
+ public override BaseWrappedAssertion IsReady() => new(Context);
+ }
+
+ [ShouldGeneratePartial(typeof(DerivedWrappedAssertion))]
+ public sealed partial class ShouldDerivedSource : IShouldSource
+ {
+ public AssertionContext Context { get; }
+ public ShouldDerivedSource(AssertionContext context) => Context = context;
+ string? IShouldSource.ConsumeBecauseMessage() => null;
+ }
+ """);
+
+ await Assert.That(CountOccurrences(output, " BeReady(")).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task IsNot_prefix_conjugates_to_NotBe()
+ {
+ var output = await RunGenerator("""
+ using TUnit.Assertions.Core;
+
+ namespace MyNamespace;
+
+ public class NotEmptyAssertion : Assertion
+ {
+ public NotEmptyAssertion(AssertionContext context) : base(context) { }
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ => Task.FromResult(AssertionResult.Passed);
+ protected override string GetExpectation() => "to not be empty";
+ }
+
+ public static class NotEmptyExtensions
+ {
+ public static NotEmptyAssertion IsNotEmpty(this IAssertionSource source)
+ => new(source.Context);
+ }
+ """);
+
+ await Assert.That(output).Contains("NotBeEmpty");
+ }
+
+ [Test]
+ public async Task CallerArgumentExpression_attribute_is_propagated()
+ {
+ var output = await RunGenerator("""
+ using TUnit.Assertions.Core;
+ using System.Runtime.CompilerServices;
+
+ namespace MyNamespace;
+
+ public class MyBetweenAssertion : Assertion
+ {
+ public MyBetweenAssertion(AssertionContext context, TValue min, TValue max) : base(context) { }
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ => Task.FromResult(AssertionResult.Passed);
+ protected override string GetExpectation() => "to be between";
+ }
+
+ public static class MyBetweenExtensions
+ {
+ public static MyBetweenAssertion IsBetween(
+ this IAssertionSource source,
+ TValue min,
+ TValue max,
+ [CallerArgumentExpression(nameof(min))] string? minExpression = null,
+ [CallerArgumentExpression(nameof(max))] string? maxExpression = null)
+ => new(source.Context, min, max);
+ }
+ """);
+
+ await Assert.That(output).Contains("BeBetween");
+ await Assert.That(output).Contains("CallerArgumentExpression(\"min\")");
+ await Assert.That(output).Contains("CallerArgumentExpression(\"max\")");
+ }
+
+ ///
+ /// Compiles with the Should-generator's input dependencies,
+ /// runs , snapshots the full generated source via
+ /// Verify (per-TFM .verified.txt files), and returns the concatenated output so
+ /// callers can additionally string-match key tokens. The Verify snapshot catches
+ /// formatting/ordering/global:: regressions that token-level checks miss; the
+ /// inline Contains assertions remain as explicit guard-rails for the user-visible
+ /// API tokens that any change should call out deliberately.
+ ///
+ private static async Task RunGenerator(string userSource, [CallerMemberName] string testName = "")
+ {
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "GeneratorTest",
+ syntaxTrees: [CSharpSyntaxTree.ParseText(userSource)],
+ references: GetReferences(),
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ var driver = CSharpGeneratorDriver.Create(new ShouldExtensionGenerator());
+ driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation(
+ compilation, out var updatedCompilation, out var diagnostics);
+
+ await Assert.That(diagnostics.Length).IsEqualTo(0)
+ .Because("Generator should not emit diagnostics for valid input");
+
+ var trees = updatedCompilation.SyntaxTrees
+ .Where(t => t != compilation.SyntaxTrees[0])
+ .Select(t => t.ToString());
+
+ var combined = string.Join("\n//------\n", trees);
+
+ await Verify(combined)
+ .UseFileName($"{nameof(ShouldExtensionGeneratorTests)}.{testName}")
+ .UniqueForTargetFrameworkAndVersion();
+
+ return combined;
+ }
+
+ private static IEnumerable GetReferences()
+ {
+ // Mirror the loaded assemblies of the test process. Works on both .NET Core
+ // (where TRUSTED_PLATFORM_ASSEMBLIES populates this set) and .NET Framework,
+ // unlike AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") which returns
+ // null on .NET Framework and would leave the in-memory compilation without BCL
+ // references — making symbol resolution silently fail in the generator.
+ foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ if (asm.IsDynamic || string.IsNullOrWhiteSpace(asm.Location))
+ {
+ continue;
+ }
+ yield return MetadataReference.CreateFromFile(asm.Location);
+ }
+
+ yield return MetadataReference.CreateFromFile(typeof(Assertion<>).Assembly.Location);
+ yield return MetadataReference.CreateFromFile(typeof(TUnit.Assertions.Should.Core.IShouldSource).Assembly.Location);
+ }
+
+ private static int CountOccurrences(string value, string search)
+ {
+ var count = 0;
+ var index = 0;
+ while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += search.Length;
+ }
+ return count;
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator.Tests/TUnit.Assertions.Should.SourceGenerator.Tests.csproj b/TUnit.Assertions.Should.SourceGenerator.Tests/TUnit.Assertions.Should.SourceGenerator.Tests.csproj
new file mode 100644
index 0000000000..1302b34d0f
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator.Tests/TUnit.Assertions.Should.SourceGenerator.Tests.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+
+ $(NoWarn);MSB3277
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/TUnit.Assertions.Should.SourceGenerator/NameConjugator.cs b/TUnit.Assertions.Should.SourceGenerator/NameConjugator.cs
new file mode 100644
index 0000000000..bf6d946fdd
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator/NameConjugator.cs
@@ -0,0 +1,159 @@
+namespace TUnit.Assertions.Should.SourceGenerator;
+
+///
+/// Pure-function transformer from Is*/Has*/Does*/3rd-person-singular method
+/// names produced by [AssertionExtension] to FluentAssertions-style imperative
+/// Should-flavored equivalents (BeEqualTo, HaveCount, NotContain, etc.).
+///
+internal static class NameConjugator
+{
+ ///
+ /// Conjugate an [AssertionExtension] method name into its Should-flavored counterpart.
+ /// Names that don't match any rule are returned unchanged — this is the intended forward-compat
+ /// behaviour for unrecognised naming conventions; callers can layer [ShouldName] overrides
+ /// on top to rename specific assertions.
+ ///
+ public static string Conjugate(string methodName)
+ {
+ if (string.IsNullOrEmpty(methodName))
+ {
+ return methodName;
+ }
+
+ // Order: longest-prefix-first so negated prefixes win over their positive forms.
+ if (TryReplacePrefix(methodName, "IsNot", "NotBe", out var s)) return s;
+ if (TryReplacePrefix(methodName, "Is", "Be", out s)) return s;
+ if (TryReplacePrefix(methodName, "HasNot", "NotHave", out s)) return s;
+ if (TryReplacePrefix(methodName, "Has", "Have", out s)) return s;
+ if (TryReplacePrefix(methodName, "DoesNot", "Not", out s)) return s;
+ if (TryReplacePrefix(methodName, "Does", "", out s)) return s;
+ if (TryDropTrailingS(methodName, out s)) return s;
+
+ return methodName;
+ }
+
+ ///
+ /// Replaces with only when it ends
+ /// at a CamelCase word boundary (next char uppercase, or end of name). Prevents matches like
+ /// "Issue" → "Besue".
+ ///
+ private static bool TryReplacePrefix(string name, string prefix, string replacement, out string result)
+ {
+ if (!name.StartsWith(prefix, System.StringComparison.Ordinal)
+ || (name.Length > prefix.Length && !char.IsUpper(name[prefix.Length])))
+ {
+ result = name;
+ return false;
+ }
+
+ result = replacement + name.Substring(prefix.Length);
+ return true;
+ }
+
+ ///
+ /// First-word trailing-s drop. Contains→Contain, StartsWith→StartWith.
+ /// Recognises English -es third-person endings (Matches→Match,
+ /// Washes→Wash, Fixes→Fix, Buzzes→Buzz,
+ /// Goes→Go, Passes→Pass) and consonant+-ies
+ /// endings (Applies→Apply).
+ /// Plain -ss endings stay put so Pass remains Pass.
+ ///
+ private static bool TryDropTrailingS(string name, out string result)
+ {
+ var firstWordEnd = name.Length;
+ for (var i = 1; i < name.Length; i++)
+ {
+ if (char.IsUpper(name[i]))
+ {
+ firstWordEnd = i;
+ break;
+ }
+ }
+
+ if (firstWordEnd < 2 || name[firstWordEnd - 1] != 's')
+ {
+ result = name;
+ return false;
+ }
+
+ if (TryReplaceTrailingIes(name, firstWordEnd, out result))
+ {
+ return true;
+ }
+
+ var dropCount = GetTrailingSDropCount(name, firstWordEnd);
+ if (dropCount == 0)
+ {
+ result = name;
+ return false;
+ }
+
+ result = name.Substring(0, firstWordEnd - dropCount) + name.Substring(firstWordEnd);
+ return true;
+ }
+
+ private static bool TryReplaceTrailingIes(string name, int firstWordEnd, out string result)
+ {
+ if (firstWordEnd < 5
+ || name[firstWordEnd - 3] != 'i'
+ || name[firstWordEnd - 2] != 'e'
+ || IsVowel(name[firstWordEnd - 4]))
+ {
+ result = name;
+ return false;
+ }
+
+ result = name.Substring(0, firstWordEnd - 3) + "y" + name.Substring(firstWordEnd);
+ return true;
+ }
+
+ private static bool IsVowel(char c)
+ => c is 'a' or 'e' or 'i' or 'o' or 'u' or 'A' or 'E' or 'I' or 'O' or 'U';
+
+ private static bool IsVowelOrY(char c)
+ => IsVowel(c) || c is 'y' or 'Y';
+
+ ///
+ /// Returns how many trailing letters of the first word should be removed: 2 for an English
+ /// -es 3rd-person ending where the stem ends in a sibilant (ch, sh,
+ /// ss, x, z) or in o; 1 for a plain -s; 0 to leave the
+ /// name unchanged (e.g. plain -ss, which is a stem rather than a verb form).
+ ///
+ private static int GetTrailingSDropCount(string name, int firstWordEnd)
+ {
+ if (firstWordEnd >= 3 && name[firstWordEnd - 2] == 'e')
+ {
+ var c = name[firstWordEnd - 3];
+ if (c == 'x' || c == 'o')
+ {
+ return 2;
+ }
+ if (c == 'z')
+ {
+ // "buzzes" adds -es to a z-ending stem, but "normalizes" is
+ // "normalize" + s; keep the stem's silent e when z follows a vowel-like char.
+ return firstWordEnd >= 4 && IsVowelOrY(name[firstWordEnd - 4]) ? 1 : 2;
+ }
+ if (firstWordEnd >= 4)
+ {
+ var c2 = name[firstWordEnd - 4];
+ if (c == 'h' && (c2 == 'c' || c2 == 's'))
+ {
+ return 2; // ches / shes
+ }
+ if (c == 's' && c2 == 's')
+ {
+ return 2; // sses
+ }
+ }
+ return 1; // generic -es (e.g. Writes -> Write)
+ }
+
+ if (name[firstWordEnd - 2] == 's')
+ {
+ return 0; // plain -ss; not a verb form
+ }
+
+ return 1;
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator/ShouldExtensionGenerator.cs b/TUnit.Assertions.Should.SourceGenerator/ShouldExtensionGenerator.cs
new file mode 100644
index 0000000000..ad54707268
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator/ShouldExtensionGenerator.cs
@@ -0,0 +1,1220 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using TUnit.Core.SourceGenerator.Models;
+
+namespace TUnit.Assertions.Should.SourceGenerator;
+
+///
+/// Source generator that scans the current compilation and every referenced assembly for
+/// public extension methods on IAssertionSource<T> whose return type derives
+/// from Assertion<TReturn> and whose body is a "simple factory" — meaning the
+/// non-CAE method parameters map 1-to-1 (by name and type) onto a public constructor of
+/// the return type, after the leading AssertionContext<T> parameter. For each
+/// match, emits a Should-flavored counterpart on IShouldSource<T>.
+///
+/// This unifies four assertion sources: classes with [AssertionExtension], methods
+/// with [GenerateAssertion], types decorated with [AssertionFrom<T>],
+/// and any hand-written extension methods whose body is just new T(ctx, args).
+/// Methods that don't fit the factory template (context mapping, transformations, etc.)
+/// are silently skipped — they couldn't be wrapped without inspecting their body anyway.
+///
+///
+[Generator]
+public sealed class ShouldExtensionGenerator : IIncrementalGenerator
+{
+ private const string AssertionSourceFullName = "TUnit.Assertions.Core.IAssertionSource`1";
+ private const string AssertionBaseFullName = "TUnit.Assertions.Core.Assertion`1";
+ private const string AssertionContextFullName = "TUnit.Assertions.Core.AssertionContext`1";
+ private const string ShouldExtensionsNamespace = "TUnit.Assertions.Should.Extensions";
+ private const string ShouldNameAttributeFullName = "TUnit.Assertions.Should.Attributes.ShouldNameAttribute";
+ private const string CallerArgumentExpressionAttributeName = "CallerArgumentExpressionAttribute";
+ private const string RequiresUnreferencedCodeAttributeName = "RequiresUnreferencedCodeAttribute";
+ private const string UnconditionalSuppressMessageAttributeName = "UnconditionalSuppressMessageAttribute";
+ private const string DynamicallyAccessedMembersAttributeName = "DynamicallyAccessedMembersAttribute";
+ private const string ShouldGeneratePartialAttributeFullName = "TUnit.Assertions.Should.Attributes.ShouldGeneratePartialAttribute";
+
+ private static readonly SymbolDisplayFormat NoGlobalFormat =
+ SymbolDisplayFormat.FullyQualifiedFormat
+ .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)
+ .AddMiscellaneousOptions(SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
+
+ private static readonly SymbolDisplayFormat NameWithoutTypeArgsFormat =
+ SymbolDisplayFormat.FullyQualifiedFormat
+ .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)
+ .WithGenericsOptions(SymbolDisplayGenericsOptions.None);
+
+ public void Initialize(IncrementalGeneratorInitializationContext context)
+ {
+ // CompilationProvider fires per-keystroke. Cross-assembly cost is absorbed by the
+ // ConditionalWeakTable cache below; the current-compilation walk still re-runs every
+ // keystroke. A SyntaxProvider-based pipeline (paired with ForAttributeWithMetadataName
+ // for [AssertionExtension] declarations and a custom-syntax filter for hand-rolled
+ // extensions on IAssertionSource) would eliminate that cost. Deferred to a follow-up
+ // — the structural refactor is non-trivial and the current bound (~types-with-extension-
+ // methods in current compilation) is acceptable for v1.
+ var provider = context.CompilationProvider.Select((compilation, _) => Collect(compilation));
+
+ context.RegisterSourceOutput(provider, static (ctx, payload) =>
+ {
+ var emittedHints = new HashSet(StringComparer.Ordinal);
+
+ // Wrappers first: they own the return types they cover, and their method names
+ // win over extension methods at call sites anyway.
+ foreach (var wrapper in payload.Wrappers)
+ {
+ EmitWrapperPartial(ctx, wrapper, emittedHints);
+ }
+
+ foreach (var group in payload.Methods.GroupBy(m => m.ContainerName, StringComparer.Ordinal))
+ {
+ EmitContainer(ctx, group.Key, group.ToArray(), emittedHints);
+ }
+ });
+ }
+
+ ///
+ /// Caches the data extracted from each referenced assembly, keyed on the
+ /// instance. Roslyn typically reuses the same
+ /// MetadataReference across compilations as long as the underlying assembly
+ /// hasn't been rebuilt, so cache hits eliminate the expensive cross-assembly walk on
+ /// every keystroke. The cache stores raw walk results (no dedup applied) so that the
+ /// dedup sets — built from the union of all references plus the current compilation —
+ /// can be applied at merge time without invalidating cache entries.
+ ///
+ /// uses weak keys so entries become
+ /// eligible for GC the moment Roslyn drops the underlying MetadataReference (e.g.
+ /// when the dependency assembly is rebuilt). A ConcurrentDictionary would pin
+ /// stale references for the lifetime of the IDE process and cause unbounded memory
+ /// growth across long sessions with frequent rebuilds.
+ ///
+ ///
+ private static readonly ConditionalWeakTable s_referenceCache = new();
+
+ // IMPORTANT: keep ReferenceData compilation-independent. Do not cache live ISymbol
+ // instances here; symbols are tied to the compilation that produced them and become stale
+ // when Roslyn creates the next compilation.
+ private sealed record ReferenceData(
+ EquatableArray Methods,
+ EquatableArray Wrappers,
+ EquatableArray AlreadyBakedNames);
+
+ private static GeneratorPayload Collect(Compilation compilation)
+ {
+ var assertionSource = compilation.GetTypeByMetadataName(AssertionSourceFullName);
+ var assertionBase = compilation.GetTypeByMetadataName(AssertionBaseFullName);
+ var assertionContext = compilation.GetTypeByMetadataName(AssertionContextFullName);
+ var shouldNameAttr = compilation.GetTypeByMetadataName(ShouldNameAttributeFullName);
+ var partialMarker = compilation.GetTypeByMetadataName(ShouldGeneratePartialAttributeFullName);
+
+ if (assertionSource is null || assertionBase is null || assertionContext is null)
+ {
+ return new GeneratorPayload(
+ new EquatableArray(Array.Empty()),
+ new EquatableArray(Array.Empty()));
+ }
+
+ // Phase 1 — per-reference scan, cached by MetadataReference identity.
+ // Skip references that don't transitively reference TUnit.Assertions: the BCL plus arbitrary
+ // NuGet packages can't possibly contain extension methods on IAssertionSource, and the
+ // closure pre-filter is the single biggest perf win for the generator.
+ var assertionsAssembly = assertionSource.ContainingAssembly;
+ var refResults = new List();
+ foreach (var refAssembly in compilation.SourceModule.ReferencedAssemblySymbols)
+ {
+ if (!ReferencesAssertionsAssembly(refAssembly, assertionsAssembly))
+ {
+ continue;
+ }
+ refResults.Add(GetOrComputeReferenceData(
+ compilation, refAssembly, assertionSource, assertionBase, assertionContext, shouldNameAttr, partialMarker));
+ }
+
+ // Phase 2 — union dedup sets across all references.
+ var alreadyBaked = new HashSet(StringComparer.Ordinal);
+ foreach (var r in refResults)
+ {
+ foreach (var name in r.AlreadyBakedNames)
+ {
+ alreadyBaked.Add(name);
+ }
+ }
+
+ // Phase 3 — walk the current compilation (cannot be cached: changes on every edit).
+ var localMethods = ImmutableArray.CreateBuilder();
+ var localCtx = new CollectionContext(
+ compilation,
+ assertionSource,
+ assertionBase,
+ assertionContext,
+ shouldNameAttr,
+ alreadyBaked,
+ localMethods);
+ WalkNamespace(compilation.Assembly.GlobalNamespace, localCtx);
+
+ var localWrappers = new List();
+ if (partialMarker is not null)
+ {
+ WalkForWrappers(compilation.Assembly.GlobalNamespace, partialMarker, assertionBase, assertionContext, localWrappers, isCurrentAssembly: true);
+ }
+
+ // Phase 4 — merge and apply post-walk dedup. Wrapper instance methods and Should-flavored
+ // extensions co-exist by design: the wrapper's [ShouldGeneratePartial] only emits methods
+ // whose source overload exactly matches a public ctor on the inner assertion (the simple-
+ // factory rule), so overloads with optional/default parameters land only on the extension
+ // surface. Instance methods take overload-resolution precedence at call sites, so there's
+ // no ambiguity. References whose ShouldNameExtensions counterpart is already baked are
+ // dropped to prevent CS0121.
+ var allMethods = ImmutableArray.CreateBuilder();
+ foreach (var m in localMethods)
+ {
+ allMethods.Add(m);
+ }
+ foreach (var r in refResults)
+ {
+ foreach (var m in r.Methods)
+ {
+ if (alreadyBaked.Contains($"Should{m.ContainerName}"))
+ {
+ continue;
+ }
+ allMethods.Add(m);
+ }
+ }
+
+ return new GeneratorPayload(
+ new EquatableArray(allMethods.ToArray()),
+ new EquatableArray(localWrappers.ToArray()));
+ }
+
+ ///
+ /// Returns the cached for , or
+ /// performs a one-shot scan and stores the result. The scan is dedup-free — the union dedup
+ /// is applied at merge time, so a cache entry remains valid even when other references in
+ /// the compilation change.
+ ///
+ private static ReferenceData GetOrComputeReferenceData(
+ Compilation compilation,
+ IAssemblySymbol refAssembly,
+ INamedTypeSymbol assertionSource,
+ INamedTypeSymbol assertionBase,
+ INamedTypeSymbol assertionContext,
+ INamedTypeSymbol? shouldNameAttr,
+ INamedTypeSymbol? partialMarker)
+ {
+ var metadataRef = compilation.GetMetadataReference(refAssembly);
+ if (metadataRef is null)
+ {
+ return ScanReference(refAssembly, compilation, assertionSource, assertionBase, assertionContext, shouldNameAttr, partialMarker);
+ }
+
+ if (s_referenceCache.TryGetValue(metadataRef, out var cached))
+ {
+ return cached;
+ }
+
+ var fresh = ScanReference(refAssembly, compilation, assertionSource, assertionBase, assertionContext, shouldNameAttr, partialMarker);
+
+ // Concurrent races between two compilations seeing the same uncached MetadataReference
+ // are harmless — both compute the same ReferenceData; first writer wins. Catching
+ // ArgumentException is the documented way to handle the "already added" case on
+ // ConditionalWeakTable.Add (no TryAdd overload exists in netstandard2.0).
+ try
+ {
+ s_referenceCache.Add(metadataRef, fresh);
+ }
+ catch (ArgumentException)
+ {
+ }
+ return fresh;
+ }
+
+ private static ReferenceData ScanReference(
+ IAssemblySymbol refAssembly,
+ Compilation compilation,
+ INamedTypeSymbol assertionSource,
+ INamedTypeSymbol assertionBase,
+ INamedTypeSymbol assertionContext,
+ INamedTypeSymbol? shouldNameAttr,
+ INamedTypeSymbol? partialMarker)
+ {
+ var methods = ImmutableArray.CreateBuilder();
+ var ctx = new CollectionContext(
+ compilation,
+ assertionSource,
+ assertionBase,
+ assertionContext,
+ shouldNameAttr,
+ new HashSet(StringComparer.Ordinal), // no per-reference dedup; applied at merge
+ methods);
+ WalkNamespace(refAssembly.GlobalNamespace, ctx);
+
+ var wrappers = new List();
+ if (partialMarker is not null)
+ {
+ WalkForWrappers(refAssembly.GlobalNamespace, partialMarker, assertionBase, assertionContext, wrappers, isCurrentAssembly: false);
+ }
+
+ var bakedNames = new List();
+ var bakedNs = LookupNamespace(refAssembly.GlobalNamespace, ShouldExtensionsNamespace);
+ if (bakedNs is not null)
+ {
+ foreach (var t in bakedNs.GetTypeMembers())
+ {
+ bakedNames.Add(t.Name);
+ }
+ }
+
+ return new ReferenceData(
+ new EquatableArray(methods.ToArray()),
+ new EquatableArray(wrappers.ToArray()),
+ new EquatableArray(bakedNames.ToArray()));
+ }
+
+ private static void WalkForWrappers(
+ INamespaceSymbol ns,
+ INamedTypeSymbol marker,
+ INamedTypeSymbol assertionBase,
+ INamedTypeSymbol assertionContext,
+ List builder,
+ bool isCurrentAssembly)
+ {
+ foreach (var type in ns.GetTypeMembers())
+ {
+ CollectWrapper(type, marker, assertionBase, assertionContext, builder, isCurrentAssembly);
+ }
+ foreach (var nested in ns.GetNamespaceMembers())
+ {
+ WalkForWrappers(nested, marker, assertionBase, assertionContext, builder, isCurrentAssembly);
+ }
+ }
+
+ private static void CollectWrapper(
+ INamedTypeSymbol type,
+ INamedTypeSymbol marker,
+ INamedTypeSymbol assertionBase,
+ INamedTypeSymbol assertionContext,
+ List builder,
+ bool isCurrentAssembly)
+ {
+ foreach (var nested in type.GetTypeMembers())
+ {
+ CollectWrapper(nested, marker, assertionBase, assertionContext, builder, isCurrentAssembly);
+ }
+
+ // Read the wrapped type from [ShouldGeneratePartial(typeof(...))]. The attribute's
+ // single ctor argument names the wrapped definition explicitly, so the wrapper class
+ // is free to construct its own AssertionContext rather than piggybacking on the
+ // wrapped type's constructor. For 1-arity generics the open form is supplied
+ // (typeof(Foo<>)) and we substitute the wrapper class's type parameter to close it.
+ INamedTypeSymbol? wrappedType = null;
+ ITypeSymbol? wrappedAssertionTypeArg = null;
+ foreach (var attr in type.GetAttributes())
+ {
+ if (!SymbolEqualityComparer.Default.Equals(attr.AttributeClass, marker)) continue;
+ if (attr.ConstructorArguments.Length != 1) continue;
+ if (attr.ConstructorArguments[0].Value is not INamedTypeSymbol declared) continue;
+
+ var closed = CloseWrappedType(declared, type);
+ if (closed is null) continue;
+ if (!DerivesFromAssertion(closed, assertionBase, out var typeArg)) continue;
+
+ wrappedType = closed;
+ wrappedAssertionTypeArg = typeArg;
+ break;
+ }
+
+ if (wrappedType is null || wrappedAssertionTypeArg is null)
+ {
+ return;
+ }
+
+ // Wrappers from referenced assemblies are still collected — their return-type keys
+ // feed the dedup set so the main extension-method scan skips already-baked extensions.
+ // The IsCurrentAssembly flag on WrapperData controls whether the emission step actually
+ // generates partial methods (only true for wrappers in this compilation).
+ var methods = ImmutableArray.CreateBuilder();
+ foreach (var sourceMember in EnumerateInstanceMethods(wrappedType))
+ {
+ if (TryDescribeWrapperMethod(sourceMember, wrappedAssertionTypeArg, assertionBase, assertionContext, out var data))
+ {
+ methods.Add(data);
+ }
+ }
+
+ if (methods.Count == 0 && isCurrentAssembly)
+ {
+ return;
+ }
+
+ builder.Add(new WrapperData(
+ ContainingNamespace: type.ContainingNamespace?.ToDisplayString(NoGlobalFormat) ?? string.Empty,
+ ClassName: type.Name,
+ ClassGenericParams: new EquatableArray(type.TypeParameters.Select(tp => GenericParamData.From(tp, NoGlobalFormat)).ToList()),
+ ClassGenericSuffix: type.IsGenericType ? "<" + string.Join(", ", type.TypeParameters.Select(tp => tp.Name)) + ">" : string.Empty,
+ AssertionTypeArgDisplay: wrappedAssertionTypeArg.ToDisplayString(NoGlobalFormat),
+ Methods: new EquatableArray(methods),
+ IsCurrentAssembly: isCurrentAssembly));
+ }
+
+ ///
+ /// Closes against 's type parameters.
+ /// Already-closed types pass through unchanged. Open generics whose arity matches the wrapper
+ /// are constructed by substituting the wrapper's type parameters in declaration order — this
+ /// covers the typical typeof(Foo<>) on a 1-arity wrapper case. Anything else
+ /// returns null so the caller skips emission.
+ ///
+ private static INamedTypeSymbol? CloseWrappedType(INamedTypeSymbol declared, INamedTypeSymbol wrapper)
+ {
+ if (!declared.IsUnboundGenericType && !declared.IsGenericType)
+ {
+ return declared;
+ }
+ if (!declared.IsUnboundGenericType)
+ {
+ return declared; // already closed
+ }
+ if (declared.TypeParameters.Length != wrapper.TypeParameters.Length)
+ {
+ return null;
+ }
+ return declared.OriginalDefinition.Construct(wrapper.TypeParameters.Cast().ToArray());
+ }
+
+ private static IEnumerable EnumerateInstanceMethods(INamedTypeSymbol type)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ for (var current = type;
+ current is not null && current.SpecialType != SpecialType.System_Object;
+ current = current.BaseType)
+ {
+ foreach (var member in current.GetMembers())
+ {
+ if (member is IMethodSymbol m
+ && m.MethodKind == MethodKind.Ordinary
+ && !m.IsStatic
+ && m.DeclaredAccessibility == Accessibility.Public
+ && seen.Add(GetMethodSignatureKey(m)))
+ {
+ yield return m;
+ }
+ }
+ }
+ }
+
+ private static string GetMethodSignatureKey(IMethodSymbol method)
+ {
+ var sb = new StringBuilder(method.Name);
+ sb.Append('`').Append(method.Arity);
+ foreach (var parameter in method.Parameters)
+ {
+ sb.Append('|')
+ .Append(parameter.RefKind)
+ .Append(':')
+ .Append(parameter.Type.ToDisplayString(NoGlobalFormat));
+ }
+ return sb.ToString();
+ }
+
+ private static bool TryDescribeWrapperMethod(
+ IMethodSymbol method,
+ ITypeSymbol assertionTypeArg,
+ INamedTypeSymbol assertionBase,
+ INamedTypeSymbol assertionContext,
+ out WrapperMethodData data)
+ {
+ data = null!;
+
+ // Skip methods with method-level generic parameters for v1 — emitting them requires
+ // propagating type-arg references that appear in the return type's generic arguments
+ // (e.g. IsAssignableTo returns IsAssignableToAssertion) and
+ // the inference works less reliably without explicit declaration site info.
+ if (method.TypeParameters.Length > 0)
+ {
+ return false;
+ }
+
+ if (method.ReturnType is not INamedTypeSymbol returnType
+ || !DerivesFromAssertion(returnType, assertionBase, out var returnedAssertionArg))
+ {
+ return false;
+ }
+
+ // Wrapper instance methods only make sense when the underlying assertion's value type
+ // matches the wrapper's wrapped type — anything else would require a context Map.
+ if (!SymbolEqualityComparer.Default.Equals(returnedAssertionArg, assertionTypeArg))
+ {
+ return false;
+ }
+
+ var paramData = ImmutableArray.CreateBuilder();
+ var ctorCandidates = new List();
+ foreach (var p in method.Parameters)
+ {
+ var caeTarget = TryGetCallerArgumentExpressionTarget(p);
+ paramData.Add(new ParameterData(
+ Name: p.Name,
+ TypeName: p.Type.ToDisplayString(NoGlobalFormat),
+ HasDefaultValue: p.HasExplicitDefaultValue,
+ DefaultValueLiteral: p.HasExplicitDefaultValue ? FormatDefaultValue(p.ExplicitDefaultValue, p.Type) : null,
+ CallerArgumentExpressionTarget: caeTarget));
+ if (caeTarget is null) ctorCandidates.Add(p);
+ }
+
+ if (!HasMatchingConstructor(returnType, assertionTypeArg, assertionContext, ctorCandidates))
+ {
+ return false;
+ }
+
+ data = new WrapperMethodData(
+ SourceMethodName: method.Name,
+ Parameters: new EquatableArray(paramData),
+ ReturnTypeFullName: returnType.ConstructedFrom.ToDisplayString(NameWithoutTypeArgsFormat),
+ ReturnTypeGenericArgs: new EquatableArray(returnType.TypeArguments.Select(a => a.ToDisplayString(NoGlobalFormat)).ToList()),
+ RequiresUnreferencedCodeMessage: TryGetRucMessage(method.GetAttributes())
+ ?? TryGetRucMessage(returnType.GetAttributes())
+ ?? TryGetRucMessageFromConstructors(returnType));
+ return true;
+ }
+
+ ///
+ /// Pre-filter: returns true only when the reference (or one of its direct module references)
+ /// is itself. The check is one-level deep, NOT transitive — a
+ /// transitive reference is treated as "doesn't contain Should-relevant types" and skipped.
+ /// In practice this is safe because IAssertionSource<T> lives in TUnit.Assertions,
+ /// so any assembly declaring extension methods on it must have a direct reference. Skipping
+ /// transitively-only-referenced assemblies is the single biggest perf win for the generator
+ /// (otherwise it'd walk the entire BCL).
+ ///
+ private static bool ReferencesAssertionsAssembly(IAssemblySymbol reference, IAssemblySymbol assertionsAssembly)
+ {
+ if (SymbolEqualityComparer.Default.Equals(reference, assertionsAssembly))
+ {
+ return true;
+ }
+
+ foreach (var module in reference.Modules)
+ {
+ foreach (var refed in module.ReferencedAssemblySymbols)
+ {
+ if (SymbolEqualityComparer.Default.Equals(refed, assertionsAssembly))
+ {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static INamespaceSymbol? LookupNamespace(INamespaceSymbol root, string dottedName)
+ {
+ var current = root;
+ foreach (var segment in dottedName.Split('.'))
+ {
+ current = current.GetNamespaceMembers().FirstOrDefault(n => n.Name == segment);
+ if (current is null) return null;
+ }
+ return current;
+ }
+
+ private static void WalkNamespace(INamespaceSymbol ns, CollectionContext ctx)
+ {
+ foreach (var type in ns.GetTypeMembers())
+ {
+ CollectFromContainer(type, ctx);
+ }
+ foreach (var nested in ns.GetNamespaceMembers())
+ {
+ WalkNamespace(nested, ctx);
+ }
+ }
+
+ private static void CollectFromContainer(INamedTypeSymbol type, CollectionContext ctx)
+ {
+ foreach (var nested in type.GetTypeMembers())
+ {
+ CollectFromContainer(nested, ctx);
+ }
+
+ if (type.DeclaredAccessibility != Accessibility.Public
+ || !type.IsStatic)
+ {
+ return;
+ }
+
+ // Generic static containers are not supported; [AssertionExtension] methods live on
+ // non-generic static classes so the generated Should wrapper has a concrete owner.
+ if (type.IsGenericType)
+ {
+ return;
+ }
+
+ if (!SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, ctx.Compilation.Assembly)
+ && ctx.AlreadyBakedShouldExtensionNames.Contains($"Should{type.Name}"))
+ {
+ return;
+ }
+
+ foreach (var member in type.GetMembers())
+ {
+ if (member is IMethodSymbol method)
+ {
+ CollectFromMethod(method, type, ctx);
+ }
+ }
+ }
+
+ private static void CollectFromMethod(IMethodSymbol method, INamedTypeSymbol container, CollectionContext ctx)
+ {
+ if (method.DeclaredAccessibility != Accessibility.Public
+ || !method.IsStatic
+ || !method.IsExtensionMethod
+ || method.Parameters.Length == 0)
+ {
+ return;
+ }
+
+ if (method.Parameters[0].Type is not INamedTypeSymbol firstParamType
+ || !IsAssertionSourceInterface(firstParamType, ctx.AssertionSource))
+ {
+ return;
+ }
+
+ if (method.ReturnType is not INamedTypeSymbol returnType
+ || !DerivesFromAssertion(returnType, ctx.AssertionBase, out var assertionTypeArg))
+ {
+ return;
+ }
+
+ var paramData = ImmutableArray.CreateBuilder();
+ var ctorCandidates = new List();
+ for (var i = 1; i < method.Parameters.Length; i++)
+ {
+ var p = method.Parameters[i];
+ var caeTarget = TryGetCallerArgumentExpressionTarget(p);
+ paramData.Add(new ParameterData(
+ Name: p.Name,
+ TypeName: p.Type.ToDisplayString(NoGlobalFormat),
+ HasDefaultValue: p.HasExplicitDefaultValue,
+ DefaultValueLiteral: p.HasExplicitDefaultValue ? FormatDefaultValue(p.ExplicitDefaultValue, p.Type) : null,
+ CallerArgumentExpressionTarget: caeTarget));
+ if (caeTarget is null) ctorCandidates.Add(p);
+ }
+
+ // Skip cross-type extensions where the source's TypeArg differs from the return-type's
+ // assertion TypeArg (e.g. ImplicitConversionEqualityExtensions.IsEqualTo).
+ // These need a Context.Map call we can't synthesize without inspecting the body.
+ if (!SymbolEqualityComparer.Default.Equals(firstParamType.TypeArguments[0], assertionTypeArg))
+ {
+ return;
+ }
+
+ // Find a public ctor on the return type whose param list (after the leading
+ // AssertionContext) matches our ctor candidates by type.
+ if (!HasMatchingConstructor(returnType, assertionTypeArg, ctx.AssertionContext, ctorCandidates))
+ {
+ return;
+ }
+
+ var classGenericParams = ImmutableArray.CreateBuilder();
+ foreach (var tp in method.TypeParameters)
+ {
+ classGenericParams.Add(GenericParamData.From(tp, NoGlobalFormat));
+ }
+
+ var rucMessage = TryGetRucMessage(method.GetAttributes())
+ ?? TryGetRucMessage(returnType.GetAttributes())
+ ?? TryGetRucMessageFromConstructors(returnType);
+
+ var suppressedTrimWarnings = CollectSuppressedTrimWarnings(method.GetAttributes());
+
+ var forwardedAttributes = CollectForwardedAttributes(method.GetAttributes());
+
+ var overrideName = TryGetShouldNameOverride(returnType, ctx.ShouldNameAttribute);
+
+ ctx.Builder.Add(new MethodData(
+ ContainerName: container.Name,
+ MethodName: method.Name,
+ MethodGenericParams: new EquatableArray(classGenericParams),
+ SourceTypeArgDisplay: firstParamType.TypeArguments[0].ToDisplayString(NoGlobalFormat),
+ AssertionTypeArgDisplay: assertionTypeArg.ToDisplayString(NoGlobalFormat),
+ ReturnTypeFullName: returnType.ConstructedFrom.ToDisplayString(NameWithoutTypeArgsFormat),
+ ReturnTypeGenericArgs: new EquatableArray(returnType.TypeArguments.Select(a => a.ToDisplayString(NoGlobalFormat)).ToList()),
+ Parameters: new EquatableArray(paramData),
+ ShouldNameOverride: overrideName,
+ RequiresUnreferencedCodeMessage: rucMessage,
+ SuppressedTrimWarnings: new EquatableArray(suppressedTrimWarnings),
+ ForwardedAttributes: new EquatableArray(forwardedAttributes)));
+ }
+
+ ///
+ /// Captures and
+ /// on the source extension method
+ /// so they propagate to the Should-flavored counterpart. Without forwarding, deprecating an
+ /// underlying assertion (IsAll) would leave the Should counterpart (BeAll)
+ /// undeprecated — users would see the warning on one entry but not the other.
+ ///
+ private static List CollectForwardedAttributes(ImmutableArray attrs)
+ {
+ var result = new List();
+ foreach (var a in attrs)
+ {
+ var ns = a.AttributeClass?.ContainingNamespace?.ToDisplayString();
+ if (a.AttributeClass?.Name == "ObsoleteAttribute" && ns == "System")
+ {
+ result.Add(FormatObsolete(a));
+ }
+ else if (a.AttributeClass?.Name == "EditorBrowsableAttribute" && ns == "System.ComponentModel")
+ {
+ result.Add(FormatEditorBrowsable(a));
+ }
+ }
+ return result;
+ }
+
+ private static string FormatObsolete(AttributeData attr)
+ => TUnit.SourceGen.Shared.AttributeForwardingFormatters.FormatObsolete(attr, globalQualifier: "global::");
+
+ private static string FormatEditorBrowsable(AttributeData attr)
+ => TUnit.SourceGen.Shared.AttributeForwardingFormatters.FormatEditorBrowsable(attr, globalQualifier: "global::");
+
+ private static List CollectSuppressedTrimWarnings(ImmutableArray attrs)
+ {
+ var result = new List();
+ foreach (var a in attrs)
+ {
+ if (a.AttributeClass?.Name != UnconditionalSuppressMessageAttributeName
+ || a.ConstructorArguments.Length < 2)
+ {
+ continue;
+ }
+ if (a.ConstructorArguments[0].Value is string category && category == "Trimming"
+ && a.ConstructorArguments[1].Value is string code)
+ {
+ result.Add(code);
+ }
+ }
+ return result;
+ }
+
+ ///
+ /// Returns true when has a public ctor whose parameters,
+ /// after a leading AssertionContext<assertionTypeArg>, match
+ /// by type. This guards the "simple factory" template
+ /// against extension methods that map context or otherwise transform before construction.
+ ///
+ private static bool HasMatchingConstructor(
+ INamedTypeSymbol returnType,
+ ITypeSymbol assertionTypeArg,
+ INamedTypeSymbol assertionContextSymbol,
+ List ctorCandidates)
+ {
+ foreach (var ctor in returnType.Constructors)
+ {
+ if (ctor.DeclaredAccessibility != Accessibility.Public
+ || ctor.IsStatic
+ || ctor.Parameters.Length != ctorCandidates.Count + 1)
+ {
+ continue;
+ }
+
+ var firstCtorParam = ctor.Parameters[0].Type as INamedTypeSymbol;
+ if (firstCtorParam is null
+ || !SymbolEqualityComparer.Default.Equals(firstCtorParam.OriginalDefinition, assertionContextSymbol)
+ || firstCtorParam.TypeArguments.Length != 1
+ || !SymbolEqualityComparer.Default.Equals(firstCtorParam.TypeArguments[0], assertionTypeArg))
+ {
+ continue;
+ }
+
+ var allMatch = true;
+ for (var i = 0; i < ctorCandidates.Count; i++)
+ {
+ if (!SymbolEqualityComparer.Default.Equals(ctor.Parameters[i + 1].Type, ctorCandidates[i].Type))
+ {
+ allMatch = false;
+ break;
+ }
+ }
+ if (allMatch) return true;
+ }
+ return false;
+ }
+
+ private static string? TryGetShouldNameOverride(INamedTypeSymbol returnType, INamedTypeSymbol? shouldNameAttr)
+ {
+ if (shouldNameAttr is null) return null;
+ foreach (var attr in returnType.GetAttributes())
+ {
+ if (SymbolEqualityComparer.Default.Equals(attr.AttributeClass, shouldNameAttr)
+ && attr.ConstructorArguments.Length > 0)
+ {
+ return attr.ConstructorArguments[0].Value as string;
+ }
+ }
+ return null;
+ }
+
+ private static bool IsAssertionSourceInterface(INamedTypeSymbol type, INamedTypeSymbol assertionSource)
+ => type.OriginalDefinition is { } def
+ && SymbolEqualityComparer.Default.Equals(def, assertionSource)
+ && type.TypeArguments.Length == 1;
+
+ private static bool DerivesFromAssertion(INamedTypeSymbol type, INamedTypeSymbol assertionBase, out ITypeSymbol assertionTypeArg)
+ {
+ for (var current = type; current is not null; current = current.BaseType)
+ {
+ if (current.OriginalDefinition is { } def
+ && SymbolEqualityComparer.Default.Equals(def, assertionBase))
+ {
+ if (current.TypeArguments.Length != 1)
+ {
+ break;
+ }
+ assertionTypeArg = current.TypeArguments[0];
+ return true;
+ }
+ }
+ assertionTypeArg = null!;
+ return false;
+ }
+
+ private static string? TryGetCallerArgumentExpressionTarget(IParameterSymbol parameter)
+ {
+ foreach (var attr in parameter.GetAttributes())
+ {
+ if (attr.AttributeClass?.Name == CallerArgumentExpressionAttributeName
+ && attr.ConstructorArguments.Length > 0
+ && attr.ConstructorArguments[0].Value is string target)
+ {
+ return target;
+ }
+ }
+ return null;
+ }
+
+ private static string? TryGetRucMessage(ImmutableArray attrs)
+ {
+ foreach (var a in attrs)
+ {
+ if (a.AttributeClass?.Name == RequiresUnreferencedCodeAttributeName
+ && a.ConstructorArguments.Length > 0)
+ {
+ return a.ConstructorArguments[0].Value as string;
+ }
+ }
+ return null;
+ }
+
+ private static string? TryGetRucMessageFromConstructors(INamedTypeSymbol type)
+ {
+ foreach (var ctor in type.Constructors)
+ {
+ var msg = TryGetRucMessage(ctor.GetAttributes());
+ if (msg is not null) return msg;
+ }
+ return null;
+ }
+
+ private static string FormatDefaultValue(object? defaultValue, ITypeSymbol type)
+ {
+ if (defaultValue is null)
+ {
+ return type.IsReferenceType && type.NullableAnnotation != NullableAnnotation.Annotated
+ ? "default!"
+ : "default";
+ }
+
+ if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol enumType)
+ {
+ foreach (var member in enumType.GetMembers())
+ {
+ if (member is IFieldSymbol { HasConstantValue: true } field
+ && field.ConstantValue is not null
+ && field.ConstantValue.Equals(defaultValue))
+ {
+ return $"{enumType.ToDisplayString(NoGlobalFormat)}.{field.Name}";
+ }
+ }
+ return $"({enumType.ToDisplayString(NoGlobalFormat)})({defaultValue})";
+ }
+
+ // Numeric literals need their C# type suffix or they'd default-bind to int/double:
+ // a `float` parameter with default 1.5f would otherwise emit `= 1.5` (a double literal)
+ // and fail to compile. Cast through invariant culture so locales using comma decimal
+ // separators don't produce malformed literals like `1,5F`.
+ return defaultValue switch
+ {
+ string s => "\"" + s.Replace("\"", "\\\"") + "\"",
+ bool b => b ? "true" : "false",
+ char c => $"'{c}'",
+ float f => System.FormattableString.Invariant($"{f}F"),
+ double d => System.FormattableString.Invariant($"{d}D"),
+ decimal m => System.FormattableString.Invariant($"{m}M"),
+ long l => System.FormattableString.Invariant($"{l}L"),
+ ulong ul => System.FormattableString.Invariant($"{ul}UL"),
+ uint u => System.FormattableString.Invariant($"{u}U"),
+ _ => System.Convert.ToString(defaultValue, System.Globalization.CultureInfo.InvariantCulture) ?? "default",
+ };
+ }
+
+ private static void EmitWrapperPartial(SourceProductionContext ctx, WrapperData wrapper, HashSet emittedHints)
+ {
+ if (!wrapper.IsCurrentAssembly || wrapper.Methods.Length == 0)
+ {
+ return;
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Runtime.CompilerServices;");
+ sb.AppendLine("using TUnit.Assertions.Core;");
+ sb.AppendLine();
+ if (!string.IsNullOrEmpty(wrapper.ContainingNamespace))
+ {
+ sb.AppendLine($"namespace {wrapper.ContainingNamespace};");
+ sb.AppendLine();
+ }
+
+ var classGenericList = wrapper.ClassGenericParams.Length > 0
+ ? "<" + string.Join(", ", wrapper.ClassGenericParams.Select(p => p.Name)) + ">"
+ : string.Empty;
+
+ sb.AppendLine($"partial class {wrapper.ClassName}{classGenericList}");
+ sb.AppendLine("{");
+
+ foreach (var m in wrapper.Methods)
+ {
+ EmitWrapperMethod(sb, wrapper, m);
+ }
+
+ sb.AppendLine("}");
+
+ var hint = $"{wrapper.ClassName}.Generated.g.cs";
+ var suffix = 0;
+ while (!emittedHints.Add(hint))
+ {
+ hint = $"{wrapper.ClassName}_{++suffix}.Generated.g.cs";
+ }
+ ctx.AddSource(hint, sb.ToString());
+ }
+
+ private static void EmitWrapperMethod(StringBuilder sb, WrapperData wrapper, WrapperMethodData m)
+ {
+ var positiveName = NameConjugator.Conjugate(m.SourceMethodName);
+ var returnType = $"global::TUnit.Assertions.Should.Core.ShouldAssertion<{wrapper.AssertionTypeArgDisplay}>";
+
+ sb.AppendLine();
+ if (!string.IsNullOrEmpty(m.RequiresUnreferencedCodeMessage))
+ {
+ var escaped = m.RequiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
+ sb.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"{escaped}\")]");
+ }
+
+ sb.Append($" public {returnType} {positiveName}(");
+ var first = true;
+ foreach (var p in m.Parameters)
+ {
+ if (p.CallerArgumentExpressionTarget is not null) continue;
+ if (!first) sb.Append(", ");
+ sb.Append($"{p.TypeName} {p.Name}");
+ if (p.HasDefaultValue)
+ {
+ sb.Append(" = ").Append(p.DefaultValueLiteral);
+ }
+ first = false;
+ }
+ foreach (var p in m.Parameters)
+ {
+ if (p.CallerArgumentExpressionTarget is null) continue;
+ if (!first) sb.Append(", ");
+ sb.Append($"[global::System.Runtime.CompilerServices.CallerArgumentExpression(\"{p.CallerArgumentExpressionTarget}\")] string? {p.Name} = null");
+ first = false;
+ }
+ sb.AppendLine(")");
+ sb.AppendLine(" {");
+ sb.AppendLine($" Context.ExpressionBuilder.Append(\".{positiveName}(\");");
+
+ var caeParams = m.Parameters.Where(p => p.CallerArgumentExpressionTarget is not null).ToArray();
+ if (caeParams.Length == 1)
+ {
+ sb.AppendLine($" Context.ExpressionBuilder.Append({caeParams[0].Name});");
+ }
+ else if (caeParams.Length > 1)
+ {
+ sb.AppendLine(" var __added = false;");
+ foreach (var p in caeParams)
+ {
+ sb.AppendLine($" if ({p.Name} is not null)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" if (__added) Context.ExpressionBuilder.Append(\", \");");
+ sb.AppendLine($" Context.ExpressionBuilder.Append({p.Name});");
+ sb.AppendLine(" __added = true;");
+ sb.AppendLine(" }");
+ }
+ }
+ sb.AppendLine(" Context.ExpressionBuilder.Append(\")\");");
+
+ var ctorArgs = new List { "Context" };
+ ctorArgs.AddRange(m.Parameters.Where(p => p.CallerArgumentExpressionTarget is null).Select(p => p.Name));
+
+ sb.AppendLine($" var inner = new global::{m.ReturnTypeFullName}{FormatGenericArgs(m.ReturnTypeGenericArgs)}({string.Join(", ", ctorArgs)});");
+ sb.AppendLine($" var __tunit_should_because = ((global::TUnit.Assertions.Should.Core.IShouldSource<{wrapper.AssertionTypeArgDisplay}>)this).ConsumeBecauseMessage();");
+ sb.AppendLine(" if (__tunit_should_because is not null)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" inner.Because(__tunit_should_because);");
+ sb.AppendLine(" }");
+ sb.AppendLine($" return new global::TUnit.Assertions.Should.Core.ShouldAssertion<{wrapper.AssertionTypeArgDisplay}>(Context, inner);");
+ sb.AppendLine(" }");
+ }
+
+ private static void EmitContainer(SourceProductionContext ctx, string containerName, MethodData[] methods, HashSet emittedHints)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Runtime.CompilerServices;");
+ sb.AppendLine("using TUnit.Assertions.Core;");
+ sb.AppendLine("using TUnit.Assertions.Should.Core;");
+ sb.AppendLine();
+ sb.AppendLine("namespace TUnit.Assertions.Should.Extensions;");
+ sb.AppendLine();
+
+ var className = "Should" + containerName;
+ sb.AppendLine($"public static partial class {className}");
+ sb.AppendLine("{");
+
+ foreach (var m in methods)
+ {
+ EmitMethod(sb, m);
+ }
+
+ sb.AppendLine("}");
+
+ var hint = className + ".g.cs";
+ var suffix = 0;
+ while (!emittedHints.Add(hint))
+ {
+ hint = $"{className}_{++suffix}.g.cs";
+ }
+ ctx.AddSource(hint, sb.ToString());
+ }
+
+ private static void EmitMethod(StringBuilder sb, MethodData m)
+ {
+ var positiveName = m.ShouldNameOverride ?? NameConjugator.Conjugate(m.MethodName);
+
+ var genericList = m.MethodGenericParams.Length > 0
+ ? "<" + string.Join(", ", m.MethodGenericParams.Select(p =>
+ p.DynamicallyAccessedMembersAttribute is null
+ ? p.Name
+ : $"{p.DynamicallyAccessedMembersAttribute} {p.Name}")) + ">"
+ : string.Empty;
+
+ var constraints = string.Join(" ", m.MethodGenericParams
+ .Select(p => p.ConstraintClause)
+ .Where(c => c is not null));
+
+ var sourceType = $"global::TUnit.Assertions.Should.Core.IShouldSource<{m.SourceTypeArgDisplay}>";
+ var returnType = $"global::TUnit.Assertions.Should.Core.ShouldAssertion<{m.AssertionTypeArgDisplay}>";
+
+ sb.AppendLine();
+ if (!string.IsNullOrEmpty(m.RequiresUnreferencedCodeMessage))
+ {
+ var escaped = m.RequiresUnreferencedCodeMessage!.Replace("\"", "\\\"");
+ sb.AppendLine($" [global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode(\"{escaped}\")]");
+ }
+ foreach (var code in m.SuppressedTrimWarnings)
+ {
+ sb.AppendLine($" [global::System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"Trimming\", \"{code}\", Justification = \"Forwarded from source method\")]");
+ }
+ foreach (var attr in m.ForwardedAttributes)
+ {
+ sb.AppendLine($" {attr}");
+ }
+
+ sb.Append($" public static {returnType} {positiveName}{genericList}(this {sourceType} source");
+ foreach (var p in m.Parameters)
+ {
+ if (p.CallerArgumentExpressionTarget is not null) continue;
+ sb.Append($", {p.TypeName} {p.Name}");
+ if (p.HasDefaultValue)
+ {
+ sb.Append(" = ").Append(p.DefaultValueLiteral);
+ }
+ }
+ foreach (var p in m.Parameters)
+ {
+ if (p.CallerArgumentExpressionTarget is null) continue;
+ sb.Append($", [global::System.Runtime.CompilerServices.CallerArgumentExpression(\"{p.CallerArgumentExpressionTarget}\")] string? {p.Name} = null");
+ }
+ sb.Append(')');
+
+ if (!string.IsNullOrEmpty(constraints))
+ {
+ sb.AppendLine();
+ sb.Append(" ").Append(constraints);
+ }
+
+ sb.AppendLine();
+ sb.AppendLine(" {");
+ sb.AppendLine(" var innerContext = source.Context;");
+ sb.AppendLine($" innerContext.ExpressionBuilder.Append(\".{positiveName}(\");");
+
+ var caeParams = m.Parameters.Where(p => p.CallerArgumentExpressionTarget is not null).ToArray();
+ if (caeParams.Length == 1)
+ {
+ sb.AppendLine($" innerContext.ExpressionBuilder.Append({caeParams[0].Name});");
+ }
+ else if (caeParams.Length > 1)
+ {
+ sb.AppendLine(" var __added = false;");
+ foreach (var p in caeParams)
+ {
+ sb.AppendLine($" if ({p.Name} is not null)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" if (__added) innerContext.ExpressionBuilder.Append(\", \");");
+ sb.AppendLine($" innerContext.ExpressionBuilder.Append({p.Name});");
+ sb.AppendLine(" __added = true;");
+ sb.AppendLine(" }");
+ }
+ }
+ sb.AppendLine(" innerContext.ExpressionBuilder.Append(\")\");");
+
+ var ctorArgs = new List { "innerContext" };
+ ctorArgs.AddRange(m.Parameters.Where(p => p.CallerArgumentExpressionTarget is null).Select(p => p.Name));
+
+ sb.AppendLine($" var inner = new global::{m.ReturnTypeFullName}{FormatGenericArgs(m.ReturnTypeGenericArgs)}({string.Join(", ", ctorArgs)});");
+ sb.AppendLine(" var __tunit_should_because = source.ConsumeBecauseMessage();");
+ sb.AppendLine(" if (__tunit_should_because is not null)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" inner.Because(__tunit_should_because);");
+ sb.AppendLine(" }");
+ sb.AppendLine($" return new global::TUnit.Assertions.Should.Core.ShouldAssertion<{m.AssertionTypeArgDisplay}>(innerContext, inner);");
+ sb.AppendLine(" }");
+ }
+
+ private static string FormatGenericArgs(EquatableArray args)
+ => args.Length == 0 ? string.Empty : "<" + string.Join(", ", args) + ">";
+
+ private sealed record GeneratorPayload(
+ EquatableArray Methods,
+ EquatableArray Wrappers);
+
+ private sealed record WrapperData(
+ string ContainingNamespace,
+ string ClassName,
+ EquatableArray ClassGenericParams,
+ string ClassGenericSuffix,
+ string AssertionTypeArgDisplay,
+ EquatableArray Methods,
+ bool IsCurrentAssembly);
+
+ private sealed record WrapperMethodData(
+ string SourceMethodName,
+ EquatableArray Parameters,
+ string ReturnTypeFullName,
+ EquatableArray ReturnTypeGenericArgs,
+ string? RequiresUnreferencedCodeMessage);
+
+ ///
+ /// Mutable bag of pre-resolved Roslyn symbols and the in-flight
+ /// builder, threaded through the namespace walk. Not a record — it doesn't flow through
+ /// the incremental pipeline as a cache key, and embeds a mutable builder.
+ ///
+ private sealed class CollectionContext
+ {
+ public CollectionContext(
+ Compilation compilation,
+ INamedTypeSymbol assertionSource,
+ INamedTypeSymbol assertionBase,
+ INamedTypeSymbol assertionContext,
+ INamedTypeSymbol? shouldNameAttribute,
+ HashSet alreadyBakedShouldExtensionNames,
+ ImmutableArray.Builder builder)
+ {
+ Compilation = compilation;
+ AssertionSource = assertionSource;
+ AssertionBase = assertionBase;
+ AssertionContext = assertionContext;
+ ShouldNameAttribute = shouldNameAttribute;
+ AlreadyBakedShouldExtensionNames = alreadyBakedShouldExtensionNames;
+ Builder = builder;
+ }
+
+ public Compilation Compilation { get; }
+ public INamedTypeSymbol AssertionSource { get; }
+ public INamedTypeSymbol AssertionBase { get; }
+ public INamedTypeSymbol AssertionContext { get; }
+ public INamedTypeSymbol? ShouldNameAttribute { get; }
+ public HashSet AlreadyBakedShouldExtensionNames { get; }
+ public ImmutableArray.Builder Builder { get; }
+ }
+
+ private sealed record MethodData(
+ string ContainerName,
+ string MethodName,
+ EquatableArray MethodGenericParams,
+ string SourceTypeArgDisplay,
+ string AssertionTypeArgDisplay,
+ string ReturnTypeFullName,
+ EquatableArray ReturnTypeGenericArgs,
+ EquatableArray Parameters,
+ string? ShouldNameOverride,
+ string? RequiresUnreferencedCodeMessage,
+ EquatableArray SuppressedTrimWarnings,
+ EquatableArray ForwardedAttributes);
+
+ private sealed record ParameterData(
+ string Name,
+ string TypeName,
+ bool HasDefaultValue,
+ string? DefaultValueLiteral,
+ string? CallerArgumentExpressionTarget);
+
+ private sealed record GenericParamData(string Name, string? ConstraintClause, string? DynamicallyAccessedMembersAttribute)
+ {
+ public static GenericParamData From(ITypeParameterSymbol tp, SymbolDisplayFormat format)
+ {
+ var constraints = new List();
+ if (tp.HasReferenceTypeConstraint) constraints.Add("class");
+ if (tp.HasValueTypeConstraint) constraints.Add("struct");
+ if (tp.HasNotNullConstraint) constraints.Add("notnull");
+ foreach (var ct in tp.ConstraintTypes)
+ {
+ constraints.Add(ct.ToDisplayString(format));
+ }
+ if (tp.HasConstructorConstraint) constraints.Add("new()");
+
+ string? damAttr = null;
+ foreach (var attr in tp.GetAttributes())
+ {
+ if (attr.AttributeClass?.Name != DynamicallyAccessedMembersAttributeName
+ || attr.ConstructorArguments.Length == 0)
+ {
+ continue;
+ }
+ var ctorArg = attr.ConstructorArguments[0];
+ if (ctorArg.Type is INamedTypeSymbol enumType && ctorArg.Value is int intValue)
+ {
+ damAttr = $"[global::System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(({enumType.ToDisplayString(format)}){intValue})]";
+ }
+ break;
+ }
+
+ return new GenericParamData(
+ tp.Name,
+ constraints.Count > 0 ? $"where {tp.Name} : {string.Join(", ", constraints)}" : null,
+ damAttr);
+ }
+ }
+}
diff --git a/TUnit.Assertions.Should.SourceGenerator/TUnit.Assertions.Should.SourceGenerator.csproj b/TUnit.Assertions.Should.SourceGenerator/TUnit.Assertions.Should.SourceGenerator.csproj
new file mode 100644
index 0000000000..3c06e84a78
--- /dev/null
+++ b/TUnit.Assertions.Should.SourceGenerator/TUnit.Assertions.Should.SourceGenerator.csproj
@@ -0,0 +1,39 @@
+
+
+
+
+
+ netstandard2.0
+ enable
+ latest
+ true
+ true
+ TUnit.Assertions.Should.SourceGenerator
+ TUnit.Assertions.Should.SourceGenerator
+ false
+ false
+
+
+
+
+
+
+
+
+
+ <_Parameter1>TUnit.Assertions.Should.SourceGenerator.Tests
+
+
+
+
+
+ all
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/TUnit.Assertions.Should.Tests/AssertMultipleTests.cs b/TUnit.Assertions.Should.Tests/AssertMultipleTests.cs
new file mode 100644
index 0000000000..5cb64ecb05
--- /dev/null
+++ b/TUnit.Assertions.Should.Tests/AssertMultipleTests.cs
@@ -0,0 +1,48 @@
+using TUnit.Assertions.Exceptions;
+using TUnit.Assertions.Should;
+using TUnit.Assertions.Should.Extensions;
+
+namespace TUnit.Assertions.Should.Tests;
+
+public class AssertMultipleTests
+{
+ [Test]
+ public async Task All_pass_inside_Multiple()
+ {
+ using (Assert.Multiple())
+ {
+ await 5.Should().BeEqualTo(5);
+ await "hello".Should().BeEqualTo("hello");
+ await new[] { 1, 2 }.Should().Contain(1);
+ }
+ }
+
+ [Test]
+ public async Task Multiple_failures_aggregate()
+ {
+ var ex = await Assert.That(async () =>
+ {
+ using (Assert.Multiple())
+ {
+ await 5.Should().BeEqualTo(99);
+ await "hello".Should().BeEqualTo("world");
+ }
+ }).Throws();
+
+ // Both failures should appear in the aggregated message.
+ await Assert.That(ex.Message).Contains("BeEqualTo");
+ }
+
+ [Test]
+ public async Task Single_failure_in_Multiple_still_throws()
+ {
+ await Assert.That(async () =>
+ {
+ using (Assert.Multiple())
+ {
+ await 5.Should().BeEqualTo(5);
+ await 5.Should().BeEqualTo(99);
+ }
+ }).Throws();
+ }
+}
diff --git a/TUnit.Assertions.Should.Tests/BasicShouldTests.cs b/TUnit.Assertions.Should.Tests/BasicShouldTests.cs
new file mode 100644
index 0000000000..f229d546be
--- /dev/null
+++ b/TUnit.Assertions.Should.Tests/BasicShouldTests.cs
@@ -0,0 +1,127 @@
+using TUnit.Assertions.Should;
+using TUnit.Assertions.Should.Extensions;
+
+namespace TUnit.Assertions.Should.Tests;
+
+public class BasicShouldTests
+{
+ [Test]
+ public async Task Value_BeEqualTo_passes()
+ {
+ await 42.Should().BeEqualTo(42);
+ }
+
+ [Test]
+ public async Task Value_NotBeEqualTo_passes()
+ {
+ await 42.Should().NotBeEqualTo(7);
+ }
+
+ [Test]
+ public async Task Value_BeEqualTo_fails_with_message()
+ {
+ var ex = await Assert.That(async () => await 42.Should().BeEqualTo(7))
+ .Throws();
+ await Assert.That(ex.Message).Contains("BeEqualTo");
+ }
+
+ [Test]
+ public async Task String_BeEqualTo_works()
+ {
+ await "hello".Should().BeEqualTo("hello");
+ }
+
+ [Test]
+ public async Task String_Contain_works()
+ {
+ await "hello world".Should().Contain("world");
+ }
+
+ [Test]
+ public async Task String_StartWith_works()
+ {
+ await "hello world".Should().StartWith("hello");
+ }
+
+ [Test]
+ public async Task Collection_Contain_works()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().Contain(2);
+ }
+
+ [Test]
+ public async Task Chain_And_works()
+ {
+ await 42.Should().BeEqualTo(42).And.NotBeEqualTo(7);
+ }
+
+ [Test]
+ public async Task Chain_Or_works()
+ {
+ await 42.Should().BeEqualTo(7).Or.BeEqualTo(42);
+ }
+
+ [Test]
+ public async Task Default_BeDefault_works()
+ {
+ int value = 0;
+ await value.Should().BeDefault();
+ }
+
+ [Test]
+ public async Task NotDefault_NotBeDefault_works()
+ {
+ int value = 42;
+ await value.Should().NotBeDefault();
+ }
+
+ [Test]
+ public async Task Action_Throw_works()
+ {
+ Action act = () => throw new InvalidOperationException("boom");
+ await act.Should().Throw();
+ }
+
+ [Test]
+ public async Task Action_ThrowExactly_works()
+ {
+ Action act = () => throw new InvalidOperationException("boom");
+ await act.Should().ThrowExactly();
+ }
+
+ [Test]
+ public async Task FuncTask_Throw_works()
+ {
+ Func act = () => throw new InvalidOperationException("boom");
+ await act.Should().Throw();
+ }
+
+ [Test]
+ public async Task FuncResult_does_not_throw()
+ {
+ Func f = () => 42;
+ await f.Should().NotBeEqualTo(7);
+ }
+
+ [Test]
+ public async Task List_Contain_infers_element_type_without_cast()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().Contain(2);
+ }
+
+ [Test]
+ public async Task ReadOnlyList_Contain_infers_element_type_without_cast()
+ {
+ IReadOnlyList list = new[] { "a", "b", "c" };
+ await list.Should().Contain("b");
+ }
+
+ [Test]
+ public async Task Array_Contain_infers_element_type_without_cast()
+ {
+ var arr = new[] { 1, 2, 3 };
+ await arr.Should().Contain(2);
+ }
+}
diff --git a/TUnit.Assertions.Should.Tests/ChainingTests.cs b/TUnit.Assertions.Should.Tests/ChainingTests.cs
new file mode 100644
index 0000000000..777c2e005f
--- /dev/null
+++ b/TUnit.Assertions.Should.Tests/ChainingTests.cs
@@ -0,0 +1,96 @@
+using TUnit.Assertions.Exceptions;
+using TUnit.Assertions.Should;
+using TUnit.Assertions.Should.Extensions;
+
+namespace TUnit.Assertions.Should.Tests;
+
+public class ChainingTests
+{
+ [Test]
+ public async Task And_two_passes()
+ {
+ await 5.Should().BeEqualTo(5).And.NotBeEqualTo(7);
+ }
+
+ [Test]
+ public async Task And_three_passes()
+ {
+ await 5.Should().BeEqualTo(5).And.NotBeEqualTo(7).And.BeBetween(1, 10);
+ }
+
+ [Test]
+ public async Task And_first_fails_throws()
+ {
+ await Assert.That(async () => await 5.Should().BeEqualTo(99).And.NotBeEqualTo(7))
+ .Throws();
+ }
+
+ [Test]
+ public async Task And_second_fails_throws()
+ {
+ await Assert.That(async () => await 5.Should().BeEqualTo(5).And.BeEqualTo(99))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Or_first_passes_short_circuits()
+ {
+ await 5.Should().BeEqualTo(5).Or.BeEqualTo(99);
+ }
+
+ [Test]
+ public async Task Or_second_passes()
+ {
+ await 5.Should().BeEqualTo(99).Or.BeEqualTo(5);
+ }
+
+ [Test]
+ public async Task Or_both_fail_throws()
+ {
+ await Assert.That(async () => await 5.Should().BeEqualTo(99).Or.BeEqualTo(100))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Mixed_And_Or_throws()
+ {
+ await Assert.That(async () => await 5.Should().BeEqualTo(5).And.BeEqualTo(5).Or.BeEqualTo(7))
+ .Throws();
+ }
+
+ [Test]
+ public async Task Chain_keeps_Should_naming_throughout()
+ {
+ // After .And the source is ShouldContinuation: IShouldSource; only Should-flavored
+ // extensions should resolve.
+ var list = new List { 1, 2, 3 };
+ await list.Should().Contain(1).And.Contain(2).And.NotContain(99);
+ }
+
+ [Test]
+ public async Task Because_propagates_to_failure_message()
+ {
+ var ex = await Assert.That(async () =>
+ await 5.Should().BeEqualTo(99).Because("business rule X"))
+ .Throws();
+ await Assert.That(ex.Message).Contains("business rule X");
+ }
+
+ [Test]
+ public async Task Pre_chain_Because_propagates_to_failure_message()
+ {
+ var ex = await Assert.That(async () =>
+ await 5.Should().Because("business rule Y").BeEqualTo(99))
+ .Throws();
+ await Assert.That(ex.Message).Contains("business rule Y");
+ }
+
+ [Test]
+ public async Task Continuation_Because_propagates_to_next_assertion()
+ {
+ var ex = await Assert.That(async () =>
+ await 5.Should().BeEqualTo(5).And.Because("business rule Z").BeEqualTo(99))
+ .Throws();
+ await Assert.That(ex.Message).Contains("business rule Z");
+ }
+}
diff --git a/TUnit.Assertions.Should.Tests/CollectionTests.cs b/TUnit.Assertions.Should.Tests/CollectionTests.cs
new file mode 100644
index 0000000000..8c1e215950
--- /dev/null
+++ b/TUnit.Assertions.Should.Tests/CollectionTests.cs
@@ -0,0 +1,132 @@
+using TUnit.Assertions.Should;
+using TUnit.Assertions.Should.Extensions;
+
+namespace TUnit.Assertions.Should.Tests;
+
+public class CollectionTests
+{
+ [Test]
+ public async Task List_Contain()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().Contain(2);
+ }
+
+ [Test]
+ public async Task List_NotContain()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().NotContain(99);
+ }
+
+ [Test]
+ public async Task IReadOnlyList_Contain_infers_element_type()
+ {
+ IReadOnlyList list = new[] { "a", "b", "c" };
+ await list.Should().Contain("b");
+ }
+
+ [Test]
+ public async Task Array_Contain()
+ {
+ var arr = new[] { 1, 2, 3 };
+ await arr.Should().Contain(2);
+ }
+
+ [Test]
+ public async Task IEnumerable_Contain()
+ {
+ IEnumerable seq = Enumerable.Range(1, 5);
+ await seq.Should().Contain(3);
+ }
+
+ [Test]
+ public async Task BeInOrder()
+ {
+ var list = new List { 1, 2, 3, 4, 5 };
+ await list.Should().BeInOrder();
+ }
+
+ [Test]
+ public async Task BeInDescendingOrder()
+ {
+ var list = new List { 5, 4, 3, 2, 1 };
+ await list.Should().BeInDescendingOrder();
+ }
+
+ [Test]
+ public async Task All_predicate()
+ {
+ var list = new List { 2, 4, 6 };
+ await list.Should().All(x => x % 2 == 0);
+ }
+
+ [Test]
+ public async Task Any_predicate()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().Any(x => x > 2);
+ }
+
+ [Test]
+ public async Task HaveSingleItem()
+ {
+ var list = new List { 42 };
+ await list.Should().HaveSingleItem();
+ }
+
+ [Test]
+ public async Task HaveSingleItem_with_predicate()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().HaveSingleItem(x => x == 2);
+ }
+
+ [Test]
+ public async Task HaveDistinctItems()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().HaveDistinctItems();
+ }
+
+ [Test]
+ public async Task HaveCount()
+ {
+ var list = new List { 1, 2, 3 };
+ await list.Should().HaveCount(3);
+ }
+
+ [Test]
+ public async Task Contain_predicate()
+ {
+ var list = new List