From 539bf1700f12f3d40bacd64c7de64e48eac2519f Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sun, 9 Aug 2026 19:28:52 +0200 Subject: [PATCH 1/3] Enqueue referenced members by definition in the decompile worklist In generic types, resolve results reference members specialized by the type's own type parameters. The worklist dedupe and entityMap in DoDecompile(ITypeDefinition) are keyed by definition, so a hidden member re-added through the worklist (e.g. a property backing field referenced from an accessor) was decompiled under a key the output pass never looks up, silently dropping the declaration while keeping its uses. Assisted-by: Claude:claude-fable-5:Claude Code --- ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 2e4ce50676..80361eb410 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -2030,7 +2030,11 @@ void EnqueueReferencedMembers(EntityDeclaration decl) && mrr.Member.DeclaringTypeDefinition == typeDef && !(mrr.Member is IMethod { IsLocalFunction: true })) { - workList.Enqueue(mrr.Member); + // In generic types the reference is to a member specialized by the type's + // own type parameters, but entityMap and the dequeue dedupe are keyed by + // the definition; enqueueing the specialized member would decompile the + // member under a key the output pass never looks up. + workList.Enqueue(mrr.Member.MemberDefinition); } else if (rr is TypeResolveResult trr && trr.Type.GetDefinition()?.DeclaringTypeDefinition == typeDef) From 2a88656cede283f062d8da19fbf762e72f555d56 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 6 Jul 2026 11:37:04 +0200 Subject: [PATCH 2/3] Fix #3624: decompile semi-auto properties with the C# 14 field keyword Backing-field references inside a property's own get/set/init accessors are emitted as the `field` keyword at IL-to-AST translation time (ExpressionBuilder.ConvertField), so arbitrary accessor bodies become expressible and no separate rewrite pass is needed. Compiler-generated trivial accessors then collapse individually to `get;`/`set;`, which also handles mixed shapes like `{ get; set { ... field ... } }`; the backing-field declaration is removed with its remaining attributes re-hosted as `field:` sections, and constructor stores become property initializers (or property assignments, for setter-less properties). Implicit zero-stores that auto-default struct constructors emit for unassigned backing fields are dropped rather than lifted. Recognition stays AST/metadata-based rather than mirroring the ILAst analysis used for automatic events: events must prove compiler-generated bodies before discarding them, while the field keyword discards nothing, so name association plus the accessor context is sufficient. Below C# 14 (or with the new FieldKeyword setting off), the field declaration survives under its metadata name, so the UI keeps showing the truth; EscapeInvalidIdentifiers - the transform the compilable-output flows (project export, VS, tests) already add - now maps `

