Skip to content

Add support for C# 14 user-defined compound assignment operators - #3972

Open
siegfriedpammer wants to merge 7 commits into
masterfrom
compound-assignment-operators
Open

Add support for C# 14 user-defined compound assignment operators#3972
siegfriedpammer wants to merge 7 commits into
masterfrom
compound-assignment-operators

Conversation

@siegfriedpammer

@siegfriedpammer siegfriedpammer commented Aug 9, 2026

Copy link
Copy Markdown
Member

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 to x += 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_*Assignment method. MetadataMethod classifies 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 no ref/params parameter. F# mangles static member (+=) to a static, value-returning op_AdditionAssignment and C++/CLI emits value-returning instance operators; both stay plain methods. Explicit interface implementations, whose metadata carries only the dotted name and no specialname, are recognized too, so void ICompound<int>.operator +=(int rhs) round-trips.

Recognition is gated by a UserDefinedCompoundAssignmentOperators decompiler setting and a matching TypeSystemOptions flag (the way the extension-method classification already is). Below C# 14 the methods stay plain op_*Assignment methods and keep their specialname flag as a [SpecialName] attribute; [IsReadOnly] is now surfaced for operators so a readonly struct 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++ with x an 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, and CallInstruction vetoes unfit receiver replacements through SatisfiesSlotRestrictionForInlining: not writable (this in a class, foreach/using/fixed variables, in parameters, 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 pair x += y binds 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 on CSharpResolver for the C# layer. Copy propagation asks the same check per receiver load, and foreach/using hand the operator a writable copy where the loop or using variable would otherwise be read-only.

Bind (builders and resolver)

CSharpResolver gains the two-phase rules next to GetUserDefinedOperatorCandidates: x op= y and a prefix increment consider the instance operators reachable from the static type of x first — 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. CallBuilder models 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) and void operator +=(int), the fold is written in a static-binding form. Binary operators become x = 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 and FixRemainingIncrements gives the increment a statement of its own. This closes a silent rebinding: UserDefinedCompoundAssignmentInheritance runs each shape before and after decompilation and previously printed 1 before and 100 after.

