Skip to content

Add support for C# 15 closed hierarchies#3869

Draft
christophwille wants to merge 1 commit into
masterfrom
christophwille/closedhierarchies
Draft

Add support for C# 15 closed hierarchies#3869
christophwille wants to merge 1 commit into
masterfrom
christophwille/closedhierarchies

Conversation

@christophwille

Copy link
Copy Markdown
Member

What this adds

C# 15 (shipping with .NET 11, currently preview 5) introduces closed hierarchies: a closed modifier on classes/records that restricts direct derivation to the declaring assembly, enabling exhaustive switch expressions without a default arm (keyword reference, feature spec).

The preview 5 compiler encodes the modifier as (verified against the shipped Roslyn binary and the red-test diff; the older spec text still says IsClosedTypeAttribute, which is not what is emitted):

  • System.Runtime.CompilerServices.ClosedAttribute on the type,
  • [CompilerFeatureRequired("ClosedClasses")] on every constructor, including the synthesized copy constructor of records (no paired [Obsolete], unlike required members),
  • the abstract flag on the type (closed is implicitly abstract).

The BCL does not ship ClosedAttribute yet, so every assembly using closed declares its own copy (or references one from another assembly); the compiler matches it by full name.

Previously ILSpy showed the raw encoding ([Closed] public abstract class ... with [CompilerFeatureRequired] on constructors); now it prints public closed class ... again.

Implementation

