From 2a87766ee12686c950941d548788702ca26fb9ff Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 1 Aug 2026 22:08:48 +0200 Subject: [PATCH 1/4] Add support for C# 14 user-defined compound assignment operators Instance operators (dotnet/csharplang user-defined-compound-assignment) compile to void-returning specialname instance methods (op_AdditionAssignment, op_IncrementAssignment, op_Checked*, ...). Roslyn lowers every variable-classified target to a single instance call on the lvalue (verified against Roslyn 5.9), so call sites are rewritten at the AST level in ReplaceMethodCallsWithOperators; no new ILAst instruction is needed. Classifying the new names as operators flips IMethod.IsOperator for instance methods, so the static-operator assumptions in CallBuilder, CSharpResolver and TransformAssignment now check IsStatic explicitly. Declarations gate on a new C# 14 setting and fall back to plain op_* methods below C# 14, matching the C# 11 operator-checked precedent. Not yet handled: explicit interface implementations (their metadata lacks specialname and the existing dotted-name operator detection is static-only) and result-used forms, which decompile as separate valid statements. Assisted-by: Claude:claude-fable-5:Claude Code --- .../PrettyTestRunner.cs | 6 + .../Pretty/UserDefinedCompoundAssignment.cs | 231 ++++++++++++++++++ .../CSharp/CSharpDecompiler.cs | 5 + ICSharpCode.Decompiler/CSharp/CallBuilder.cs | 2 +- .../CSharp/OutputVisitor/CSharpAmbience.cs | 4 +- .../CSharp/Resolver/CSharpResolver.cs | 3 +- .../Syntax/TypeMembers/OperatorDeclaration.cs | 59 ++++- .../CSharp/Syntax/TypeSystemAstBuilder.cs | 7 + .../ReplaceMethodCallsWithOperators.cs | 99 ++++++++ ICSharpCode.Decompiler/DecompilerSettings.cs | 7 + .../IL/Transforms/TransformAssignment.cs | 2 +- ICSharpCode.Decompiler/Output/IAmbience.cs | 4 + .../Implementation/AttributeListBuilder.cs | 1 + ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs | 2 +- ILSpy/Properties/Resources.resx | 3 + 15 files changed, 428 insertions(+), 7 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs diff --git a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs index 634aaa5cf1..09bc9eae21 100644 --- a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs @@ -800,6 +800,12 @@ public async Task RefStructInterfaces([ValueSource(nameof(roslyn4OrNewerOptions) await RunForLibrary(cscOptions: cscOptions); } + [Test] + public async Task UserDefinedCompoundAssignment([ValueSource(nameof(roslyn5OrNewerOptions))] CompilerOptions cscOptions) + { + await RunForLibrary(cscOptions: cscOptions); + } + [Test] public async Task ExpandParamsArgumentsDisabled([ValueSource(nameof(defaultOptions))] CompilerOptions cscOptions) { diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs new file mode 100644 index 0000000000..f28e683a22 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs @@ -0,0 +1,231 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; + +namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty +{ + internal class UserDefinedCompoundAssignment + { + public class CompoundClass + { + public int Value; + + public void operator +=(int rhs) + { + Value += rhs; + } + + public void operator checked +=(int rhs) + { + checked + { + Value += rhs; + } + } + + public void operator -=(int rhs) + { + Value -= rhs; + } + + public void operator checked -=(int rhs) + { + checked + { + Value -= rhs; + } + } + + public void operator *=(int rhs) + { + Value *= rhs; + } + + public void operator checked *=(int rhs) + { + checked + { + Value *= rhs; + } + } + + public void operator /=(int rhs) + { + Value /= rhs; + } + + public void operator checked /=(int rhs) + { + Value /= rhs; + } + + public void operator %=(int rhs) + { + Value %= rhs; + } + + public void operator &=(int rhs) + { + Value &= rhs; + } + + public void operator |=(int rhs) + { + Value |= rhs; + } + + public void operator ^=(int rhs) + { + Value ^= rhs; + } + + public void operator <<=(int rhs) + { + Value <<= rhs; + } + + public void operator >>=(int rhs) + { + Value >>= rhs; + } + + public void operator >>>=(int rhs) + { + Value >>>= rhs; + } + + public void operator ++() + { + Value++; + } + + public void operator checked ++() + { + checked + { + Value++; + } + } + + public void operator --() + { + Value--; + } + + public void operator checked --() + { + checked + { + Value--; + } + } + + public virtual void operator +=(long rhs) + { + Value += (int)rhs; + } + } + + public struct CompoundStruct + { + public int Value; + + public void operator +=(int rhs) + { + Value += rhs; + } + + public readonly void operator -=(int rhs) + { + Console.WriteLine(Value - rhs); + } + + public void operator ++() + { + Value++; + } + + public void AddViaThis() + { + this += 10; + } + } + + public interface ICompound + { + void operator +=(T rhs); + void operator ++(); + } + + private static CompoundClass staticField = new CompoundClass(); + + private CompoundClass instanceField = new CompoundClass(); + + public static void UseClass(CompoundClass c, int n) + { + c += n; + c += 1; + c -= n; + c *= n; + c /= n; + c %= n; + c &= n; + c |= n; + c ^= n; + c <<= n; + c >>= n; + c >>>= n; + c += 2L; + c++; + c--; + checked + { + c += n; + c -= n; + c *= n; + c /= n; + c++; + c--; + } + } + + public static void UseStruct(CompoundStruct s, int n) + { + s += n; + s -= n; + s++; + } + + public static void UseOtherTargets(CompoundClass[] arr, ref CompoundClass rc, ref CompoundStruct rs, UserDefinedCompoundAssignment inst, int n) + { + staticField += n; + inst.instanceField += n; + arr[0] += n; + rc += n; + rs += n; + rs++; + } + + public static void UseGeneric(T x, int n) where T : ICompound + { + x += n; + x++; + } + } +} diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 0a2cd75f83..151e1ec2f8 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -794,6 +794,7 @@ static TypeSystemAstBuilder CreateAstBuilder(DecompilerSettings settings) typeSystemAstBuilder.SupportOperatorChecked = settings.CheckedOperators; typeSystemAstBuilder.AlwaysUseGlobal = settings.AlwaysUseGlobal; typeSystemAstBuilder.SupportExtensionDeclarations = settings.ExtensionMembers; + typeSystemAstBuilder.SupportUserDefinedCompoundAssignmentOperators = settings.UserDefinedCompoundAssignmentOperators; return typeSystemAstBuilder; } @@ -2219,6 +2220,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 (methodDecl is OperatorDeclaration operatorDecl && OperatorDeclaration.IsCompoundAssignment(operatorDecl.OperatorType)) + { + RemoveCompilerFeatureRequiredAttribute(methodDecl, "UserDefinedCompoundAssignmentOperators"); + } return methodDecl; bool IsTypeHierarchyKnown(IType type) diff --git a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs index 1efe31af69..2e7e11ca6a 100644 --- a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs @@ -1586,7 +1586,7 @@ OverloadResolutionErrors IsUnambiguousCall(ExpectedTargetDetails expectedTargetD } } } - else if (method.IsOperator) + else if (method.IsOperator && method.IsStatic) { IEnumerable operatorCandidates; if (arguments.Length == 1) diff --git a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpAmbience.cs b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpAmbience.cs index 509c3ea7c4..12e4314f09 100644 --- a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpAmbience.cs +++ b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpAmbience.cs @@ -286,6 +286,7 @@ TypeSystemAstBuilder CreateAstBuilder() astBuilder.SupportUnsignedRightShift = (ConversionFlags & ConversionFlags.SupportUnsignedRightShift) != 0; astBuilder.SupportOperatorChecked = (ConversionFlags & ConversionFlags.SupportOperatorChecked) != 0; astBuilder.SupportExtensionDeclarations = (ConversionFlags & ConversionFlags.SupportExtensionDeclarations) != 0; + astBuilder.SupportUserDefinedCompoundAssignmentOperators = (ConversionFlags & ConversionFlags.SupportUserDefinedCompoundAssignmentOperators) != 0; return astBuilder; } @@ -394,7 +395,8 @@ void WriteMemberDeclarationName(IMember member, TokenWriter writer, CSharpFormat writer.WriteKeyword("operator"); writer.Space(); var operatorType = OperatorDeclaration.GetOperatorType(name); - if (operatorType.HasValue && !((ConversionFlags & ConversionFlags.SupportOperatorChecked) == 0 && OperatorDeclaration.IsChecked(operatorType.Value))) + if (operatorType.HasValue && !((ConversionFlags & ConversionFlags.SupportOperatorChecked) == 0 && OperatorDeclaration.IsChecked(operatorType.Value)) + && !((ConversionFlags & ConversionFlags.SupportUserDefinedCompoundAssignmentOperators) == 0 && OperatorDeclaration.IsCompoundAssignment(operatorType.Value))) { if (OperatorDeclaration.IsChecked(operatorType.Value)) { diff --git a/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs b/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs index b54f309d6a..55a2f8cde0 100644 --- a/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs +++ b/ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs @@ -1289,7 +1289,8 @@ public IEnumerable GetUserDefinedOperatorCandidates(IType return EmptyList.Instance; } // C# spec (draft-v11): §12.4.6 Candidate user-defined operators - var operators = type.GetMethods(m => m.IsOperator && m.Name == operatorName).ToList(); + // C# 14 instance compound-assignment operators are not candidates for the static operator forms. + var operators = type.GetMethods(m => m.IsOperator && m.IsStatic && m.Name == operatorName).ToList(); LiftUserDefinedOperators(operators); return operators; } diff --git a/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs b/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs index 86bb76c76b..50ea0bbeee 100644 --- a/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs +++ b/ICSharpCode.Decompiler/CSharp/Syntax/TypeMembers/OperatorDeclaration.cs @@ -75,7 +75,28 @@ public enum OperatorType // Implicit and Explicit Implicit, Explicit, - CheckedExplicit + CheckedExplicit, + + // C# 14 user-defined compound assignment (void-returning instance operators) + AdditionAssignment, + CheckedAdditionAssignment, + SubtractionAssignment, + CheckedSubtractionAssignment, + MultiplicationAssignment, + CheckedMultiplicationAssignment, + DivisionAssignment, + CheckedDivisionAssignment, + ModulusAssignment, + BitwiseAndAssignment, + BitwiseOrAssignment, + ExclusiveOrAssignment, + LeftShiftAssignment, + RightShiftAssignment, + UnsignedRightShiftAssignment, + IncrementAssignment, + CheckedIncrementAssignment, + DecrementAssignment, + CheckedDecrementAssignment } /// @@ -100,7 +121,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 +157,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 +241,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 7daaa3f6e5..fb461074cd 100644 --- a/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs @@ -261,6 +261,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 @@ -2428,6 +2433,8 @@ EntityDeclaration ConvertOperator(IMethod op) return ConvertMethod(op); if (opType == OperatorType.UnsignedRightShift && !SupportUnsignedRightShift) return ConvertMethod(op); + if (!SupportUserDefinedCompoundAssignmentOperators && OperatorDeclaration.IsCompoundAssignment(opType.Value)) + return ConvertMethod(op); if (!SupportOperatorChecked && OperatorDeclaration.IsChecked(opType.Value)) return ConvertMethod(op); diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs b/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs index 6136ec4963..0b5269962b 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs @@ -192,6 +192,61 @@ void ProcessInvocationExpression(InvocationExpression invocationExpression) context.EndStep(binaryOperator); return; } + if (context.Settings.UserDefinedCompoundAssignmentOperators && method is { IsOperator: true, IsStatic: false } + && invocationExpression.Target is MemberReferenceExpression targetMemberRef) + { + Expression assignmentTarget = targetMemberRef.Target; + if (assignmentTarget is CastExpression castExpr && castExpr.Expression.GetResolveResult().Type is ITypeParameter) + { + // Calls through a generic type parameter constrained to an interface declaring the + // operator are built as "((I)x).op_...(y)"; the cast is not a valid assignment + // target, and "x op= y" binds to the same operator via the constraint. + assignmentTarget = castExpr.Expression; + } + AssignmentOperatorType? aop = GetCompoundAssignmentOperatorTypeFromMetadataName(method.Name, out isChecked); + if (aop != null && arguments.Length == 1) + { + context.Step("Replace instance operator method with compound assignment", invocationExpression); + if (isChecked) + { + invocationExpression.AddAnnotation(AddCheckedBlocks.CheckedAnnotation); + } + else if (HasCheckedEquivalent(method)) + { + invocationExpression.AddAnnotation(AddCheckedBlocks.UncheckedAnnotation); + } + var assignment = new AssignmentExpression( + assignmentTarget.Detach(), + aop.Value, + arguments[0].Detach().UnwrapInDirectionExpression() + ).CopyAnnotationsFrom(invocationExpression); + invocationExpression.ReplaceWith(assignment); + context.EndStep(assignment); + return; + } + UnaryOperatorType? incDecOp = method.Name switch { + "op_IncrementAssignment" or "op_CheckedIncrementAssignment" => UnaryOperatorType.PostIncrement, + "op_DecrementAssignment" or "op_CheckedDecrementAssignment" => UnaryOperatorType.PostDecrement, + _ => null, + }; + if (incDecOp != null && arguments.Length == 0) + { + context.Step("Replace instance operator method with increment/decrement", invocationExpression); + if (method.Name is "op_CheckedIncrementAssignment" or "op_CheckedDecrementAssignment") + { + invocationExpression.AddAnnotation(AddCheckedBlocks.CheckedAnnotation); + } + else if (HasCheckedEquivalent(method)) + { + invocationExpression.AddAnnotation(AddCheckedBlocks.UncheckedAnnotation); + } + var incDec = new UnaryOperatorExpression(incDecOp.Value, assignmentTarget.Detach()) + .CopyAnnotationsFrom(invocationExpression); + invocationExpression.ReplaceWith(incDec); + context.EndStep(incDec); + return; + } + } UnaryOperatorType? uop = GetUnaryOperatorTypeFromMetadataName(method.Name, out isChecked, context.Settings); if (uop != null && arguments.Length == 1) { @@ -517,6 +572,50 @@ static bool ToStringIsKnownEffectFree(IType type) } } + static AssignmentOperatorType? GetCompoundAssignmentOperatorTypeFromMetadataName(string name, out bool isChecked) + { + isChecked = false; + switch (name) + { + case "op_AdditionAssignment": + return AssignmentOperatorType.Add; + case "op_CheckedAdditionAssignment": + isChecked = true; + return AssignmentOperatorType.Add; + case "op_SubtractionAssignment": + return AssignmentOperatorType.Subtract; + case "op_CheckedSubtractionAssignment": + isChecked = true; + return AssignmentOperatorType.Subtract; + case "op_MultiplicationAssignment": + return AssignmentOperatorType.Multiply; + case "op_CheckedMultiplicationAssignment": + isChecked = true; + return AssignmentOperatorType.Multiply; + case "op_DivisionAssignment": + return AssignmentOperatorType.Divide; + case "op_CheckedDivisionAssignment": + isChecked = true; + return AssignmentOperatorType.Divide; + case "op_ModulusAssignment": + return AssignmentOperatorType.Modulus; + case "op_BitwiseAndAssignment": + return AssignmentOperatorType.BitwiseAnd; + case "op_BitwiseOrAssignment": + return AssignmentOperatorType.BitwiseOr; + case "op_ExclusiveOrAssignment": + return AssignmentOperatorType.ExclusiveOr; + case "op_LeftShiftAssignment": + return AssignmentOperatorType.ShiftLeft; + case "op_RightShiftAssignment": + return AssignmentOperatorType.ShiftRight; + case "op_UnsignedRightShiftAssignment": + return AssignmentOperatorType.UnsignedShiftRight; + default: + return null; + } + } + static readonly Expression getMethodOrConstructorFromHandlePattern = new CastExpression(new Choice { new TypePattern(typeof(MethodInfo)), 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/IL/Transforms/TransformAssignment.cs b/ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs index 8edba814aa..0c0ade99ab 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs @@ -386,7 +386,7 @@ internal static bool HandleCompoundAssign(ILInstruction compoundStore, Statement binary, target, targetKind, binary.Right, targetType, CompoundEvalMode.EvaluatesToNewValue); } - else if (setterValue is Call operatorCall && operatorCall.Method.IsOperator) + else if (setterValue is Call operatorCall && operatorCall.Method.IsOperator && operatorCall.Method.IsStatic) { if (operatorCall.Arguments.Count == 0) return false; diff --git a/ICSharpCode.Decompiler/Output/IAmbience.cs b/ICSharpCode.Decompiler/Output/IAmbience.cs index f88a8576e3..a60d6f2665 100644 --- a/ICSharpCode.Decompiler/Output/IAmbience.cs +++ b/ICSharpCode.Decompiler/Output/IAmbience.cs @@ -125,6 +125,10 @@ public enum ConversionFlags /// Support C# 14 extension declarations. /// SupportExtensionDeclarations = 0x400000, + /// + /// Support C# 14 user-defined compound assignment operators (operator +=). + /// + SupportUserDefinedCompoundAssignmentOperators = 0x800000, StandardConversionFlags = ShowParameterNames | ShowAccessibility | 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.ILSpyCmd/IlspyCmdProgram.cs b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs index 9ecc669868..4c6d235507 100644 --- a/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs +++ b/ICSharpCode.ILSpyCmd/IlspyCmdProgram.cs @@ -149,7 +149,7 @@ class ILSpyCmdProgram [Option("-lv|--languageversion ", "C# Language version: CSharp1, CSharp2, CSharp3, " + "CSharp4, CSharp5, CSharp6, CSharp7, CSharp7_1, CSharp7_2, CSharp7_3, CSharp8_0, CSharp9_0, " + - "CSharp10_0, CSharp11_0, CSharp12_0, CSharp13_0, Preview or Latest", CommandOptionType.SingleValue)] + "CSharp10_0, CSharp11_0, CSharp12_0, CSharp13_0, CSharp14_0, CSharp15_0, Preview or Latest", CommandOptionType.SingleValue)] public LanguageVersion LanguageVersion { get; } = LanguageVersion.Latest; [FileExists] diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index f65ae29f58..9782fb6dd9 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -654,6 +654,9 @@ Are you sure you want to continue? Use variable names from debug symbols, if available + + User-defined compound assignment operators + UTF-8 string literals From d218467e415512001cc9c1b81214a79aa4730711 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Thu, 13 Aug 2026 20:40:41 +0200 Subject: [PATCH 2/4] Restrict compound assignment folding to what C# 14 can express Review of the operator support turned up call sites and declarations the folds must not touch. Instance "op_*Assignment" names are only C# operators when the method is void-returning and non-static, so F#'s mangled "static member (+=)" and C++/CLI's value-returning instance operators stay plain methods. The receiver of the call becomes the assignment target, which C# requires to be a variable, so properties, base references and compiler-removed temporaries keep the explicit call, and the value that initializes such a receiver is no longer inlined into it. Stripping the interface cast on a generic receiver needs the constraint that makes "x op= y" bind to the same operator. Conversely, a type declaring both a static operator and its instance compound counterpart must keep "x = x op y" spelled out: C# 14 binds "x op= y" to the instance operator, which mutates in place instead of storing a new instance. Overload resolution for the folded call now collects instance operator candidates itself (member lookup skips operators), so an inherited operator no longer drags a cast to the declaring type into the assignment target. Explicit interface implementations decompile as operators too - their metadata has no specialname, only the dotted name - and the checked and unsigned-right-shift names follow their feature settings like every other operator name does. Assisted-by: Claude:claude-opus-5:Claude Code --- .../ICSharpCode.Decompiler.Tests.csproj | 3 + .../ILPrettyTestRunner.cs | 6 + .../CompoundAssignmentOperatorEdgeCases.cs | 61 +++++++ .../CompoundAssignmentOperatorEdgeCases.il | 162 ++++++++++++++++++ .../Pretty/UserDefinedCompoundAssignment.cs | 62 +++++++ ICSharpCode.Decompiler/CSharp/CallBuilder.cs | 9 + .../CSharp/Syntax/TypeSystemAstBuilder.cs | 2 +- .../CSharp/Transforms/PrettifyAssignments.cs | 11 +- .../ReplaceMethodCallsWithOperators.cs | 48 ++++-- .../CompoundAssignmentInstruction.cs | 44 +++++ .../IL/Transforms/ILInlining.cs | 17 ++ .../IL/Transforms/TransformAssignment.cs | 6 + .../Implementation/MetadataMethod.cs | 22 ++- 13 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index c0d10da6fc..da9bcafd5c 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -196,6 +196,9 @@ + + + diff --git a/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs index ea17c49f82..b7d7673a64 100644 --- a/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/ILPrettyTestRunner.cs @@ -333,6 +333,12 @@ public async Task CallIndirect() await Run(); } + [Test] + public async Task CompoundAssignmentOperatorEdgeCases() + { + await Run(); + } + [Test] public async Task FSharpLoops_Debug() { diff --git a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs new file mode 100644 index 0000000000..cce858f137 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs @@ -0,0 +1,61 @@ +using System.Runtime.CompilerServices; + +public class CompoundTarget +{ + public int Value; + + public void operator +=(int rhs) + { + Value += rhs; + } + + [SpecialName] + public CompoundTarget op_SubtractionAssignment(int rhs) + { + return this; + } + + [SpecialName] + public static CompoundTarget op_MultiplicationAssignment(CompoundTarget lhs, int rhs) + { + return lhs; + } +} +public class DerivedCompoundTarget : CompoundTarget +{ + public void CallBaseOperator(int n) + { + base.op_AdditionAssignment(n); + } +} +public class EdgeCases +{ + public static CompoundTarget GetTarget() + { + return new CompoundTarget(); + } + + public static void NonVariableReceiver(int n) + { + GetTarget().op_AdditionAssignment(n); + } + + public static void UnconstrainedGenericReceiver(T x, int n) where T : IOther + { + ((ICompound)(object)x).op_AdditionAssignment(n); + } + + public static void CallNonCSharpOperators(CompoundTarget t, int n) + { + t.op_SubtractionAssignment(n); + CompoundTarget.op_MultiplicationAssignment(t, n); + } +} +public interface ICompound +{ + void operator +=(int rhs); +} +public interface IOther +{ + void M(); +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il new file mode 100644 index 0000000000..300fd7604d --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il @@ -0,0 +1,162 @@ +// Metadata version: v4.0.30319 +.assembly extern System.Runtime +{ + .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....: + .ver 11:0:0:0 +} +.assembly CompoundAssignmentOperatorEdgeCases +{ + .ver 1:0:0:0 +} +.module CompoundAssignmentOperatorEdgeCases.dll +.imagebase 0x10000000 +.file alignment 0x00000200 +.stackreserve 0x00100000 +.subsystem 0x0003 // WINDOWS_CUI +.corflags 0x00000001 // ILONLY + +.class public auto ansi beforefieldinit CompoundTarget + extends [System.Runtime]System.Object +{ + .field public int32 Value + + // A C# 14 user-defined compound assignment operator. + .method public hidebysig specialname instance void + op_AdditionAssignment(int32 rhs) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldarg.0 + IL_0002: ldfld int32 CompoundTarget::Value + IL_0007: ldarg.1 + IL_0008: add + IL_0009: stfld int32 CompoundTarget::Value + IL_000e: ret + } + + // C++/CLI emits value-returning instance operators; C# has no syntax for those. + .method public hidebysig specialname instance class CompoundTarget + op_SubtractionAssignment(int32 rhs) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ret + } + + // F# mangles "static member (*=)" to a static, value-returning op_MultiplicationAssignment. + .method public hidebysig specialname static class CompoundTarget + op_MultiplicationAssignment(class CompoundTarget lhs, + int32 rhs) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ret + } + + .method public hidebysig specialname rtspecialname + instance void .ctor() cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void [System.Runtime]System.Object::.ctor() + IL_0006: ret + } +} + +.class interface public abstract auto ansi beforefieldinit ICompound +{ + .method public hidebysig newslot specialname abstract virtual + instance void op_AdditionAssignment(int32 rhs) cil managed + { + } +} + +.class interface public abstract auto ansi beforefieldinit IOther +{ + .method public hidebysig newslot abstract virtual + instance void M() cil managed + { + } +} + +.class public auto ansi beforefieldinit DerivedCompoundTarget + extends CompoundTarget +{ + .method public hidebysig instance void CallBaseOperator(int32 n) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: call instance void CompoundTarget::op_AdditionAssignment(int32) + IL_0007: ret + } + + .method public hidebysig specialname rtspecialname + instance void .ctor() cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void CompoundTarget::.ctor() + IL_0006: ret + } +} + +.class public auto ansi beforefieldinit EdgeCases + extends [System.Runtime]System.Object +{ + .method public hidebysig static class CompoundTarget + GetTarget() cil managed + { + .maxstack 8 + IL_0000: newobj instance void CompoundTarget::.ctor() + IL_0005: ret + } + + // The receiver is not a variable, so it cannot become the target of "x += n". + .method public hidebysig static void NonVariableReceiver(int32 n) cil managed + { + .maxstack 8 + IL_0000: call class CompoundTarget EdgeCases::GetTarget() + IL_0005: ldarg.0 + IL_0006: callvirt instance void CompoundTarget::op_AdditionAssignment(int32) + IL_000b: ret + } + + // T is not constrained to ICompound, so the cast selects the operator and has to stay. + .method public hidebysig static void UnconstrainedGenericReceiver<(IOther) T>(!!T x, + int32 n) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: box !!T + IL_0006: castclass ICompound + IL_000b: ldarg.1 + IL_000c: callvirt instance void ICompound::op_AdditionAssignment(int32) + IL_0011: ret + } + + .method public hidebysig static void CallNonCSharpOperators(class CompoundTarget t, + int32 n) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: callvirt instance class CompoundTarget CompoundTarget::op_SubtractionAssignment(int32) + IL_0007: pop + IL_0008: ldarg.0 + IL_0009: ldarg.1 + IL_000a: call class CompoundTarget CompoundTarget::op_MultiplicationAssignment(class CompoundTarget, + int32) + IL_000f: pop + IL_0010: ret + } + + .method public hidebysig specialname rtspecialname + instance void .ctor() cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: call instance void [System.Runtime]System.Object::.ctor() + IL_0006: ret + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs index f28e683a22..0ab9db826b 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs @@ -167,12 +167,53 @@ public void AddViaThis() } } + public class DerivedCompoundClass : CompoundClass + { + } + + public class BothOperators + { + public int Value; + + public static BothOperators operator +(BothOperators lhs, int rhs) + { + return new BothOperators { + Value = lhs.Value + rhs + }; + } + + public void operator +=(int rhs) + { + Value += rhs; + } + + public void operator ++() + { + Value++; + } + } + public interface ICompound { void operator +=(T rhs); void operator ++(); } + public class ExplicitCompound : ICompound + { + public int Value; + + void ICompound.operator +=(int rhs) + { + Value += rhs; + } + + void ICompound.operator ++() + { + Value++; + } + } + private static CompoundClass staticField = new CompoundClass(); private CompoundClass instanceField = new CompoundClass(); @@ -227,5 +268,26 @@ public static void UseGeneric(T x, int n) where T : ICompound x += n; x++; } + + public static void UseInheritedOperator(DerivedCompoundClass d, int n) + { + d += n; + d++; + } + + public static BothOperators UseStaticOperator(BothOperators b, int n) + { + // The IL calls the static operator, so the decompiled code has to keep calling it: + // "b += n" and "b++" would bind to the instance operators instead. + b = b + n; + b = b + 1; + return b; + } + + public static void UseInstanceOperator(BothOperators b, int n) + { + b += n; + b++; + } } } diff --git a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs index 2e7e11ca6a..9fe634eed0 100644 --- a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs @@ -1622,6 +1622,15 @@ OverloadResolutionErrors IsUnambiguousCall(ExpectedTargetDetails expectedTargetD or.AddCandidate(m); } } + else if (method.IsOperator && !method.IsStatic && target != null) + { + // C# 14 compound assignment operators are instance methods; member lookup skips + // operators, so collect the candidates from the receiver type instead. + foreach (var m in target.Type.GetMethods(m => m.IsOperator && !m.IsStatic && m.Name == method.Name)) + { + or.AddCandidate(m); + } + } else if (target == null) { var result = resolver.ResolveSimpleName(method.Name, typeArguments, isInvocationTarget: true) diff --git a/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs b/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs index fb461074cd..9635df5228 100644 --- a/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs @@ -2431,7 +2431,7 @@ 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); diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/PrettifyAssignments.cs b/ICSharpCode.Decompiler/CSharp/Transforms/PrettifyAssignments.cs index 9c89119b0e..04c689af95 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/PrettifyAssignments.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/PrettifyAssignments.cs @@ -61,7 +61,8 @@ public override void VisitAssignmentExpression(AssignmentExpression assignment) if (rhs is BinaryOperatorExpression binary && assignment.Operator == AssignmentOperatorType.Assign) { if (CanConvertToCompoundAssignment(assignment.Left) && assignment.Left.IsMatch(binary.Left) - && binary.Right != null && IsImplicitlyConvertible(binary.Right, expectedType)) + && binary.Right != null && IsImplicitlyConvertible(binary.Right, expectedType) + && !IsShadowedByInstanceOperator(binary)) { var newOperator = GetAssignmentOperatorForBinaryOperator(binary.Operator); if (newOperator != AssignmentOperatorType.Assign) @@ -102,6 +103,14 @@ public override void VisitAssignmentExpression(AssignmentExpression assignment) } } + bool IsShadowedByInstanceOperator(BinaryOperatorExpression binary) + { + // "x = x + y" must keep calling the static operator: if the type also declares the + // C# 14 instance "operator +=", "x += y" binds to that one instead. + return binary.GetSymbol() is IMethod method + && IL.UserDefinedCompoundAssign.IsShadowedByInstanceOperator(method, context.Settings); + } + bool IsImplicitlyConvertible(Expression rhs, IType? expectedType) { if (expectedType == null) diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs b/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs index 0b5269962b..339ab47875 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs @@ -196,14 +196,19 @@ void ProcessInvocationExpression(InvocationExpression invocationExpression) && invocationExpression.Target is MemberReferenceExpression targetMemberRef) { Expression assignmentTarget = targetMemberRef.Target; - if (assignmentTarget is CastExpression castExpr && castExpr.Expression.GetResolveResult().Type is ITypeParameter) + if (assignmentTarget is CastExpression castExpr + && castExpr.Expression.GetResolveResult().Type is ITypeParameter typeParameter + && typeParameter.DirectBaseTypes.Any(t => t.Equals(castExpr.Type.GetResolveResult().Type))) { // Calls through a generic type parameter constrained to an interface declaring the // operator are built as "((I)x).op_...(y)"; the cast is not a valid assignment // target, and "x op= y" binds to the same operator via the constraint. + // Without that constraint the cast selects the operator and cannot be dropped. assignmentTarget = castExpr.Expression; } - AssignmentOperatorType? aop = GetCompoundAssignmentOperatorTypeFromMetadataName(method.Name, out isChecked); + if (!IsValidAssignmentTarget(assignmentTarget)) + return; + AssignmentOperatorType? aop = GetCompoundAssignmentOperatorTypeFromMetadataName(method.Name, out isChecked, context.Settings); if (aop != null && arguments.Length == 1) { context.Step("Replace instance operator method with compound assignment", invocationExpression); @@ -225,8 +230,10 @@ void ProcessInvocationExpression(InvocationExpression invocationExpression) return; } UnaryOperatorType? incDecOp = method.Name switch { - "op_IncrementAssignment" or "op_CheckedIncrementAssignment" => UnaryOperatorType.PostIncrement, - "op_DecrementAssignment" or "op_CheckedDecrementAssignment" => UnaryOperatorType.PostDecrement, + "op_IncrementAssignment" => UnaryOperatorType.PostIncrement, + "op_DecrementAssignment" => UnaryOperatorType.PostDecrement, + "op_CheckedIncrementAssignment" when context.Settings.CheckedOperators => UnaryOperatorType.PostIncrement, + "op_CheckedDecrementAssignment" when context.Settings.CheckedOperators => UnaryOperatorType.PostDecrement, _ => null, }; if (incDecOp != null && arguments.Length == 0) @@ -572,29 +579,29 @@ static bool ToStringIsKnownEffectFree(IType type) } } - static AssignmentOperatorType? GetCompoundAssignmentOperatorTypeFromMetadataName(string name, out bool isChecked) + static AssignmentOperatorType? GetCompoundAssignmentOperatorTypeFromMetadataName(string name, out bool isChecked, DecompilerSettings settings) { isChecked = false; switch (name) { case "op_AdditionAssignment": return AssignmentOperatorType.Add; - case "op_CheckedAdditionAssignment": + case "op_CheckedAdditionAssignment" when settings.CheckedOperators: isChecked = true; return AssignmentOperatorType.Add; case "op_SubtractionAssignment": return AssignmentOperatorType.Subtract; - case "op_CheckedSubtractionAssignment": + case "op_CheckedSubtractionAssignment" when settings.CheckedOperators: isChecked = true; return AssignmentOperatorType.Subtract; case "op_MultiplicationAssignment": return AssignmentOperatorType.Multiply; - case "op_CheckedMultiplicationAssignment": + case "op_CheckedMultiplicationAssignment" when settings.CheckedOperators: isChecked = true; return AssignmentOperatorType.Multiply; case "op_DivisionAssignment": return AssignmentOperatorType.Divide; - case "op_CheckedDivisionAssignment": + case "op_CheckedDivisionAssignment" when settings.CheckedOperators: isChecked = true; return AssignmentOperatorType.Divide; case "op_ModulusAssignment": @@ -609,13 +616,34 @@ static bool ToStringIsKnownEffectFree(IType type) return AssignmentOperatorType.ShiftLeft; case "op_RightShiftAssignment": return AssignmentOperatorType.ShiftRight; - case "op_UnsignedRightShiftAssignment": + case "op_UnsignedRightShiftAssignment" when settings.UnsignedRightShift: return AssignmentOperatorType.UnsignedShiftRight; default: return null; } } + /// + /// Gets whether the expression is classified as a variable, the only thing a user-defined + /// compound assignment operator can be applied to. Instance operator calls carry their + /// receiver as the call target, which is under no such restriction: properties and indexers + /// route through the static operator instead, and hand-written IL can call the operator on + /// any value at all. + /// + static bool IsValidAssignmentTarget(Expression expression) + { + return expression switch { + BaseReferenceExpression => false, + IndexerExpression => expression.GetSymbol() is not IProperty, + _ => expression.GetResolveResult() switch { + ILVariableResolveResult => true, + ThisResolveResult => true, + MemberResolveResult mrr => mrr.Member is IField, + _ => false, + } + }; + } + static readonly Expression getMethodOrConstructorFromHandlePattern = new CastExpression(new Choice { new TypePattern(typeof(MethodInfo)), diff --git a/ICSharpCode.Decompiler/IL/Instructions/CompoundAssignmentInstruction.cs b/ICSharpCode.Decompiler/IL/Instructions/CompoundAssignmentInstruction.cs index 85f49aad6e..f0f82b7410 100644 --- a/ICSharpCode.Decompiler/IL/Instructions/CompoundAssignmentInstruction.cs +++ b/ICSharpCode.Decompiler/IL/Instructions/CompoundAssignmentInstruction.cs @@ -19,6 +19,7 @@ using System; using System.Diagnostics; +using System.Linq; using System.Linq.Expressions; using ICSharpCode.Decompiler.TypeSystem; @@ -322,6 +323,8 @@ public static bool IsIncrementOrDecrement(IMethod method, DecompilerSettings? se { if (!(method.IsOperator && method.IsStatic)) return false; + if (IsShadowedByInstanceOperator(method, settings)) + return false; if (method.Name is "op_Increment" or "op_Decrement") return true; if (method.Name is "op_CheckedIncrement" or "op_CheckedDecrement") @@ -329,6 +332,47 @@ public static bool IsIncrementOrDecrement(IMethod method, DecompilerSettings? se return false; } + /// + /// Gets whether the declaring type of the static operator also + /// declares the matching C# 14 instance compound assignment operator. C# 14 overload + /// resolution prefers the instance operator for "x op= y", so folding the static call into + /// a compound assignment would make the recompiled code call a different method. + /// + public static bool IsShadowedByInstanceOperator(IMethod method, DecompilerSettings? settings = null) + { + if (settings?.UserDefinedCompoundAssignmentOperators == false) + return false; + string? name = GetCompoundAssignmentOperatorName(method.Name); + if (name == null) + return false; + // Both the checked and the unchecked instance operator shadow the static one: which of + // them applies depends on the checked context the assignment ends up in. + string checkedName = "op_Checked" + name.Substring("op_".Length); + return method.DeclaringType + .GetMethods(m => !m.IsStatic && m.IsOperator && (m.Name == name || m.Name == checkedName)) + .Any(); + } + + 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, + }; + } + public static bool IsStringConcat(IMethod method) { return method.Name == "Concat" && method.IsStatic && method.DeclaringType.IsKnownType(KnownTypeCode.String); diff --git a/ICSharpCode.Decompiler/IL/Transforms/ILInlining.cs b/ICSharpCode.Decompiler/IL/Transforms/ILInlining.cs index 6c0c98c880..cbe13c8d01 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/ILInlining.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/ILInlining.cs @@ -264,6 +264,13 @@ static bool DoInline(ILVariable v, ILInstruction inlinedExpression, ILInstructio { return false; } + if (context.Settings.UserDefinedCompoundAssignmentOperators && IsCompoundAssignmentOperatorReceiver(loadInst)) + { + // The receiver of a C# 14 instance compound assignment operator call becomes the + // target of "x op= y", which must remain a variable: inlining the expression that + // initializes it would produce "GetX() += y". + return false; + } if (loadInst.OpCode == OpCode.LdLoca) { if (!IsGeneratedTemporaryForAddressOf((LdLoca)loadInst, v, inlinedExpression, options)) @@ -325,6 +332,16 @@ static bool IsStackAllocSpanConstructorArgument(ILInstruction loadInst) || newObj.Method.DeclaringType.IsKnownType(KnownTypeCode.ReadOnlySpanOfT)); } + /// + /// Returns true if is the receiver of a call to a C# 14 + /// user-defined compound assignment operator (a void-returning instance operator). + /// + static bool IsCompoundAssignmentOperatorReceiver(ILInstruction loadInst) + { + return loadInst.Parent is CallInstruction { Method: { IsOperator: true, IsStatic: false } } call + && call.Arguments.Count > 0 && call.Arguments[0] == loadInst; + } + /// /// Is this a temporary variable generated by the C# compiler for instance method calls on value type values /// diff --git a/ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs b/ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs index 0c0ade99ab..a9615994af 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs @@ -390,6 +390,8 @@ internal static bool HandleCompoundAssign(ILInstruction compoundStore, Statement { if (operatorCall.Arguments.Count == 0) return false; + if (UserDefinedCompoundAssign.IsShadowedByInstanceOperator(operatorCall.Method, context.Settings)) + return false; if (!IsMatchingCompoundLoad(operatorCall.Arguments[0], compoundStore, out var target, out var targetKind, out var finalizeMatch, forbiddenVariable: storeInSetter?.Variable)) return false; ILInstruction rhs; @@ -892,6 +894,8 @@ bool TransformPreIncDecOperatorWithInlineStore(Block block, int pos) { if (!(operatorCall.Method.Name == "op_Increment" || operatorCall.Method.Name == "op_Decrement")) return false; + if (UserDefinedCompoundAssign.IsShadowedByInstanceOperator(operatorCall.Method, context.Settings)) + return false; if (operatorCall.IsLifted) return false; // TODO: add tests and think about whether nullables need special considerations ldloc = operatorCall.Arguments[0] as LdLoc; @@ -977,6 +981,8 @@ bool TransformPostIncDecOperatorWithInlineStore(Block block, int pos) { if (!(operatorCall.Method.Name == "op_Increment" || operatorCall.Method.Name == "op_Decrement")) return false; + if (UserDefinedCompoundAssign.IsShadowedByInstanceOperator(operatorCall.Method, context.Settings)) + return false; if (operatorCall.IsLifted) return false; // TODO: add tests and think about whether nullables need special considerations stloc = operatorCall.Arguments[0] as StLoc; diff --git a/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs b/ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs index 1f69799d07..70c171e1bb 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) || IsCompoundAssignmentOperatorSignature())) { 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) + ? IsCompoundAssignmentOperatorSignature() + : (attributes & MethodAttributes.Static) != 0)) { this.symbolKind = SymbolKind.Operator; } @@ -118,6 +122,18 @@ 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. 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. + /// + bool IsCompoundAssignmentOperatorSignature() + { + return (attributes & MethodAttributes.Static) == 0 + && ReturnType.IsKnownType(KnownTypeCode.Void); + } + public EntityHandle MetadataToken => handle; public override string ToString() From 9f05027d5773b3afc08e32de9ab0d514ee2d6906 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 14 Aug 2026 05:12:14 +0200 Subject: [PATCH 3/4] Report a call as unresolvable when nothing matched at all Overload resolution reports no error for an empty candidate set - there is no best candidate to carry one - so the call builder read the null result as success and dereferenced it while checking the call target. It crashes whenever the receiver's static type does not declare the method being called, which happens with mis-bound references (nugetfuzz found it on FSharp.DataFrame) and, since instance operators now collect their own candidates from the receiver type, on operator calls too. Assisted-by: Claude:claude-opus-5:Claude Code --- .../ILPretty/CompoundAssignmentOperatorEdgeCases.cs | 7 ++++++- .../ILPretty/CompoundAssignmentOperatorEdgeCases.il | 12 ++++++++++++ ICSharpCode.Decompiler/CSharp/CallBuilder.cs | 7 +++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs index cce858f137..9859e03c53 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs @@ -1,4 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; public class CompoundTarget { @@ -45,6 +45,11 @@ public static void UnconstrainedGenericReceiver(T x, int n) where T : IOther ((ICompound)(object)x).op_AdditionAssignment(n); } + public static void MismatchedReceiverType(object o, int n) + { + ((CompoundTarget)o).op_AdditionAssignment(n); + } + public static void CallNonCSharpOperators(CompoundTarget t, int n) { t.op_SubtractionAssignment(n); diff --git a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il index 300fd7604d..7305c3ed40 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il +++ b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.il @@ -135,6 +135,18 @@ IL_0011: ret } + // The receiver's static type does not declare the operator, so overload resolution has no + // candidate to pick. + .method public hidebysig static void MismatchedReceiverType(object o, + int32 n) cil managed + { + .maxstack 8 + IL_0000: ldarg.0 + IL_0001: ldarg.1 + IL_0002: callvirt instance void CompoundTarget::op_AdditionAssignment(int32) + IL_0007: ret + } + .method public hidebysig static void CallNonCSharpOperators(class CompoundTarget t, int32 n) cil managed { diff --git a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs index 9fe634eed0..c41e0908c2 100644 --- a/ICSharpCode.Decompiler/CSharp/CallBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/CallBuilder.cs @@ -1652,6 +1652,13 @@ OverloadResolutionErrors IsUnambiguousCall(ExpectedTargetDetails expectedTargetD if (or.IsAmbiguous) return OverloadResolutionErrors.AmbiguousMatch; foundMember = or.GetBestCandidateWithSubstitutedTypeArguments(); + if (foundMember == null) + { + // No candidate at all: overload resolution reports no error for an empty candidate + // set, so this is the only place the caller learns that the call cannot be spelled + // out with the arguments it has. + return OverloadResolutionErrors.AmbiguousMatch; + } if (!IsAppropriateCallTarget(expectedTargetDetails, method, foundMember)) return OverloadResolutionErrors.AmbiguousMatch; var map = or.GetArgumentToParameterMap(); From 597d505987e44fd418ceed7fa0f8fd5936231fed Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Fri, 14 Aug 2026 12:11:33 +0200 Subject: [PATCH 4/4] fixup! Restrict compound assignment folding to what C# 14 can express --- .../CompoundAssignmentOperatorEdgeCases.cs | 2 ++ .../Pretty/UserDefinedCompoundAssignment.cs | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs index 9859e03c53..99cf2afe5b 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/ILPretty/CompoundAssignmentOperatorEdgeCases.cs @@ -9,6 +9,8 @@ public class CompoundTarget Value += rhs; } + // Not "operator -=": C# 14 only recognizes void-returning instance methods as compound + // assignment operators, so a value-returning one stays a plain method. [SpecialName] public CompoundTarget op_SubtractionAssignment(int rhs) { diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs index 0ab9db826b..67a125886b 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/UserDefinedCompoundAssignment.cs @@ -218,6 +218,8 @@ public class ExplicitCompound : ICompound private CompoundClass instanceField = new CompoundClass(); + private BothOperators bothField = new BothOperators(); + public static void UseClass(CompoundClass c, int n) { c += n; @@ -284,6 +286,19 @@ public static BothOperators UseStaticOperator(BothOperators b, int n) return b; } + public static void UseStaticOperatorOnArrayElement(BothOperators[] arr, int n) + { + // Same shape as UseStaticOperator, but the target is an array element rather than a + // local: compound assignments to locals take a separate path through the decompiler. + arr[0] = arr[0] + n; + arr[0] = arr[0] + 1; + } + + public static void UseStaticOperatorOnField(UserDefinedCompoundAssignment inst, int n) + { + inst.bothField = inst.bothField + n; + } + public static void UseInstanceOperator(BothOperators b, int n) { b += n;