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 7 commits into
Conversation
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).
46beab7 to
d218467
Compare
| 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. |
There was a problem hiding this comment.
This doesn't look like a compound assignment to the decompiler -- for a real compound assignment on a class without operator+=, csc would emit only a single ldelema.
There was a problem hiding this comment.
Right - written out in full it is a plain store and never reaches the compound assignment path.
9cbc574 adds UseCompoundAssignmentOnArrayElement and UseCompoundAssignmentOnStructArrayElement
on element types that declare no instance operator, so arr[0] += n compiles to the shapes the
decompiler has to recognize: the array spilled to a local for a reference type, a single ldelema
for a value type. The original case is kept next to them, where the element type does declare an
instance operator and the static call has to stay spelled out.
Reply written by an AI agent (Claude) on Siegfried's behalf.
There was a problem hiding this comment.
I think there's still no test for the ref-local operator+ call that could get mis-decompiled into an operator+= call.
I'm asking for a test that would fail if we didn't have IsShadowedByInstanceOperator.
644dfea to
aff4d4a
Compare
christophwille
left a comment
There was a problem hiding this comment.
Review: C# 14 user-defined compound assignment operators
Overall the feature is solid: the two-phase binding model, the instance/static shadowing guard and the receiver-lvalue protection are well thought out and well covered by fixtures. The problems below all sit at the seams of that guard. I went through the three commits (recognize / decompile / render, 40 files) with several independent passes; one candidate (the HasDefaultStackSlotType change) was refuted empirically (no output diff master vs PR) and is not listed.
Correctness (inline comments carry the details)
in-parameter operators defeat the shadow/rebind check -HandleCompoundAssign(TransformAssignment.cs:403) andWouldRebindOperator(IMethod, IType, ICompilation)(CSharpResolver.cs:1369) feed aByReferenceTypeinto overload resolution, which is applicable to nothing, so the check is always "not shadowed".x = x + ywithstatic operator +(Foo, in Foo)next tooperator +=(in Foo)folds tox += y, which C# 14 binds to the instance operator.UnwrapByRef()(as CallBuilder.cs:1752 already does) fixes both. Noin-parameter operator exists in the fixtures.- Statement-level
x++/++s/ foreach-local receiver escape the guard on three paths: the dead-store branch ofTransformPostIncDecOperator(and...WithInlineStore),FixRemainingIncrementswhen the store variable is still an object-typed stack slot, andCanBeDeconstructedInForeach(the deconstruction branch runs before the stloc branch that has the guard). - Receiver materialization for 0-parameter operators (
op_IncrementAssignmentetc.) stores the receiver slot beforeFlushExpressionStack()runs, because the flush sits inside the per-parameter loop. Reproduced with hand-assembled IL:Foo(A(), ++x)whereA()reassignsxdecompiles to increment the oldx. Roslyn happens to emitdupfor this shape, so C#-compiled input is unaffected; other compilers/weavers are not. - Receiver machinery keys on
IsOperator && !IsStatic, not on the compound-assignment shape (ILReader.cs:1842,UserDefinedCompoundAssign.IsCompoundAssignmentReceiverUse), so C++/CLI-style value-returning instance operators get forced into stack slots that inlining then refuses to fold (R r = GetR(); r.op_Addition(x);), and with the setting off such calls no longer reach any operator branch inCallBuilder(->AmbiguousMatch-> casts).
Cleanup
IsShadowedByInstanceOperatoris not gated by the setting at its four callers and walks the type hierarchy twice per call (plain + checked name) plus an O(n^2) dedup; everyx = x + y/++xondecimal/DateTime/BigInteger/... pays for it even when the feature is off.CopyPropagation.CannotReplaceCompoundAssignmentReceiverre-inlines the match thatUserDefinedCompoundAssign.IsCompoundAssignmentReceiverUse(added by this PR, used by ILInlining/UsingTransform) already expresses.MetadataMethod.IsUserDefinedCompoundAssignmentOperatorcarries two stacked<summary>blocks; the first belongs onIsCompoundAssignmentOperatorSignature, which has none.OperatorDeclaration.IsCompoundAssignmentrelies on enum order (type >= AdditionAssignment).CSharpResolver.PruneCandidatesHiddenByDerivedApplicable/IsApplicableduplicateOverloadResolution.AddMethodLists;ReplaceMethodCallsWithOperators.IsValidAssignmentTarget/IsAssignableTargetoverlap each other and restateILInlining.IsReadonlyCompoundAssignmentTarget.ConversionFlags.All = 0xffffffnow also switches onUsePrivateProtectedAccessibility/SupportExtensionDeclarationsfor tooltips and compare - probably desirable, but worth calling out in the PR description.CSharpAmbienceprintsoperator +=for a non-public compound operator whileTypeSystemAstBuilderwrites it as a method.CorrectnessTestRunner.roslyn5OrNewerOptionsomitsexecutesCompiledOutput: true.- CallBuilder.cs:1703: "modelled" -> "modeled" (en-US rule in CLAUDE.md).
| if (CSharp.ExpressionBuilder.GetAssignmentOperatorTypeFromMetadataName(operatorCall.Method.Name, context.Settings) == null) | ||
| return false; | ||
| rhs = operatorCall.Arguments[1]; | ||
| valueType = operatorCall.GetParameter(1).Type; |
There was a problem hiding this comment.
operatorCall.GetParameter(1).Type is a ByReferenceType when the static operator takes in Foo / ref readonly Foo. IsShadowedByInstanceOperator wraps that in a plain ResolveResult, and OverloadResolution.CheckApplicability (OverloadResolution.cs:702-728) only strips the parameter's by-ref and then asks for ImplicitConversion(ByReferenceType(Foo) -> Foo), which is None - so no candidate is ever applicable and the shadow check silently returns false.
Repro: a type with static Foo operator +(Foo a, in Foo b) and public void operator +=(in Foo b) (or +=(Foo)); source x = x + y compiles to stloc x(call op_Addition(ldloc x, ldloca y)), this transform emits x += y, and C# 14 binds that to the instance operator (instance phase first) - recompiled code calls a different method.
PrettifyAssignments uses binary.Right.GetResolveResult().Type (by-value) and is fine. Fix here: GetParameter(1).Type.UnwrapByRef() (TypeSystemExtensions.cs:459, as CallBuilder.cs:1752 already does). None of the new fixtures has an in-parameter operator; worth adding one next to BothOperators.
| { | ||
| ResolveResult[] arguments = called.Parameters.Count == 0 | ||
| ? [] | ||
| : [new ResolveResult(called.Parameters[0].Type)]; |
There was a problem hiding this comment.
Same by-ref issue as in HandleCompoundAssign: for operator +=(in T) called.Parameters[0].Type is a ByReferenceType, so no candidate is applicable, PruneCandidatesHiddenByDerivedApplicable prunes nothing, and rebinding through a new operator on a derived receiver type is never detected from ILInlining.CanReplaceCompoundAssignmentReceiver / CopyPropagation.
class Base { public void operator +=(in Foo f) }, class Derived : Base { public new void operator +=(in Foo f) }, source Base b = derived; b += f; -> inlining substitutes ldloc derived for the receiver slot, ReplaceMethodCallsWithOperators then re-checks with the real argument resolve result (231-237), sees the new operator and keeps the call -> derived.op_AdditionAssignment(in f) (not valid C#) instead of the foldable b += f.
called.Parameters[0].Type.UnwrapByRef() (a by-value ResolveResult is the right model; a ByReferenceResolveResult(In) would wrongly skip by-value siblings, see OverloadResolution.cs:686-689). Only the ReplaceMethodCallsWithOperators overload is exercised by the CallInOverload ILPretty case.
| { | ||
| firstArgumentInstruction = new LdObjIfRef(firstArgumentInstruction, typeOfThis); | ||
| } | ||
| else if (materializeReceiver) |
There was a problem hiding this comment.
Evaluation-order bug for the zero-parameter operators (op_IncrementAssignment / op_DecrementAssignment and checked forms): AllocateStackSlot appends stloc S(receiver) to the current block, but the FlushExpressionStack() at 1844-1847 is inside the per-parameter loop, which has zero iterations here - so the receiver read is hoisted above pending side effects on the expression stack.
Reproduced with this branch's ilspycmd on hand-assembled IL ldarg.0; call object Test::A(); ldarg.0; ldfld Counter Test::x; callvirt void Counter::op_IncrementAssignment(); ldarg.0; ldfld x; call Foo(object, Counter) where A() reassigns this.x: output is Counter counter = x; object o = A(); counter++; Foo(o, x); - increments the old x, the original increments the new one. The same IL with a 1-arg op_AdditionAssignment decompiles correctly (object o = A(); x += 1; Foo(o, x);).
Roslyn emits dup for Foo(A(), ++x) so C#-compiled input happens to be unaffected, but any other compiler/weaver/hand IL is not. Fix: if (materializeReceiver) FlushExpressionStack(); before the loop, independent of Parameters.Count.
| // Only an object reference is worth materializing: a value-type receiver is passed by | ||
| // address, so it already denotes a variable, and copying it into another one would | ||
| // make the operator mutate the copy. | ||
| bool materializeReceiver = IsNonStaticOperatorCall() && expectedStackType == StackType.O; |
There was a problem hiding this comment.
materializeReceiver keys on IsNonStaticOperatorCall() (IsOperator && !IsStatic) and is not gated by any setting; UserDefinedCompoundAssign.IsCompoundAssignmentReceiverUse (CompoundAssignmentInstruction.cs:357) uses the same predicate. Neither checks the compound-assignment name or the void return, so every value-returning instance operator - C++/CLI R^ operator+(R^), still SymbolKind.Operator via MetadataMethod.cs:84-90 exactly as before this PR - gets its receiver forced into a stack slot that ILInlining.CanReplaceCompoundAssignmentReceiver (351-361) then refuses to inline unless it is LdLoc/LdObj/LdFlda/LdsFlda.
Net effect on a C++/CLI assembly: GetR().op_Addition(x) / r.Prop.op_Addition(x) / new R().op_Addition(x) regress to R r = GetR(); r.op_Addition(x);, and foreach/using receivers get forced local copies (StatementBuilder 1112-1118, PatternStatementTransform 326-332/674-676, UsingTransform 193-205, CopyPropagation 163-176) - although ReplaceMethodCallsWithOperators will never emit an op= form for a non-*Assignment name. The InstanceOperatorCall ILPretty fixture only has ldarg.0 receivers, so it cannot catch this.
Suggest restricting both predicates to the C# 14 shape (OperatorDeclaration.IsCompoundAssignment(GetOperatorType(name)) + void return) and gating the reader on the setting like every other site.
There was a problem hiding this comment.
Caution: this PR is already overly complicated. If complexity is exploded further for minor gains (like this AI comment seems to want), the whole feature will be rejected.
Reviewed with Stampeded!
| /// </summary> | ||
| static IType GetIncrementTargetType(Call call) | ||
| { | ||
| if (call.SlotInfo == StLoc.ValueSlot && call.Parent!.SlotInfo == Block.InstructionSlot) |
There was a problem hiding this comment.
GetIncrementTargetType returns ((StLoc)call.Parent).Variable.Type; for an ILReader StackSlot that is still the System.Object placeholder, so IsShadowedByInstanceOperator finds no candidates, the call is rewritten to stloc S(ldloc x); ++S (86-89), and ExpressionBuilder.VisitStLoc (872-880, HasDefaultStackSlotType true for object) later retypes S to x's real type.
Scenario: Both declares static Both operator ++(Both) and void operator ++(). Foo(++x) reads as stloc S(call op_Increment(ldloc x)); stloc x(ldloc S); call Foo(ldloc S) (S has two loads, not inlined). With MakeAssignmentExpressions=false (TransformAssignment.cs:46-54 skips both inline-assignment transforms) or when TransformInlineAssignmentStObjOrCall bails (impure/used-within target, parameterized setter, 139-199), the object-typed block-level stloc reaches this transform, passes the shadow check against object, and the output Both s = x; ++s; x = s; Foo(s); binds the instance operator ++() under C# 14 instead of the static op_Increment the IL called.
For a StackSlot store variable use call.Arguments[0].InferType(...) / chase the alias like ILInlining.GetReceiverType (ILInlining.cs:375-385), or fall back to call.GetParameter(0).Type.
| // as "x op= y" or "x++". C# resolves that form in two phases, the instance operators | ||
| // on the static type of x first and the static operators only if none of them is | ||
| // applicable, so these candidates are the whole of what recompilation considers here. | ||
| // The fallback phase is not modelled: a call this set cannot account for gives up the |
There was a problem hiding this comment.
nit: "modelled" -> "modeled" (en-US per CLAUDE.md).
| } | ||
| // IsCompoundStore accepts a store to a local (StLoc) or to a field, array element, | ||
| // ref or pointer (StObj), which are variables, and a setter call, which is not. | ||
| if (CSharpResolver.IsShadowedByInstanceOperator(operatorCall.Method, targetType, valueType, |
There was a problem hiding this comment.
Cost/gating: IsShadowedByInstanceOperator is called from four sites (here, 906, FixRemainingIncrements.cs:51, PrettifyAssignments.cs:112) without a Settings.UserDefinedCompoundAssignmentOperators gate, and each call walks the full type hierarchy twice (plain name + checked name via two GetInstanceOperatorCandidates calls, CSharpResolver.cs:1304-1306) plus an O(n^2) GetBaseMembers dedup (1266-1270).
So every x = x + y / ++x on any user-defined type (decimal, DateTime, TimeSpan, BigInteger, ...) reaching these transforms now pays two BaseTypeCollector walks over every method of every non-interface base type - even when the setting is off, in which case DecompilerTypeSystem.GetOptions never classifies op_*Assignment as Operator and a candidate can never be found. ILInlining / CopyPropagation / StatementBuilder / UsingTransform all gate on the setting; these four do not.
Cheaper: gate on the setting (parameter or call site), collect both names in one GetMethods pass (m.Name == name || m.Name == checkedName), and if (candidates.Count <= 1) return candidates; before the dedup.
| /// Gets whether the operator type is a C# 14 user-defined compound assignment operator | ||
| /// (a void-returning instance operator, including the increment/decrement forms). | ||
| /// </summary> | ||
| public static bool IsCompoundAssignment(OperatorType type) |
There was a problem hiding this comment.
type >= AdditionAssignment depends on the enum order staying as it is; an explicit switch (like IsChecked next to it) or deriving from the names table would not break silently when someone appends a non-assignment member to OperatorType.
aff4d4a to
bb6a4c4
Compare
… system C# 14 lets a type declare "void operator +=(T)" and friends: void-returning instance methods under the op_*Assignment metadata names. Only that exact shape becomes SymbolKind.Operator, and only while the new setting is on, because other languages use the same names for unrelated methods: F# mangles "static member (+=)" to a static, value-returning op_AdditionAssignment, and C++/CLI emits value-returning instance operators. Those, and every method when the setting is off, stay plain methods with a surfaced [SpecialName]. Assisted-by: Claude:claude-opus-5:Claude Code
An instance compound assignment operator has no callable spelling: its call sites can only be written as "x op= y" or "x++", and C# requires x to be an assignable variable whose static type binds the operator the call names. The reader therefore parks an object-typed receiver in a stack slot typed with the operator's declaring type, and the passes that would substitute or retype such a receiver hold back. CallInstruction vetoes unfit receiver replacements through SatisfiesSlotRestrictionForInlining - not writable, not a storage location, or of a type that declares a hiding operator of the same name or its checked/unchecked sibling (a declaration-existence check on the type system, deliberately not a binding question) - which covers inlining; copy propagation asks the same check per receiver load, the using-transform hands receiver loads a writable copy of the read-only using variable, and stack slots that were typed on purpose keep their type when translated. Assisted-by: Claude:claude-opus-5:Claude Code
"x op= y" and a prefix increment resolve in two phases: the instance operators reachable from the static type of x come first wherever x is a variable - whether or not the result is used - and the static operators only if none of them is applicable. A postfix increment whose result is used is the one form that always binds a static operator. CallBuilder models the first phase when it checks that recompiling an instance operator call binds the same method; the fallback phase is deliberately not modeled, since a call the instance candidates cannot account for must give up the operator form rather than collide with a static operator. The same rule cuts the other way for the folds built from static operator calls: a shadowed fold has to be written in one of the static-binding forms. The binary operators become "x = x + y"; an increment becomes a postfix increment whose result goes to a discard, "_ = x++;". That form only exists as a statement, so the folds that would embed a shadowed increment in an expression hold back and FixRemainingIncrements gives the increment a statement of its own instead. A foreach variable cannot be such a receiver at all (it is read-only), so the loop keeps the existing variable as a writable copy, both for a plain loop variable and for a deconstruction. Assisted-by: Claude:claude-opus-5:Claude Code
A call to an instance compound assignment operator becomes "x op= y" or "x++" only when C# would accept the receiver in that position and bind the same operator there: the receiver has to be an assignable variable (rather than "this" in a class, a foreach or using variable, an "in" parameter, or a readonly field outside its constructor), an operator declared in an interface needs an interface- or type-parameter-typed receiver, and the operator form cannot pick an overload by parameter modifier, so a call to an "in" overload whose by-value sibling is applicable stays a call. A non-public operator has no operator form at all (CS9308), and stays a call too. In the other direction, "x = x + y" keeps the explicit spelling wherever folding it to "x += y" would hand the statement to an applicable instance operator. Assisted-by: Claude:claude-opus-5:Claude Code
TypeSystemAstBuilder writes the C# 14 "operator +=" declaration form, behind a support flag like the other version-gated operator syntax; a non-public operator has no legal operator declaration (CS9308) and falls back to a plain method, except an explicit interface implementation, which is private in metadata but still written in operator form. An instance operator hides by signature like an ordinary method, so it can carry "new"; the [CompilerFeatureRequired] marker the compiler emits is removed like the other feature markers. Tooltips get the same rendering via a ConversionFlags bit; widening ConversionFlags.All also turns on the existing checked-operator and unsigned-right-shift flags, which the tooltip ambience now sets explicitly. The test fixtures land here, where the pipeline is complete end to end: pretty and IL round-trips, correctness runs against Roslyn's C# 14 binding (including the operator-inheritance matrix and a ref-local target), and the ugly configuration pins the output with the setting off. Assisted-by: Claude:claude-opus-5:Claude Code
bb6a4c4 to
e4fa167
Compare
The receiver slot is appended to the current block, but the expression stack was only flushed inside the per-parameter loop - which the increment operators, taking no parameters, never enter. A side effect still pending on the stack was then emitted after the receiver read it should precede, so the increment applied to a stale value of the field it targets. Assisted-by: Claude:claude-opus-5:Claude Code
The reader's receiver materialization, the inlining slot restriction, and the receiver-use predicate keyed on any instance operator, so C++/CLI-style value-returning classic operators - which have an ordinary call spelling and need none of it - got their receivers spilled into locals. CallBuilder had the mirror problem: it took classic instance operators off the candidate path they always used. One predicate now decides what the machinery applies to: an instance operator under one of the op_*Assignment names, which classification already guarantees has the C# 14 shape. Assisted-by: Claude:claude-opus-5:Claude Code
| /// derived from the stack type alone and therefore says nothing about the value stored in it. | ||
| /// A slot that was given a type on purpose has to keep it. | ||
| /// </summary> | ||
| bool HasDefaultStackSlotType(ILVariable variable) |
There was a problem hiding this comment.
Maybe no longer needed after my ILAst typing improvements?
Reviewed with Stampeded!
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 binding rules are taken from the proposal and validated against Roslyn throughout.The branch is five commits, one per architecture layer.
Classify (type system)
C# 14 emits an instance compound assignment operator as a void-returning
op_*Assignmentmethod.MetadataMethodclassifies such a method as an operator only when it has the shape C# requires — an instance, void-returning method with the right arity (one parameter, none for++/--) and noref/paramsparameter. F# manglesstatic member (+=)to a static, value-returningop_AdditionAssignmentand C++/CLI emits value-returning instance operators; both stay plain methods. Explicit interface implementations, whose metadata carries only the dotted name and nospecialname, are recognized too, sovoid ICompound<int>.operator +=(int rhs)round-trips.Recognition is gated by a
UserDefinedCompoundAssignmentOperatorsdecompiler setting and a matchingTypeSystemOptionsflag (the way the extension-method classification already is). Below C# 14 the methods stay plainop_*Assignmentmethods and keep theirspecialnameflag as a[SpecialName]attribute;[IsReadOnly]is now surfaced for operators so areadonlystruct operator prints with the modifier.Keep the receiver a variable (IL)
Once classified, an operator has no callable spelling (CS0571), so every call site must end as
x op= y/x++withxan assignable variable whose static type binds the operator the call names. The reader materializes a reference-type receiver into a stack slot typed with the operator's declaring type, andCallInstructionvetoes unfit receiver replacements throughSatisfiesSlotRestrictionForInlining: not writable (thisin a class, foreach/using/fixed variables,inparameters, readonly fields outside their constructor), not a storage location, or of a type that declares a hiding operator of the same name or its checked/unchecked sibling — which of the pairx += ybinds depends on the checked context the statement ends up in, so both count. That last test is a declaration-existence walk on the type system, deliberately not a binding question; the exact, overload-resolution-based form of the same question lives onCSharpResolverfor the C# layer. Copy propagation asks the same check per receiver load, andforeach/usinghand the operator a writable copy where the loop or using variable would otherwise be read-only.Bind (builders and resolver)
CSharpResolvergains the two-phase rules next toGetUserDefinedOperatorCandidates:x op= yand a prefix increment consider the instance operators reachable from the static type ofxfirst — whether or not the result is used — and the static operators only when none is applicable. The one form that always binds a static operator is a postfix increment whose result is used.CallBuildermodels the first phase when checking that recompiling an instance operator call binds the same method.The same rule governs the folds built from static operator calls: where a type declares both
static C operator +(C, int)andvoid operator +=(int), the fold is written in a static-binding form. Binary operators becomex = x + y(any position); an increment becomes a postfix increment whose result goes to a discard,_ = x++;. That form only exists as a statement, so folds that would embed a shadowed increment in an expression hold back andFixRemainingIncrementsgives the increment a statement of its own. This closes a silent rebinding:UserDefinedCompoundAssignmentInheritanceruns each shape before and after decompilation and previously printed1before and100after.Fold calls (C# transforms)
A call to an instance operator becomes
x op= y/x++inReplaceMethodCallsWithOperatorsonly when C# accepts the receiver in that position and binds the same operator there; the operator form cannot pick an overload by parameter modifier, so a call to aninoverload whose by-value sibling is applicable stays a call, and a non-public operator (CS9308) stays a call too. In the other direction,PrettifyAssignmentskeepsx = x + yspelled out wherever folding it would hand the statement to an applicable instance operator.Render (UI)
TypeSystemAstBuilderwrites theoperator +=declaration form behind a support flag like the other version-gated operator syntax; non-public operators fall back to plain methods (except explicit interface implementations), instance operators can carrynew, and the[CompilerFeatureRequired]marker is removed. Tree and tooltips showoperator +=(int) : voidvia a newConversionFlagsbit — wideningConversionFlags.Allalso turns on the existing checked-operator and>>>flags, which the tooltip ambience now sets explicitly. ilspycmd's-lvhelp lists the C# 14/15 language versions. The checked and>>>=names followCheckedOperators/UnsignedRightShiftlike every other operator name, at call sites and declarations alike.Not covered
Result-used forms like
d = (c += 5)decompile as two equivalent statements. Hand-written IL with no C# spelling keeps an explicit call even though it will not recompile — an operator called on anobject-typed receiver, or a shadowed increment in a method that declares a local named_(the discard is the only static-binding spelling): visibly broken output over silently binding the wrong operator.Tests
The
UserDefinedCompoundAssignmentpretty fixture covers all 19 operators, the call-site shapes above, inheritance, explicit interface implementation, ref locals and ref returns, a checked operator hidden by an unchecked sibling, and types declaring both the static and the instance operator.UserDefinedCompoundAssignmentInheritanceexecutes every combination of where a staticoperator +and an instanceoperator +=are declared across two levels, before and after decompilation — the only test kind that catches a silent rebinding, since the decompiled text looks fine.CompoundAssignmentOperatorEdgeCasespins hand-written-IL cases: the F#/C++ method shapes,basecalls, non-public operators, mismatched receiver types, unconstrained generic receivers, covariant increment returns, and the statement-level shapes that print_ = x++;.NoUserDefinedCompoundAssignmentOperatorspins the output with the setting off, byte-identical to master. Each shadowing guard has a fixture that fails when that guard is disabled. Full ICSharpCode.Decompiler.Tests sweep: 3520 total, 0 failed, 45 skipped.🤖 Generated with Claude Code