From f036126607dd33aa6fece46259ceb46b96b41268 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 22 Aug 2026 20:31:18 +0200 Subject: [PATCH 1/3] Recognize C# 14 user-defined compound assignment operators C# 14 lets a type declare instance compound assignment operators (operator +=, operator ++, and their checked forms), which the compiler emits as void-returning op_*Assignment methods. Classify those methods as operators in the type system - by name and required shape (instance, void, correct arity, no ref/params) - and gate it on a new decompiler setting and a matching TypeSystemOptions flag, so a lower language version keeps them as plain [SpecialName] methods. Model the new operator declarations and their metadata names, and give the resolver the two-phase binding rules "x op= y" follows: instance operators reachable from the static type of x, with the static operators considered only when none applies. This is the type-system foundation the rest of the feature builds on. Assisted-by: Claude:claude-fable-5:Claude Code Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../CSharp/Resolver/CSharpResolver.cs | 185 ++++++++++++++++++ .../Syntax/TypeMembers/OperatorDeclaration.cs | 62 +++++- .../CSharp/Syntax/TypeSystemAstBuilder.cs | 15 +- ICSharpCode.Decompiler/DecompilerSettings.cs | 7 + .../TypeSystem/DecompilerTypeSystem.cs | 11 +- .../Implementation/AttributeListBuilder.cs | 1 + .../Implementation/MetadataMethod.cs | 57 +++++- 7 files changed, 331 insertions(+), 7 deletions(-) diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs b/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs index 2cd0f341d5..ea1c8a37ab 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs @@ -1245,6 +1245,191 @@ public IEnumerable GetUserDefinedOperatorCandidates(IType return operators; } + #region C# 14 user-defined compound assignment operator binding + /// + /// Gets the C# 14 instance compound assignment operators that "x op= y" considers when x + /// has the type . + /// + /// + /// Ordinary member lookup skips operators, so they have to be collected from the type by + /// hand. C# requires a user-defined operator to be public (CS9308), so one that is not + /// cannot be bound in operator form at all. + /// + public static List GetInstanceOperatorCandidates(IType targetType, string name) + { + var candidates = targetType.GetMethods( + m => m.IsOperator && !m.IsStatic && m.Accessibility == Accessibility.Public && m.Name == name + ).ToList(); + // An override and the member it overrides share a signature; member lookup keeps only + // the most derived one, and passing both to overload resolution would make every call + // ambiguous. + return candidates.Where( + m => !candidates.Any(other => other != m + && InheritanceHelper.GetBaseMembers(other, includeImplementedInterfaces: false) + .Any(b => b.MemberDefinition == m.MemberDefinition)) + ).ToList(); + } + + /// + /// Gets whether "x op= y" would bind a C# 14 instance compound assignment operator instead + /// of , the static operator the IL calls, where x has the type + /// and y the type + /// (null for the increment and decrement operators, which take no argument). + /// + /// + /// Whether x denotes a storage location. An instance operator mutates its receiver in + /// place, so C# only considers one where x is a variable; for a property or an indexer the + /// first phase described below does not happen at all. + /// + /// + /// C# resolves "x op= y" in two phases: the instance operators reachable from the static + /// type of x are considered first, and the static operators only if none of them is + /// applicable. An applicable instance operator wins even where a static one would be the + /// better match by conversion, and one inherited from a base class beats a static operator + /// declared on the target's own type. Folding "x = x op y" into "x op= y" is therefore only + /// safe while the first phase comes up empty. + /// + public static bool IsShadowedByInstanceOperator(IMethod method, IType targetType, IType valueType, + bool targetIsVariable, ICompilation compilation) + { + if (!targetIsVariable) + return false; + string name = GetCompoundAssignmentOperatorName(method.Name); + if (name == null) + return false; + // Both the checked and the unchecked instance operator can take the call: which of them + // applies depends on the checked context the assignment ends up in. + string checkedName = "op_Checked" + name.Substring("op_".Length); + var candidates = GetInstanceOperatorCandidates(targetType, name); + candidates.AddRange(GetInstanceOperatorCandidates(targetType, checkedName)); + if (candidates.Count == 0) + return false; + ResolveResult[] arguments = valueType == null + ? [] + : [new ResolveResult(valueType)]; + var or = new OverloadResolution(compilation, arguments); + foreach (var candidate in candidates) + { + or.AddCandidate(candidate); + } + return or.FoundApplicableCandidate; + } + + /// + /// Gets the candidates of reduced + /// the way C# overload resolution reduces them for the given arguments: a candidate declared + /// in a base type is removed when a candidate declared in a more derived type is applicable. + /// An applicable operator on the receiver's own type therefore takes the call even where a + /// base-type operator would be the better match by conversion. + /// + public static List GetInstanceOperatorCandidates(ICompilation compilation, IType targetType, string name, ResolveResult[] arguments) + { + return PruneCandidatesHiddenByDerivedApplicable(GetInstanceOperatorCandidates(targetType, name), arguments, compilation); + } + + static List PruneCandidatesHiddenByDerivedApplicable(List candidates, ResolveResult[] arguments, ICompilation compilation) + { + if (candidates.Count <= 1) + return candidates; + var applicable = candidates.Where(c => IsApplicable(c, arguments, compilation)).ToList(); + if (applicable.Count == 0) + return candidates; + return candidates.Where( + c => !applicable.Any(a => a.DeclaringTypeDefinition != c.DeclaringTypeDefinition + && a.DeclaringTypeDefinition != null && c.DeclaringTypeDefinition != null + && a.DeclaringTypeDefinition.GetAllBaseTypeDefinitions().Contains(c.DeclaringTypeDefinition)) + ).ToList(); + } + + static bool IsApplicable(IMethod candidate, ResolveResult[] arguments, ICompilation compilation) + { + var or = new OverloadResolution(compilation, arguments); + or.AddCandidate(candidate); + return or.FoundApplicableCandidate; + } + + /// + /// Gets whether "x op= y", where x has the type , would bind + /// an operator other than - the one the call being rewritten names. + /// + /// + /// The form takes its operator from the static type of x, so an expression standing in for + /// the receiver must be one that still selects the same operator: a more derived type can + /// hide it. The cast that would pin the type down is not a valid assignment target, so the + /// receiver has to keep a type that binds what the call names. + /// + /// A type that carries no operator of this name at all is not a rebinding: several ILAst + /// nodes are typed by their stack type rather than by the C# type they will be written as, + /// and the C# layer resolves those itself. + /// + public static bool WouldRebindOperator(IMethod called, IType receiverType, ICompilation compilation) + { + ResolveResult[] arguments = called.Parameters.Count == 0 + ? [] + : [new ResolveResult(called.Parameters[0].Type)]; + return WouldRebindOperator(called, receiverType, arguments, compilation); + } + + /// + /// Gets whether "x op= y", where x has the type and y is + /// described by , would bind an operator other than + /// . The operator form has no way to pick an overload by + /// parameter modifier, so a call to an "in" overload whose by-value sibling is applicable + /// cannot be folded. + /// + public static bool WouldRebindOperator(IMethod called, IType receiverType, ResolveResult[] arguments, ICompilation compilation) + { + if (called.DeclaringType.Kind == TypeKind.Interface + && receiverType.Kind is not (TypeKind.Interface or TypeKind.TypeParameter) + && !receiverType.IsKnownType(KnownTypeCode.Object)) + { + // An operator declared in an interface is only reachable from a receiver of + // interface (or type-parameter) type: class member lookup does not see + // interface members, so a class-typed replacement cannot bind the call. + // System.Object stays permissive - it is the stack-type placeholder several + // ILAst nodes carry. + return true; + } + var candidates = GetInstanceOperatorCandidates(compilation, receiverType, called.Name, arguments); + if (candidates.Count == 0) + return false; + var or = new OverloadResolution(compilation, arguments); + foreach (var candidate in candidates) + { + or.AddCandidate(candidate); + } + if (!or.FoundApplicableCandidate || or.BestCandidate is not IMethod best) + return false; + // An override occupies the slot of the operator the call names, so binding to it is + // binding to the call; a "new" member only shares the signature. + if (best.Equals(called)) + return false; + return !(best.IsOverride + && InheritanceHelper.GetBaseMembers(best, includeImplementedInterfaces: false) + .Any(m => m.MemberDefinition == called.MemberDefinition)); + } + + static string GetCompoundAssignmentOperatorName(string staticOperatorName) + { + return staticOperatorName switch { + "op_Addition" or "op_CheckedAddition" => "op_AdditionAssignment", + "op_Subtraction" or "op_CheckedSubtraction" => "op_SubtractionAssignment", + "op_Multiply" or "op_CheckedMultiply" => "op_MultiplicationAssignment", + "op_Division" or "op_CheckedDivision" => "op_DivisionAssignment", + "op_Modulus" => "op_ModulusAssignment", + "op_BitwiseAnd" => "op_BitwiseAndAssignment", + "op_BitwiseOr" => "op_BitwiseOrAssignment", + "op_ExclusiveOr" => "op_ExclusiveOrAssignment", + "op_LeftShift" => "op_LeftShiftAssignment", + "op_RightShift" => "op_RightShiftAssignment", + "op_UnsignedRightShift" => "op_UnsignedRightShiftAssignment", + "op_Increment" or "op_CheckedIncrement" => "op_IncrementAssignment", + "op_Decrement" or "op_CheckedDecrement" => "op_DecrementAssignment", + _ => null, + }; + } + #endregion + void LiftUserDefinedOperators(List operators) { int nonLiftedMethodCount = operators.Count; diff --git a/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs b/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs index 86bb76c76b..a7f303bb76 100644 --- a/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs +++ b/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs @@ -75,7 +75,31 @@ public enum OperatorType // Implicit and Explicit Implicit, Explicit, - CheckedExplicit + CheckedExplicit, + + // C# 14 user-defined compound assignment (void-returning instance operators). + // IsCompoundAssignment tells these apart by comparing against AdditionAssignment, so they + // have to stay last as a block: a member added after them would be taken for one of them, + // and one added before would stop being recognized. + AdditionAssignment, + CheckedAdditionAssignment, + SubtractionAssignment, + CheckedSubtractionAssignment, + MultiplicationAssignment, + CheckedMultiplicationAssignment, + DivisionAssignment, + CheckedDivisionAssignment, + ModulusAssignment, + BitwiseAndAssignment, + BitwiseOrAssignment, + ExclusiveOrAssignment, + LeftShiftAssignment, + RightShiftAssignment, + UnsignedRightShiftAssignment, + IncrementAssignment, + CheckedIncrementAssignment, + DecrementAssignment, + CheckedDecrementAssignment } /// @@ -100,7 +124,7 @@ public sealed partial class OperatorDeclaration : EntityDeclaration static OperatorDeclaration() { - names = new string[(int)OperatorType.CheckedExplicit + 1][]; + names = new string[(int)OperatorType.CheckedDecrementAssignment + 1][]; names[(int)OperatorType.LogicalNot] = new string[] { "!", "op_LogicalNot" }; names[(int)OperatorType.OnesComplement] = new string[] { "~", "op_OnesComplement" }; names[(int)OperatorType.Increment] = new string[] { "++", "op_Increment" }; @@ -136,6 +160,25 @@ static OperatorDeclaration() names[(int)OperatorType.Implicit] = new string[] { "implicit", "op_Implicit" }; names[(int)OperatorType.Explicit] = new string[] { "explicit", "op_Explicit" }; names[(int)OperatorType.CheckedExplicit] = new string[] { "explicit", "op_CheckedExplicit" }; + names[(int)OperatorType.AdditionAssignment] = new string[] { "+=", "op_AdditionAssignment" }; + names[(int)OperatorType.CheckedAdditionAssignment] = new string[] { "+=", "op_CheckedAdditionAssignment" }; + names[(int)OperatorType.SubtractionAssignment] = new string[] { "-=", "op_SubtractionAssignment" }; + names[(int)OperatorType.CheckedSubtractionAssignment] = new string[] { "-=", "op_CheckedSubtractionAssignment" }; + names[(int)OperatorType.MultiplicationAssignment] = new string[] { "*=", "op_MultiplicationAssignment" }; + names[(int)OperatorType.CheckedMultiplicationAssignment] = new string[] { "*=", "op_CheckedMultiplicationAssignment" }; + names[(int)OperatorType.DivisionAssignment] = new string[] { "/=", "op_DivisionAssignment" }; + names[(int)OperatorType.CheckedDivisionAssignment] = new string[] { "/=", "op_CheckedDivisionAssignment" }; + names[(int)OperatorType.ModulusAssignment] = new string[] { "%=", "op_ModulusAssignment" }; + names[(int)OperatorType.BitwiseAndAssignment] = new string[] { "&=", "op_BitwiseAndAssignment" }; + names[(int)OperatorType.BitwiseOrAssignment] = new string[] { "|=", "op_BitwiseOrAssignment" }; + names[(int)OperatorType.ExclusiveOrAssignment] = new string[] { "^=", "op_ExclusiveOrAssignment" }; + names[(int)OperatorType.LeftShiftAssignment] = new string[] { "<<=", "op_LeftShiftAssignment" }; + names[(int)OperatorType.RightShiftAssignment] = new string[] { ">>=", "op_RightShiftAssignment" }; + names[(int)OperatorType.UnsignedRightShiftAssignment] = new string[] { ">>>=", "op_UnsignedRightShiftAssignment" }; + names[(int)OperatorType.IncrementAssignment] = new string[] { "++", "op_IncrementAssignment" }; + names[(int)OperatorType.CheckedIncrementAssignment] = new string[] { "++", "op_CheckedIncrementAssignment" }; + names[(int)OperatorType.DecrementAssignment] = new string[] { "--", "op_DecrementAssignment" }; + names[(int)OperatorType.CheckedDecrementAssignment] = new string[] { "--", "op_CheckedDecrementAssignment" }; } public override SymbolKind SymbolKind { @@ -201,10 +244,25 @@ public static bool IsChecked(OperatorType type) OperatorType.CheckedIncrement => true, OperatorType.CheckedDecrement => true, OperatorType.CheckedExplicit => true, + OperatorType.CheckedAdditionAssignment => true, + OperatorType.CheckedSubtractionAssignment => true, + OperatorType.CheckedMultiplicationAssignment => true, + OperatorType.CheckedDivisionAssignment => true, + OperatorType.CheckedIncrementAssignment => true, + OperatorType.CheckedDecrementAssignment => true, _ => false, }; } + /// + /// Gets whether the operator type is a C# 14 user-defined compound assignment operator + /// (a void-returning instance operator, including the increment/decrement forms). + /// + public static bool IsCompoundAssignment(OperatorType type) + { + return type >= OperatorType.AdditionAssignment; + } + /// /// Gets the token for the operator type ("+", "implicit", etc.). /// Does not include the "checked" modifier. diff --git a/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs b/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs index e3c66d95e8..94e7028de8 100644 --- a/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs @@ -260,6 +260,11 @@ void InitProperties() /// Controls whether C# 14 "extension" declarations are supported. /// public bool SupportExtensionDeclarations { get; set; } + + /// + /// Controls whether C# 14 user-defined compound assignment operators ("operator +=") are supported. + /// + public bool SupportUserDefinedCompoundAssignmentOperators { get; set; } #endregion #region Convert Type @@ -2556,7 +2561,15 @@ EntityDeclaration ConvertOperator(IMethod op) OperatorType? opType = OperatorDeclaration.GetOperatorType(name); if (opType == null) return ConvertMethod(op); - if (opType == OperatorType.UnsignedRightShift && !SupportUnsignedRightShift) + if (opType is OperatorType.UnsignedRightShift or OperatorType.UnsignedRightShiftAssignment && !SupportUnsignedRightShift) + return ConvertMethod(op); + if (!SupportUserDefinedCompoundAssignmentOperators && OperatorDeclaration.IsCompoundAssignment(opType.Value)) + return ConvertMethod(op); + // C# requires a user-defined operator to be public, so a compound assignment operator + // that is not can only be written as a plain method. An explicit interface + // implementation is private in metadata but is still written in operator form. + if (OperatorDeclaration.IsCompoundAssignment(opType.Value) + && op.Accessibility != Accessibility.Public && !op.IsExplicitInterfaceImplementation) return ConvertMethod(op); if (!SupportOperatorChecked && OperatorDeclaration.IsChecked(opType.Value)) return ConvertMethod(op); diff --git a/ICSharpCode.Decompiler/DecompilerSettings.cs b/ICSharpCode.Decompiler/DecompilerSettings.cs index be0f91eebf..8728663a0a 100644 --- a/ICSharpCode.Decompiler/DecompilerSettings.cs +++ b/ICSharpCode.Decompiler/DecompilerSettings.cs @@ -862,6 +862,13 @@ public bool LifetimeAnnotations { [DecompilerSetting(CSharp.LanguageVersion.CSharp14_0)] public partial bool FieldKeyword { get; set; } + /// + /// Gets/Sets whether C# 14.0 user-defined compound assignment operators should be used. + /// + [Description("DecompilerSettings.UserDefinedCompoundAssignmentOperators")] + [DecompilerSetting(CSharp.LanguageVersion.CSharp14_0)] + public partial bool UserDefinedCompoundAssignmentOperators { get; set; } + /// /// Gets/sets whether the decompiler should separate local variable declarations /// from their initialization. diff --git a/ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs b/ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs index f641da40a1..72c18d8ccd 100644 --- a/ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs +++ b/ICSharpCode.Decompiler/TypeSystem/DecompilerTypeSystem.cs @@ -159,13 +159,20 @@ public enum TypeSystemOptions /// RuntimeAsync = 0x100000, /// + /// If this option is active, a void-returning instance method whose name is one of the C# 14 + /// compound assignment operators (op_AdditionAssignment, op_IncrementAssignment, ...) is + /// classified as an operator. Without it such a method stays a plain method, so it keeps its + /// metadata name and its specialname flag surfaces as a [SpecialName] attribute. + /// + UserDefinedCompoundAssignmentOperators = 0x200000, + /// /// Default settings: typical options for the decompiler, with all C# language features enabled. /// Default = Dynamic | Tuple | ExtensionMethods | DecimalConstants | ReadOnlyStructsAndParameters | RefStructs | UnmanagedConstraints | NullabilityAnnotations | ReadOnlyMethods | NativeIntegers | FunctionPointers | ScopedRef | NativeIntegersWithoutAttribute | RefReadOnlyParameters | ParamsCollections | FirstClassSpanTypes | ExtensionMembers - | RuntimeAsync + | RuntimeAsync | UserDefinedCompoundAssignmentOperators } /// @@ -215,6 +222,8 @@ public static TypeSystemOptions GetOptions(DecompilerSettings settings) typeSystemOptions |= TypeSystemOptions.ExtensionMembers; if (settings.AsyncAwait) typeSystemOptions |= TypeSystemOptions.RuntimeAsync; + if (settings.UserDefinedCompoundAssignmentOperators) + typeSystemOptions |= TypeSystemOptions.UserDefinedCompoundAssignmentOperators; return typeSystemOptions; } diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/AttributeListBuilder.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/AttributeListBuilder.cs index 8f6e8f06be..eaa046c402 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/AttributeListBuilder.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/AttributeListBuilder.cs @@ -234,6 +234,7 @@ internal bool IgnoreAttribute(TopLevelTypeName attributeType, SymbolKind target) return (options & TypeSystemOptions.ReadOnlyStructsAndParameters) != 0; case SymbolKind.Method: case SymbolKind.Accessor: + case SymbolKind.Operator: return (options & TypeSystemOptions.ReadOnlyMethods) != 0; case SymbolKind.ReturnType: case SymbolKind.Property: diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs index 1f69799d07..c2f54ed6b4 100644 --- a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs +++ b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs @@ -83,7 +83,8 @@ internal MetadataMethod(MetadataModule module, MethodDefinitionHandle handle) this.symbolKind = SymbolKind.Constructor; } else if (name.StartsWith("op_", StringComparison.Ordinal) - && CSharp.Syntax.OperatorDeclaration.GetOperatorType(name) != null) + && CSharp.Syntax.OperatorDeclaration.GetOperatorType(name) is CSharp.Syntax.OperatorType operatorType + && (!CSharp.Syntax.OperatorDeclaration.IsCompoundAssignment(operatorType) || IsUserDefinedCompoundAssignmentOperator(operatorType))) { this.symbolKind = SymbolKind.Operator; } @@ -96,7 +97,7 @@ internal MetadataMethod(MetadataModule module, MethodDefinitionHandle handle) this.symbolKind = SymbolKind.Destructor; } } - else if ((attributes & MethodAttributes.Static) != 0 && typeParameters.Length == 0) + else if (typeParameters.Length == 0) { // Operators that are explicit interface implementations are not marked // with MethodAttributes.SpecialName or MethodAttributes.RTSpecialName @@ -107,7 +108,10 @@ internal MetadataMethod(MetadataModule module, MethodDefinitionHandle handle) name = name.Substring(index + 1); if (name.StartsWith("op_", StringComparison.Ordinal) - && CSharp.Syntax.OperatorDeclaration.GetOperatorType(name) != null) + && CSharp.Syntax.OperatorDeclaration.GetOperatorType(name) is CSharp.Syntax.OperatorType operatorType + && (CSharp.Syntax.OperatorDeclaration.IsCompoundAssignment(operatorType) + ? IsUserDefinedCompoundAssignmentOperator(operatorType) + : (attributes & MethodAttributes.Static) != 0)) { this.symbolKind = SymbolKind.Operator; } @@ -118,6 +122,53 @@ internal MetadataMethod(MetadataModule module, MethodDefinitionHandle handle) && def.GetCustomAttributes().HasKnownAttribute(metadata, KnownAttribute.Extension); } + /// + /// Gets whether this method has the shape C# requires of a user-defined compound assignment + /// operator: an instance method returning void, taking one parameter (none for the + /// increment and decrement operators). Other languages give unrelated methods the same + /// metadata names: F# mangles + /// "static member (+=)" to a static, value-returning op_AdditionAssignment, and C++/CLI emits + /// value-returning instance operators. Those are plain methods as far as C# is concerned. + /// + /// + /// Gets whether this method should be classified as a C# 14 user-defined compound assignment + /// operator: the feature has to be enabled () + /// and the method has to have the required shape. When the feature is off the method stays a + /// plain method, so it keeps its op_*Assignment name and its specialname flag is surfaced. + /// + bool IsUserDefinedCompoundAssignmentOperator(CSharp.Syntax.OperatorType operatorType) + { + return (module.TypeSystemOptions & TypeSystemOptions.UserDefinedCompoundAssignmentOperators) != 0 + && IsCompoundAssignmentOperatorSignature(operatorType); + } + + bool IsCompoundAssignmentOperatorSignature(CSharp.Syntax.OperatorType operatorType) + { + if ((attributes & MethodAttributes.Static) != 0 || !ReturnType.IsKnownType(KnownTypeCode.Void)) + return false; + // a static class cannot contain operators + if (DeclaringTypeDefinition is { IsAbstract: true, IsSealed: true }) + return false; + int parameterCount = operatorType + is CSharp.Syntax.OperatorType.IncrementAssignment + or CSharp.Syntax.OperatorType.CheckedIncrementAssignment + or CSharp.Syntax.OperatorType.DecrementAssignment + or CSharp.Syntax.OperatorType.CheckedDecrementAssignment + ? 0 : 1; + if (Parameters.Count != parameterCount) + return false; + if (parameterCount == 1) + { + // C# allows only value, "in" and "ref readonly" parameters on operators + IParameter parameter = Parameters[0]; + if (parameter.ReferenceKind is not (ReferenceKind.None or ReferenceKind.In or ReferenceKind.RefReadOnly)) + return false; + if (parameter.IsParams) + return false; + } + return true; + } + public EntityHandle MetadataToken => handle; public override string ToString() From cbe9b822eff3bb77ad58a6037bdf1ec775f13dbf Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 22 Aug 2026 20:31:18 +0200 Subject: [PATCH 2/3] Decompile calls to user-defined compound assignment operators Fold a call to an instance compound assignment operator, X::op_AdditionAssignment(x, y), back into x += y (and x++, the checked forms), rewriting at the AST level in ReplaceMethodCallsWithOperators rather than introducing a new IL instruction. The form takes its operator from the static type of x and needs x to stay an assignable variable, so the receiver is protected end to end: the reader materializes a reference-type receiver into a stack slot, and inlining, copy propagation, foreach and using all refuse to replace it with something that is not an assignable variable or that would bind a different operator - redirecting to a copy where the variable would otherwise become read-only, so foreach and using statements are still emitted. Includes the round-trip, pretty, IL-pretty and ugly test fixtures. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../CorrectnessTestRunner.cs | 18 + .../ICSharpCode.Decompiler.Tests.csproj | 5 + .../ILPrettyTestRunner.cs | 6 + .../PrettyTestRunner.cs | 6 + .../UserDefinedCompoundAssignment.cs | 320 ++++++++ ...serDefinedCompoundAssignmentInheritance.cs | 681 ++++++++++++++++++ .../CompoundAssignmentOperatorEdgeCases.cs | 200 +++++ .../CompoundAssignmentOperatorEdgeCases.il | 456 ++++++++++++ .../Pretty/UserDefinedCompoundAssignment.cs | 555 ++++++++++++++ ...nedCompoundAssignmentOperators.Expected.cs | 49 ++ ...oUserDefinedCompoundAssignmentOperators.cs | 60 ++ .../UglyTestRunner.cs | 8 + .../CSharp/CSharpDecompiler.cs | 17 +- ICSharpCode.Decompiler/CSharp/CallBuilder.cs | 19 +- .../CSharp/ExpressionBuilder.cs | 15 +- .../CSharp/StatementBuilder.cs | 43 +- .../Transforms/PatternStatementTransform.cs | 10 + .../CSharp/Transforms/PrettifyAssignments.cs | 15 +- .../ReplaceMethodCallsWithOperators.cs | 219 ++++++ ICSharpCode.Decompiler/IL/ILReader.cs | 33 +- .../CompoundAssignmentInstruction.cs | 31 + .../IL/Transforms/CopyPropagation.cs | 38 +- .../IL/Transforms/FixRemainingIncrements.cs | 19 + .../IL/Transforms/ILInlining.cs | 106 +++ .../IL/Transforms/TransformAssignment.cs | 25 +- .../IL/Transforms/UsingTransform.cs | 34 + 26 files changed, 2966 insertions(+), 22 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Correctness/UserDefinedCompoundAssignment.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Correctness/UserDefinedCompoundAssignmentInheritance.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoUserDefinedCompoundAssignmentOperators.Expected.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoUserDefinedCompoundAssignmentOperators.cs diff --git a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs index a00fc4425a..0e898a8f5c 100644 --- a/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/CorrectnessTestRunner.cs @@ -145,6 +145,12 @@ public void AllFilesHaveTests() CompilerOptions.Optimize | CompilerOptions.UseRoslynLatest, }, executesCompiledOutput: true); + static readonly CompilerOptions[] roslyn5OrNewerOptions = Tester.SupportedOnCurrentPlatform(new[] + { + CompilerOptions.UseRoslynLatest, + CompilerOptions.Optimize | CompilerOptions.UseRoslynLatest, + }); + static readonly CompilerOptions[] roslyn2OrNewerOptions = Tester.SupportedOnCurrentPlatform(new[] { CompilerOptions.UseRoslyn2_10_0 | CompilerOptions.TargetNet40, @@ -211,6 +217,18 @@ public async Task CompoundAssignment([ValueSource(nameof(defaultOptions))] Compi await RunCS(options: options); } + [Test] + public async Task UserDefinedCompoundAssignment([ValueSource(nameof(roslyn5OrNewerOptions))] CompilerOptions options) + { + await RunCS(options: options); + } + + [Test] + public async Task UserDefinedCompoundAssignmentInheritance([ValueSource(nameof(roslyn5OrNewerOptions))] CompilerOptions options) + { + await RunCS(options: options); + } + [Test] public async Task PropertiesAndEvents([ValueSource(nameof(defaultOptions))] CompilerOptions options) { diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index 714454f75b..ca125f4bb2 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -209,6 +209,9 @@ + + + @@ -280,6 +283,8 @@ + +