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 2e4ce50676..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(); @@ -2030,7 +2036,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) 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..2f4adf6e40 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,227 @@ 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) + { + // 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, accessorsMustBeCompilerGenerated); + } + } + 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, + bool accessorMustBeCompilerGenerated) + { + if (accessor?.Body is null) + return; + if (accessorMustBeCompilerGenerated + && (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 +1065,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 +1101,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 +1120,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 +1154,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