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 +}