Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion ICSharpCode.Decompiler.Tests/Helpers/Tester.cs
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,16 @@ public static async Task<CompilerResults> 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));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
Expand Down
12 changes: 12 additions & 0 deletions ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
74 changes: 74 additions & 0 deletions ICSharpCode.Decompiler.Tests/TestCases/Pretty/ClosedHierarchies.cs
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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 changes: 1 addition & 1 deletion ICSharpCode.Decompiler.Tests/TestCases/Pretty/Issue3684.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public class DerivedClass : BaseClass, IInterface
{
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?

}
}
}
Expand Down
10 changes: 10 additions & 0 deletions ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

typeDecl.Modifiers |= Modifiers.Closed;
typeDecl.Modifiers &= ~Modifiers.Abstract;
}
if (typeDecl.ClassType == ClassType.Enum)
{
Debug.Assert(typeDef.Kind == TypeKind.Enum);
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

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.

? "preview"
: project.LanguageVersion.ToString().Replace("CSharp", "").Replace('_', '.'));
xml.WriteElementString("AllowUnsafeBlocks", TrueString);
xml.WriteElementString("CheckForOverflowUnderflow", project.CheckForOverflowUnderflow ? TrueString : FalseString);

Expand Down
10 changes: 8 additions & 2 deletions ICSharpCode.Decompiler/CSharp/RecordDecompiler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion ICSharpCode.Decompiler/CSharp/Syntax/Modifiers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public enum Modifiers
Async = 0x10000,
Ref = 0x20000,
Required = 0x40000,
Closed = 0x80000,

VisibilityMask = Private | Internal | Protected | Public,

Expand All @@ -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,
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions ICSharpCode.Decompiler/DecompilerSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2194,6 +2200,24 @@ public bool ExtensionMembers {
}
}

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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions ILSpy/Properties/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading