Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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: 90 additions & 2 deletions TUnit.Core/Helpers/CastHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
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)
{
Expand All @@ -35,7 +35,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 +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))
{
Expand Down Expand Up @@ -142,6 +148,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,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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
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