From 41b3dd54dfc344700ca9006b97a8a167e3d79fd9 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Mon, 6 Jul 2026 17:25:51 +0200 Subject: [PATCH] Add support for C# 15 closed hierarchies 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 --- .../Helpers/Tester.cs | 11 ++- .../ICSharpCode.Decompiler.Tests.csproj | 6 ++ .../PrettyTestRunner.cs | 12 +++ .../TestCases/Pretty/ClosedHierarchies.cs | 74 +++++++++++++++++++ .../Pretty/ClosedHierarchiesCrossAssembly.cs | 9 +++ .../ClosedHierarchiesCrossAssembly.dep.cs | 17 +++++ .../TestCases/Pretty/Issue3684.cs | 2 +- .../CSharp/CSharpDecompiler.cs | 10 +++ .../ProjectFileWriterDefault.cs | 5 +- .../ProjectFileWriterSdkStyle.cs | 5 +- .../CSharp/RecordDecompiler.cs | 10 ++- .../CSharp/Syntax/Modifiers.cs | 5 +- .../Transforms/EscapeInvalidIdentifiers.cs | 1 + ICSharpCode.Decompiler/DecompilerSettings.cs | 24 ++++++ .../Implementation/KnownAttributes.cs | 7 +- ILSpy/Properties/Resources.resx | 3 + 16 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchies.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.dep.cs diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs index 499e82c4c3..2ffb392acd 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs @@ -575,7 +575,16 @@ public static async Task CompileCSharp(string sourceFileName, C { string depSourcePath = Path.GetFullPath(Path.Combine( Path.GetDirectoryName(sourceFileName), match.Groups[1].Value)); - var depResults = await CompileCSharp(depSourcePath, flags | CompilerOptions.Library).ConfigureAwait(false); + // Compile the dependency next to the main output, so that the assembly resolver + // finds it when the main assembly is decompiled. + string depOutputFileName = null; + if (outputFileName != null) + { + depOutputFileName = Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(outputFileName)), + Path.GetFileNameWithoutExtension(depSourcePath) + GetSuffix(flags | CompilerOptions.Library) + ".dll"); + } + var depResults = await CompileCSharp(depSourcePath, flags | CompilerOptions.Library, depOutputFileName).ConfigureAwait(false); dependencyAssemblies.Add(Path.GetFullPath(depResults.PathToAssembly)); } diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index d38245d468..f6cb1035db 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -221,6 +221,12 @@ + + + + + + diff --git a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs index f68632b758..ad7823a001 100644 --- a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs @@ -663,6 +663,18 @@ public async Task ExtensionEverything([ValueSource(nameof(roslyn5OrNewerOptions) await RunForLibrary(cscOptions: cscOptions | CompilerOptions.Preview | CompilerOptions.NullableEnable); } + [Test] + public async Task ClosedHierarchies([ValueSource(nameof(roslyn5OrNewerOptions))] CompilerOptions cscOptions) + { + await RunForLibrary(cscOptions: cscOptions | CompilerOptions.Preview); + } + + [Test] + public async Task ClosedHierarchiesCrossAssembly([ValueSource(nameof(roslyn5OrNewerOptions))] CompilerOptions cscOptions) + { + await RunForLibrary(cscOptions: cscOptions | CompilerOptions.Preview); + } + [Test] public async Task NullPropagation([ValueSource(nameof(roslynOnlyOptions))] CompilerOptions cscOptions) { diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchies.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchies.cs new file mode 100644 index 0000000000..e17d9fd899 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchies.cs @@ -0,0 +1,74 @@ +namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty +{ + internal class ClosedHierarchies + { + public closed class Animal + { + public string Name { get; } + + protected Animal() + { + Name = "unnamed"; + } + + protected Animal(string name) + { + Name = name; + } + } + + public class Cat : Animal + { + } + + public sealed class Dog : Animal + { + public Dog() + : base("Dog") + { + } + } + + public closed class Job + { + public required string Title { get; set; } + } + + public sealed class CompileJob : Job + { + } + + public closed class Tree + { + } + + public sealed class Leaf : Tree + { + } + + public sealed class ArrayLeaf : Tree + { + } + } + public closed record JobStatus; + internal record JobStatusCanceled : JobStatus; + public record JobStatusQueued : JobStatus; + public record JobStatusRunning(int PercentComplete) : JobStatus; + internal closed class State + { + } + internal sealed class StateActive : State + { + } +} +#if !EXPECTED_OUTPUT +// The .NET 11 preview 5 BCL does not ship ClosedAttribute yet; the compiler requires +// every assembly using the 'closed' modifier to declare it (matched by full name). +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + internal sealed class ClosedAttribute : Attribute + { + } +} +#endif diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.cs new file mode 100644 index 0000000000..d1e729f65e --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.cs @@ -0,0 +1,9 @@ +// #dependency ClosedHierarchiesCrossAssembly.dep.cs +using CrossAssemblyClosed; + +namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty +{ + public closed record LocalMessage; + public record LocalTextMessage(string Text) : LocalMessage; + public record Sedan(int Doors) : Car(Doors); +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.dep.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.dep.cs new file mode 100644 index 0000000000..46e8318a39 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.dep.cs @@ -0,0 +1,17 @@ +namespace CrossAssemblyClosed +{ + public closed record Vehicle; + public record Car(int Doors) : Vehicle; + public sealed record Truck(double PayloadTons) : Vehicle; +} + +// Public so that the main test assembly can use the 'closed' modifier without +// declaring its own copy of the attribute (the shape the BCL will provide once +// ClosedAttribute ships). +namespace System.Runtime.CompilerServices +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] + public sealed class ClosedAttribute : Attribute + { + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3684.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3684.cs index 631830e39d..b18a71d38f 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3684.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3684.cs @@ -15,7 +15,7 @@ public class DerivedClass : BaseClass, IInterface { T IInterface.Convert(T input) { - return ((BaseClass)this).Convert(input); + return Convert(input); } } } diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 0fd1294a1d..3c21fdbf71 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -1703,6 +1703,12 @@ EntityDeclaration DoDecompile(ITypeDefinition typeDef, DecompileRun decompileRun { RemoveAttribute(typeDecl, KnownAttribute.Required); } + if (settings.ClosedHierarchies && RemoveAttribute(typeDecl, KnownAttribute.Closed)) + { + // closed classes are implicitly abstract + typeDecl.Modifiers |= Modifiers.Closed; + typeDecl.Modifiers &= ~Modifiers.Abstract; + } if (typeDecl.ClassType == ClassType.Enum) { Debug.Assert(typeDef.Kind == TypeKind.Enum); @@ -2007,6 +2013,10 @@ EntityDeclaration DoDecompile(IMethod method, DecompileRun decompileRun, ITypeRe { RemoveObsoleteAttribute(methodDecl, "Constructors of types with required members are not supported in this version of your compiler."); } + if (method.IsConstructor && settings.ClosedHierarchies) + { + RemoveCompilerFeatureRequiredAttribute(methodDecl, "ClosedClasses"); + } return methodDecl; bool IsTypeHierarchyKnown(IType type) diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterDefault.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterDefault.cs index 4447be86dd..8aeb2d409f 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterDefault.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterDefault.cs @@ -97,7 +97,10 @@ public void Write( } w.WriteElementString("OutputType", outputType); - w.WriteElementString("LangVersion", project.LanguageVersion.ToString().Replace("CSharp", "").Replace('_', '.')); + // C# 15 is still in preview; the compiler only accepts -langversion:preview for it. + w.WriteElementString("LangVersion", project.LanguageVersion >= LanguageVersion.CSharp15_0 + ? "preview" + : project.LanguageVersion.ToString().Replace("CSharp", "").Replace('_', '.')); w.WriteElementString("CheckForOverflowUnderflow", project.CheckForOverflowUnderflow ? "true" : "false"); w.WriteElementString("AssemblyName", module.Name); diff --git a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs index 0f79025246..aa21c67a42 100644 --- a/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs +++ b/ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs @@ -190,7 +190,10 @@ static void WriteDesktopExtensions(XmlTextWriter xml, ProjectType projectType) static void WriteProjectInfo(XmlTextWriter xml, IProjectInfoProvider project) { - 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 + ? "preview" + : project.LanguageVersion.ToString().Replace("CSharp", "").Replace('_', '.')); xml.WriteElementString("AllowUnsafeBlocks", TrueString); xml.WriteElementString("CheckForOverflowUnderflow", project.CheckForOverflowUnderflow ? TrueString : FalseString); diff --git a/ICSharpCode.Decompiler/CSharp/RecordDecompiler.cs b/ICSharpCode.Decompiler/CSharp/RecordDecompiler.cs index 84a80cc185..d62c6b8752 100644 --- a/ICSharpCode.Decompiler/CSharp/RecordDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/RecordDecompiler.cs @@ -200,7 +200,7 @@ bool IsAutoSetter(IMethod method, [NotNullWhen(true)] out IField? field) IMethod? chainedCtor = (IMethod?)FindChainedCtor(body)?.MemberDefinition; ctorChainMap[method] = chainedCtor; - if (chainedCtor != null && chainedCtor.DeclaringTypeDefinition!.Equals(recordTypeDef)) + if (chainedCtor != null && recordTypeDef.Equals(chainedCtor.DeclaringTypeDefinition)) { continue; } @@ -233,7 +233,7 @@ bool IsAutoSetter(IMethod method, [NotNullWhen(true)] out IField? field) // follow this rule. // we don't have to check the full chain, because the C# compiler enforces that // there are no loops in the ctor call graph. - if (target == null || !target.DeclaringTypeDefinition!.Equals(recordTypeDef)) + if (target == null || !recordTypeDef.Equals(target.DeclaringTypeDefinition)) { guessedPrimaryCtor = null; break; @@ -522,6 +522,12 @@ private bool IsAllowedAttribute(IAttribute attribute) { case "System.Runtime.CompilerServices.CompilerGeneratedAttribute": return true; + case "System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute": + // closed records carry CompilerFeatureRequired("ClosedClasses") on all constructors + return settings.ClosedHierarchies + && attribute.FixedArguments.Length == 1 + && attribute.FixedArguments[0].Value is string feature + && feature == "ClosedClasses"; default: return false; } diff --git a/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs b/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs index d286f21cf8..b9869dc685 100644 --- a/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs +++ b/ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs @@ -59,6 +59,7 @@ public enum Modifiers Async = 0x10000, Ref = 0x20000, Required = 0x40000, + Closed = 0x80000, VisibilityMask = Private | Internal | Protected | Public, @@ -76,7 +77,7 @@ public static class CSharpModifiers Modifiers.Public, Modifiers.Private, Modifiers.Protected, Modifiers.Internal, Modifiers.New, Modifiers.Unsafe, - Modifiers.Static, Modifiers.Abstract, Modifiers.Virtual, Modifiers.Sealed, Modifiers.Override, + Modifiers.Static, Modifiers.Abstract, Modifiers.Virtual, Modifiers.Sealed, Modifiers.Closed, Modifiers.Override, Modifiers.Required, Modifiers.Readonly, Modifiers.Volatile, Modifiers.Ref, Modifiers.Extern, Modifiers.Partial, Modifiers.Const, @@ -126,6 +127,8 @@ public static string GetModifierName(Modifiers modifier) return "ref"; case Modifiers.Required: return "required"; + case Modifiers.Closed: + return "closed"; case Modifiers.Any: // even though it's used for pattern matching only, 'any' needs to be in this list to be usable in the AST return "any"; diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs b/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs index 67b2dab919..61ee14271e 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs @@ -186,6 +186,7 @@ public class RemoveEmbeddedAttributes : DepthFirstAstVisitor, IAstTransform "System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute", "System.Runtime.CompilerServices.RequiredMemberAttribute", "System.Runtime.CompilerServices.IsExternalInit", + "System.Runtime.CompilerServices.ClosedAttribute", }; public override void VisitTypeDeclaration(TypeDeclaration typeDeclaration) diff --git a/ICSharpCode.Decompiler/DecompilerSettings.cs b/ICSharpCode.Decompiler/DecompilerSettings.cs index 955003893e..3717e75e54 100644 --- a/ICSharpCode.Decompiler/DecompilerSettings.cs +++ b/ICSharpCode.Decompiler/DecompilerSettings.cs @@ -177,10 +177,16 @@ public void SetLanguageVersion(CSharp.LanguageVersion languageVersion) extensionMembers = false; firstClassSpanTypes = false; } + if (languageVersion < CSharp.LanguageVersion.CSharp15_0) + { + closedHierarchies = false; + } } public CSharp.LanguageVersion GetMinimumRequiredVersion() { + if (closedHierarchies) + return CSharp.LanguageVersion.CSharp15_0; if (extensionMembers || firstClassSpanTypes) return CSharp.LanguageVersion.CSharp14_0; if (paramsCollections) @@ -2194,6 +2200,24 @@ public bool ExtensionMembers { } } + bool closedHierarchies = true; + + /// + /// Gets/Sets whether the closed modifier should be reconstructed on closed hierarchies. + /// + [Category("C# 15.0 / VS 2026")] + [Description("DecompilerSettings.ClosedHierarchies")] + public bool ClosedHierarchies { + get { return closedHierarchies; } + set { + if (closedHierarchies != value) + { + closedHierarchies = value; + OnPropertyChanged(); + } + } + } + bool firstClassSpanTypes = true; /// diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs index 52a20c3375..227ae1afe1 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs @@ -120,11 +120,14 @@ public enum KnownAttribute // C# 14 attributes: ExtensionMarker, + + // C# 15 attributes: + Closed, } public static class KnownAttributes { - internal const int Count = (int)KnownAttribute.ExtensionMarker + 1; + internal const int Count = (int)KnownAttribute.Closed + 1; static readonly TopLevelTypeName[] typeNames = new TopLevelTypeName[Count]{ default, @@ -200,6 +203,8 @@ public static class KnownAttributes new TopLevelTypeName("System.Runtime.CompilerServices", "InlineArrayAttribute"), // C# 14 attributes: new TopLevelTypeName("System.Runtime.CompilerServices", "ExtensionMarkerAttribute"), + // C# 15 attributes: + new TopLevelTypeName("System.Runtime.CompilerServices", "ClosedAttribute"), }; public static ref readonly TopLevelTypeName GetTypeName(this KnownAttribute attr) diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 7e34703366..609dfd1dda 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -360,6 +360,9 @@ Are you sure you want to continue? User-defined checked operators + + Use the closed modifier on closed hierarchies + Covariant return types