k__BackingField` to the readable `P__BackingField` instead of the generic character escape. A genuine field literally named "field" is qualified as `this.field` inside accessors, and locals are not named "field" there, since C# 14 rebinds the bare identifier. Bodiless accessors mixed into multi-line properties get their own line in the output. The fixture covering the feature surface lands with the implementation rather than as a separate xfailed commit. It is excluded from the test-assembly compilation because its nullable annotations would trip warnings-as-errors there. Assisted-by: Claude:claude-fable-5:Claude Code --- .../Helpers/Tester.cs | 1 + .../ICSharpCode.Decompiler.Tests.csproj | 4 + .../PrettyTestRunner.cs | 6 + .../TestCases/Pretty/FieldKeyword.cs | 246 +++++++++++++++ .../TestCases/Ugly/NoFieldKeyword.Expected.cs | 60 ++++ .../TestCases/Ugly/NoFieldKeyword.cs | 58 ++++ .../UglyTestRunner.cs | 15 + .../CSharp/CSharpDecompiler.cs | 8 +- .../CSharp/ExpressionBuilder.cs | 45 +++ .../OutputVisitor/CSharpOutputVisitor.cs | 31 +- .../Transforms/EscapeInvalidIdentifiers.cs | 25 +- .../Transforms/PatternStatementTransform.cs | 286 +++++++++++++++++- ...ransformFieldAndConstructorInitializers.cs | 56 +++- ICSharpCode.Decompiler/DecompilerSettings.cs | 8 + .../IL/Transforms/AssignVariableNames.cs | 8 + ILSpy/Properties/Resources.resx | 3 + 16 files changed, 840 insertions(+), 20 deletions(-) create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Pretty/FieldKeyword.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.Expected.cs create mode 100644 ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.cs diff --git a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs index adebe99317..c3f0a80768 100644 --- a/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs +++ b/ICSharpCode.Decompiler.Tests/Helpers/Tester.cs @@ -457,6 +457,7 @@ private static string ReplacePrivImplDetails(string il) "System.Linq.Queryable.dll", "System.IO.FileSystem.Watcher.dll", "System.Memory.dll", + "System.ObjectModel.dll", "System.Threading.dll", "System.Threading.Thread.dll", "System.Runtime.dll", diff --git a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj index f112879f55..c0d10da6fc 100644 --- a/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj +++ b/ICSharpCode.Decompiler.Tests/ICSharpCode.Decompiler.Tests.csproj @@ -234,6 +234,8 @@ + + @@ -248,6 +250,8 @@ + + diff --git a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs index 644b12a440..634aaa5cf1 100644 --- a/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/PrettyTestRunner.cs @@ -687,6 +687,12 @@ public async Task ExtensionEverything([ValueSource(nameof(roslyn5OrNewerOptions) await RunForLibrary(cscOptions: cscOptions | CompilerOptions.Preview | CompilerOptions.NullableEnable); } + [Test] + public async Task FieldKeyword([ValueSource(nameof(roslyn5OrNewerOptions))] CompilerOptions cscOptions) + { + await RunForLibrary(cscOptions: cscOptions | CompilerOptions.NullableEnable); + } + [Test] public async Task NullPropagation([ValueSource(nameof(roslynOnlyOptions))] CompilerOptions cscOptions) { diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FieldKeyword.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FieldKeyword.cs new file mode 100644 index 0000000000..643b27d651 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/FieldKeyword.cs @@ -0,0 +1,246 @@ +using System; +using System.ComponentModel; + +namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty +{ + internal class FieldKeyword + { + public class BaseVirtual + { + public virtual int P { + get { + return field; + } + set { + field = value + 1; + } + } + } + + public class DerivedVirtual : BaseVirtual + { + public override int P { + get { + return field * 2; + } + set { + field = value - 1; + } + } + } + + public class Generic where T : class + { + public T? Item { + get { + return field; + } + set { + if (value != null) + { + field = value; + } + } + } + + public T Lazy => field ?? (field = Activator.CreateInstance()); + } + + public class Notify : INotifyPropertyChanged + { + public string Name { + get { + return field; + } + set { + if (field != value) + { + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Name")); + } + } + } = ""; + + public event PropertyChangedEventHandler? PropertyChanged; + } + + public struct StructProperties + { + public int Value { + get { + return field; + } + set { + field = value & 0xFF; + } + } + + public readonly int Doubled => field * 2; + + public StructProperties(int doubled) + { + Doubled = doubled; + } + } + + public class RealFieldName + { + private int field; + + public int PlusOne => this.field + 1; + + public void Reset() + { + field = 0; + } + } + + // 0.00m and -0.0 compare equal to their defaults but are observably different, so + // neither initializer may be dropped as a redundant default. + public struct PreciseDefaults + { + public decimal Scale { + get { + return field; + } + set { + field = value; + } + } = 0.00m; + + public double Sign { + get { + return field; + } + set { + field = value; + } + } = -0.0; + + public PreciseDefaults() + { + } + } + + public Func Capture { + get { + return () => (field != null) ? 1 : 0; + } + set; + } + + public int GetOnly { + get { + if (field == 0) + { + field = 42; + } + return field; + } + } + + public string InitChecked { + get; + init { + field = value.Trim(); + } + } = ""; + + public int LazyGet { + get { + if (field == 0) + { + field = ComputeDefault(); + } + return field; + } + set; + } + + public string NullResilient => field ?? (field = CreateDefault()); + + public string? OptionalText { + get { + return field; + } + set { + field = value ?? string.Empty; + } + } + + public int SetOnly { + set { + field = value * 2; + } + } + + public int SetterValidated { + get; + set { + if (value < 0) + { + throw new ArgumentOutOfRangeException("value"); + } + field = value; + } + } + + public static int StaticCounter { + get { + return field; + } + set { + field = Math.Max(field, value); + } + } + + public static Func StaticFuncProperty { + get { + return () => (field == null) ? 1 : 2; + } + set; + } + + public int TrivialGet => field; + + public int ViaLocalFunction { + get { + return Twice(); + int Twice() + { + return field * 2; + } + } + set; + } + + [field: NonSerialized] + public int WithFieldAttribute { + get { + return field; + } + set { + field = value & 0xF; + } + } + + public int WithInit { + get { + return field; + } + set { + field = value + 1; + } + } = 5; + + private static int ComputeDefault() + { + return 7; + } + + private static string CreateDefault() + { + return "x"; + } + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.Expected.cs b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.Expected.cs new file mode 100644 index 0000000000..a9336540d6 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.Expected.cs @@ -0,0 +1,60 @@ +using System; +#if !OPT +using System.Diagnostics; +#endif +using System.Runtime.CompilerServices; + +namespace ICSharpCode.Decompiler.Tests.TestCases.Ugly; + +internal class GenericHolder where T : class +{ + [CompilerGenerated] +#if !OPT + [DebuggerBrowsable(DebuggerBrowsableState.Never)] +#endif + private T Value__BackingField; + + public T Value { + get { + return Value__BackingField; + } + set { + if (value != null) + { + Value__BackingField = value; + } + } + } +} +internal class NoFieldKeyword +{ + [CompilerGenerated] +#if !OPT + [DebuggerBrowsable(DebuggerBrowsableState.Never)] +#endif + private int Clamped__BackingField; + + [CompilerGenerated] +#if !OPT + [DebuggerBrowsable(DebuggerBrowsableState.Never)] +#endif + private int WithInitializer__BackingField = 5; + + public int Clamped { + get { + return Clamped__BackingField; + } + set { + Clamped__BackingField = Math.Max(0, value); + } + } + + public int WithInitializer { + get { + return WithInitializer__BackingField; + } + set { + WithInitializer__BackingField = value + 1; + } + } +} diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.cs b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.cs new file mode 100644 index 0000000000..2c5b742f1e --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/TestCases/Ugly/NoFieldKeyword.cs @@ -0,0 +1,58 @@ +// 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.Ugly +{ + internal class NoFieldKeyword + { + public int Clamped { + get { + return field; + } + set { + field = Math.Max(0, value); + } + } + + public int WithInitializer { + get { + return field; + } + set { + field = value + 1; + } + } = 5; + } + + internal class GenericHolder where T : class + { + public T Value { + get { + return field; + } + set { + if (value != null) + { + field = value; + } + } + } + } +} diff --git a/ICSharpCode.Decompiler.Tests/UglyTestRunner.cs b/ICSharpCode.Decompiler.Tests/UglyTestRunner.cs index 6256ec52ac..9d9111c371 100644 --- a/ICSharpCode.Decompiler.Tests/UglyTestRunner.cs +++ b/ICSharpCode.Decompiler.Tests/UglyTestRunner.cs @@ -69,6 +69,13 @@ public void AllFilesHaveTests() CompilerOptions.Optimize | CompilerOptions.UseRoslynLatest, }); + // the field keyword requires a C# 14 compiler + static readonly CompilerOptions[] roslynLatestOnlyOptions = Tester.SupportedOnCurrentPlatform(new[] + { + CompilerOptions.UseRoslynLatest, + CompilerOptions.Optimize | CompilerOptions.UseRoslynLatest, + }); + // top-level statements require C# 9 and cannot target .NET Framework 4.0 static readonly CompilerOptions[] topLevelProgramOptions = Tester.SupportedOnCurrentPlatform(new[] { @@ -112,6 +119,14 @@ public async Task NoExtensionMethods([ValueSource(nameof(roslynOnlyOptions))] Co }); } + [Test] + public async Task NoFieldKeyword([ValueSource(nameof(roslynLatestOnlyOptions))] CompilerOptions cscOptions) + { + await RunForLibrary(cscOptions: cscOptions, decompilerSettings: new DecompilerSettings { + FieldKeyword = false + }); + } + [Test] public async Task NoForEachStatement([ValueSource(nameof(defaultOptions))] CompilerOptions cscOptions) { diff --git a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs index 80361eb410..0a2cd75f83 100644 --- a/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs +++ b/ICSharpCode.Decompiler/CSharp/CSharpDecompiler.cs @@ -458,8 +458,14 @@ public static bool MemberIsHidden(MetadataFile? module, EntityHandle member, Dec return true; if (settings.UsePrimaryConstructorSyntaxForNonRecordTypes && IsPrimaryConstructorParameterBackingField(field, metadata)) return true; - if (settings.AutomaticProperties && module.PropertyAndEventBackingFieldLookup.IsPropertyBackingField(fieldHandle, out var propertyHandle)) + if ((settings.AutomaticProperties || settings.FieldKeyword) + && module.PropertyAndEventBackingFieldLookup.IsPropertyBackingField(fieldHandle, out var propertyHandle)) { + // GetterOnlyAutomaticProperties exists so output stays compilable on + // toolchains that predate C# 6 getter-only auto-properties. Switching it off + // is a stronger statement than leaving FieldKeyword at its default, and it + // wins: accessors needing the C# 14 field keyword would not compile on such + // a toolchain either. if (!settings.GetterOnlyAutomaticProperties) { PropertyAccessors accessors = metadata.GetPropertyDefinition(propertyHandle).GetAccessors(); diff --git a/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs b/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs index ed233139cd..231c008900 100644 --- a/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs @@ -299,6 +299,21 @@ bool RequiresQualifier(IMember member, TranslatedExpression target) return !(target.Expression is ThisReferenceExpression || target.Expression is BaseReferenceExpression); } + ///

+ /// True when the field access has no target at all (already-collapsed `this` access) or + /// loads it from `this`, directly or through the address of `this` that a struct + /// accessor uses. + /// + static bool TargetIsThis(ILInstruction? targetInstruction) + { + return targetInstruction switch { + null => true, + var inst when inst.MatchLdThis() => true, + LdLoca { Variable.Kind: VariableKind.Parameter, Variable.Index: < 0 } => true, + _ => false, + }; + } + ExpressionWithResolveResult ConvertField(IField field, ILInstruction? targetInstruction = null) { if (settings.AutomaticEvents && IsBackingFieldOfAutomaticEvent(field, out var ev)) @@ -317,6 +332,29 @@ ExpressionWithResolveResult ConvertField(IField field, ILInstruction? targetInst 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))) + { + // 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". + return new IdentifierExpression("field") + .WithRR(new MemberResolveResult(null, field)); + } + var target = TranslateTarget(targetInstruction, nonVirtualInvocation: true, memberStatic: field.IsStatic, @@ -333,6 +371,13 @@ ExpressionWithResolveResult ConvertField(IField field, ILInstruction? targetInst { requireTarget = RequiresQualifier(property, target); } + else if (settings.FieldKeyword && field.Name == "field" + && decompilationContext.CurrentMember is IProperty { Parameters.Count: 0 }) + { + // In a C# 14 property accessor a bare "field" identifier binds to the backing + // field keyword, so a genuine field of that name needs a qualifier. + requireTarget = true; + } else { requireTarget = RequiresQualifier(field, target); diff --git a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs index 5c76e9139a..67c31facb8 100644 --- a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs +++ b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs @@ -236,13 +236,29 @@ bool SkipNewLine() return false; if (!(kind == Slots.Getter || kind == Slots.Setter)) return false; - bool isAutoProperty = accessor.Body is null - && !accessor.Attributes.Any() - && policy.AutoPropertyFormatting == PropertyFormatting.SingleLine; - return isAutoProperty; + if (accessor.Body is not null || accessor.Attributes.Any() + || policy.AutoPropertyFormatting != PropertyFormatting.SingleLine) + { + return false; + } + // A bodiless accessor mixed into a multi-line property (the other accessor has + // a body, e.g. "get; set { ... }" with the field keyword) gets its own line; + // only single-line properties keep "get; set;" inline. + if (accessor.Parent is PropertyDeclaration pd) + return IsPrintedAsSingleLine(pd, policy.AutoPropertyFormatting); + return true; } } + internal static bool IsPrintedAsSingleLine(PropertyDeclaration propertyDeclaration, PropertyFormatting autoPropertyFormatting) + { + return autoPropertyFormatting == PropertyFormatting.SingleLine + && (propertyDeclaration.Getter is null || propertyDeclaration.Getter.Body is null) + && (propertyDeclaration.Setter is null || propertyDeclaration.Setter.Body is null) + && (propertyDeclaration.Getter is null || !propertyDeclaration.Getter.Attributes.Any()) + && (propertyDeclaration.Setter is null || !propertyDeclaration.Setter.Attributes.Any()); + } + /// /// Writes a space depending on policy. /// @@ -2659,12 +2675,7 @@ public virtual void VisitPropertyDeclaration(PropertyDeclaration propertyDeclara WriteIdentifier(propertyDeclaration.NameToken); if (propertyDeclaration.ExpressionBody is null) { - bool isSingleLine = - (policy.AutoPropertyFormatting == PropertyFormatting.SingleLine) - && (propertyDeclaration.Getter is null || propertyDeclaration.Getter.Body is null) - && (propertyDeclaration.Setter is null || propertyDeclaration.Setter.Body is null) - && (propertyDeclaration.Getter is null || !propertyDeclaration.Getter.Attributes.Any()) - && (propertyDeclaration.Setter is null || !propertyDeclaration.Setter.Attributes.Any()); + bool isSingleLine = IsPrintedAsSingleLine(propertyDeclaration, policy.AutoPropertyFormatting); OpenBrace(isSingleLine ? BraceStyle.EndOfLine : policy.PropertyBraceStyle, newLine: !isSingleLine); if (isSingleLine) Space(); diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs b/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs index 67b2dab919..f57291752f 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/EscapeInvalidIdentifiers.cs @@ -18,6 +18,7 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; @@ -44,8 +45,24 @@ bool IsValid(char ch) return false; } - string ReplaceInvalid(string s) + string ReplaceInvalid(string s, ISet? existingNames = null) { + // A property backing field that stayed declared (the property is not an + // auto-property and the "field" keyword is unavailable or insufficient) gets a + // readable name instead of the generic escape of "

k__BackingField". The mapping + // is name-deterministic like the escape itself, so declarations and references + // stay consistent without symbol tracking. + if (s.StartsWith("<", System.StringComparison.Ordinal) + && PatternStatementTransform.NameCouldBeBackingFieldOfAutomaticProperty(s, out string? propertyName)) + { + var readable = ReplaceInvalid(propertyName) + "__BackingField"; + // "P__BackingField" is a plausible identifier a human could have written, unlike + // the generic escape it replaces. If the tree already contains that name, the + // rename would produce two members with one name; fall back to the escape, which + // keeps the output compilable at the cost of readability. + if (existingNames?.Contains(readable) != true) + return readable; + } string name = string.Concat(s.Select(ch => IsValid(ch) ? ch.ToString() : string.Format("_{0:X4}", (int)ch))); if (name.Length >= 1 && !(char.IsLetter(name[0]) || name[0] == '_')) name = "_" + name; @@ -54,9 +71,13 @@ string ReplaceInvalid(string s) public void Run(AstNode rootNode, TransformContext context) { + // Every name already in the tree, so the readable backing-field rename can detect + // that its target name is taken. + var existingNames = new HashSet( + rootNode.DescendantsAndSelf.OfType().Select(i => i.Name), StringComparer.Ordinal); foreach (var ident in rootNode.DescendantsAndSelf.OfType()) { - string newName = ReplaceInvalid(ident.Name); + string newName = ReplaceInvalid(ident.Name, existingNames); if (newName != ident.Name) { context.Step($"Escape identifier '{ident.Name}'", ident); diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs b/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs index 41e7a28b89..a2fab525ff 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs @@ -755,6 +755,8 @@ bool CanTransformToAutomaticProperty(IProperty property, bool accessorsMustBeCom IProperty? property = propertyDeclaration.GetSymbol() as IProperty; if (property == null) return null; + if (context.Settings.FieldKeyword) + return TransformFieldBackedProperty(propertyDeclaration, property); if (!CanTransformToAutomaticProperty(property, !(property.DeclaringTypeDefinition?.Fields.Any(f => f.Name == "_" + property.Name && f.IsCompilerGenerated()) ?? false))) return null; IField? field = null; @@ -773,6 +775,9 @@ bool CanTransformToAutomaticProperty(IProperty property, bool accessorsMustBeCom } if (field == null || !NameCouldBeBackingFieldOfAutomaticProperty(field.Name, out _)) return null; + // In generic types the accessor bodies reference the field specialized by the + // type's own type parameters; the field declaration's symbol is the definition. + field = (IField)field.MemberDefinition; if (propertyDeclaration.Setter?.HasModifier(Modifiers.Readonly) == true || (propertyDeclaration.HasModifier(Modifiers.Readonly) && propertyDeclaration.Setter is not null)) return null; if (field.IsCompilerGenerated() && field.DeclaringTypeDefinition == property.DeclaringTypeDefinition) @@ -814,6 +819,218 @@ bool CanTransformToAutomaticProperty(IProperty property, bool accessorsMustBeCom return null; } + static readonly BlockStatement trivialFieldGetterBody = new BlockStatement { + new ReturnStatement { + Expression = new NamedNode("fieldReference", new IdentifierExpression("field")) + } + }; + + static readonly BlockStatement trivialFieldSetterBody = new BlockStatement { + new AssignmentExpression { + Left = new NamedNode("fieldReference", new IdentifierExpression("field")), + Right = new IdentifierExpression("value") + } + }; + + ///

+ /// Handles all field-backed properties when the C# 14 "field" keyword is available: + /// accessor bodies already refer to the backing field as "field" (see + /// ExpressionBuilder.ConvertField), so compiler-generated trivial accessors collapse + /// individually to "get;"/"set;" and the backing-field declaration disappears, with its + /// attributes re-hosted as "field:" sections on the property. + /// + PropertyDeclaration? TransformFieldBackedProperty(PropertyDeclaration propertyDeclaration, IProperty property) + { + if (!TryGetBackingField(property, out var field)) + return null; + if (!OutsideReferencesAreExpressible(propertyDeclaration, field)) + { + // The field stays declared, so the "field" keyword references emitted by + // ExpressionBuilder.ConvertField have to become ordinary field references again. + foreach (var identifierExpression in propertyDeclaration.Descendants.OfType()) + { + if (identifierExpression.Identifier == "field" + && identifierExpression.GetSymbol() is IField referencedField + && field.Equals(referencedField.MemberDefinition)) + { + identifierExpression.Identifier = field.Name; + } + } + return null; + } + context.Step("Transform field-backed property", propertyDeclaration); + var getter = propertyDeclaration.Getter; + var setter = propertyDeclaration.Setter; + if (context.Settings.AutomaticProperties) + { + CollapseTrivialAccessor(getter, trivialFieldGetterBody, field); + // A readonly setter cannot become an auto-accessor (same rule as the pre-C# 14 + // transform); readonly getters collapse fine because auto-getters are + // implicitly readonly. + if (setter?.HasModifier(Modifiers.Readonly) != true + && !(propertyDeclaration.HasModifier(Modifiers.Readonly) && setter is not null)) + { + CollapseTrivialAccessor(setter, trivialFieldSetterBody, field); + } + } + if (getter?.Body == null && setter?.Body == null) + { + // The property became a full auto-property; readonly is implied like before. + propertyDeclaration.Modifiers &= ~Modifiers.Readonly; + if (getter is not null) + getter.Modifiers &= ~Modifiers.Readonly; + } + var fieldDecl = propertyDeclaration.Parent?.Children.OfType() + .FirstOrDefault(fd => field.Equals(fd.GetSymbol())); + if (fieldDecl != null) + { + fieldDecl.Remove(); + CSharpDecompiler.RemoveAttribute(fieldDecl, KnownAttribute.CompilerGenerated); + CSharpDecompiler.RemoveAttribute(fieldDecl, KnownAttribute.DebuggerBrowsable); + foreach (var section in fieldDecl.Attributes) + { + section.AttributeTarget = "field"; + propertyDeclaration.Attributes.Add(section.Detach()); + } + } + return null; + } + + void CollapseTrivialAccessor(Accessor? accessor, BlockStatement pattern, IField field) + { + if (accessor?.Body is null) + return; + if (accessor.GetSymbol() is not IMethod method || !method.IsCompilerGenerated()) + return; + Match m = pattern.Match(accessor.Body); + if (!m.Success) + return; + if (m.Get("fieldReference").Single().GetSymbol() is not IField referencedField + || !field.Equals(referencedField.MemberDefinition)) + { + return; + } + RemoveCompilerGeneratedAttribute(accessor.Attributes); + // Auto-accessors are implicitly readonly. + accessor.Modifiers &= ~Modifiers.Readonly; + // Clearing the accessor body turns it into an auto-property accessor. + accessor.Body = null; + } + + internal static bool TryGetBackingField(IProperty property, [NotNullWhen(true)] out IField? field) + { + field = null; + if (property.Parameters.Count > 0 || property.DeclaringTypeDefinition == null) + return false; + // A type definition's fields are unspecialized, so compare against the property + // DEFINITION's return type; a specialized property in a generic type would + // otherwise never match its own backing field. + var propertyType = ((IProperty)property.MemberDefinition).ReturnType; + foreach (var candidate in property.DeclaringTypeDefinition.Fields) + { + if (candidate.IsCompilerGenerated() + && candidate.IsStatic == property.IsStatic + // The trivial accessor bodies of a classic auto-property guaranteed this + // structurally; arbitrary accessor bodies do not. A field of a different + // type is not this property's storage, and removing it while printing + // `field` would substitute storage of the property's type instead. + && candidate.Type.Equals(propertyType) + && NameCouldBeBackingFieldOfAutomaticProperty(candidate.Name, out var propertyName) + && propertyName == property.Name) + { + field = candidate; + return true; + } + } + return false; + } + + /// + /// The "field" keyword cannot express backing-field accesses outside the owning + /// property's accessors. The only shapes C# can express are stores in a constructor of + /// the declaring type (property initializers, or assignments to setter-less + /// properties); anything else means the field declaration has to be kept. + /// + bool OutsideReferencesAreExpressible(AstNode nodeInTree, IField field) + { + var root = nodeInTree.Ancestors.LastOrDefault() ?? nodeInTree; + if (outsideReferenceRoot != root) + { + outsideReferenceRoot = root; + outsideReferenceVerdicts = BuildOutsideReferenceIndex(root); + } + // Absent means no reference to this field was found outside its own property. + return !outsideReferenceVerdicts!.TryGetValue((IField)field.MemberDefinition, out bool expressible) + || expressible; + } + + // The verdict per backing field for one syntax tree. Answering each property with its + // own full walk made whole-module output quadratic in the number of properties, which + // a property-heavy assembly feels as minutes instead of seconds. Later transforms only + // ever REMOVE references to the field of the property they are rewriting, so a verdict + // computed up front stays valid for every other property in the tree. + AstNode? outsideReferenceRoot; + Dictionary? outsideReferenceVerdicts; + + /// + /// Whether a constructor store to still has a home once the + /// field declaration is gone. Something has to turn it into an initializer or a + /// property assignment, and not every store qualifies. + /// + bool StoreSurvivesAsInitializer(IField field) + { + // A setter-less property's store is rewritten to a property assignment by + // ReplaceBackingFieldUsage, in this same transform - no later transform involved. + if (IsBackingFieldOfAutomaticProperty(field, out var property) && !property.CanSet) + return true; + // Everything else waits for TransformFieldAndConstructorInitializers, which runs + // after this transform and declines to move non-constant stores out of an EXPLICIT + // static constructor (see its `onlyMoveConstants`). Removing the declaration here + // would leave that store referencing a field that no longer exists. + if (field.IsStatic && !context.Settings.AlwaysMoveInitializer + && !IsBeforeFieldInit(field.DeclaringTypeDefinition)) + { + return false; + } + return true; + } + + bool IsBeforeFieldInit(ITypeDefinition? typeDefinition) + { + if (typeDefinition?.MetadataToken.IsNil != false) + return false; + var metadata = context.TypeSystem.MainModule.MetadataFile.Metadata; + var td = metadata.GetTypeDefinition((TypeDefinitionHandle)typeDefinition.MetadataToken); + return td.HasFlag(System.Reflection.TypeAttributes.BeforeFieldInit); + } + + Dictionary BuildOutsideReferenceIndex(AstNode root) + { + var verdicts = new Dictionary(); + foreach (var node in root.Descendants) + { + if (node is not (IdentifierExpression or MemberReferenceExpression)) + continue; + if (node.GetSymbol() is not IField referencedField) + continue; + var definition = (IField)referencedField.MemberDefinition; + // A reference inside the accessors of the field's own property is exactly what + // the "field" keyword expresses; anything else is an outside reference. + if (node.Ancestors.OfType().FirstOrDefault()?.GetSymbol() is IProperty owner + && TryGetBackingField(owner, out var ownerField) + && definition.Equals(ownerField.MemberDefinition)) + { + continue; + } + var enclosingMethod = node.Ancestors.OfType().FirstOrDefault()?.GetSymbol() as IMethod; + if (!IsConstructorStore(node, definition, enclosingMethod) || !StoreSurvivesAsInitializer(definition)) + verdicts[definition] = false; + else if (!verdicts.ContainsKey(definition)) + verdicts[definition] = true; + } + return verdicts; + } + static void RemoveCompilerGeneratedAttribute(AstNodeCollection attributeSections) { RemoveCompilerGeneratedAttribute(attributeSections, "System.Runtime.CompilerServices.CompilerGeneratedAttribute"); @@ -839,7 +1056,7 @@ static void RemoveCompilerGeneratedAttribute(AstNodeCollection public override AstNode VisitIdentifier(Identifier identifier) { - if (context.Settings.AutomaticProperties) + if (context.Settings.AutomaticProperties || context.Settings.FieldKeyword) { var newIdentifier = ReplaceBackingFieldUsage(identifier); if (newIdentifier != null) @@ -875,7 +1092,7 @@ internal static bool IsBackingFieldOfAutomaticProperty(IField field, [NotNullWhe static readonly System.Text.RegularExpressions.Regex automaticPropertyBackingFieldNameRegex = new System.Text.RegularExpressions.Regex(@"^(<(?.+)>k__BackingField|_(?.+))$"); - static bool NameCouldBeBackingFieldOfAutomaticProperty(string name, [NotNullWhen(true)] out string? propertyName) + internal static bool NameCouldBeBackingFieldOfAutomaticProperty(string name, [NotNullWhen(true)] out string? propertyName) { propertyName = null; var m = automaticPropertyBackingFieldNameRegex.Match(name); @@ -894,11 +1111,31 @@ static bool NameCouldBeBackingFieldOfAutomaticProperty(string name, [NotNullWhen return null; var mrr = parent.Annotation(); if (mrr?.Member is IField field && IsBackingFieldOfAutomaticProperty(field, out var property) - && CanTransformToAutomaticProperty(property, !(field.IsCompilerGenerated() && field.Name == "_" + property.Name)) && currentMethod?.AccessorOwner != property) { - if (!property.CanSet && !context.Settings.GetterOnlyAutomaticProperties) + if (CanTransformToAutomaticProperty(property, !(field.IsCompilerGenerated() && field.Name == "_" + property.Name))) + { + if (!property.CanSet && !context.Settings.GetterOnlyAutomaticProperties && !context.Settings.FieldKeyword) + return null; + } + else if (context.Settings.FieldKeyword && !property.CanSet && IsConstructorStoreTarget(parent, field) + && BackingFieldWillBeRemoved(property, field, parent)) + { + // A direct store to the backing field of a setter-less field-backed + // property is expressible as a property assignment in a constructor - + // but only where the property declaration actually becomes field-backed. + // If TransformFieldBackedProperty bails, the property keeps explicit + // accessors and no setter, so assigning it would not compile (CS0200). + } + else + { + // Stores that initialize a field-backed property with a setter are left + // as field references and lifted into the property initializer by + // TransformFieldAndConstructorInitializers (a property assignment would + // invoke the setter); everything else is inexpressible with the "field" + // keyword and keeps the field declared. return null; + } context.Step("Replace backing field use with property", identifier); parent.RemoveAnnotations(); parent.AddAnnotation(new MemberResolveResult(mrr.TargetResult, property)); @@ -908,6 +1145,47 @@ static bool NameCouldBeBackingFieldOfAutomaticProperty(string name, [NotNullWhen return null; } + bool IsConstructorStoreTarget(AstNode node, IField field) + => IsConstructorStore(node, field, currentMethod); + + /// + /// Whether will remove this field's + /// declaration. Rewriting a store before knowing that produces an assignment to a + /// property that keeps explicit, setter-less accessors. + /// + bool BackingFieldWillBeRemoved(IProperty property, IField field, AstNode nodeInTree) + { + return TryGetBackingField(property, out var backingField) + && field.MemberDefinition.Equals(backingField.MemberDefinition) + && OutsideReferencesAreExpressible(nodeInTree, backingField); + } + + /// + /// True when is the left-hand side of a plain assignment to + /// inside a constructor of the field's declaring type - the + /// only outside reference the "field" keyword can still express (as a property + /// initializer, or an assignment to a setter-less property). + /// + /// + /// Shared by , which decides whether the + /// field declaration may be removed, and , which + /// rewrites the store. The two must agree: if only one of them accepts a store, the + /// output either references a removed field or assigns a property that stayed + /// field-backed. They differ only in how the enclosing method is known, which is why + /// it is a parameter here. + /// + static bool IsConstructorStore(AstNode node, IField field, IMethod? enclosingMethod) + { + if (node.Parent is not AssignmentExpression { Operator: AssignmentOperatorType.Assign } assignment + || assignment.Left != node) + { + return false; + } + return enclosingMethod is { IsConstructor: true } ctor + && ctor.IsStatic == field.IsStatic + && ctor.DeclaringTypeDefinition == field.DeclaringTypeDefinition; + } + #region Automatic Events internal static readonly string[] attributeTypesToRemoveFromAutoProperties = new[] { "System.Runtime.CompilerServices.CompilerGeneratedAttribute", diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs b/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs index 09af9bbfcf..48aefd8731 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/TransformFieldAndConstructorInitializers.cs @@ -187,6 +187,30 @@ class InitializerSequence return sequence; } + internal static bool IsDefaultValueInitializer(Expression initializer) + { + if (initializer is DefaultValueExpression) + return true; + var rr = initializer.GetResolveResult(); + if (!rr.IsCompileTimeConstant) + return false; + return rr.ConstantValue switch { + null or false or '\0' + or (sbyte)0 or (byte)0 or (short)0 or (ushort)0 + or 0 or 0u or 0L or 0uL => true, + // Equality is not identity for these: -0.0 equals 0.0, and 0.00m equals 0m + // while carrying a different scale. Both differences are observable + // (1.0 / -0.0 is negative infinity; 0.00m prints as "0.00"), so dropping + // such an initializer as a redundant default would change behaviour on + // recompile. Compare the representation rather than the value. + // (GetBytes rather than SingleToInt32Bits: netstandard2.0 lacks the latter.) + float f => BitConverter.ToInt32(BitConverter.GetBytes(f), 0) == 0, + double d => BitConverter.DoubleToInt64Bits(d) == 0, + decimal m => m == 0m && decimal.GetBits(m)[3] == 0, + _ => false, + }; + } + private static bool CanHaveInitializer(IMember member, ConstructorInitializerAnalyzer context) { if (context.MemberToDeclaringSyntaxNodeMap == null) @@ -195,7 +219,9 @@ private static bool CanHaveInitializer(IMember member, ConstructorInitializerAna return true; return declaringSyntaxNode is FieldDeclaration or PropertyDeclaration { IsAutomaticProperty: true } - or EventDeclaration; + or EventDeclaration + // backing-field store of a field-backed property (initializers bypass the setter) + || (declaringSyntaxNode is PropertyDeclaration && member is IField); } public bool IsMatch(ConstructorDeclaration ctor) @@ -286,6 +312,22 @@ public bool Analyze(IEnumerable members) .Where(_ => _.symbol is IMember) .ToDictionary(_ => (IMember)_.symbol!, _ => _.entity); + if (context.Settings.FieldKeyword) + { + // Constructor stores to the backing field of a field-backed property become + // the property's initializer. The field only keeps its own declaration (and + // mapping) when the property could not be transformed. + foreach (var pd in members.OfType()) + { + if (pd.GetSymbol() is IProperty property + && PatternStatementTransform.TryGetBackingField(property, out var backingField) + && !MemberToDeclaringSyntaxNodeMap.ContainsKey(backingField)) + { + MemberToDeclaringSyntaxNodeMap.Add(backingField, pd); + } + } + } + List constructorsNotChainedWithThis = []; List allCtors = []; @@ -586,8 +628,16 @@ public bool MoveFieldInitializersToDeclarations(InitializerSequence sequence, In } break; case PropertyDeclaration pd: - Debug.Assert(pd.IsAutomaticProperty); - if (pd.Initializer is null) + Debug.Assert(pd.IsAutomaticProperty || member is IField); + if (member is IField { DeclaringTypeDefinition.Kind: TypeKind.Struct } + && InitializerSequence.IsDefaultValueInitializer(initializer)) + { + // Struct constructors zero-initialize backing fields the constructor + // does not assign (auto-default structs); recompilation regenerates + // these stores, so they are dropped instead of becoming initializers. + context.Step("Drop implicit struct default initialization", stmt); + } + else if (pd.Initializer is null) { context.Step("Move assignment to property initializer", stmt); var movedInitializer = initializer.Detach(); diff --git a/ICSharpCode.Decompiler/DecompilerSettings.cs b/ICSharpCode.Decompiler/DecompilerSettings.cs index 31082c6244..be0f91eebf 100644 --- a/ICSharpCode.Decompiler/DecompilerSettings.cs +++ b/ICSharpCode.Decompiler/DecompilerSettings.cs @@ -854,6 +854,14 @@ public bool LifetimeAnnotations { [DecompilerSetting(CSharp.LanguageVersion.CSharp14_0)] public partial bool FirstClassSpanTypes { get; set; } + /// + /// Gets/Sets whether property accessors should use the C# 14.0 "field" keyword to + /// refer to the compiler-generated backing field. + /// + [Description("DecompilerSettings.FieldKeyword")] + [DecompilerSetting(CSharp.LanguageVersion.CSharp14_0)] + public partial bool FieldKeyword { get; set; } + /// /// Gets/sets whether the decompiler should separate local variable declarations /// from their initialization. diff --git a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs index 06acdff08b..2aa1f5a4db 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/AssignVariableNames.cs @@ -128,6 +128,14 @@ public VariableScope(ILFunction function, ILTransformContext context, VariableSc } this.currentLowerCaseTypeOrMemberNames = currentLowerCaseTypeOrMemberNames.ToImmutableHashSet(); + if (context.Settings.FieldKeyword + && function.Method?.AccessorOwner is IProperty { Parameters.Count: 0 }) + { + // "field" is a keyword in C# 14 property accessors; a local of that name + // would shadow the backing field. + AddExistingName(reservedVariableNames, "field"); + } + // handle implicit parameters of set or event accessors if (function.Method != null && IsSetOrEventAccessor(function.Method) && function.Parameters.Count > 0) { diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 7135b23dcf..f65ae29f58 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -438,6 +438,9 @@ Are you sure you want to continue? F#-specific options + + Use the 'field' keyword in property accessors + Use file-scoped namespace declarations From 146fd7b802a4a6a5e731f9f3e78490285f85d8c2 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Wed, 12 Aug 2026 10:59:55 +0200 Subject: [PATCH 3/3] Keep VB auto-properties collapsing when the field keyword is on VB emits an auto-property as a "_" backing field plus accessors it does not mark [CompilerGenerated]. The pre-C# 14 transform knows this: it relaxes its accessor requirement whenever it finds such a field, which is how a VB auto-property still prints as "{ get; set; }". Routing every property through the field-backed path lost that. Collapsing an accessor demanded [CompilerGenerated] unconditionally, so a VB auto-property stopped collapsing and grew explicit "field" accessors instead - correct code, but noise where every other compiler's equivalent stays a one-liner. Only the legacy vbc configurations show it, and those run on Windows alone, so the Linux and macOS jobs stayed green while both Windows ones failed on VBPropertiesTest and Async. Assisted-by: Claude:claude-opus-5:Claude Code --- .../Transforms/PatternStatementTransform.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs b/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs index a2fab525ff..2f4adf6e40 100644 --- a/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs +++ b/ICSharpCode.Decompiler/CSharp/Transforms/PatternStatementTransform.cs @@ -863,14 +863,19 @@ bool CanTransformToAutomaticProperty(IProperty property, bool accessorsMustBeCom var setter = propertyDeclaration.Setter; if (context.Settings.AutomaticProperties) { - CollapseTrivialAccessor(getter, trivialFieldGetterBody, field); + // VB auto-properties do not mark their accessors [CompilerGenerated]; the + // pre-C# 14 transform recognizes them by their "_" backing field + // instead. Keep that rule, or a VB auto-property grows explicit "field" + // accessors where every other compiler's collapses to "{ get; set; }". + bool accessorsMustBeCompilerGenerated = field.Name != "_" + property.Name; + CollapseTrivialAccessor(getter, trivialFieldGetterBody, field, accessorsMustBeCompilerGenerated); // A readonly setter cannot become an auto-accessor (same rule as the pre-C# 14 // transform); readonly getters collapse fine because auto-getters are // implicitly readonly. if (setter?.HasModifier(Modifiers.Readonly) != true && !(propertyDeclaration.HasModifier(Modifiers.Readonly) && setter is not null)) { - CollapseTrivialAccessor(setter, trivialFieldSetterBody, field); + CollapseTrivialAccessor(setter, trivialFieldSetterBody, field, accessorsMustBeCompilerGenerated); } } if (getter?.Body == null && setter?.Body == null) @@ -896,12 +901,16 @@ bool CanTransformToAutomaticProperty(IProperty property, bool accessorsMustBeCom return null; } - void CollapseTrivialAccessor(Accessor? accessor, BlockStatement pattern, IField field) + void CollapseTrivialAccessor(Accessor? accessor, BlockStatement pattern, IField field, + bool accessorMustBeCompilerGenerated) { if (accessor?.Body is null) return; - if (accessor.GetSymbol() is not IMethod method || !method.IsCompilerGenerated()) + if (accessorMustBeCompilerGenerated + && (accessor.GetSymbol() is not IMethod method || !method.IsCompilerGenerated())) + { return; + } Match m = pattern.Match(accessor.Body); if (!m.Success) return;