Skip to content

Handle C# 15 unions in culture-sensitive formatting analysis - #1293

Merged
meziantou merged 1 commit into
mainfrom
feature/csharp-15-union-culture-sensitive-c8e506
Aug 17, 2026
Merged

meziantou merged 1 commit into
mainfrom
feature/csharp-15-union-culture-sensitive-c8e506

Conversation

@meziantou

Copy link
Copy Markdown
Owner

What

CultureSensitiveFormattingContext now understands C# 15 union types: a union is culture-sensitive when at least one of its case types is culture-sensitive, and culture-insensitive when none of them is.

This affects every rule built on that context: MA0011, MA0075, MA0076, MA0077 and MA0111.

Why

The type generated for a union declaration is a sealed struct that doesn't implement IFormattable and has no provider-aware ToString, so the context classified every union as culture-insensitive — including union Sample(bool, double).

How

Roslyn exposes ITypeSymbol.IsUnion (Roslyn 5.9, still experimental) but no public API for the case types, so they are read from the union creation members as defined by the specification:

  • the public constructors with a single by-value or in parameter;
  • the Create methods of the nested IUnionMembers interface, for union member providers (whose constructor is private).

Both helpers live in TypeSymbolExtensions under #if ROSLYN_5_9_OR_GREATER.

In the context itself:

  • the union check is placed after the CultureInsensitiveTypeAttribute check, so annotating a union still wins;
  • it is also applied in the format-aware overload, so the format string is evaluated against the case types ($"{value:o}" on a union with a DateTime case stays insensitive);
  • nested unions are expanded inline with a set of visited types — union A(B); union B(A); compiles, so cycles are real — and nullable case types are unwrapped when consider_nullable_types is enabled, which is where a cycle could otherwise slip through;
  • when no case type can be determined, the previous classification is kept instead of falling back to "insensitive".

Notes for the reviewer

The compiler-generated union struct does not override ToString() (verified on the emitted metadata), so $"{pet}" on a plain union Pet(int, string) prints the type name and is culture-insensitive in practice. The new logic matters for unions that do override ToString — a union body with a ToString override, or a custom [Union] type — which is why the interpolation tests declare one. MA0077 (UsesObjectToString) consequently fires on plain unions in interpolated strings; that behavior is unchanged and out of scope here.

Tests

10 tests in DoNotUseImplicitCultureSensitiveToStringAnalyzerTests under #if CSHARP15_OR_GREATER: insensitive/sensitive case types, nested unions, mutually-referencing unions, nullable case type, CultureInsensitiveTypeAttribute, custom [Union] type, union member provider, and the two format cases. 6 of them fail without the change (verified).

  • Full suite passes on the default Roslyn version (3618 tests).
  • The analyzer builds on roslyn4.8, roslyn4.14, roslyn5.0 and roslyn5.6; the culture-sensitivity suites pass on roslyn4.8 and roslyn5.6.
  • dotnet run --project src/DocumentationGenerator reports no pending change.

The culture sensitivity of a union type must be derived from its case
types: a union is culture-sensitive when at least one of its case types
is culture-sensitive, and culture-insensitive when none of them is.
Without this, a union was reported as culture-insensitive because the
generated type is a sealed struct that doesn't implement IFormattable.

Roslyn exposes no public API to get the case types, so they are read
from the union creation members defined by the specification: the public
constructors with a single by-value or `in` parameter, and the `Create`
methods of the nested `IUnionMembers` interface for union member
providers. Nested unions are expanded inline using a set of visited
types as case types may reference each other.

The case types are also evaluated against the format string, so
`$"{value:o}"` stays culture-insensitive when the case types support
this invariant format.
@meziantou
meziantou merged commit ec7ac3d into main Aug 17, 2026
12 checks passed
@meziantou
meziantou deleted the feature/csharp-15-union-culture-sensitive-c8e506 branch August 17, 2026 22:05
This was referenced Aug 17, 2026
This was referenced Sep 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.

1 participant