Mirrors the required-members pipeline end to end, gated on a new DecompilerSettings.ClosedHierarchies toggle (default on, disabled for language versions below C# 15):

Area Change
KnownAttributes new KnownAttribute.Closed -> S.R.CS.ClosedAttribute
Modifiers new Modifiers.Closed, printed after sealed in the modifier order
CSharpDecompiler type level: remove [Closed], set Modifiers.Closed, clear the implicit Modifiers.Abstract; constructor level: strip CompilerFeatureRequired("ClosedClasses") (composes with the existing RequiredMembers stripper when both features are used on one type)
RecordDecompiler IsAllowedAttribute now accepts CompilerFeatureRequired("ClosedClasses"), so the synthesized copy constructor of a closed record is still recognized as compiler-generated and hidden
DecompilerSettings new C# 15 gate in SetLanguageVersion/GetMinimumRequiredVersion
project export ProjectFileWriterDefault/ProjectFileWriterSdkStyle emit <LangVersion>preview</LangVersion> for C# 15, because csc does not accept -langversion:15.0 yet (and LanguageVersion.CSharp15_0/Preview share the enum value 1500, making ToString() ambiguous)
test harness RemoveEmbeddedAttributes also strips a source-declared S.R.CS.ClosedAttribute polyfill class from decompiled output
ILSpy UI resource string for the new setting

Tests

Two new Pretty fixtures on roslyn5OrNewerOptions + CompilerOptions.Preview (the pinned Roslyn 5.8.0-1.26302.115 / net11 preview 5 ref pack; same shape as ExtensionEverything):

  • ClosedHierarchies.cs - minimum covering set for the definition side: plain closed class with multiple constructors (attribute stripping on each), closed record with positional record descendants, an internal descendant of a public closed record, an internal closed class, a nested closed class, a generic closed class Tree<T> with Leaf<U> : Tree<U> and ArrayLeaf<V> : Tree<V[]>, and a closed class with a required member so both CompilerFeatureRequired values land on one constructor. The ClosedAttribute polyfill is declared under #if !EXPECTED_OUTPUT.
  • ClosedHierarchiesCrossAssembly.cs + .dep.cs - cross-assembly dimension via the // #dependency mechanism: the attribute and a closed hierarchy live in the referenced assembly; the decompiled assembly declares its own closed record using the dep-provided attribute (the shape everything will have once the BCL ships it) and derives a record from the dep's non-closed descendant (the legal cross-assembly derivation).

Deliberately not covered: consuming-side exhaustive switch expressions. Exhaustiveness is compile-time knowledge only - the IL of a switch over a closed hierarchy is identical to one over an open hierarchy, so there is nothing for a decompiler test to observe.

The fixtures were written and shown red first (TDD): the red diff doubled as the authoritative probe of what preview 5 actually emits.

Changes to existing decompilation behavior

  • Issue3684.cs expected output changed from ((BaseClass)this).Convert<T>(input) to Convert(input). This falls out of the harness fix below: the fixture's dependency assembly is now resolvable while decompiling, so ILSpy can prove the simple-name call binds to the base method and no longer emits the defensive cast + explicit type arguments. The calls are semantically identical, and the point of that test (removal of compiler-generated interface accessor helpers) is unaffected.
  • Whole-project export now writes <LangVersion>preview</LangVersion> whenever the effective minimum language version is C# 15 - which it is by default, since the new setting defaults to on (the same convention as every previous default-on language feature setting).

Pre-existing issues surfaced by the new tests

  • #dependency assemblies were never resolvable at decompile time. Tester.CompileCSharp compiled them to a random temp file (CompilerResults.PathToAssembly falls back to GetTempFileName()), so the UniversalAssemblyResolver could not find them next to the main test assembly. Issue3684 never noticed because plain classes decompile fine with an unresolved base type. Dependencies now compile to <depName><config-suffix>.dll next to the main output.
  • Latent NRE in RecordDecompiler. DetectPrimaryConstructor dereferenced chainedCtor.DeclaringTypeDefinition! (and the same for the ctor-chain check), which is null when a record's base type lives in an assembly that cannot be resolved - crashing decompilation of such records anywhere, not just in tests. Both checks now use null-safe comparisons and degrade gracefully.

Verification

  • New tests: 4/4 green (both fixtures, optimized and non-optimized).
  • Full PrettyTestRunner: 1854 test cases green.
  • Whole ICSharpCode.Decompiler.Tests project: 3666 passed, 0 failed (12 pre-existing skips: mono/known bugs/ILSpy-tests submodule not checked out).
  • ILSpy.Tests + ILSpy.Tests.Windows: 1091 passed.

🤖 Generated with Claude Code

C# 15 (.NET 11 preview 5) encodes the closed modifier as
System.Runtime.CompilerServices.ClosedAttribute on the implicitly
abstract type plus CompilerFeatureRequired("ClosedClasses") on every
constructor; the BCL does not ship the attribute yet, so assemblies
declare their own copy or reference one from another assembly. The
decompiler now reconstructs the modifier from this encoding, gated on a
new C# 15 ClosedHierarchies setting. Exported projects state LangVersion
preview because the compiler does not accept 15.0 yet.

The pretty tests cover the definition side only: switch exhaustiveness
over a closed hierarchy is compile-time knowledge that leaves no trace
in the IL of consuming code.

Compiling "#dependency" test assemblies to a temp file left them
unresolvable while decompiling the main test assembly; they now land
next to the main output. With its dependency resolvable, Issue3684 no
longer needs the disambiguating base-class cast. The cross-assembly
fixture also exposed a latent NRE in RecordDecompiler when a record's
base type cannot be resolved.

Assisted-by: Claude:claude-fable-5:Claude Code
T IInterface.Convert<T>(T input)
{
return ((BaseClass)this).Convert<T>(input);
return Convert(input);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

how is this related to the new language feature? A consequence of the Tester change?

{
xml.WriteElementString("LangVersion", project.LanguageVersion.ToString().Replace("CSharp", "").Replace('_', '.'));
// C# 15 is still in preview; the compiler only accepts -langversion:preview for it.
xml.WriteElementString("LangVersion", project.LanguageVersion >= LanguageVersion.CSharp15_0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this should use "Preview" instead of the explicit version. Otherwise we will have to keep this updated. Preview and CSharp15_0 point to the same number as long as C# 15 is in preview.

@siegfriedpammer

Copy link
Copy Markdown
Member

Missing:

  • CSharpHighlightingTokenWriter
  • Type System should provide this info, so we can perform switch expression exhaustiveness checks and remove the default case.
  • Test cases that actually make use of the feature in switch expressions, however, this is blocked because we don't support pattern matching in switch at all.

@siegfriedpammer

Copy link
Copy Markdown
Member

@dgrunwald given that this PR only implements the declaration part of the feature and switch-on-type/pattern is a bigger chunk, which I definitely wouldn't want vibe-coded without prior careful thought and design (aka "prompt engineering"), should we pause work now, or are you fine with declaration site only, just like we did with extension everything? There is a small difference, since extension method syntax is purely optional, but this effectively changes the meaning of switch.

}
if (settings.ClosedHierarchies && RemoveAttribute(typeDecl, KnownAttribute.Closed))
{
// closed classes are implicitly abstract

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If C# closed classes are implicitly abstract then we should only do this change for abstract classes; not for other classes that happen to have the attribute (because someone manually added it).

@siegfriedpammer siegfriedpammer Jul 7, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✕ Do not use 'System.Runtime.CompilerServices.IsClosedTypeAttribute'. This is reserved for compiler usage.[CS8335](https://msdn.microsoft.com/query/roslyn.query?appId=roslyn&k=k(CS8335))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

but yes, the pattern is: IsClosedTypeAttribute implies abstract

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

CS8335 is only a C# compiler error; other languages can use that attribute however they like.

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.

3 participants