Decide backing-field references by one verdict, not three

Three places decided independently whether a backing field would still be
declared, and they disagreed. ReplaceBackingFieldUsage rewrote a constructor
store into a property assignment whenever the property looked collapsible,
without asking whether PatternStatementTransform would actually remove the
declaration; ConvertField printed the "field" keyword on the FieldKeyword
setting alone, ignoring the GetterOnlyAutomaticProperties veto that
MemberIsHidden applies to the same field.

Two consequences, both silent. A setter-less property under
GetterOnlyAutomaticProperties = false kept its declaration and got "field" in
the getter anyway, so the keyword bound to a second synthesized field and the
declared one went unwritten. A settable property under AutomaticProperties =
false had its initializing store turned into a property assignment that
TransformFieldAndConstructorInitializers could no longer lift, leaving the
constructor in the output with an unconverted base-constructor call.

BackingFieldWillBeRemoved is now the single verdict every branch consults, and
it mirrors the transform's own entry gate. A property that keeps explicit
accessors is no longer addressed by name: the store stays a field reference and
becomes the property initializer, which is what field-backed storage means. The
one exception is a setter-less property's constructor store, which C# allows to
be written as an assignment and which has no other expressible form.

IsBackingFieldOfAutomaticProperty now answers through TryGetBackingField instead
of its own name check, so the two directions of "is this field that property's
storage" cannot diverge on staticness or field type. ReplaceBackingFieldUsage
dispatches on the resolve result rather than the identifier's spelling; after
ConvertField the same field appears both as "field" and under its metadata name
carrying the same annotation, so matching the name was matching the wrong thing.

The keyword's own precondition moves into a CanUseFieldKeyword local function.
Seven clauses with comment blocks wedged between them had to be read as one
expression; as one early return per rule the list reads in order.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
This commit is contained in:
Siegfried Pammer
2026-08-30 11:48:44 +02:00
parent bdf48e5a47
commit e423cfb9ca
8 changed files with 175 additions and 60 deletions
@@ -331,25 +331,10 @@ namespace ICSharpCode.Decompiler.CSharp
return eventReference.WithRR(eventResolveResult);
}
if (settings.FieldKeyword
&& decompilationContext.CurrentMember is IProperty accessedProperty
&& accessedProperty.Parameters.Count == 0
// Ask exactly the question PatternStatementTransform asks when it decides whether
// the field declaration can go away. A looser test here prints `field` inside a
// property whose declaration then keeps explicit accessors and its field: on
// recompile the keyword binds to a freshly synthesized backing field while the
// original one stays declared and unwritten - silently different storage.
&& PatternStatementTransform.TryGetBackingField(accessedProperty, out var backingField)
&& field.MemberDefinition.Equals(backingField.MemberDefinition)
// Only THIS instance's field is the `field` keyword. IL can load another
// instance's backing field inside an accessor (weavers, obfuscators, hand-written
// IL); rendering that as `field` would redirect the access, and drop whatever
// side effect producing the target had.
&& (field.IsStatic || TargetIsThis(targetInstruction)))
if (CanUseFieldKeyword())
{
// Inside its own property's get/set/init accessor (including nested lambdas and
// local functions), the backing field is the C# 14 "field" keyword. It must stay
// unqualified: "this.field" would refer to a real member named "field".
// The keyword must stay unqualified: "this.field" would refer to a real member
// named "field".
return new IdentifierExpression("field")
.WithRR(new MemberResolveResult(null, field));
}
@@ -441,6 +426,38 @@ namespace ICSharpCode.Decompiler.CSharp
}
return expr;
// Whether this access may be rendered as the C# 14 "field" keyword: it has to be the
// backing field of the property whose accessor is being decompiled, read off this
// instance, in a property the declaration can actually disappear from. Nested lambdas
// and local functions inside the accessor count as being inside it.
bool CanUseFieldKeyword()
{
if (!settings.FieldKeyword)
return false;
if (decompilationContext.CurrentMember is not IProperty property || property.Parameters.Count != 0)
return false;
// With GetterOnlyAutomaticProperties off, a setter-less property keeps its backing
// field declared (CSharpDecompiler.MemberIsHidden) and PatternStatementTransform
// leaves the property alone, so the keyword would land next to the declaration it
// is supposed to replace.
if (!property.CanSet && !settings.GetterOnlyAutomaticProperties)
return false;
// Exactly the question PatternStatementTransform asks before removing the
// declaration. A looser test prints "field" in a property that then keeps its
// field: on recompile the keyword binds to a freshly synthesized backing field
// while the original stays declared and unwritten - silently different storage.
if (!PatternStatementTransform.TryGetBackingField(property, out var backingField)
|| !field.MemberDefinition.Equals(backingField.MemberDefinition))
{
return false;
}
// Only THIS instance's field is the keyword. IL can load another instance's backing
// field inside an accessor (weavers, obfuscators, hand-written IL); rendering that
// as "field" would redirect the access and drop whatever side effect produced the
// target.
return field.IsStatic || TargetIsThis(targetInstruction);
}
}
// References to an automatic event's backing field are printed as the event. Gated on