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
3 changes: 2 additions & 1 deletion Roslyn.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
<Compile Include="..\$(_BaseProjectName)\**\*.cs" Exclude="..\$(_BaseProjectName)\obj\**\*.cs" />
</ItemGroup>

<!-- Analyzer projects: include .resx embedded resources -->
<!-- Analyzer projects: include .resx embedded resources and shared extensions -->
<ItemGroup Condition="$(_BaseProjectName.EndsWith('.Analyzers'))">
<EmbeddedResource Include="..\$(_BaseProjectName)\**\*.resx" />
<Compile Include="..\TUnit.Core.SourceGenerator\Extensions\ParsableTypeExtensions.cs" />
</ItemGroup>

<!-- AspNetCore.Analyzers: include analyzer release tracking files -->
Expand Down
4 changes: 4 additions & 0 deletions TUnit.Analyzers/TUnit.Analyzers.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>

<ItemGroup>
<Compile Include="..\TUnit.Core.SourceGenerator\Extensions\ParsableTypeExtensions.cs" Link="Extensions\ParsableTypeExtensions.cs" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Update="Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
Expand Down
7 changes: 7 additions & 0 deletions TUnit.Analyzers/TestDataAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs
Original file line number Diff line number Diff line change
@@ -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";
}
}
92 changes: 91 additions & 1 deletion TUnit.Core/Helpers/CastHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2087:'type' argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call to target method.",
Justification = "Cast<T> 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)
Expand All @@ -35,7 +37,7 @@ public static class CastHelper
/// 3. Reflection fallback: custom operators, arrays (throws in AOT)
/// </summary>
[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)
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -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<Type, MethodInfo?> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> 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
Expand Down
48 changes: 48 additions & 0 deletions TUnit.TestProject/StringToParsableArgumentsTests.cs
Original file line number Diff line number Diff line change
@@ -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
}
Loading