Skip to content

fix(sourcegen): emit default literal for value-type assertion parameters#5919

Merged
thomhurst merged 2 commits into
thomhurst:mainfrom
JohnVerheij:fix/method-assertion-default-value-emit
May 14, 2026
Merged

fix(sourcegen): emit default literal for value-type assertion parameters#5919
thomhurst merged 2 commits into
thomhurst:mainfrom
JohnVerheij:fix/method-assertion-default-value-emit

Conversation

@JohnVerheij
Copy link
Copy Markdown
Contributor

Description

Both source generators in TUnit.Assertions.SourceGenerator carried a near-duplicate private FormatDefaultValue helper, and both returned "null" when the parameter's Roslyn-reported default-value constant was null. For a non-nullable value-type parameter declared with the default literal (e.g. CancellationToken ct = default), Roslyn reports the default as null, so the generator emitted = null. That literal is invalid as the default for a value type and produces CS1750.

Fix: when the constant is null, branch on the parameter type. Non-nullable value types emit the bare default literal (the compiler infers the target type from the parameter, matching the user's original declaration). Reference types and Nullable<T> continue to emit null.

Unified the two private helpers into a single shared DefaultValueFormatter.FormatDefaultValue (new file Generators/DefaultValueFormatter.cs, sibling to the existing CovarianceHelper). The helper takes a bool useFullyQualifiedEnumName parameter so each generator preserves its intentional enum-emit style: MethodAssertionGenerator passes true (emits Namespace.Enum.Member to match its fully-qualified-everywhere style), and AssertionExtensionGenerator passes false (emits Enum.Member because it relies on using directives at the top of its generated files). Both generators now share a single source of truth for default-value emit while keeping the call-site-specific behaviour explicit.

Test coverage:

  • New ValueTypeDefaultParameter test on MethodAssertionGeneratorTests ([GenerateAssertion] path).
  • New ValueTypeDefaultParameter test on AssertionExtensionGeneratorTests ([AssertionExtension] path).
  • Both tests pin the surface (token = default,) and run a structural compile-clean gate.
  • Extracted a shared CompileChecker.AssertNoErrors(generatedFiles) test helper (sibling to ReferencesHelper) so the compile-clean assertion isn't duplicated across the two new tests.

Related Issue

Fixes #5917

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Refactoring (no functional changes)

Checklist

Required

  • I have read the Contributing Guidelines
  • If this is a new feature, I started a discussion first and received agreement
  • My code follows the project's code style (modern C# syntax, proper naming conventions)
  • I have written tests that prove my fix is effective or my feature works

TUnit-Specific Requirements

  • Dual-Mode Implementation: If this change affects test discovery/execution, I have implemented it in BOTH:
    • Source Generator path (TUnit.Core.SourceGenerator)
    • Reflection path (TUnit.Engine)
  • Snapshot Tests: If I changed source generator output or public APIs:
    • I ran TUnit.Core.SourceGenerator.Tests and/or TUnit.PublicAPI tests
    • I reviewed the .received.txt files and accepted them as .verified.txt
    • I committed the updated .verified.txt files
  • Performance: If this change affects hot paths (test discovery, execution, assertions):
    • I minimized allocations and avoided LINQ in hot paths
    • I cached reflection results where appropriate
  • AOT Compatibility: If this change uses reflection:
    • I added appropriate [DynamicallyAccessedMembers] annotations
    • I verified the change works with dotnet publish -p:PublishAot=true

Testing

  • All existing tests pass (dotnet test)
  • I have added tests that cover my changes
  • I have tested both source-generated and reflection modes (if applicable)

Additional Notes

  • Dual-Mode rule is not applicable. Per .claude/docs/mandatory-rules.md Rule 1, dual-mode does not apply to assertion library changes. This PR fixes an emit defect in TUnit.Assertions.SourceGenerator only.
  • Performance / AOT rules are not applicable. Trivial branch in the formatter; no allocation, no reflection.
  • Helper unification preserves existing behaviour. All existing snapshots compare identically before and after the unification. The useFullyQualifiedEnumName parameter encodes the previously-duplicated semantic difference between the two generators, so neither generator's output for any existing case changes.
  • net472 CS0122 conditional in the new CompileChecker test helper: on the net472 leg of CI, the test project's Polyfill assembly marks CallerArgumentExpressionAttribute as internal, producing a CS0122 ('inaccessible due to its protection level') false positive when Roslyn compiles the generator's output through the test reference set. Conditional #if NETFRAMEWORK filter pre-empts this in the helper. On modern TFMs the BCL attribute is public, so CS0122 (if it ever appears) remains a real failure signal. All four TFMs (net472 + net8.0 + net9.0 + net10.0) verified green locally before push.
  • Multi-target snapshots updated/added for net8.0, net9.0, net10.0, net472.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 13, 2026

Not up to standards ⛔

🔴 Issues 1 minor

Alerts:
⚠ 1 issue (≤ 0 issues of at least minor severity)

Results:
1 new issue

Category Results
CodeStyle 1 minor

View in Codacy

🟢 Metrics -7 complexity

Metric Results
Complexity -7

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a clean, well-scoped fix for a genuine Roslyn edge case. Here's my assessment:

What's Good

Correct root cause diagnosis. Roslyn reports null as the constant value for = default on non-nullable value types, but = null is invalid C# for those types (CS1750). The fix correctly branches on IsValueType and the Nullable<T> carve-out is right — Nullable<T> should stay on the null path since null is a valid default for it.

Sensible deduplication. The two FormatDefaultValue private methods were nearly identical; pulling them into DefaultValueFormatter is the right call and the useFullyQualifiedEnumName parameter neatly encodes the one intentional semantic difference between the two call sites.

Snapshot hygiene. .verified.txt files committed; .received.txt not committed. All four TFMs covered.

CompileChecker is a good addition. Structurally-correct-but-compile-broken output would otherwise slip past content-only snapshot tests. The #if NETFRAMEWORK CS0122 filter is well-documented.


Suggestions

1. Boolean trap on useFullyQualifiedEnumName

// Current — caller must know which bool means what
DefaultValueFormatter.FormatDefaultValue(value, type, useFullyQualifiedEnumName: true)

Named arguments mitigate this, but with only two call sites it's also fine to keep two thin static helpers instead:

internal static class DefaultValueFormatter
{
    public static string FormatDefaultValue(object? defaultValue, ITypeSymbol type)
        => FormatDefaultValueCore(defaultValue, type, useFullyQualifiedEnumName: false);

    public static string FormatDefaultValueFullyQualified(object? defaultValue, ITypeSymbol type)
        => FormatDefaultValueCore(defaultValue, type, useFullyQualifiedEnumName: true);

    private static string FormatDefaultValueCore(...) { ... }
}

This makes the intent at each call site self-documenting without needing to read the parameter name. Not a blocker — the named-argument form is readable enough — but worth considering if more generators are added later.

2. Pre-existing: incomplete string escaping

return $"\"{str.Replace("\"", "\\\"")}\"";

