diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs index 5f975588103..565893474b6 100644 --- a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs @@ -201,4 +201,37 @@ public Task FileScopedClassWithInlining() => RunTest( // Snapshot test - the actual verification is done by snapshot comparison await Assert.That(generatedFiles.Count).IsGreaterThanOrEqualTo(1); }); + +#if NET6_0_OR_GREATER + [Test] + public Task RefStructParameter() => RunTest( + Path.Combine(Sourcy.Git.RootDirectory.FullName, + "TUnit.Assertions.SourceGenerator.Tests", + "TestData", + "RefStructParameterAssertion.cs"), + async generatedFiles => + { + await Assert.That(generatedFiles).HasCount(1); + + var mainFile = generatedFiles.First(); + await Assert.That(mainFile).IsNotNull(); + + // Verify that the field type is string, not the ref struct + await Assert.That(mainFile).Contains("private readonly string _message;"); + await Assert.That(mainFile).Contains("private readonly string _suffix;"); + + // Verify that the extension method converts the ref struct to string + await Assert.That(mainFile).Contains("message.ToStringAndClear()"); + await Assert.That(mainFile).Contains("suffix.ToStringAndClear()"); + + // Verify the constructor takes string, not the ref struct + await Assert.That(mainFile).Contains("string message)"); + await Assert.That(mainFile).Contains("string suffix)"); + + // Verify that .ToStringAndClear() is removed in the inlined body + // (since the field is already a string) + // The inlined body should use _message directly, not _message.ToStringAndClear() + await Assert.That(mainFile).Contains("value!.Contains(_message)"); + }); +#endif } diff --git a/TUnit.Assertions.SourceGenerator.Tests/TestData/RefStructParameterAssertion.cs b/TUnit.Assertions.SourceGenerator.Tests/TestData/RefStructParameterAssertion.cs new file mode 100644 index 00000000000..6fa3559fa05 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/TestData/RefStructParameterAssertion.cs @@ -0,0 +1,30 @@ +#if NET6_0_OR_GREATER +using System.Runtime.CompilerServices; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Tests.TestData; + +/// +/// Test case: Method with ref struct parameter (DefaultInterpolatedStringHandler) +/// The generator should convert the ref struct to string before storing it +/// +public static class RefStructParameterAssertions +{ + /// + /// Test that interpolated string handlers are properly converted to strings + /// + [GenerateAssertion(ExpectationMessage = "to contain {message}", InlineMethodBody = true)] + public static bool ContainsMessage(this string value, ref DefaultInterpolatedStringHandler message) + { + var stringMessage = message.ToStringAndClear(); + return value.Contains(stringMessage); + } + + /// + /// Test with a simpler expression body + /// + [GenerateAssertion(ExpectationMessage = "to end with {suffix}", InlineMethodBody = true)] + public static bool EndsWithMessage(this string value, ref DefaultInterpolatedStringHandler suffix) + => value.EndsWith(suffix.ToStringAndClear()); +} +#endif diff --git a/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs index 177cc51d831..95bbfcae85f 100644 --- a/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs +++ b/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs @@ -47,6 +47,15 @@ public sealed class MethodAssertionGenerator : IIncrementalGenerator isEnabledByDefault: true, description: "Methods decorated with [GenerateAssertion] must return bool, AssertionResult, Task, or Task."); + private static readonly DiagnosticDescriptor RefStructRequiresInliningRule = new DiagnosticDescriptor( + id: "TUNITGEN004", + title: "Ref struct parameter requires method body inlining", + messageFormat: "Method '{0}' has ref struct parameter '{1}' of type '{2}'. Use InlineMethodBody = true and ensure the method has a single-expression or single-return-statement body", + category: "TUnit.Assertions.SourceGenerator", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Methods with ref struct parameters (like DefaultInterpolatedStringHandler) require InlineMethodBody = true because ref structs cannot be stored as class fields. The method must have a simple body that can be inlined."); + public void Initialize(IncrementalGeneratorInitializationContext context) { // Find all methods decorated with [GenerateAssertion] @@ -259,6 +268,22 @@ private static (AssertionMethodData? Data, Diagnostic? Diagnostic) GetAssertionM } } + // Validate that methods with ref struct parameters have inlined method bodies + // Ref structs cannot be stored as class fields, so we need to inline the method body + foreach (var param in additionalParameters) + { + if (IsRefStruct(param.Type) && string.IsNullOrEmpty(methodBody)) + { + var diagnostic = Diagnostic.Create( + RefStructRequiresInliningRule, + location, + methodSymbol.Name, + param.Name, + param.Type.ToDisplayString()); + return (null, diagnostic); + } + } + var data = new AssertionMethodData( methodSymbol, targetType, @@ -545,9 +570,11 @@ private static void GenerateAssertionClass(StringBuilder sb, AssertionMethodData sb.AppendLine("{"); // Private fields for additional parameters + // Note: Ref struct types (like DefaultInterpolatedStringHandler) are stored as string foreach (var param in data.AdditionalParameters) { - sb.AppendLine($" private readonly {param.Type.ToDisplayString()} _{param.Name};"); + var fieldType = IsRefStruct(param.Type) ? "string" : param.Type.ToDisplayString(); + sb.AppendLine($" private readonly {fieldType} _{param.Name};"); } if (data.AdditionalParameters.Length > 0) @@ -556,10 +583,12 @@ private static void GenerateAssertionClass(StringBuilder sb, AssertionMethodData } // Constructor + // Note: Ref struct parameters are received as string (pre-converted by extension method) sb.Append($" public {className}(AssertionContext<{targetTypeName}> context"); foreach (var param in data.AdditionalParameters) { - sb.Append($", {param.Type.ToDisplayString()} {param.Name}"); + var paramType = IsRefStruct(param.Type) ? "string" : param.Type.ToDisplayString(); + sb.Append($", {paramType} {param.Name}"); } sb.AppendLine(")"); sb.AppendLine(" : base(context)"); @@ -730,6 +759,26 @@ private static string BuildInlinedExpression(AssertionMethodData data) $"_{paramName}"); } + // For ref struct parameters that have been converted to strings, + // remove calls to .ToStringAndClear() and .ToString() since the value is already a string + foreach (var param in data.AdditionalParameters) + { + if (IsRefStruct(param.Type)) + { + var fieldName = $"_{param.Name}"; + // Remove .ToStringAndClear() - the value is already a string + inlinedBody = Regex.Replace( + inlinedBody, + $@"{Regex.Escape(fieldName)}\.ToStringAndClear\(\)", + fieldName); + // Remove .ToString() - the value is already a string + inlinedBody = Regex.Replace( + inlinedBody, + $@"{Regex.Escape(fieldName)}\.ToString\(\)", + fieldName); + } + } + // Add null-forgiving operator for reference types if not already present // This is safe because we've already checked for null above var isNullable = data.TargetType.IsReferenceType || data.TargetType.NullableAnnotation == NullableAnnotation.Annotated; @@ -873,10 +922,23 @@ private static void GenerateExtensionMethod(StringBuilder sb, AssertionMethodDat } // Construct and return assertion + // Note: Ref struct parameters (like interpolated string handlers) are converted to string sb.Append($" return new {className}{genericDeclaration}(source.Context"); foreach (var param in data.AdditionalParameters) { - sb.Append($", {param.Name}"); + if (IsRefStruct(param.Type)) + { + // Convert ref struct to string - use ToStringAndClear for interpolated string handlers + // or ToString() for other ref structs + var conversion = IsInterpolatedStringHandler(param.Type) + ? $"{param.Name}.ToStringAndClear()" + : $"{param.Name}.ToString()"; + sb.Append($", {conversion}"); + } + else + { + sb.Append($", {param.Name}"); + } } sb.AppendLine(");"); @@ -1001,6 +1063,61 @@ private static List CollectGenericConstraints(IMethodSymbol method) return constraints; } + /// + /// Checks if a type is a ref struct (ref-like type). + /// Ref structs cannot be stored as fields in classes. + /// + private static bool IsRefStruct(ITypeSymbol type) + { + if (type is not INamedTypeSymbol namedType) + { + return false; + } + + // Use reflection to access IsRefLikeType property which may not be available in all Roslyn versions + var isRefLikeProperty = namedType.GetType().GetProperty("IsRefLikeType"); + if (isRefLikeProperty?.GetValue(namedType) is bool isRefLike && isRefLike) + { + return true; + } + + // Fallback: check for common ref struct types by name + var typeName = namedType.ToDisplayString(); + if (typeName.StartsWith("System.Span<") || + typeName.StartsWith("System.ReadOnlySpan<") || + typeName == "System.Runtime.CompilerServices.DefaultInterpolatedStringHandler") + { + return true; + } + + // Check for InterpolatedStringHandlerAttribute on the type + return namedType.GetAttributes().Any(attr => + attr.AttributeClass?.ToDisplayString() == "System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute"); + } + + /// + /// Checks if a type is an interpolated string handler (e.g., DefaultInterpolatedStringHandler). + /// These types need special handling as they should be converted to string. + /// + private static bool IsInterpolatedStringHandler(ITypeSymbol type) + { + if (type is not INamedTypeSymbol namedType) + { + return false; + } + + // Check for DefaultInterpolatedStringHandler specifically + var typeName = namedType.ToDisplayString(); + if (typeName == "System.Runtime.CompilerServices.DefaultInterpolatedStringHandler") + { + return true; + } + + // Check for InterpolatedStringHandlerAttribute on the type + return namedType.GetAttributes().Any(attr => + attr.AttributeClass?.ToDisplayString() == "System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute"); + } + private enum ReturnTypeKind { Bool,