Browse Source

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
christophwille/closedhierarchies
Christoph Wille 2 months ago
parent
commit
41b3dd54df
  1. 11
      ICSharpCode.Decompiler.Tests/Helpers/Tester.cs
  2. 6
      ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj
  3. 12
      ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs
  4. 74
      ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchies.cs
  5. 9
      ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.cs
  6. 17
      ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.dep.cs
  7. 2
      ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3684.cs
  8. 10
      ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
  9. 5
      ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterDefault.cs
  10. 5
      ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs
  11. 10
      ICSharpCode.Decompiler/CSharp/RecordDecompiler.cs
  12. 5
      ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs
  13. 1
      ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs
  14. 24
      ICSharpCode.Decompiler/DecompilerSettings.cs
  15. 7
      ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs
  16. 3
      ILSpy/Properties/Resources.resx

11
ICSharpCode.Decompiler.Tests/Helpers/Tester.cs

@ -575,7 +575,16 @@ namespace System.Runtime.CompilerServices @@ -575,7 +575,16 @@ namespace System.Runtime.CompilerServices
{
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));
}

6
ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj

@ -221,6 +221,12 @@ @@ -221,6 +221,12 @@
<None Include="TestCases\ILPretty\Unsafe.cs" />
<Compile Remove="TestCases\ILPretty\WeirdEnums.cs" />
<None Include="TestCases\ILPretty\WeirdEnums.cs" />
<Compile Remove="TestCases\Pretty\ClosedHierarchies.cs" />
<None Include="TestCases\Pretty\ClosedHierarchies.cs" />
<Compile Remove="TestCases\Pretty\ClosedHierarchiesCrossAssembly.cs" />
<None Include="TestCases\Pretty\ClosedHierarchiesCrossAssembly.cs" />
<Compile Remove="TestCases\Pretty\ClosedHierarchiesCrossAssembly.dep.cs" />
<None Include="TestCases\Pretty\ClosedHierarchiesCrossAssembly.dep.cs" />
<Compile Remove="TestCases\Pretty\IndexRangeTest.cs" />
<None Include="TestCases\Pretty\IndexRangeTest.cs" />
<Compile Remove="TestCases\Pretty\MetadataAttributes.cs" />

12
ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs

@ -663,6 +663,18 @@ namespace ICSharpCode.Decompiler.Tests @@ -663,6 +663,18 @@ namespace ICSharpCode.Decompiler.Tests
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)
{

74
ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchies.cs

@ -0,0 +1,74 @@ @@ -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<T>
{
}
public sealed class Leaf<U> : Tree<U>
{
}
public sealed class ArrayLeaf<V> : Tree<V[]>
{
}
}
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

9
ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.cs

@ -0,0 +1,9 @@ @@ -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);
}

17
ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchiesCrossAssembly.dep.cs

@ -0,0 +1,17 @@ @@ -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
{
}
}

2
ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3684.cs

@ -15,7 +15,7 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty @@ -15,7 +15,7 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty
{
T IInterface.Convert<T>(T input)
{
return ((BaseClass)this).Convert<T>(input);
return Convert(input);
}
}
}

10
ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs

@ -1703,6 +1703,12 @@ namespace ICSharpCode.Decompiler.CSharp @@ -1703,6 +1703,12 @@ namespace ICSharpCode.Decompiler.CSharp
{
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 @@ namespace ICSharpCode.Decompiler.CSharp @@ -2007,6 +2013,10 @@ namespace ICSharpCode.Decompiler.CSharp
{
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)

5
ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterDefault.cs

@ -97,7 +97,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler @@ -97,7 +97,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
}
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);

5
ICSharpCode.Decompiler/CSharp/ProjectDecompiler/ProjectFileWriterSdkStyle.cs

@ -190,7 +190,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler @@ -190,7 +190,10 @@ namespace ICSharpCode.Decompiler.CSharp.ProjectDecompiler
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);

10
ICSharpCode.Decompiler/CSharp/RecordDecompiler.cs

@ -200,7 +200,7 @@ namespace ICSharpCode.Decompiler.CSharp @@ -200,7 +200,7 @@ namespace ICSharpCode.Decompiler.CSharp
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 @@ namespace ICSharpCode.Decompiler.CSharp @@ -233,7 +233,7 @@ namespace ICSharpCode.Decompiler.CSharp
// 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 @@ namespace ICSharpCode.Decompiler.CSharp @@ -522,6 +522,12 @@ namespace ICSharpCode.Decompiler.CSharp
{
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;
}

5
ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs

@ -59,6 +59,7 @@ namespace ICSharpCode.Decompiler.CSharp.Syntax @@ -59,6 +59,7 @@ namespace ICSharpCode.Decompiler.CSharp.Syntax
Async = 0x10000,
Ref = 0x20000,
Required = 0x40000,
Closed = 0x80000,
VisibilityMask = Private | Internal | Protected | Public,
@ -76,7 +77,7 @@ namespace ICSharpCode.Decompiler.CSharp.Syntax @@ -76,7 +77,7 @@ namespace ICSharpCode.Decompiler.CSharp.Syntax
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 @@ namespace ICSharpCode.Decompiler.CSharp.Syntax @@ -126,6 +127,8 @@ namespace ICSharpCode.Decompiler.CSharp.Syntax
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";

1
ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs

@ -186,6 +186,7 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms @@ -186,6 +186,7 @@ namespace ICSharpCode.Decompiler.CSharp.Transforms
"System.Runtime.CompilerServices.CompilerFeatureRequiredAttribute",
"System.Runtime.CompilerServices.RequiredMemberAttribute",
"System.Runtime.CompilerServices.IsExternalInit",
"System.Runtime.CompilerServices.ClosedAttribute",
};
public override void VisitTypeDeclaration(TypeDeclaration typeDeclaration)

24
ICSharpCode.Decompiler/DecompilerSettings.cs

@ -177,10 +177,16 @@ namespace ICSharpCode.Decompiler @@ -177,10 +177,16 @@ namespace ICSharpCode.Decompiler
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 @@ namespace ICSharpCode.Decompiler @@ -2194,6 +2200,24 @@ namespace ICSharpCode.Decompiler
}
}
bool closedHierarchies = true;
/// <summary>
/// Gets/Sets whether the closed modifier should be reconstructed on closed hierarchies.
/// </summary>
[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;
/// <summary>

7
ICSharpCode.Decompiler/TypeSystem/Implementation/KnownAttributes.cs

@ -120,11 +120,14 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -120,11 +120,14 @@ namespace ICSharpCode.Decompiler.TypeSystem
// 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 @@ namespace ICSharpCode.Decompiler.TypeSystem @@ -200,6 +203,8 @@ namespace ICSharpCode.Decompiler.TypeSystem
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)

3
ILSpy/Properties/Resources.resx

@ -360,6 +360,9 @@ Are you sure you want to continue?</value> @@ -360,6 +360,9 @@ Are you sure you want to continue?</value>
<data name="DecompilerSettings.CheckedOperators" xml:space="preserve">
<value>User-defined checked operators</value>
</data>
<data name="DecompilerSettings.ClosedHierarchies" xml:space="preserve">
<value>Use the closed modifier on closed hierarchies</value>
</data>
<data name="DecompilerSettings.CovariantReturns" xml:space="preserve">
<value>Covariant return types</value>
</data>

Loading…
Cancel
Save