This only escapes ". A default value of e.g. "line1\nline2" would emit as a literal newline inside a string literal. Since this PR didn't introduce the issue and it's in a code path for test-assertion generators where string defaults are rare, it's fine to leave it, but worth tracking separately.

3. CompileChecker compiles all generated files together

This is correct behaviour (multi-file generators can have cross-file dependencies), but it means a missing reference will silently drop the error if ReferencesHelper.References doesn't include the missing assembly. If CI ever shows a false-green, this is the first place to check.


Verdict

Approving. The fix is correct, well-tested, and the deduplication is a genuine improvement. The suggestions above are minor and non-blocking.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct fix, clean deduplication, good test coverage. See comment for minor suggestions.

JohnVerheij added a commit to JohnVerheij/TUnit that referenced this pull request May 13, 2026
…hods

Per review feedback on thomhurst#5919: replace the `useFullyQualifiedEnumName` boolean with two thin public wrappers (`FormatDefaultValue` for the short enum form and `FormatDefaultValueFullyQualified` for the namespace-prefixed form), each delegating to a private `FormatDefaultValueCore` that retains the original parameterised body. Removes the boolean trap at call sites without changing emit behaviour. Existing snapshots compare identically.
@thomhurst
Copy link
Copy Markdown
Owner

Thanks!

@thomhurst thomhurst enabled auto-merge (squash) May 13, 2026 22:41
thomhurst pushed a commit to JohnVerheij/TUnit that referenced this pull request May 14, 2026
…hods

Per review feedback on thomhurst#5919: replace the `useFullyQualifiedEnumName` boolean with two thin public wrappers (`FormatDefaultValue` for the short enum form and `FormatDefaultValueFullyQualified` for the namespace-prefixed form), each delegating to a private `FormatDefaultValueCore` that retains the original parameterised body. Removes the boolean trap at call sites without changing emit behaviour. Existing snapshots compare identically.
@thomhurst thomhurst force-pushed the fix/method-assertion-default-value-emit branch from 9ef34a6 to 024a415 Compare May 14, 2026 08:45
…hods

Per review feedback on thomhurst#5919: replace the `useFullyQualifiedEnumName` boolean with two thin public wrappers (`FormatDefaultValue` for the short enum form and `FormatDefaultValueFullyQualified` for the namespace-prefixed form), each delegating to a private `FormatDefaultValueCore` that retains the original parameterised body. Removes the boolean trap at call sites without changing emit behaviour. Existing snapshots compare identically.
This was referenced May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: assertion generators emit null as default for non-nullable value-type parameters (CS1750)

2 participants