Add support for C# 15 closed hierarchies#3869
Conversation
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); |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
|
Missing:
|
|
@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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
✕ 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))
There was a problem hiding this comment.
but yes, the pattern is: IsClosedTypeAttribute implies abstract
There was a problem hiding this comment.
CS8335 is only a C# compiler error; other languages can use that attribute however they like.
What this adds
C# 15 (shipping with .NET 11, currently preview 5) introduces closed hierarchies: a
closedmodifier on classes/records that restricts direct derivation to the declaring assembly, enabling exhaustiveswitchexpressions 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.ClosedAttributeon the type,[CompilerFeatureRequired("ClosedClasses")]on every constructor, including the synthesized copy constructor of records (no paired[Obsolete], unlike required members),abstractflag on the type (closedis implicitly abstract).The BCL does not ship
ClosedAttributeyet, so every assembly usingcloseddeclares 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 printspublic closed class ...again.Implementation
Mirrors the
required-members pipeline end to end, gated on a newDecompilerSettings.ClosedHierarchiestoggle (default on, disabled for language versions below C# 15):KnownAttributesKnownAttribute.Closed->S.R.CS.ClosedAttributeModifiersModifiers.Closed, printed aftersealedin the modifier orderCSharpDecompiler[Closed], setModifiers.Closed, clear the implicitModifiers.Abstract; constructor level: stripCompilerFeatureRequired("ClosedClasses")(composes with the existingRequiredMembersstripper when both features are used on one type)RecordDecompilerIsAllowedAttributenow acceptsCompilerFeatureRequired("ClosedClasses"), so the synthesized copy constructor of a closed record is still recognized as compiler-generated and hiddenDecompilerSettingsSetLanguageVersion/GetMinimumRequiredVersionProjectFileWriterDefault/ProjectFileWriterSdkStyleemit<LangVersion>preview</LangVersion>for C# 15, because csc does not accept-langversion:15.0yet (andLanguageVersion.CSharp15_0/Previewshare the enum value 1500, makingToString()ambiguous)RemoveEmbeddedAttributesalso strips a source-declaredS.R.CS.ClosedAttributepolyfill class from decompiled outputTests
Two new Pretty fixtures on
roslyn5OrNewerOptions+CompilerOptions.Preview(the pinned Roslyn5.8.0-1.26302.115/ net11 preview 5 ref pack; same shape asExtensionEverything):ClosedHierarchies.cs- minimum covering set for the definition side: plainclosed classwith multiple constructors (attribute stripping on each),closed recordwith positional record descendants, an internal descendant of a public closed record, an internal closed class, a nested closed class, a genericclosed class Tree<T>withLeaf<U> : Tree<U>andArrayLeaf<V> : Tree<V[]>, and a closed class with arequiredmember so bothCompilerFeatureRequiredvalues land on one constructor. TheClosedAttributepolyfill is declared under#if !EXPECTED_OUTPUT.ClosedHierarchiesCrossAssembly.cs+.dep.cs- cross-assembly dimension via the// #dependencymechanism: 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.csexpected output changed from((BaseClass)this).Convert<T>(input)toConvert(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.<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
#dependencyassemblies were never resolvable at decompile time.Tester.CompileCSharpcompiled them to a random temp file (CompilerResults.PathToAssemblyfalls back toGetTempFileName()), so theUniversalAssemblyResolvercould not find them next to the main test assembly.Issue3684never noticed because plain classes decompile fine with an unresolved base type. Dependencies now compile to<depName><config-suffix>.dllnext to the main output.RecordDecompiler.DetectPrimaryConstructordereferencedchainedCtor.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
PrettyTestRunner: 1854 test cases green.ICSharpCode.Decompiler.Testsproject: 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