Fold calls (C# transforms)

A call to an instance operator becomes x op= y / x++ in ReplaceMethodCallsWithOperators only 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 an in overload whose by-value sibling is applicable stays a call, and a non-public operator (CS9308) stays a call too. In the other direction, PrettifyAssignments keeps x = x + y spelled out wherever folding it would hand the statement to an applicable instance operator.

Render (UI)

TypeSystemAstBuilder writes the operator += 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 carry new, and the [CompilerFeatureRequired] marker is removed. Tree and tooltips show operator +=(int) : void via a new ConversionFlags bit — widening ConversionFlags.All also turns on the existing checked-operator and >>> flags, which the tooltip ambience now sets explicitly. ilspycmd's -lv help lists the C# 14/15 language versions. The checked and >>>= names follow CheckedOperators / UnsignedRightShift like 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 an object-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 UserDefinedCompoundAssignment pretty 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. UserDefinedCompoundAssignmentInheritance executes every combination of where a static operator + and an instance operator += are declared across two levels, before and after decompilation — the only test kind that catches a silent rebinding, since the decompiled text looks fine. CompoundAssignmentOperatorEdgeCases pins hand-written-IL cases: the F#/C++ method shapes, base calls, non-public operators, mismatched receiver types, unconstrained generic receivers, covariant increment returns, and the statement-level shapes that print _ = x++;. NoUserDefinedCompoundAssignmentOperators pins 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

@christophwille christophwille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Explicit interface implementations of instance compound operators decompile as plain methods (OperatorDeclaration.cs) - output fails to compile (CS0539).
  2. Derived-typed receivers get a cast that becomes the assignment target (CallBuilder.cs) - emits (C)d += n (CS0131).
  3. Non-lvalue receivers become an invalid assignment LHS (ReplaceMethodCallsWithOperators.cs) - GetC() += n; (CS0131).
  4. Type-parameter cast stripped without checking constraints (ReplaceMethodCallsWithOperators.cs) - non-compiling or wrong-binding output.
  5. x op= y printed for static operator calls even when an instance compound operator exists (TransformAssignment.cs) - recompiled code binds to the instance operator, silently different runtime behavior.
  6. Static / value-returning op_*Assignment (F#, C++/CLI) now rendered as operator declarations (TypeSystemAstBuilder.cs) - invalid C#.
  7. No void-return check on the instance-operator rewrite (ReplaceMethodCallsWithOperators.cs).
  8. Checked compound-assignment names not gated on settings.CheckedOperators (ReplaceMethodCallsWithOperators.cs).
  9. HasCheckedEquivalent is signature-blind (ReplaceMethodCallsWithOperators.cs) - spurious unchecked wrappers.
  10. op_UnsignedRightShiftAssignment not gated on settings.UnsignedRightShift (ReplaceMethodCallsWithOperators.cs).

Comment thread ICSharpCode.Decompiler/CSharp/CallBuilder.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/TransformAssignment.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/Syntax/TypeSystemAstBuilder.cs
Comment thread ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/CallBuilder.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/Resolver/CSharpResolver.cs Outdated
Comment thread ICSharpCode.Decompiler/CSharp/Transforms/ReplaceMethodCallsWithOperators.cs Outdated
Comment thread ICSharpCode.Decompiler/IL/Transforms/ILInlining.cs Outdated
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@siegfriedpammer
siegfriedpammer force-pushed the compound-assignment-operators branch 5 times, most recently from 644dfea to aff4d4a Compare August 22, 2026 18:38

@christophwille christophwille left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. in-parameter operators defeat the shadow/rebind check - HandleCompoundAssign (TransformAssignment.cs:403) and WouldRebindOperator(IMethod, IType, ICompilation) (CSharpResolver.cs:1369) feed a ByReferenceType into overload resolution, which is applicable to nothing, so the check is always "not shadowed". x = x + y with static operator +(Foo, in Foo) next to operator +=(in Foo) folds to x += y, which C# 14 binds to the instance operator. UnwrapByRef() (as CallBuilder.cs:1752 already does) fixes both. No in-parameter operator exists in the fixtures.
  2. Statement-level x++ / ++s / foreach-local receiver escape the guard on three paths: the dead-store branch of TransformPostIncDecOperator (and ...WithInlineStore), FixRemainingIncrements when the store variable is still an object-typed stack slot, and CanBeDeconstructedInForeach (the deconstruction branch runs before the stloc branch that has the guard).
  3. Receiver materialization for 0-parameter operators (op_IncrementAssignment etc.) stores the receiver slot before FlushExpressionStack() runs, because the flush sits inside the per-parameter loop. Reproduced with hand-assembled IL: Foo(A(), ++x) where A() reassigns x decompiles to increment the old x. Roslyn happens to emit dup for this shape, so C#-compiled input is unaffected; other compilers/weavers are not.
  4. 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 in CallBuilder (-> AmbiguousMatch -> casts).

Cleanup

  • IsShadowedByInstanceOperator is 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; every x = x + y / ++x on decimal/DateTime/BigInteger/... pays for it even when the feature is off.
  • CopyPropagation.CannotReplaceCompoundAssignmentReceiver re-inlines the match that UserDefinedCompoundAssign.IsCompoundAssignmentReceiverUse (added by this PR, used by ILInlining/UsingTransform) already expresses.
  • MetadataMethod.IsUserDefinedCompoundAssignmentOperator carries two stacked <summary> blocks; the first belongs on IsCompoundAssignmentOperatorSignature, which has none.
  • OperatorDeclaration.IsCompoundAssignment relies on enum order (type >= AdditionAssignment).
  • CSharpResolver.PruneCandidatesHiddenByDerivedApplicable / IsApplicable duplicate OverloadResolution.AddMethodLists; ReplaceMethodCallsWithOperators.IsValidAssignmentTarget / IsAssignableTarget overlap each other and restate ILInlining.IsReadonlyCompoundAssignmentTarget.
  • ConversionFlags.All = 0xffffff now also switches on UsePrivateProtectedAccessibility / SupportExtensionDeclarations for tooltips and compare - probably desirable, but worth calling out in the PR description.
  • CSharpAmbience prints operator += for a non-public compound operator while TypeSystemAstBuilder writes it as a method.
  • CorrectnessTestRunner.roslyn5OrNewerOptions omits executesCompiledOutput: 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ICSharpCode.Decompiler/IL/Transforms/CopyPropagation.cs Outdated
Comment thread ICSharpCode.Decompiler/TypeSystem/Implementation/MetadataMethod.cs
/// 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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

… 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
@siegfriedpammer
siegfriedpammer force-pushed the compound-assignment-operators branch from bb6a4c4 to e4fa167 Compare August 29, 2026 08:01
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe no longer needed after my ILAst typing improvements?

Reviewed with Stampeded!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants