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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
{
continue;
}

// Track if this is an unbound generic type for special handling
var isUnboundGeneric = containingType.IsUnboundGenericType;

string? customName = null;
if (attributeData.NamedArguments.Any(na => na.Key == "CustomName"))
{
Expand Down Expand Up @@ -123,7 +127,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
negateLogic,
requiresGenericTypeParameter,
treatAsInstance,
expectationMessage
expectationMessage,
isUnboundGeneric
);

attributeDataList.Add(new AttributeWithClassData(classSymbol, createAssertionAttributeData));
Expand Down Expand Up @@ -199,6 +204,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
continue;
}

// Track if this is an unbound generic type for special handling
var isUnboundGeneric = containingType.IsUnboundGenericType;

string? customName = null;
bool negateLogic = false;
bool requiresGenericTypeParameter = false;
Expand Down Expand Up @@ -235,7 +243,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
negateLogic,
requiresGenericTypeParameter,
treatAsInstance,
expectationMessage
expectationMessage,
isUnboundGeneric
);

attributeDataList.Add(new AttributeWithClassData(classSymbol, createAssertionAttributeData));
Expand Down Expand Up @@ -360,8 +369,33 @@ private static void GenerateAssertionsForSpecificClass(SourceProductionContext c
{
var attributeData = attributeWithClassData.AttributeData;

// For unbound generic types, we need to temporarily construct them for member lookup
var typeForMemberLookup = attributeData.ContainingType;
if (attributeData.IsUnboundGeneric && typeForMemberLookup.IsUnboundGenericType)
{
// Get System.Object by searching through the type's containing assembly references
INamedTypeSymbol? objectType = null;

// Look through the referenced assemblies to find System.Object
foreach (var refAssembly in typeForMemberLookup.ContainingAssembly.Modules.FirstOrDefault()?.ReferencedAssemblySymbols ?? Enumerable.Empty<IAssemblySymbol>())
{
var systemNs = refAssembly.GlobalNamespace.GetNamespaceMembers().FirstOrDefault(ns => ns.Name == "System");
if (systemNs != null)
{
objectType = systemNs.GetTypeMembers("Object").FirstOrDefault();
if (objectType != null) break;
}
}

if (objectType != null)
{
var typeArgs = Enumerable.Repeat<ITypeSymbol>(objectType, typeForMemberLookup.TypeParameters.Length).ToArray();
typeForMemberLookup = typeForMemberLookup.Construct(typeArgs);
}
}

// First try to find methods
var methodMembers = attributeData.ContainingType.GetMembers(attributeData.MethodName)
var methodMembers = typeForMemberLookup.GetMembers(attributeData.MethodName)
.OfType<IMethodSymbol>()
.Where(m => IsValidReturnType(m.ReturnType, out _) &&
(attributeData.TreatAsInstance ?
Expand All @@ -385,10 +419,10 @@ private static void GenerateAssertionsForSpecificClass(SourceProductionContext c
var propertyMembers = new List<IPropertySymbol>();
if (!methodMembers.Any())
{
propertyMembers = attributeData.ContainingType.GetMembers(attributeData.MethodName)
propertyMembers = typeForMemberLookup.GetMembers(attributeData.MethodName)
.OfType<IPropertySymbol>()
.Where(p => p.Type.SpecialType == SpecialType.System_Boolean &&
p is { GetMethod: not null, IsStatic: false } && SymbolEqualityComparer.Default.Equals(p.ContainingType, attributeData.TargetType))
p is { GetMethod: not null, IsStatic: false })
.ToList();
}

Expand All @@ -405,15 +439,20 @@ private static void GenerateAssertionsForSpecificClass(SourceProductionContext c

if (!matchingMethods.Any())
{
context.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor(
"TU0001",
"Method not found",
$"No boolean method '{attributeData.MethodName}' found on type '{attributeData.ContainingType.ToDisplayString()}'",
"TUnit.Assertions",
DiagnosticSeverity.Error,
true),
Location.None));
// For unbound generic types, we'll generate generic methods even if we can't find the property now
// The property will exist on the constructed type at runtime
if (!attributeData.IsUnboundGeneric)
{
context.ReportDiagnostic(Diagnostic.Create(
new DiagnosticDescriptor(
"TU0001",
"Method not found",
$"No boolean method '{attributeData.MethodName}' found on type '{attributeData.ContainingType.ToDisplayString()}'",
"TUnit.Assertions",
DiagnosticSeverity.Error,
true),
Location.None));
}
continue;
}

Expand Down Expand Up @@ -557,6 +596,21 @@ private static void GenerateAssertConditionClassForMethod(SourceProductionContex
sourceBuilder.AppendLine($"public class {className}<TTask> : Assertion<TTask>");
sourceBuilder.AppendLine($" where TTask : {targetTypeName}");
}
else if (attributeData.IsUnboundGeneric)
{
// For unbound generic types like Memory<T>, generate generic assertion class
// Generate type parameter names: T, T1, T2, etc.
var typeParamCount = attributeData.TargetType.TypeParameters.Length;
var typeParamNames = typeParamCount == 1 ? new[] { "T" } :
Enumerable.Range(1, typeParamCount).Select(i => $"T{i}").ToArray();
var typeParamsList = string.Join(", ", typeParamNames);

// Generate assertion class with generic parameters
// Example: public class MemoryIsEmptyAssertion<T> : Assertion<Memory<T>>
var unboundTypeName = attributeData.TargetType.Name; // e.g., "Memory"
var constructedTargetType = $"{unboundTypeName}<{typeParamsList}>"; // e.g., "Memory<T>"
sourceBuilder.AppendLine($"public class {className}<{typeParamsList}> : Assertion<{constructedTargetType}>");
}
else if (hasMethodTypeParameters)
{
// Generate generic assertion class with the method's type parameters
Expand Down Expand Up @@ -1062,6 +1116,19 @@ private static void GenerateMethod(StringBuilder sourceBuilder, string targetTyp
// For Task, generate a generic method that works with Task and Task<T>
sourceBuilder.Append($" public static {assertConditionClassName}<TTask> {generatedMethodName}<TTask>(this IAssertionSource<TTask> source");
}
else if (attributeData.IsUnboundGeneric)
{
// For unbound generic types like Memory<T>, generate generic extension method
var typeParamCount = attributeData.TargetType.TypeParameters.Length;
var typeParamNames = typeParamCount == 1 ? new[] { "T" } :
Enumerable.Range(1, typeParamCount).Select(i => $"T{i}").ToArray();
var typeParamsList = string.Join(", ", typeParamNames);

var unboundTypeName = attributeData.TargetType.Name;
var constructedTargetType = $"{unboundTypeName}<{typeParamsList}>";
// Example: public static MemoryIsEmptyAssertion<T> IsEmpty<T>(this IAssertionSource<Memory<T>> source
sourceBuilder.Append($" public static {assertConditionClassName}<{typeParamsList}> {generatedMethodName}<{typeParamsList}>(this IAssertionSource<{constructedTargetType}> source");
}
else if (hasMethodTypeParameters)
{
// Method has generic type parameters - include them in the extension method
Expand Down Expand Up @@ -1161,6 +1228,7 @@ private record CreateAssertionAttributeData(
bool NegateLogic,
bool RequiresGenericTypeParameter,
bool TreatAsInstance,
string? ExpectationMessage
string? ExpectationMessage,
bool IsUnboundGeneric
);
}
12 changes: 12 additions & 0 deletions TUnit.Assertions/Attributes/AssertionFromAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,16 @@ public AssertionFromAttribute(Type targetType, Type containingType, string metho
/// When false (default), the generator automatically determines the pattern.
/// </summary>
public bool TreatAsInstance { get; set; }

/// <summary>
/// Optional custom expectation message shown in assertion failures.
/// If not specified, a default message based on the method name will be used.
/// </summary>
/// <example>
/// <code>
/// [AssertionFrom(typeof(double), "IsNaN", ExpectationMessage = "be NaN")]
/// // Generates failure message: "Expected value to be NaN"
/// </code>
/// </example>
public string? ExpectationMessage { get; set; }
}
27 changes: 27 additions & 0 deletions TUnit.Assertions/Conditions/BigIntegerAssertionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Numerics;
using TUnit.Assertions.Attributes;

namespace TUnit.Assertions.Conditions;

/// <summary>
/// Source-generated assertions for BigInteger type using [AssertionFrom&lt;BigInteger&gt;] attributes.
/// Each assertion wraps a property from the BigInteger structure for numeric checks.
/// </summary>
[AssertionFrom<BigInteger>(nameof(BigInteger.IsZero), ExpectationMessage = "be zero")]
[AssertionFrom<BigInteger>(nameof(BigInteger.IsZero), CustomName = "IsNotZero", NegateLogic = true, ExpectationMessage = "be zero")]

#if NET6_0_OR_GREATER
[AssertionFrom<BigInteger>(nameof(BigInteger.IsOne), ExpectationMessage = "be one")]
[AssertionFrom<BigInteger>(nameof(BigInteger.IsOne), CustomName = "IsNotOne", NegateLogic = true, ExpectationMessage = "be one")]
#endif

[AssertionFrom<BigInteger>(nameof(BigInteger.IsEven), ExpectationMessage = "be even")]
[AssertionFrom<BigInteger>(nameof(BigInteger.IsEven), CustomName = "IsNotEven", NegateLogic = true, ExpectationMessage = "be even")]

#if NET6_0_OR_GREATER
[AssertionFrom<BigInteger>(nameof(BigInteger.IsPowerOfTwo), ExpectationMessage = "be a power of two")]
[AssertionFrom<BigInteger>(nameof(BigInteger.IsPowerOfTwo), CustomName = "IsNotPowerOfTwo", NegateLogic = true, ExpectationMessage = "be a power of two")]
#endif
public static partial class BigIntegerAssertionExtensions
{
}
20 changes: 20 additions & 0 deletions TUnit.Assertions/Conditions/CookieAssertionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Net;
using TUnit.Assertions.Attributes;

namespace TUnit.Assertions.Conditions;

/// <summary>
/// Source-generated assertions for Cookie type using [AssertionFrom&lt;Cookie&gt;] attributes.
/// Each assertion wraps a property from the Cookie class for web and authentication testing.
/// </summary>
[AssertionFrom<Cookie>(nameof(Cookie.HttpOnly), ExpectationMessage = "be HTTP-only")]
[AssertionFrom<Cookie>(nameof(Cookie.HttpOnly), CustomName = "IsNotHttpOnly", NegateLogic = true, ExpectationMessage = "be HTTP-only")]

[AssertionFrom<Cookie>(nameof(Cookie.Secure), ExpectationMessage = "be secure")]
[AssertionFrom<Cookie>(nameof(Cookie.Secure), CustomName = "IsNotSecure", NegateLogic = true, ExpectationMessage = "be secure")]

[AssertionFrom<Cookie>(nameof(Cookie.Expired), ExpectationMessage = "be expired")]
[AssertionFrom<Cookie>(nameof(Cookie.Expired), CustomName = "IsNotExpired", NegateLogic = true, ExpectationMessage = "be expired")]
public static partial class CookieAssertionExtensions
{
}
33 changes: 33 additions & 0 deletions TUnit.Assertions/Conditions/DoubleAssertionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using TUnit.Assertions.Attributes;

namespace TUnit.Assertions.Conditions;

/// <summary>
/// Source-generated assertions for double type using [AssertionFrom&lt;double&gt;] attributes.
/// Each assertion wraps a static method from the double class for special numeric value checks.
/// </summary>
[AssertionFrom<double>(nameof(double.IsNaN), ExpectationMessage = "be NaN")]
[AssertionFrom<double>(nameof(double.IsNaN), CustomName = "IsNotNaN", NegateLogic = true, ExpectationMessage = "be NaN")]

[AssertionFrom<double>(nameof(double.IsInfinity), ExpectationMessage = "be infinity")]
[AssertionFrom<double>(nameof(double.IsInfinity), CustomName = "IsNotInfinity", NegateLogic = true, ExpectationMessage = "be infinity")]

[AssertionFrom<double>(nameof(double.IsPositiveInfinity), ExpectationMessage = "be positive infinity")]
[AssertionFrom<double>(nameof(double.IsPositiveInfinity), CustomName = "IsNotPositiveInfinity", NegateLogic = true, ExpectationMessage = "be positive infinity")]

[AssertionFrom<double>(nameof(double.IsNegativeInfinity), ExpectationMessage = "be negative infinity")]
[AssertionFrom<double>(nameof(double.IsNegativeInfinity), CustomName = "IsNotNegativeInfinity", NegateLogic = true, ExpectationMessage = "be negative infinity")]

#if NET5_0_OR_GREATER
[AssertionFrom<double>(nameof(double.IsFinite), ExpectationMessage = "be finite")]
[AssertionFrom<double>(nameof(double.IsFinite), CustomName = "IsNotFinite", NegateLogic = true, ExpectationMessage = "be finite")]

[AssertionFrom<double>(nameof(double.IsNormal), ExpectationMessage = "be normal")]
[AssertionFrom<double>(nameof(double.IsNormal), CustomName = "IsNotNormal", NegateLogic = true, ExpectationMessage = "be normal")]

[AssertionFrom<double>(nameof(double.IsSubnormal), ExpectationMessage = "be subnormal")]
[AssertionFrom<double>(nameof(double.IsSubnormal), CustomName = "IsNotSubnormal", NegateLogic = true, ExpectationMessage = "be subnormal")]
#endif
public static partial class DoubleAssertionExtensions
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Net.Http;
using TUnit.Assertions.Attributes;

namespace TUnit.Assertions.Conditions;

/// <summary>
/// Source-generated assertions for HttpResponseMessage type using [AssertionFrom&lt;HttpResponseMessage&gt;] attributes.
/// These wrap HTTP response validation checks as extension methods.
/// </summary>
[AssertionFrom<HttpResponseMessage>(nameof(HttpResponseMessage.IsSuccessStatusCode), ExpectationMessage = "have a success status code")]
[AssertionFrom<HttpResponseMessage>(nameof(HttpResponseMessage.IsSuccessStatusCode), CustomName = "IsNotSuccessStatusCode", NegateLogic = true, ExpectationMessage = "have a success status code")]
public static partial class HttpResponseMessageAssertionExtensions
{
}
13 changes: 13 additions & 0 deletions TUnit.Assertions/Conditions/NullableAssertionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using TUnit.Assertions.Attributes;

namespace TUnit.Assertions.Conditions;

/// <summary>
/// Source-generated assertions for Nullable&lt;T&gt; type using [AssertionFrom&lt;Nullable&lt;T&gt;&gt;] attributes.
/// These wrap nullable value checks as extension methods.
/// </summary>
[AssertionFrom(typeof(int?), nameof(Nullable<int>.HasValue), ExpectationMessage = "have a value")]
[AssertionFrom(typeof(int?), nameof(Nullable<int>.HasValue), CustomName = "DoesNotHaveValue", NegateLogic = true, ExpectationMessage = "have a value")]
public static partial class NullableAssertionExtensions
{
}
55 changes: 55 additions & 0 deletions TUnit.Assertions/Conditions/RuneAssertionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using TUnit.Assertions.Attributes;

namespace TUnit.Assertions.Conditions;

#if NET5_0_OR_GREATER
using System.Text;

/// <summary>
/// Source-generated assertions for Rune type using [AssertionFrom&lt;Rune&gt;] attributes.
/// Each assertion wraps a static method or property from the Rune structure for Unicode scalar checks.
/// Rune is the modern, correct type for handling Unicode scalar values.
/// Available in .NET 5.0+
/// </summary>
[AssertionFrom<Rune>(nameof(Rune.IsAscii), ExpectationMessage = "be ASCII")]
[AssertionFrom<Rune>(nameof(Rune.IsAscii), CustomName = "IsNotAscii", NegateLogic = true, ExpectationMessage = "be ASCII")]

[AssertionFrom<Rune>(nameof(Rune.IsBmp), ExpectationMessage = "be in the Basic Multilingual Plane")]
[AssertionFrom<Rune>(nameof(Rune.IsBmp), CustomName = "IsNotBmp", NegateLogic = true, ExpectationMessage = "be in the Basic Multilingual Plane")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsLetter), ExpectationMessage = "be a letter")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsLetter), CustomName = "IsNotLetter", NegateLogic = true, ExpectationMessage = "be a letter")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsDigit), ExpectationMessage = "be a digit")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsDigit), CustomName = "IsNotDigit", NegateLogic = true, ExpectationMessage = "be a digit")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsWhiteSpace), ExpectationMessage = "be whitespace")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsWhiteSpace), CustomName = "IsNotWhiteSpace", NegateLogic = true, ExpectationMessage = "be whitespace")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsUpper), ExpectationMessage = "be uppercase")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsUpper), CustomName = "IsNotUpper", NegateLogic = true, ExpectationMessage = "be uppercase")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsLower), ExpectationMessage = "be lowercase")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsLower), CustomName = "IsNotLower", NegateLogic = true, ExpectationMessage = "be lowercase")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsControl), ExpectationMessage = "be a control character")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsControl), CustomName = "IsNotControl", NegateLogic = true, ExpectationMessage = "be a control character")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsPunctuation), ExpectationMessage = "be punctuation")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsPunctuation), CustomName = "IsNotPunctuation", NegateLogic = true, ExpectationMessage = "be punctuation")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsSymbol), ExpectationMessage = "be a symbol")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsSymbol), CustomName = "IsNotSymbol", NegateLogic = true, ExpectationMessage = "be a symbol")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsNumber), ExpectationMessage = "be a number")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsNumber), CustomName = "IsNotNumber", NegateLogic = true, ExpectationMessage = "be a number")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsSeparator), ExpectationMessage = "be a separator")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsSeparator), CustomName = "IsNotSeparator", NegateLogic = true, ExpectationMessage = "be a separator")]

[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsLetterOrDigit), ExpectationMessage = "be a letter or digit")]
[AssertionFrom<Rune>(typeof(Rune), nameof(Rune.IsLetterOrDigit), CustomName = "IsNotLetterOrDigit", NegateLogic = true, ExpectationMessage = "be a letter or digit")]
public static partial class RuneAssertionExtensions
{
}
#endif
Loading
Loading