diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet10_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet10_0.verified.txt new file mode 100644 index 0000000000..673964419f --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet10_0.verified.txt @@ -0,0 +1,32 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for StringRespectsTokenAssertion. +/// +public static partial class StringRespectsTokenAssertionExtensions +{ + + /// + /// Extension method for StringRespectsTokenAssertion. + /// + public static StringRespectsTokenAssertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append(".RespectsToken("); + source.Context.ExpressionBuilder.Append(tokenExpression); + source.Context.ExpressionBuilder.Append(")"); + return new StringRespectsTokenAssertion(source.Context, token); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet8_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet8_0.verified.txt new file mode 100644 index 0000000000..673964419f --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet8_0.verified.txt @@ -0,0 +1,32 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for StringRespectsTokenAssertion. +/// +public static partial class StringRespectsTokenAssertionExtensions +{ + + /// + /// Extension method for StringRespectsTokenAssertion. + /// + public static StringRespectsTokenAssertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append(".RespectsToken("); + source.Context.ExpressionBuilder.Append(tokenExpression); + source.Context.ExpressionBuilder.Append(")"); + return new StringRespectsTokenAssertion(source.Context, token); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet9_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet9_0.verified.txt new file mode 100644 index 0000000000..673964419f --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.DotNet9_0.verified.txt @@ -0,0 +1,32 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for StringRespectsTokenAssertion. +/// +public static partial class StringRespectsTokenAssertionExtensions +{ + + /// + /// Extension method for StringRespectsTokenAssertion. + /// + public static StringRespectsTokenAssertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append(".RespectsToken("); + source.Context.ExpressionBuilder.Append(tokenExpression); + source.Context.ExpressionBuilder.Append(")"); + return new StringRespectsTokenAssertion(source.Context, token); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.Net4_7.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.Net4_7.verified.txt new file mode 100644 index 0000000000..673964419f --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.ValueTypeDefaultParameter.Net4_7.verified.txt @@ -0,0 +1,32 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using TUnit.Assertions.Core; +using TUnit.Assertions.Enums; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated extension methods for StringRespectsTokenAssertion. +/// +public static partial class StringRespectsTokenAssertionExtensions +{ + + /// + /// Extension method for StringRespectsTokenAssertion. + /// + public static StringRespectsTokenAssertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append(".RespectsToken("); + source.Context.ExpressionBuilder.Append(tokenExpression); + source.Context.ExpressionBuilder.Append(")"); + return new StringRespectsTokenAssertion(source.Context, token); + } +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs index 7ecadf520d..19ff74a1f5 100644 --- a/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs +++ b/TUnit.Assertions.SourceGenerator.Tests/AssertionExtensionGeneratorTests.cs @@ -210,4 +210,27 @@ public Task AssertionWithMultipleParameters() => RunTest( var callerExprCount = System.Text.RegularExpressions.Regex.Matches(extensionFile!, "CallerArgumentExpression").Count; await Assert.That(callerExprCount).IsGreaterThanOrEqualTo(2); }); + + [Test] + public Task ValueTypeDefaultParameter() => RunTest( + Path.Combine(Sourcy.Git.RootDirectory.FullName, + "TUnit.Assertions.SourceGenerator.Tests", + "TestData", + "AssertionExtensionValueTypeDefaultParameterAssertion.cs"), + async generatedFiles => + { + await Assert.That(generatedFiles).Count().IsEqualTo(1); + + var extensionFile = generatedFiles.First(); + await Assert.That(extensionFile).IsNotNull(); + + // A non-nullable value-type constructor parameter declared with `= default` must + // render as the bare `default` literal, not `= null`. The literal `null` is invalid + // for a non-nullable value type and produces CS1750. The trailing comma anchors the + // assertion to bare `default`, ruling out the longer `default(TypeName)` form. + await Assert.That(extensionFile).Contains("CancellationToken token = default,"); + await Assert.That(extensionFile).DoesNotContain("CancellationToken token = null"); + + await CompileChecker.AssertNoErrors(generatedFiles); + }); } diff --git a/TUnit.Assertions.SourceGenerator.Tests/CompileChecker.cs b/TUnit.Assertions.SourceGenerator.Tests/CompileChecker.cs new file mode 100644 index 0000000000..9423b2084b --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/CompileChecker.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace TUnit.Assertions.SourceGenerator.Tests; + +/// +/// Parses generator output as C# and asserts there are no error-severity diagnostics. +/// Pins the entire class of source-generator emit bugs (mis-paired brackets, wrong +/// default-value rendering, invalid generic parameter lists) regardless of the specific +/// diagnostic id. Use alongside content-shape assertions when the test wants both a +/// targeted regression check and a structural compile-clean gate. +/// +internal static class CompileChecker +{ + public static async Task AssertNoErrors(IEnumerable generatedFiles) + { + var trees = generatedFiles + .Select(source => CSharpSyntaxTree.ParseText(source)) + .ToArray(); + + var compilation = CSharpCompilation.Create( + "CompileCheck", + trees, + ReferencesHelper.References, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + // On the net472 leg of CI, the Polyfill assembly's CallerArgumentExpressionAttribute + // is declared internal, which produces a CS0122 ('inaccessible due to its protection + // level') false positive when Roslyn compiles the generator's output through the test + // project's reference set. Filter CS0122 only on net472; the emit shape is still + // pinned by the `.Net4_7` snapshot files. On modern TFMs the BCL attribute is public, + // so CS0122 (if it ever appears) would be a genuine signal and is not filtered. + var errors = compilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) +#if NETFRAMEWORK + .Where(d => !string.Equals(d.Id, "CS0122", System.StringComparison.Ordinal)) +#endif + .Select(d => $"{d.Id}: {d.GetMessage()}") + .ToArray(); + + await Assert.That(errors).IsEmpty(); + } +} diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet10_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet10_0.verified.txt new file mode 100644 index 0000000000..f83688fb8a --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet10_0.verified.txt @@ -0,0 +1,64 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for RespectsToken +/// +public sealed class Int_RespectsToken_CancellationToken_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly System.Threading.CancellationToken _token; + + public Int_RespectsToken_CancellationToken_Assertion(AssertionContext context, System.Threading.CancellationToken token) + : base(context) + { + _token = token; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + var result = value!.RespectsToken(_token); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy RespectsToken({_token})"; + } +} + +public static partial class ValueTypeDefaultParameterAssertionExtensions +{ + /// + /// Generated extension method for RespectsToken + /// + public static Int_RespectsToken_CancellationToken_Assertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append($".RespectsToken({tokenExpression})"); + return new Int_RespectsToken_CancellationToken_Assertion(source.Context, token); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet8_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet8_0.verified.txt new file mode 100644 index 0000000000..f83688fb8a --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet8_0.verified.txt @@ -0,0 +1,64 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for RespectsToken +/// +public sealed class Int_RespectsToken_CancellationToken_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly System.Threading.CancellationToken _token; + + public Int_RespectsToken_CancellationToken_Assertion(AssertionContext context, System.Threading.CancellationToken token) + : base(context) + { + _token = token; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + var result = value!.RespectsToken(_token); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy RespectsToken({_token})"; + } +} + +public static partial class ValueTypeDefaultParameterAssertionExtensions +{ + /// + /// Generated extension method for RespectsToken + /// + public static Int_RespectsToken_CancellationToken_Assertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append($".RespectsToken({tokenExpression})"); + return new Int_RespectsToken_CancellationToken_Assertion(source.Context, token); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet9_0.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet9_0.verified.txt new file mode 100644 index 0000000000..f83688fb8a --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.DotNet9_0.verified.txt @@ -0,0 +1,64 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for RespectsToken +/// +public sealed class Int_RespectsToken_CancellationToken_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly System.Threading.CancellationToken _token; + + public Int_RespectsToken_CancellationToken_Assertion(AssertionContext context, System.Threading.CancellationToken token) + : base(context) + { + _token = token; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + var result = value!.RespectsToken(_token); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy RespectsToken({_token})"; + } +} + +public static partial class ValueTypeDefaultParameterAssertionExtensions +{ + /// + /// Generated extension method for RespectsToken + /// + public static Int_RespectsToken_CancellationToken_Assertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append($".RespectsToken({tokenExpression})"); + return new Int_RespectsToken_CancellationToken_Assertion(source.Context, token); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.Net4_7.verified.txt b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.Net4_7.verified.txt new file mode 100644 index 0000000000..f83688fb8a --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.ValueTypeDefaultParameter.Net4_7.verified.txt @@ -0,0 +1,64 @@ +[ +// +#pragma warning disable +#nullable enable + +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using TUnit.Assertions.Core; +using TUnit.Assertions.Tests.TestData; + +namespace TUnit.Assertions.Extensions; + +/// +/// Generated assertion for RespectsToken +/// +public sealed class Int_RespectsToken_CancellationToken_Assertion : Assertion +{ + private static readonly Task _passedTask = Task.FromResult(AssertionResult.Passed); + + private readonly System.Threading.CancellationToken _token; + + public Int_RespectsToken_CancellationToken_Assertion(AssertionContext context, System.Threading.CancellationToken token) + : base(context) + { + _token = token; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + var result = value!.RespectsToken(_token); + return result + ? _passedTask + : Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() + { + return $"to satisfy RespectsToken({_token})"; + } +} + +public static partial class ValueTypeDefaultParameterAssertionExtensions +{ + /// + /// Generated extension method for RespectsToken + /// + public static Int_RespectsToken_CancellationToken_Assertion RespectsToken(this IAssertionSource source, System.Threading.CancellationToken token = default, [CallerArgumentExpression(nameof(token))] string? tokenExpression = null) + { + source.Context.ExpressionBuilder.Append($".RespectsToken({tokenExpression})"); + return new Int_RespectsToken_CancellationToken_Assertion(source.Context, token); + } + +} + +] \ No newline at end of file diff --git a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs index 43fc0d2c60..06bd514fd0 100644 --- a/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs +++ b/TUnit.Assertions.SourceGenerator.Tests/MethodAssertionGeneratorTests.cs @@ -224,6 +224,29 @@ public Task MethodWithDefaultValues() => RunTest( await Assert.That(mainFile).Contains("bool exact = true"); }); + [Test] + public Task ValueTypeDefaultParameter() => RunTest( + Path.Combine(Sourcy.Git.RootDirectory.FullName, + "TUnit.Assertions.SourceGenerator.Tests", + "TestData", + "ValueTypeDefaultParameterAssertion.cs"), + async generatedFiles => + { + await Assert.That(generatedFiles).Count().IsEqualTo(1); + + var mainFile = generatedFiles.First(); + await Assert.That(mainFile).IsNotNull(); + + // A non-nullable value-type parameter declared with `= default` must render as + // the bare `default` literal, not `= null`. The literal `null` is invalid for a + // non-nullable value type and produces CS1750. The trailing comma anchors the + // assertion to bare `default`, ruling out the longer `default(TypeName)` form. + await Assert.That(mainFile).Contains("CancellationToken token = default,"); + await Assert.That(mainFile).DoesNotContain("CancellationToken token = null"); + + await CompileChecker.AssertNoErrors(generatedFiles); + }); + #if NET8_0_OR_GREATER [Test] public Task RefStructParameter() => RunTest( diff --git a/TUnit.Assertions.SourceGenerator.Tests/TestData/AssertionExtensionValueTypeDefaultParameterAssertion.cs b/TUnit.Assertions.SourceGenerator.Tests/TestData/AssertionExtensionValueTypeDefaultParameterAssertion.cs new file mode 100644 index 0000000000..b70aa905b6 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/TestData/AssertionExtensionValueTypeDefaultParameterAssertion.cs @@ -0,0 +1,33 @@ +using System.Threading; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Tests.TestData; + +/// +/// Test case: -decorated class whose constructor +/// has a non-nullable value-type parameter declared with = default. The Roslyn-reported +/// default expression is , but emitting parameter = null for a +/// value type is invalid C# (CS1750). The generator must render the bare default +/// literal, which the C# compiler infers as default(TypeName) from the parameter type. +/// +[AssertionExtension("RespectsToken")] +public class StringRespectsTokenAssertion : Assertion +{ + private readonly CancellationToken _token; + + public StringRespectsTokenAssertion(AssertionContext context, CancellationToken token = default) + : base(context) + { + _token = token; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + return _token.IsCancellationRequested + ? Task.FromResult(AssertionResult.Failed("token was canceled")) + : Task.FromResult(AssertionResult.Passed); + } + + protected override string GetExpectation() => "to respect the supplied token"; +} diff --git a/TUnit.Assertions.SourceGenerator.Tests/TestData/ValueTypeDefaultParameterAssertion.cs b/TUnit.Assertions.SourceGenerator.Tests/TestData/ValueTypeDefaultParameterAssertion.cs new file mode 100644 index 0000000000..390c67ad81 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator.Tests/TestData/ValueTypeDefaultParameterAssertion.cs @@ -0,0 +1,20 @@ +using System.Threading; +using TUnit.Assertions.Attributes; + +namespace TUnit.Assertions.Tests.TestData; + +/// +/// Test case: -decorated method whose parameter +/// is a non-nullable value type declared with = default. The Roslyn-reported default +/// expression is , but emitting parameter = null for a value +/// type is invalid C# (CS1750). The generator must render the bare default literal, +/// which the C# compiler infers as default(TypeName) from the parameter type. +/// +public static partial class ValueTypeDefaultParameterAssertionExtensions +{ + [GenerateAssertion] + public static bool RespectsToken(this int value, CancellationToken token = default) + { + return !token.IsCancellationRequested && value > 0; + } +} diff --git a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs index a3f3fed290..d87b8b4993 100644 --- a/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs +++ b/TUnit.Assertions.SourceGenerator/Generators/AssertionExtensionGenerator.cs @@ -411,7 +411,7 @@ private static void GenerateExtensionMethod( // Add default value if present if (param.HasExplicitDefaultValue) { - var defaultValue = FormatDefaultValue(param.ExplicitDefaultValue, param.Type); + var defaultValue = DefaultValueFormatter.FormatDefaultValue(param.ExplicitDefaultValue, param.Type); sourceBuilder.Append($" = {defaultValue}"); } } @@ -496,48 +496,6 @@ private static void GenerateExtensionMethod( sourceBuilder.AppendLine(" }"); } - private static string FormatDefaultValue(object? defaultValue, ITypeSymbol type) - { - if (defaultValue == null) - { - return "null"; - } - - if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol enumType) - { - // Find the enum member that matches the default value - foreach (var member in enumType.GetMembers()) - { - if (member is IFieldSymbol { HasConstantValue: true } field && - field.ConstantValue != null && - field.ConstantValue.Equals(defaultValue)) - { - // Use just the enum name without namespace since we have using TUnit.Assertions.Enums; - return $"{enumType.Name}.{field.Name}"; - } - } - // Fallback if no matching member found - return $"({enumType.ToDisplayString()})({defaultValue})"; - } - - if (defaultValue is string str) - { - return $"\"{str.Replace("\"", "\\\"")}\""; - } - - if (defaultValue is bool b) - { - return b ? "true" : "false"; - } - - if (defaultValue is char c) - { - return $"'{c}'"; - } - - return defaultValue.ToString() ?? "null"; - } - private record AssertionExtensionData( INamedTypeSymbol ClassSymbol, string MethodName, diff --git a/TUnit.Assertions.SourceGenerator/Generators/DefaultValueFormatter.cs b/TUnit.Assertions.SourceGenerator/Generators/DefaultValueFormatter.cs new file mode 100644 index 0000000000..c62a989974 --- /dev/null +++ b/TUnit.Assertions.SourceGenerator/Generators/DefaultValueFormatter.cs @@ -0,0 +1,93 @@ +using Microsoft.CodeAnalysis; + +namespace TUnit.Assertions.SourceGenerator.Generators; + +/// +/// Shared default-value emit for both assertion source generators. Renders a parameter's +/// Roslyn-reported default value as the C# literal that goes after = in the generated +/// method signature. The two public entry points differ only in how they format enum members: +/// emits the short form (Enum.Member), suitable for +/// generators that rely on using directives at the top of the generated file; +/// emits the namespace-prefixed form +/// (Namespace.Enum.Member), suitable for generators that emit fully-qualified type +/// names throughout the produced source. +/// +internal static class DefaultValueFormatter +{ + /// + /// Formats as a C# default-literal expression for a + /// parameter of , emitting enum members in the short form + /// (Enum.Member) that depends on a using directive to resolve the type name. + /// + /// The constant value Roslyn reported for the parameter's + /// explicit default, or if the parameter was declared with the + /// default keyword (which Roslyn surfaces as a null constant). + /// The parameter's declared type. + public static string FormatDefaultValue(object? defaultValue, ITypeSymbol type) + => FormatDefaultValueCore(defaultValue, type, useFullyQualifiedEnumName: false); + + /// + /// Formats as a C# default-literal expression for a + /// parameter of , emitting enum members in the fully-qualified form + /// (Namespace.Enum.Member) so the result resolves regardless of using + /// directives at the top of the generated file. + /// + /// The constant value Roslyn reported for the parameter's + /// explicit default, or if the parameter was declared with the + /// default keyword (which Roslyn surfaces as a null constant). + /// The parameter's declared type. + public static string FormatDefaultValueFullyQualified(object? defaultValue, ITypeSymbol type) + => FormatDefaultValueCore(defaultValue, type, useFullyQualifiedEnumName: true); + + private static string FormatDefaultValueCore(object? defaultValue, ITypeSymbol type, bool useFullyQualifiedEnumName) + { + if (defaultValue == null) + { + // A null Roslyn-reported default expression on a non-nullable value type means the + // parameter was declared with `= default` (e.g. `CancellationToken ct = default`). + // Emitting `= null` for such a parameter produces CS1750 because the literal null + // cannot convert to the value-type. Emit the `default` literal: the target type is + // inferred from the parameter, matching the user's original declaration. + // Nullable stays on the null path: it accepts a literal null default. + if (type.IsValueType + && type.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T) + { + return "default"; + } + return "null"; + } + + if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol enumType) + { + var enumPrefix = useFullyQualifiedEnumName ? enumType.ToDisplayString() : enumType.Name; + foreach (var member in enumType.GetMembers()) + { + if (member is IFieldSymbol { HasConstantValue: true } field && + field.ConstantValue != null && + field.ConstantValue.Equals(defaultValue)) + { + return $"{enumPrefix}.{field.Name}"; + } + } + + return $"({enumType.ToDisplayString()})({defaultValue})"; + } + + if (defaultValue is string str) + { + return $"\"{str.Replace("\"", "\\\"")}\""; + } + + if (defaultValue is bool b) + { + return b ? "true" : "false"; + } + + if (defaultValue is char c) + { + return $"'{c}'"; + } + + return defaultValue.ToString() ?? "null"; + } +} diff --git a/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs b/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs index 3d3db7de51..fe674ec808 100644 --- a/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs +++ b/TUnit.Assertions.SourceGenerator/Generators/MethodAssertionGenerator.cs @@ -141,7 +141,7 @@ private static (AssertionMethodData? Data, Diagnostic? Diagnostic) GetAssertionM IsInterpolatedStringHandler = IsInterpolatedStringHandler(p.Type), SimpleTypeName = GetSimpleTypeName(p.Type), HasExplicitDefaultValue = p.HasExplicitDefaultValue, - DefaultValueExpression = p.HasExplicitDefaultValue ? FormatDefaultValue(p.ExplicitDefaultValue, p.Type) : null, + DefaultValueExpression = p.HasExplicitDefaultValue ? DefaultValueFormatter.FormatDefaultValueFullyQualified(p.ExplicitDefaultValue, p.Type) : null, }).ToImmutableEquatableArray(); // Extract custom expectation message and inlining preference if provided @@ -1199,46 +1199,6 @@ private static string GetSimpleTypeName(ITypeSymbol type) return simpleName; } - private static string FormatDefaultValue(object? defaultValue, ITypeSymbol type) - { - if (defaultValue == null) - { - return "null"; - } - - if (type.TypeKind == TypeKind.Enum && type is INamedTypeSymbol enumType) - { - foreach (var member in enumType.GetMembers()) - { - if (member is IFieldSymbol { HasConstantValue: true } field && - field.ConstantValue != null && - field.ConstantValue.Equals(defaultValue)) - { - return $"{enumType.ToDisplayString()}.{field.Name}"; - } - } - - return $"({enumType.ToDisplayString()})({defaultValue})"; - } - - if (defaultValue is string str) - { - return $"\"{str.Replace("\"", "\\\"")}\""; - } - - if (defaultValue is bool b) - { - return b ? "true" : "false"; - } - - if (defaultValue is char c) - { - return $"'{c}'"; - } - - return defaultValue.ToString() ?? "null"; - } - /// /// Collects generic constraints from method type parameters. /// Returns a list of constraint strings in the format "where T : constraint1, constraint2"