From 1310c4015a32baf900e9d9991753ae6d0dfd03a5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:31:25 +0000 Subject: [PATCH 1/6] feat: support string-to-parseable type conversions in [Arguments] attributes Allow string arguments to be automatically converted to types that implement IParsable (DateTime, TimeSpan, Guid, DateTimeOffset, DateOnly, TimeOnly, etc.) when used with [Arguments] attributes. This enables patterns like: [Arguments("2022-5-31")] public void MyTest(DateTime testDate) Previously this would emit TUnit0001 at compile time. Closes #5186 --- TUnit.Analyzers/TestDataAnalyzer.cs | 42 +++++++++ .../Formatting/TypedConstantFormatter.cs | 47 ++++++++++ TUnit.Core/Helpers/CastHelper.cs | 94 +++++++++++++++++++ .../StringToParsableArgumentsTests.cs | 49 ++++++++++ 4 files changed, 232 insertions(+) create mode 100644 TUnit.TestProject/StringToParsableArgumentsTests.cs diff --git a/TUnit.Analyzers/TestDataAnalyzer.cs b/TUnit.Analyzers/TestDataAnalyzer.cs index 6c5b89a6b5..1b5d7b4d2b 100644 --- a/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/TUnit.Analyzers/TestDataAnalyzer.cs @@ -974,6 +974,15 @@ private static bool CanConvert(SymbolAnalysisContext context, TypedConstant argu } } + // Allow string-to-parseable-type conversions for common types + // These types support Parse(string) and will be converted at runtime or via generated code + if (argument.Type?.SpecialType == SpecialType.System_String && + argument.Value is string && + IsParsableFromString(methodParameterType)) + { + return true; + } + return CanConvert(context, argument.Type, methodParameterType); } @@ -997,6 +1006,39 @@ private static bool CanConvert(SymbolAnalysisContext context, ITypeSymbol? argum return context.Compilation.HasImplicitConversionOrGenericParameter(argumentType, methodParameterType); } + private static bool IsParsableFromString(ITypeSymbol? type) + { + if (type is null) + { + return false; + } + + // Check if the type implements IParsable (.NET 7+) + if (type.AllInterfaces.Any(i => + i is { IsGenericType: true, MetadataName: "IParsable`1" } + && i.ContainingNamespace?.ToDisplayString() == "System" + && SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], type))) + { + return true; + } + + // Fallback for well-known types when IParsable interface is not available + // (e.g. when targeting older TFMs where IParsable doesn't exist) + if (type.SpecialType == SpecialType.System_DateTime) + { + return true; + } + + var fullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return fullyQualifiedName is + "global::System.DateTimeOffset" or + "global::System.TimeSpan" or + "global::System.Guid" or + "global::System.DateOnly" or + "global::System.TimeOnly"; + } + private bool IsEnumAndInteger(ITypeSymbol? type1, ITypeSymbol? type2) { if (type1?.SpecialType is SpecialType.System_Int16 or SpecialType.System_Int32 or SpecialType.System_Int64) diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs index f02c9c614a..9f1b79cbe3 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs @@ -221,6 +221,21 @@ private string FormatPrimitiveForCode(object? value, ITypeSymbol? targetType) } return $"{value.ToInvariantString()}m"; + case SpecialType.System_DateTime: + if (value is string dateTimeStr) + { + return $"global::System.DateTime.Parse(\"{dateTimeStr}\", global::System.Globalization.CultureInfo.InvariantCulture)"; + } + break; + } + + // Handle string-to-parseable-type conversions + // Works for any type implementing IParsable or well-known parseable types + // Exclude string itself — no conversion needed + if (value is string strForParsing && targetType.SpecialType != SpecialType.System_String && IsParsableFromString(targetType)) + { + var fullyQualifiedName = targetType.GloballyQualified(); + return $"{fullyQualifiedName}.Parse(\"{strForParsing}\", global::System.Globalization.CultureInfo.InvariantCulture)"; } } @@ -275,6 +290,38 @@ private string FormatEnumForCode(TypedConstant constant, ITypeSymbol? targetType return result; } + private static bool IsParsableFromString(ITypeSymbol? type) + { + if (type is null) + { + return false; + } + + // Check if the type implements IParsable (.NET 7+) + if (type.AllInterfaces.Any(i => + i is { IsGenericType: true, MetadataName: "IParsable`1" } + && i.ContainingNamespace?.ToDisplayString() == "System" + && SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], type))) + { + return true; + } + + // Fallback for well-known types when IParsable interface is not available + if (type.SpecialType == SpecialType.System_DateTime) + { + return true; + } + + var fullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return fullyQualifiedName is + "global::System.DateTimeOffset" or + "global::System.TimeSpan" or + "global::System.Guid" or + "global::System.DateOnly" or + "global::System.TimeOnly"; + } + private string FormatArrayForCode(TypedConstant constant, ITypeSymbol? targetType = null) { // For arrays, determine the element type from the target type if available diff --git a/TUnit.Core/Helpers/CastHelper.cs b/TUnit.Core/Helpers/CastHelper.cs index 928f7eed86..597d760879 100644 --- a/TUnit.Core/Helpers/CastHelper.cs +++ b/TUnit.Core/Helpers/CastHelper.cs @@ -127,6 +127,12 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec } } + // Handle string-to-parseable-type conversions + if (value is string stringValue && TryParseFromString(targetType, stringValue, out result)) + { + return true; + } + // Unwrap single-element enumerables (but not strings or arrays) if (value is not string && !sourceType.IsArray && value is IEnumerable enumerable && !typeof(IEnumerable).IsAssignableFrom(targetType)) { @@ -142,6 +148,94 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec return false; } + private static bool TryParseFromString(Type targetType, string value, out object? result) + { +#if NET + // Use IParsable for types that implement it (.NET 7+) + if (TryParsableConvert(targetType, value, out result)) + { + return true; + } +#else + // Fallback for netstandard2.0: handle well-known types explicitly + try + { + if (targetType == typeof(DateTime)) + { + result = DateTime.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + if (targetType == typeof(DateTimeOffset)) + { + result = DateTimeOffset.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + if (targetType == typeof(TimeSpan)) + { + result = TimeSpan.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + if (targetType == typeof(Guid)) + { + result = Guid.Parse(value); + return true; + } + } + catch + { + // Parse failed + } +#endif + + result = null; + return false; + } + +#if NET + private static readonly ConcurrentDictionary ParseMethodCache = new(); + + [UnconditionalSuppressMessage("Trimming", "IL2070:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute'")] + private static bool TryParsableConvert(Type targetType, string value, out object? result) + { + var parseMethod = ParseMethodCache.GetOrAdd(targetType, static type => + { + // Check if type implements IParsable + var iParsableInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType + && i.GetGenericTypeDefinition() == typeof(IParsable<>) + && i.GenericTypeArguments[0] == type); + + if (iParsableInterface == null) + { + return null; + } + + // Get the static Parse(string, IFormatProvider?) method + return type.GetMethod("Parse", + BindingFlags.Public | BindingFlags.Static, + null, + [typeof(string), typeof(IFormatProvider)], + null); + }); + + if (parseMethod != null) + { + try + { + result = parseMethod.Invoke(null, [value, System.Globalization.CultureInfo.InvariantCulture]); + return true; + } + catch + { + // Parse failed + } + } + + result = null; + return false; + } +#endif + [RequiresDynamicCode("Uses reflection to find custom conversion operators and create arrays, which is not compatible with AOT compilation.")] [UnconditionalSuppressMessage("Trimming", "IL2072:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute'")] private static bool TryReflectionConversion(Type targetType, Type sourceType, object value, out object? result) diff --git a/TUnit.TestProject/StringToParsableArgumentsTests.cs b/TUnit.TestProject/StringToParsableArgumentsTests.cs new file mode 100644 index 0000000000..a970be67a2 --- /dev/null +++ b/TUnit.TestProject/StringToParsableArgumentsTests.cs @@ -0,0 +1,49 @@ +namespace TUnit.TestProject; + +public class StringToParsableArgumentsTests +{ + [Test] + [Arguments("2022-5-31")] + [Arguments("2022-6-1")] + public async Task DateTime_From_String(DateTime testDate) + { + await Assert.That(testDate).IsNotEqualTo(default(DateTime)); + } + + [Test] + [Arguments("01:30:00")] + public async Task TimeSpan_From_String(TimeSpan timeSpan) + { + await Assert.That(timeSpan).IsNotEqualTo(default(TimeSpan)); + } + + [Test] + [Arguments("d3b07384-d113-4ec0-8b2a-1e1f0e1e4e57")] + public async Task Guid_From_String(Guid guid) + { + await Assert.That(guid).IsNotEqualTo(Guid.Empty); + } + + [Test] + [Arguments("2022-05-31T14:30:00+02:00")] + public async Task DateTimeOffset_From_String(DateTimeOffset dto) + { + await Assert.That(dto).IsNotEqualTo(default(DateTimeOffset)); + } + +#if NET8_0_OR_GREATER + [Test] + [Arguments("2022-05-31")] + public async Task DateOnly_From_String(DateOnly date) + { + await Assert.That(date).IsNotEqualTo(default(DateOnly)); + } + + [Test] + [Arguments("14:30:00")] + public async Task TimeOnly_From_String(TimeOnly time) + { + await Assert.That(time).IsNotEqualTo(default(TimeOnly)); + } +#endif +} From 9463f38f17794bba2011113ce816a230433321f7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 23 Mar 2026 02:37:54 +0000 Subject: [PATCH 2/6] refactor: simplify string-to-parseable conversion code - Remove redundant DateTime special case in TypedConstantFormatter (the generic IParsable path already handles it) - Use TryParse instead of Parse-and-catch in netstandard2.0 fallback - Narrow catch clause in .NET path to expected exception types - Remove unnecessary comments that restate what the code does --- TUnit.Analyzers/TestDataAnalyzer.cs | 6 +-- .../Formatting/TypedConstantFormatter.cs | 12 +---- TUnit.Core/Helpers/CastHelper.cs | 48 +++++++------------ 3 files changed, 20 insertions(+), 46 deletions(-) diff --git a/TUnit.Analyzers/TestDataAnalyzer.cs b/TUnit.Analyzers/TestDataAnalyzer.cs index 1b5d7b4d2b..192f388220 100644 --- a/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/TUnit.Analyzers/TestDataAnalyzer.cs @@ -974,8 +974,6 @@ private static bool CanConvert(SymbolAnalysisContext context, TypedConstant argu } } - // Allow string-to-parseable-type conversions for common types - // These types support Parse(string) and will be converted at runtime or via generated code if (argument.Type?.SpecialType == SpecialType.System_String && argument.Value is string && IsParsableFromString(methodParameterType)) @@ -1013,7 +1011,6 @@ private static bool IsParsableFromString(ITypeSymbol? type) return false; } - // Check if the type implements IParsable (.NET 7+) if (type.AllInterfaces.Any(i => i is { IsGenericType: true, MetadataName: "IParsable`1" } && i.ContainingNamespace?.ToDisplayString() == "System" @@ -1022,8 +1019,7 @@ private static bool IsParsableFromString(ITypeSymbol? type) return true; } - // Fallback for well-known types when IParsable interface is not available - // (e.g. when targeting older TFMs where IParsable doesn't exist) + // Fallback for older TFMs where IParsable doesn't exist if (type.SpecialType == SpecialType.System_DateTime) { return true; diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs index 9f1b79cbe3..434c0beb8a 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs @@ -221,17 +221,8 @@ private string FormatPrimitiveForCode(object? value, ITypeSymbol? targetType) } return $"{value.ToInvariantString()}m"; - case SpecialType.System_DateTime: - if (value is string dateTimeStr) - { - return $"global::System.DateTime.Parse(\"{dateTimeStr}\", global::System.Globalization.CultureInfo.InvariantCulture)"; - } - break; } - // Handle string-to-parseable-type conversions - // Works for any type implementing IParsable or well-known parseable types - // Exclude string itself — no conversion needed if (value is string strForParsing && targetType.SpecialType != SpecialType.System_String && IsParsableFromString(targetType)) { var fullyQualifiedName = targetType.GloballyQualified(); @@ -297,7 +288,6 @@ private static bool IsParsableFromString(ITypeSymbol? type) return false; } - // Check if the type implements IParsable (.NET 7+) if (type.AllInterfaces.Any(i => i is { IsGenericType: true, MetadataName: "IParsable`1" } && i.ContainingNamespace?.ToDisplayString() == "System" @@ -306,7 +296,7 @@ private static bool IsParsableFromString(ITypeSymbol? type) return true; } - // Fallback for well-known types when IParsable interface is not available + // Fallback for older TFMs where IParsable doesn't exist if (type.SpecialType == SpecialType.System_DateTime) { return true; diff --git a/TUnit.Core/Helpers/CastHelper.cs b/TUnit.Core/Helpers/CastHelper.cs index 597d760879..8258aec9a2 100644 --- a/TUnit.Core/Helpers/CastHelper.cs +++ b/TUnit.Core/Helpers/CastHelper.cs @@ -127,7 +127,6 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec } } - // Handle string-to-parseable-type conversions if (value is string stringValue && TryParseFromString(targetType, stringValue, out result)) { return true; @@ -151,39 +150,30 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec private static bool TryParseFromString(Type targetType, string value, out object? result) { #if NET - // Use IParsable for types that implement it (.NET 7+) if (TryParsableConvert(targetType, value, out result)) { return true; } #else - // Fallback for netstandard2.0: handle well-known types explicitly - try + if (targetType == typeof(DateTime) && DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dateTime)) { - if (targetType == typeof(DateTime)) - { - result = DateTime.Parse(value, System.Globalization.CultureInfo.InvariantCulture); - return true; - } - if (targetType == typeof(DateTimeOffset)) - { - result = DateTimeOffset.Parse(value, System.Globalization.CultureInfo.InvariantCulture); - return true; - } - if (targetType == typeof(TimeSpan)) - { - result = TimeSpan.Parse(value, System.Globalization.CultureInfo.InvariantCulture); - return true; - } - if (targetType == typeof(Guid)) - { - result = Guid.Parse(value); - return true; - } + result = dateTime; + return true; + } + if (targetType == typeof(DateTimeOffset) && DateTimeOffset.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dateTimeOffset)) + { + result = dateTimeOffset; + return true; } - catch + if (targetType == typeof(TimeSpan) && TimeSpan.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, out var timeSpan)) { - // Parse failed + result = timeSpan; + return true; + } + if (targetType == typeof(Guid) && Guid.TryParse(value, out var guid)) + { + result = guid; + return true; } #endif @@ -199,7 +189,6 @@ private static bool TryParsableConvert(Type targetType, string value, out object { var parseMethod = ParseMethodCache.GetOrAdd(targetType, static type => { - // Check if type implements IParsable var iParsableInterface = type.GetInterfaces() .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IParsable<>) @@ -210,7 +199,6 @@ private static bool TryParsableConvert(Type targetType, string value, out object return null; } - // Get the static Parse(string, IFormatProvider?) method return type.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, null, @@ -225,9 +213,9 @@ private static bool TryParsableConvert(Type targetType, string value, out object result = parseMethod.Invoke(null, [value, System.Globalization.CultureInfo.InvariantCulture]); return true; } - catch + catch (Exception ex) when (ex is FormatException or OverflowException or ArgumentException or TargetInvocationException { InnerException: FormatException or OverflowException or ArgumentException }) { - // Parse failed + // Parse failed for the given string value } } From e457d87261f80add93e68cde73542399e065b434 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:32:56 +0000 Subject: [PATCH 3/6] fix: address PR review comments for string-to-parseable feature - Escape string literals with SymbolDisplay.FormatLiteral to prevent invalid C# when strings contain backslashes or quotes - Move TryParseFromString out of TryAotSafeConversion since it uses reflection, separating it from the AOT-safe path - Deduplicate IsParsableFromString into shared ParsableTypeExtensions linked from both Analyzers and SourceGenerator projects - Assert exact expected values in tests instead of just non-default --- Roslyn.props | 3 +- TUnit.Analyzers/TUnit.Analyzers.csproj | 4 ++ TUnit.Analyzers/TestDataAnalyzer.cs | 33 +---------------- .../Formatting/TypedConstantFormatter.cs | 35 ++---------------- .../Extensions/ParsableTypeExtensions.cs | 37 +++++++++++++++++++ TUnit.Core/Helpers/CastHelper.cs | 11 +++--- .../StringToParsableArgumentsTests.cs | 13 +++---- 7 files changed, 60 insertions(+), 76 deletions(-) create mode 100644 TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs diff --git a/Roslyn.props b/Roslyn.props index 356432b6ba..65adcce2b7 100644 --- a/Roslyn.props +++ b/Roslyn.props @@ -11,9 +11,10 @@ - + + diff --git a/TUnit.Analyzers/TUnit.Analyzers.csproj b/TUnit.Analyzers/TUnit.Analyzers.csproj index 67586b0dd3..cd41e4e34e 100644 --- a/TUnit.Analyzers/TUnit.Analyzers.csproj +++ b/TUnit.Analyzers/TUnit.Analyzers.csproj @@ -25,6 +25,10 @@ + + + + ResXFileCodeGenerator diff --git a/TUnit.Analyzers/TestDataAnalyzer.cs b/TUnit.Analyzers/TestDataAnalyzer.cs index 192f388220..fd520ac28d 100644 --- a/TUnit.Analyzers/TestDataAnalyzer.cs +++ b/TUnit.Analyzers/TestDataAnalyzer.cs @@ -976,7 +976,7 @@ private static bool CanConvert(SymbolAnalysisContext context, TypedConstant argu if (argument.Type?.SpecialType == SpecialType.System_String && argument.Value is string && - IsParsableFromString(methodParameterType)) + methodParameterType.IsParsableFromString()) { return true; } @@ -1004,37 +1004,6 @@ private static bool CanConvert(SymbolAnalysisContext context, ITypeSymbol? argum return context.Compilation.HasImplicitConversionOrGenericParameter(argumentType, methodParameterType); } - private static bool IsParsableFromString(ITypeSymbol? type) - { - if (type is null) - { - return false; - } - - if (type.AllInterfaces.Any(i => - i is { IsGenericType: true, MetadataName: "IParsable`1" } - && i.ContainingNamespace?.ToDisplayString() == "System" - && SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], type))) - { - return true; - } - - // Fallback for older TFMs where IParsable doesn't exist - if (type.SpecialType == SpecialType.System_DateTime) - { - return true; - } - - var fullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - return fullyQualifiedName is - "global::System.DateTimeOffset" or - "global::System.TimeSpan" or - "global::System.Guid" or - "global::System.DateOnly" or - "global::System.TimeOnly"; - } - private bool IsEnumAndInteger(ITypeSymbol? type1, ITypeSymbol? type2) { if (type1?.SpecialType is SpecialType.System_Int16 or SpecialType.System_Int32 or SpecialType.System_Int64) diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs index 434c0beb8a..75101c0c6d 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs @@ -2,6 +2,7 @@ using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using TUnit.Analyzers.Extensions; using TUnit.Core.SourceGenerator.Extensions; namespace TUnit.Core.SourceGenerator.CodeGenerators.Formatting; @@ -223,10 +224,11 @@ private string FormatPrimitiveForCode(object? value, ITypeSymbol? targetType) return $"{value.ToInvariantString()}m"; } - if (value is string strForParsing && targetType.SpecialType != SpecialType.System_String && IsParsableFromString(targetType)) + if (value is string strForParsing && targetType.SpecialType != SpecialType.System_String && targetType.IsParsableFromString()) { var fullyQualifiedName = targetType.GloballyQualified(); - return $"{fullyQualifiedName}.Parse(\"{strForParsing}\", global::System.Globalization.CultureInfo.InvariantCulture)"; + var escapedValue = SymbolDisplay.FormatLiteral(strForParsing, quote: true); + return $"{fullyQualifiedName}.Parse({escapedValue}, global::System.Globalization.CultureInfo.InvariantCulture)"; } } @@ -281,36 +283,7 @@ private string FormatEnumForCode(TypedConstant constant, ITypeSymbol? targetType return result; } - private static bool IsParsableFromString(ITypeSymbol? type) - { - if (type is null) - { - return false; - } - - if (type.AllInterfaces.Any(i => - i is { IsGenericType: true, MetadataName: "IParsable`1" } - && i.ContainingNamespace?.ToDisplayString() == "System" - && SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], type))) - { - return true; - } - // Fallback for older TFMs where IParsable doesn't exist - if (type.SpecialType == SpecialType.System_DateTime) - { - return true; - } - - var fullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - return fullyQualifiedName is - "global::System.DateTimeOffset" or - "global::System.TimeSpan" or - "global::System.Guid" or - "global::System.DateOnly" or - "global::System.TimeOnly"; - } private string FormatArrayForCode(TypedConstant constant, ITypeSymbol? targetType = null) { diff --git a/TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs b/TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs new file mode 100644 index 0000000000..47a0a77304 --- /dev/null +++ b/TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs @@ -0,0 +1,37 @@ +using Microsoft.CodeAnalysis; + +namespace TUnit.Analyzers.Extensions; + +internal static class ParsableTypeExtensions +{ + public static bool IsParsableFromString(this ITypeSymbol? type) + { + if (type is null) + { + return false; + } + + if (type.AllInterfaces.Any(i => + i is { IsGenericType: true, MetadataName: "IParsable`1" } + && i.ContainingNamespace?.ToDisplayString() == "System" + && SymbolEqualityComparer.Default.Equals(i.TypeArguments[0], type))) + { + return true; + } + + // Fallback for older TFMs where IParsable doesn't exist + if (type.SpecialType == SpecialType.System_DateTime) + { + return true; + } + + var fullyQualifiedName = type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + return fullyQualifiedName is + "global::System.DateTimeOffset" or + "global::System.TimeSpan" or + "global::System.Guid" or + "global::System.DateOnly" or + "global::System.TimeOnly"; + } +} diff --git a/TUnit.Core/Helpers/CastHelper.cs b/TUnit.Core/Helpers/CastHelper.cs index 8258aec9a2..1ccc0d51bf 100644 --- a/TUnit.Core/Helpers/CastHelper.cs +++ b/TUnit.Core/Helpers/CastHelper.cs @@ -66,6 +66,12 @@ public static class CastHelper return result; } + // Layer 1.5: String-to-parseable conversions (uses reflection, but safe with proper annotations) + if (value is string stringValue && TryParseFromString(targetType, stringValue, out result)) + { + return result; + } + // Layer 2: Reflection-based conversions (not AOT-compatible) if (TryReflectionConversion(targetType, sourceType, value, out result)) { @@ -127,11 +133,6 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec } } - if (value is string stringValue && TryParseFromString(targetType, stringValue, out result)) - { - return true; - } - // Unwrap single-element enumerables (but not strings or arrays) if (value is not string && !sourceType.IsArray && value is IEnumerable enumerable && !typeof(IEnumerable).IsAssignableFrom(targetType)) { diff --git a/TUnit.TestProject/StringToParsableArgumentsTests.cs b/TUnit.TestProject/StringToParsableArgumentsTests.cs index a970be67a2..d3bd2de603 100644 --- a/TUnit.TestProject/StringToParsableArgumentsTests.cs +++ b/TUnit.TestProject/StringToParsableArgumentsTests.cs @@ -4,31 +4,30 @@ public class StringToParsableArgumentsTests { [Test] [Arguments("2022-5-31")] - [Arguments("2022-6-1")] public async Task DateTime_From_String(DateTime testDate) { - await Assert.That(testDate).IsNotEqualTo(default(DateTime)); + await Assert.That(testDate).IsEqualTo(new DateTime(2022, 5, 31)); } [Test] [Arguments("01:30:00")] public async Task TimeSpan_From_String(TimeSpan timeSpan) { - await Assert.That(timeSpan).IsNotEqualTo(default(TimeSpan)); + await Assert.That(timeSpan).IsEqualTo(new TimeSpan(1, 30, 0)); } [Test] [Arguments("d3b07384-d113-4ec0-8b2a-1e1f0e1e4e57")] public async Task Guid_From_String(Guid guid) { - await Assert.That(guid).IsNotEqualTo(Guid.Empty); + await Assert.That(guid).IsEqualTo(new Guid("d3b07384-d113-4ec0-8b2a-1e1f0e1e4e57")); } [Test] [Arguments("2022-05-31T14:30:00+02:00")] public async Task DateTimeOffset_From_String(DateTimeOffset dto) { - await Assert.That(dto).IsNotEqualTo(default(DateTimeOffset)); + await Assert.That(dto).IsEqualTo(new DateTimeOffset(2022, 5, 31, 14, 30, 0, TimeSpan.FromHours(2))); } #if NET8_0_OR_GREATER @@ -36,14 +35,14 @@ public async Task DateTimeOffset_From_String(DateTimeOffset dto) [Arguments("2022-05-31")] public async Task DateOnly_From_String(DateOnly date) { - await Assert.That(date).IsNotEqualTo(default(DateOnly)); + await Assert.That(date).IsEqualTo(new DateOnly(2022, 5, 31)); } [Test] [Arguments("14:30:00")] public async Task TimeOnly_From_String(TimeOnly time) { - await Assert.That(time).IsNotEqualTo(default(TimeOnly)); + await Assert.That(time).IsEqualTo(new TimeOnly(14, 30, 0)); } #endif } From f83bdc69bbbb05c4268be10a432b587d0184e05d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 23 Mar 2026 09:59:16 +0000 Subject: [PATCH 4/6] fix: replace trimmer warning suppression with proper DynamicallyAccessedMembers annotations Use [DynamicallyAccessedMembers] on TryParsableConvert and TryParseFromString parameters instead of [UnconditionalSuppressMessage] to properly inform the trimmer which metadata to preserve for AOT scenarios. --- .../Formatting/TypedConstantFormatter.cs | 2 - TUnit.Core/Helpers/CastHelper.cs | 49 ++++++++++--------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs index 75101c0c6d..68e354a8a5 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs @@ -283,8 +283,6 @@ private string FormatEnumForCode(TypedConstant constant, ITypeSymbol? targetType return result; } - - private string FormatArrayForCode(TypedConstant constant, ITypeSymbol? targetType = null) { // For arrays, determine the element type from the target type if available diff --git a/TUnit.Core/Helpers/CastHelper.cs b/TUnit.Core/Helpers/CastHelper.cs index 1ccc0d51bf..9e5702c247 100644 --- a/TUnit.Core/Helpers/CastHelper.cs +++ b/TUnit.Core/Helpers/CastHelper.cs @@ -16,7 +16,7 @@ public static class CastHelper /// Attempts to cast or convert a value to the specified type T. /// Uses a layered approach: fast paths first (AOT-safe), then reflection fallbacks. /// - public static T? Cast<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(object? value) + public static T? Cast<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] T>(object? value) { if (value is T t) { @@ -35,7 +35,7 @@ public static class CastHelper /// 3. Reflection fallback: custom operators, arrays (throws in AOT) /// [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.")] - public static object? Cast([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type, object? value) + public static object? Cast([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type type, object? value) { // Fast path: handle null if (value is null) @@ -148,7 +148,7 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec return false; } - private static bool TryParseFromString(Type targetType, string value, out object? result) + private static bool TryParseFromString([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type targetType, string value, out object? result) { #if NET if (TryParsableConvert(targetType, value, out result)) @@ -185,27 +185,13 @@ private static bool TryParseFromString(Type targetType, string value, out object #if NET private static readonly ConcurrentDictionary ParseMethodCache = new(); - [UnconditionalSuppressMessage("Trimming", "IL2070:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute'")] - private static bool TryParsableConvert(Type targetType, string value, out object? result) + private static bool TryParsableConvert([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type targetType, string value, out object? result) { - var parseMethod = ParseMethodCache.GetOrAdd(targetType, static type => + if (!ParseMethodCache.TryGetValue(targetType, out var parseMethod)) { - var iParsableInterface = type.GetInterfaces() - .FirstOrDefault(i => i.IsGenericType - && i.GetGenericTypeDefinition() == typeof(IParsable<>) - && i.GenericTypeArguments[0] == type); - - if (iParsableInterface == null) - { - return null; - } - - return type.GetMethod("Parse", - BindingFlags.Public | BindingFlags.Static, - null, - [typeof(string), typeof(IFormatProvider)], - null); - }); + parseMethod = FindParseMethod(targetType); + ParseMethodCache.TryAdd(targetType, parseMethod); + } if (parseMethod != null) { @@ -223,6 +209,25 @@ private static bool TryParsableConvert(Type targetType, string value, out object result = null; return false; } + + private static MethodInfo? FindParseMethod([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type type) + { + var iParsableInterface = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType + && i.GetGenericTypeDefinition() == typeof(IParsable<>) + && i.GenericTypeArguments[0] == type); + + if (iParsableInterface == null) + { + return null; + } + + return type.GetMethod("Parse", + BindingFlags.Public | BindingFlags.Static, + null, + [typeof(string), typeof(IFormatProvider)], + null); + } #endif [RequiresDynamicCode("Uses reflection to find custom conversion operators and create arrays, which is not compatible with AOT compilation.")] From 8b4d97ac795101289f34ef96142d4b686b494b36 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:11:25 +0000 Subject: [PATCH 5/6] fix: update public API verified files for widened DynamicallyAccessedMembers --- ...ts.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 4 ++-- ...sts.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 4 ++-- ...sts.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 6196d9fa8f..daf73b5d6c 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2125,8 +2125,8 @@ namespace .Helpers { [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + "nctionality when AOT compiling.")] - public static object? Cast([.(..PublicParameterlessConstructor)] type, object? value) { } - public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } + public static object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } + public static T? Cast<[.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] T>(object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index b36057a79f..c00d3f45d5 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2125,8 +2125,8 @@ namespace .Helpers { [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + "nctionality when AOT compiling.")] - public static object? Cast([.(..PublicParameterlessConstructor)] type, object? value) { } - public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } + public static object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } + public static T? Cast<[.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] T>(object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 6b57b8bafb..75fd9066a6 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2125,8 +2125,8 @@ namespace .Helpers { [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + "nctionality when AOT compiling.")] - public static object? Cast([.(..PublicParameterlessConstructor)] type, object? value) { } - public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } + public static object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } + public static T? Cast<[.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] T>(object? value) { } } public static class ClassConstructorHelper { From 111d0448f3d97fec9d510da3cd05fd87ea71309b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:46:45 +0000 Subject: [PATCH 6/6] fix: keep Cast annotation narrow to avoid AOT IL3050 with enum types Widening DynamicallyAccessedMembers on Cast to include PublicMethods caused IL3050 warnings when calling Cast() in AOT builds because the analyzer traces through Enum.GetValues(Type). The source generator already handles parseable types at compile time, so the runtime fallback only runs in non-AOT scenarios. Use a targeted suppress on Cast instead. --- TUnit.Core/Helpers/CastHelper.cs | 4 +++- ....Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 6 +++++- ...s.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 6 +++++- ...s.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 6 +++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/TUnit.Core/Helpers/CastHelper.cs b/TUnit.Core/Helpers/CastHelper.cs index 9e5702c247..837c482d5c 100644 --- a/TUnit.Core/Helpers/CastHelper.cs +++ b/TUnit.Core/Helpers/CastHelper.cs @@ -16,7 +16,9 @@ public static class CastHelper /// Attempts to cast or convert a value to the specified type T. /// Uses a layered approach: fast paths first (AOT-safe), then reflection fallbacks. /// - public static T? Cast<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor | DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] T>(object? value) + [UnconditionalSuppressMessage("Trimming", "IL2087:'type' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.", + Justification = "Cast is called from source-generated code that handles parseable types at compile time. The runtime TryParsableConvert fallback is only used in non-AOT scenarios.")] + public static T? Cast<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] T>(object? value) { if (value is T t) { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index daf73b5d6c..5e257a8f54 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -2126,7 +2126,11 @@ namespace .Helpers [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + "nctionality when AOT compiling.")] public static object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } - public static T? Cast<[.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] T>(object? value) { } + [.("Trimming", "IL2087:\'type\' argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in " + + "call to target method.", Justification="Cast is called from source-generated code that handles parseable types at comp" + + "ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" + + "ios.")] + public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index c00d3f45d5..74a870198c 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -2126,7 +2126,11 @@ namespace .Helpers [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + "nctionality when AOT compiling.")] public static object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } - public static T? Cast<[.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] T>(object? value) { } + [.("Trimming", "IL2087:\'type\' argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in " + + "call to target method.", Justification="Cast is called from source-generated code that handles parseable types at comp" + + "ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" + + "ios.")] + public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } } public static class ClassConstructorHelper { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 75fd9066a6..7e554936c8 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -2126,7 +2126,11 @@ namespace .Helpers [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" + "nctionality when AOT compiling.")] public static object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { } - public static T? Cast<[.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] T>(object? value) { } + [.("Trimming", "IL2087:\'type\' argument does not satisfy \'DynamicallyAccessedMembersAttribute\' in " + + "call to target method.", Justification="Cast is called from source-generated code that handles parseable types at comp" + + "ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" + + "ios.")] + public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { } } public static class ClassConstructorHelper {