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 6c5b89a6b5..fd520ac28d 100644
--- a/TUnit.Analyzers/TestDataAnalyzer.cs
+++ b/TUnit.Analyzers/TestDataAnalyzer.cs
@@ -974,6 +974,13 @@ private static bool CanConvert(SymbolAnalysisContext context, TypedConstant argu
}
}
+ if (argument.Type?.SpecialType == SpecialType.System_String &&
+ argument.Value is string &&
+ methodParameterType.IsParsableFromString())
+ {
+ return true;
+ }
+
return CanConvert(context, argument.Type, methodParameterType);
}
diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs
index f02c9c614a..68e354a8a5 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;
@@ -222,6 +223,13 @@ private string FormatPrimitiveForCode(object? value, ITypeSymbol? targetType)
return $"{value.ToInvariantString()}m";
}
+
+ if (value is string strForParsing && targetType.SpecialType != SpecialType.System_String && targetType.IsParsableFromString())
+ {
+ var fullyQualifiedName = targetType.GloballyQualified();
+ var escapedValue = SymbolDisplay.FormatLiteral(strForParsing, quote: true);
+ return $"{fullyQualifiedName}.Parse({escapedValue}, global::System.Globalization.CultureInfo.InvariantCulture)";
+ }
}
return FormatPrimitive(value);
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 928f7eed86..837c482d5c 100644
--- a/TUnit.Core/Helpers/CastHelper.cs
+++ b/TUnit.Core/Helpers/CastHelper.cs
@@ -16,6 +16,8 @@ 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.
///
+ [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)
@@ -35,7 +37,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)
@@ -66,6 +68,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))
{
@@ -142,6 +150,88 @@ private static bool TryAotSafeConversion(Type targetType, Type sourceType, objec
return false;
}
+ private static bool TryParseFromString([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type targetType, string value, out object? result)
+ {
+#if NET
+ if (TryParsableConvert(targetType, value, out result))
+ {
+ return true;
+ }
+#else
+ if (targetType == typeof(DateTime) && DateTime.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dateTime))
+ {
+ 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;
+ }
+ if (targetType == typeof(TimeSpan) && TimeSpan.TryParse(value, System.Globalization.CultureInfo.InvariantCulture, out var timeSpan))
+ {
+ result = timeSpan;
+ return true;
+ }
+ if (targetType == typeof(Guid) && Guid.TryParse(value, out var guid))
+ {
+ result = guid;
+ return true;
+ }
+#endif
+
+ result = null;
+ return false;
+ }
+
+#if NET
+ private static readonly ConcurrentDictionary ParseMethodCache = new();
+
+ private static bool TryParsableConvert([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.Interfaces | DynamicallyAccessedMemberTypes.PublicMethods)] Type targetType, string value, out object? result)
+ {
+ if (!ParseMethodCache.TryGetValue(targetType, out var parseMethod))
+ {
+ parseMethod = FindParseMethod(targetType);
+ ParseMethodCache.TryAdd(targetType, parseMethod);
+ }
+
+ if (parseMethod != null)
+ {
+ try
+ {
+ result = parseMethod.Invoke(null, [value, System.Globalization.CultureInfo.InvariantCulture]);
+ return true;
+ }
+ catch (Exception ex) when (ex is FormatException or OverflowException or ArgumentException or TargetInvocationException { InnerException: FormatException or OverflowException or ArgumentException })
+ {
+ // Parse failed for the given string value
+ }
+ }
+
+ 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.")]
[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.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..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
@@ -2125,7 +2125,11 @@ 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 object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, 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 b36057a79f..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
@@ -2125,7 +2125,11 @@ 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 object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, 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 6b57b8bafb..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
@@ -2125,7 +2125,11 @@ 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 object? Cast([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, 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.TestProject/StringToParsableArgumentsTests.cs b/TUnit.TestProject/StringToParsableArgumentsTests.cs
new file mode 100644
index 0000000000..d3bd2de603
--- /dev/null
+++ b/TUnit.TestProject/StringToParsableArgumentsTests.cs
@@ -0,0 +1,48 @@
+namespace TUnit.TestProject;
+
+public class StringToParsableArgumentsTests
+{
+ [Test]
+ [Arguments("2022-5-31")]
+ public async Task DateTime_From_String(DateTime testDate)
+ {
+ 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).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).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).IsEqualTo(new DateTimeOffset(2022, 5, 31, 14, 30, 0, TimeSpan.FromHours(2)));
+ }
+
+#if NET8_0_OR_GREATER
+ [Test]
+ [Arguments("2022-05-31")]
+ public async Task DateOnly_From_String(DateOnly date)
+ {
+ 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).IsEqualTo(new TimeOnly(14, 30, 0));
+ }
+#endif
+}