Add support for C# 14 user-defined compound assignment operators - #3972
Add support for C# 14 user-defined compound assignment operators#3972siegfriedpammer wants to merge 1 commit into
Conversation
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
christophwille
left a comment
There was a problem hiding this comment.
Automated high-effort review (multi-agent, each finding independently verified against the PR head). Overall: the PR handles the straightforward Roslyn lvalue cases well, but the new pattern matchers are loose at several boundaries. The most severe defects break recompilation of plain C# 14 code (explicit interface operator implementations, virtual operators called through a derived-typed receiver), and the static-operator compound-assign path can now silently change runtime semantics when a type declares both static and instance operators. Secondary issues are settings-gating gaps (CheckedOperators, UnsignedRightShift) and the signature-blind checked-equivalent probe inherited by the new paths.
10 findings posted as inline comments, ordered by severity there. Summary:
- Explicit interface implementations of instance compound operators decompile as plain methods (OperatorDeclaration.cs) - output fails to compile (CS0539).
- Derived-typed receivers get a cast that becomes the assignment target (CallBuilder.cs) - emits
(C)d += n(CS0131). - Non-lvalue receivers become an invalid assignment LHS (ReplaceMethodCallsWithOperators.cs) -
GetC() += n;(CS0131). - Type-parameter cast stripped without checking constraints (ReplaceMethodCallsWithOperators.cs) - non-compiling or wrong-binding output.
x op= yprinted for static operator calls even when an instance compound operator exists (TransformAssignment.cs) - recompiled code binds to the instance operator, silently different runtime behavior.- Static / value-returning
op_*Assignment(F#, C++/CLI) now rendered as operator declarations (TypeSystemAstBuilder.cs) - invalid C#. - No void-return check on the instance-operator rewrite (ReplaceMethodCallsWithOperators.cs).
- Checked compound-assignment names not gated on
settings.CheckedOperators(ReplaceMethodCallsWithOperators.cs). HasCheckedEquivalentis signature-blind (ReplaceMethodCallsWithOperators.cs) - spuriousuncheckedwrappers.op_UnsignedRightShiftAssignmentnot gated onsettings.UnsignedRightShift(ReplaceMethodCallsWithOperators.cs).
| 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" }; |
There was a problem hiding this comment.
[correctness] The new operator-name table entries feed MetadataMethod's SpecialName branch, but the non-SpecialName fallback branch for explicit interface implementations (MetadataMethod.cs:99) still requires MethodAttributes.Static, so an explicit interface implementation of an instance compound-assignment operator is classified as a plain method.
Failure scenario: decompiling a C# 14 class containing void ICompound<int>.operator +=(int rhs) (Roslyn emits explicit operator implementations without SpecialName, per the existing comment in MetadataMethod) yields void ICompound<int>.op_AdditionAssignment(int rhs) - an explicit implementation of a nonexistent interface method - so the decompiled output fails to compile (CS0539).
Same root cause also affects the name table at line 124.
| } | ||
| } | ||
| else if (method.IsOperator) | ||
| else if (method.IsOperator && method.IsStatic) |
There was a problem hiding this comment.
[correctness] Instance operator calls always fail IsUnambiguousCall (MemberLookup.Lookup's filter at MemberLookup.cs:377 excludes SymbolKind.Operator), so the disambiguation loop casts the receiver to the declaring type whenever the receiver's static type differs from it, and ReplaceMethodCallsWithOperators then uses that cast as the assignment target.
Failure scenario: class C declares public virtual void operator +=(int); class D : C; code D d; d += n; compiles to callvirt C::op_AdditionAssignment on a D-typed receiver. Decompiling: overload resolution always returns AmbiguousMatch, the repair loop rewrites the target to ((C)d).op_AdditionAssignment(n), and the new transform emits (C)d += n - a cast expression as assignment target, so the decompiled output does not compile (CS0131).
| { | ||
| invocationExpression.AddAnnotation(AddCheckedBlocks.UncheckedAnnotation); | ||
| } | ||
| var assignment = new AssignmentExpression( |
There was a problem hiding this comment.
[correctness] assignmentTarget is never validated as an assignable expression (variable/field/property/indexer), so non-lvalue receivers are rewritten into an invalid assignment LHS.
Failure scenario: C# 14 source CompoundClass c = GetC(); c += n; where c is single-use: ILInlining inlines the receiver, giving the AST GetC().op_AdditionAssignment(n), which this transform rewrites to GetC() += n; - a CS0131 compile error on recompilation (LHS must be a variable, property or indexer). Likewise new C().op_...(n) becomes new C() += n; and an IL base-call base.op_AdditionAssignment(y) becomes the invalid base += y;.
| && invocationExpression.Target is MemberReferenceExpression targetMemberRef) | ||
| { | ||
| Expression assignmentTarget = targetMemberRef.Target; | ||
| if (assignmentTarget is CastExpression castExpr && castExpr.Expression.GetResolveResult().Type is ITypeParameter) |
There was a problem hiding this comment.
[correctness] The interface cast on a generic receiver is stripped whenever the operand is a type parameter, without verifying the cast type is among the type parameter's constraints (or is the operator's declaring type).
Failure scenario: IL box !!T; castclass ICompound`1<int32>; callvirt op_AdditionAssignment inside void M<T>(T x) where T : IOther decompiles to ((ICompound<int>)x).op_AdditionAssignment(n); the transform strips the cast, emitting x += n; which does not compile because T has no ICompound constraint. If T happens to be constrained to a different interface that also declares operator +=, the recompiled code silently binds to that other operator instead of the one behind the runtime cast - wrong method called at runtime.
| targetType, CompoundEvalMode.EvaluatesToNewValue); | ||
| } | ||
| else if (setterValue is Call operatorCall && operatorCall.Method.IsOperator) | ||
| else if (setterValue is Call operatorCall && operatorCall.Method.IsOperator && operatorCall.Method.IsStatic) |
There was a problem hiding this comment.
[correctness] HandleCompoundAssign still prints x op= y for static operator calls without checking whether the target type also declares a C# 14 instance compound-assignment operator, which C# 14 overload resolution prefers for x op= y.
Failure scenario: a type declares both public static C operator +(C c, int n) and public void operator +=(int n). IL for c = c + n (an explicit static-operator call plus store) is decompiled to c += n;. Recompiling that output under C# 14 binds to the instance operator += instead of the static operator-plus-assignment the original assembly executed, so the recompiled program mutates the existing object in place (visible through every alias of c) instead of storing a new instance - silently different runtime behavior with no compile error.
Same root cause also affects the increment/decrement path around line 891.
| return ConvertMethod(op); | ||
| if (opType == OperatorType.UnsignedRightShift && !SupportUnsignedRightShift) | ||
| return ConvertMethod(op); | ||
| if (!SupportUserDefinedCompoundAssignmentOperators && OperatorDeclaration.IsCompoundAssignment(opType.Value)) |
There was a problem hiding this comment.
[correctness] ConvertOperator gains the compound-assignment gate but no shape check: because the extended name table makes MetadataMethod classify any SpecialName method named op_AdditionAssignment etc. as SymbolKind.Operator, a static or value-returning op_*Assignment method (emitted by F# static member (+=) and C++/CLI operator+=) is now rendered as an operator declaration.
Failure scenario: decompiling an existing F# or C++/CLI assembly that defines a static op_AdditionAssignment produces public static T operator +=(T a, int b) - invalid C# (compound assignment operators must be instance and void-returning), so the output no longer compiles; before this PR the same method decompiled as a normal callable method public static T op_AdditionAssignment(...).
| context.EndStep(binaryOperator); | ||
| return; | ||
| } | ||
| if (context.Settings.UserDefinedCompoundAssignmentOperators && method is { IsOperator: true, IsStatic: false } |
There was a problem hiding this comment.
[correctness] The compound-assignment rewrite never checks that the instance operator method returns void, though C# 14 only recognizes void-returning instance operators as compound-assignment operators.
Failure scenario: decompiling a C++/CLI or IL-authored assembly with an instance op_AdditionAssignment(X) that returns a value: X r = a.op_AdditionAssignment(b); is rewritten to X r = a += b; (and even a bare statement call becomes a += b;). On recompilation the non-void instance operator is not an eligible C# 14 compound operator, so the code either fails to compile or binds to a different (static) operator with different behavior.
| { | ||
| case "op_AdditionAssignment": | ||
| return AssignmentOperatorType.Add; | ||
| case "op_CheckedAdditionAssignment": |
There was a problem hiding this comment.
[settings gating] Checked compound-assignment names are not gated on settings.CheckedOperators, unlike the adjacent GetBinaryOperatorTypeFromMetadataName/GetUnaryOperatorTypeFromMetadataName which use when settings.CheckedOperators on every op_Checked* case.
Failure scenario: with CheckedOperators=false and UserDefinedCompoundAssignmentOperators=true (independent toggles in the UI), TypeSystemAstBuilder.ConvertOperator demotes op_CheckedAdditionAssignment to a plain method declaration (the !SupportOperatorChecked && IsChecked branch), but GetCompoundAssignmentOperatorTypeFromMetadataName still rewrites its call sites into checked { x += y; }. The recompiled x += y can only bind to the unchecked operator, so the decompiled program silently calls a different method than the original IL (or fails to compile when the IL declares only the checked variant). The same gap exists in the op_CheckedIncrementAssignment/op_CheckedDecrementAssignment switch at line 227.
| { | ||
| invocationExpression.AddAnnotation(AddCheckedBlocks.CheckedAnnotation); | ||
| } | ||
| else if (HasCheckedEquivalent(method)) |
There was a problem hiding this comment.
[correctness] HasCheckedEquivalent is name-only (signature-blind), so an operator overload with no checked counterpart for its own signature still gets an UncheckedAnnotation because a checked variant with a different signature exists on the type.
Failure scenario: for a type where op_CheckedAdditionAssignment(int) exists but only op_AdditionAssignment(long) for long, original source checked { c += 2L; ... } decompiles with an unchecked annotation on c += 2L, so the output pulls the statement into an unchecked {} wrapper / out of the checked block - noisy, misleading decompiled output that misrepresents the original checked context of the statement.
| return AssignmentOperatorType.ShiftLeft; | ||
| case "op_RightShiftAssignment": | ||
| return AssignmentOperatorType.ShiftRight; | ||
| case "op_UnsignedRightShiftAssignment": |
There was a problem hiding this comment.
[settings gating] op_UnsignedRightShiftAssignment is not gated on settings.UnsignedRightShift, unlike op_UnsignedRightShift in GetBinaryOperatorTypeFromMetadataName (when settings.UnsignedRightShift), and TypeSystemAstBuilder.ConvertOperator only gates OperatorType.UnsignedRightShift, not UnsignedRightShiftAssignment.
Failure scenario: a user who disables the C# 11 'unsigned right shift' setting but leaves compound-assignment operators enabled still gets x >>>= y expressions and public void operator >>>= declarations in the output, i.e. syntax the setting explicitly turned off; every other settings-gated operator name in these helpers respects its feature toggle.
Implements decompiler support for C# 14 user-defined compound assignment operators (#829): instance operator declarations decompile to
public void operator +=(T rhs)/operator checked +=/operator ++(), and call sites fold back tox += y;/x++;.The implementation was cross-checked against Roslyn 5.9 output first: for every LHS classified as a variable (locals, fields, array elements, ref params,
thisin structs, constrained generic receivers), the compiler lowers the operation to a single instance call on the lvalue, and property LHS never binds to the instance form at all. Call sites are therefore rewritten at the AST level inReplaceMethodCallsWithOperators(a separate metadata-name table, deliberately not merged into the one that feeds the static-operator fold inTransformAssignment); no new ILAst instruction is needed. Checked variants reuse the existingAddCheckedBlocksannotation mechanism, andHasCheckedEquivalent's genericop_X->op_CheckedXmapping covers the new names unchanged.Recognizing the new
op_*Assignmentnames flipsIMethod.IsOperatoron for instance methods, so the places that assumed operators are static (CallBuilder,CSharpResolver.GetUserDefinedOperatorCandidates,TransformAssignment) now checkIsStaticexplicitly. Declarations gate on a newUserDefinedCompoundAssignmentOperatorssetting (C# 14.0); below C# 14 they fall back to plainop_*methods, following the C# 11operator checkedprecedent. TheCompilerFeatureRequired("UserDefinedCompoundAssignmentOperators")attribute Roslyn emits on each operator is removed when operator syntax is used, and[IsReadOnly]is now hidden for operators soreadonlystruct operators print with the modifier only.Not covered yet: explicit interface implementations (their metadata has no
specialnameand the existing dotted-name operator detection is static-only, so they decompile as plain methods) and result-used forms liked = (c += 5), which decompile as two equivalent statements.New
UserDefinedCompoundAssignmentpretty fixture covering all 19 operators plus the call-site shapes above. Full ICSharpCode.Decompiler.Tests sweep on the rebased branch: 3364 total, 0 failed, 46 skipped.🤖 Generated with Claude Code