From fa863a8229ffcf81d19aebaa68adee4358180e02 Mon Sep 17 00:00:00 2001 From: Daniel Grunwald Date: Sun, 11 Oct 2026 14:05:15 +0200 Subject: [PATCH] #4019: transform `value.$()` into `value with {}` even if there are no initializers. This commit also changes the ILAst representation of with-initializers to preserve the `$()` call, so that BlockKind.WithInitializer only forces the block into a certain shape, without assigning its special semantics. --- .../TestCases/Pretty/Records.cs | 5 ++ .../CSharp/ExpressionBuilder.cs | 17 ++++-- .../OutputVisitor/CSharpOutputVisitor.cs | 1 + .../IL/Instructions/Block.cs | 58 +++++++++++-------- .../IL/Transforms/ExpressionTransforms.cs | 10 +++- ...ransformCollectionAndObjectInitializers.cs | 33 ++++++++++- 6 files changed, 92 insertions(+), 32 deletions(-) diff --git a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs index 1e078e553..9a610a781 100644 --- a/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs +++ b/ICSharpCode.Decompiler.Tests/TestCases/Pretty/Records.cs @@ -129,6 +129,11 @@ namespace ICSharpCode.Decompiler.Tests.TestCases.Pretty } }; } + + public Fields EmptyWith(Fields input) + { + return input with { }; + } } public abstract record WithNestedRecords diff --git a/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs b/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs index 05264cdc0..a1a5ce75f 100644 --- a/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs +++ b/ICSharpCode.Decompiler/CSharp/ExpressionBuilder.cs @@ -4051,13 +4051,22 @@ namespace ICSharpCode.Decompiler.CSharp TranslatedExpression TranslateWithInitializer(Block block) { - var stloc = block.Instructions.FirstOrDefault() as StLoc; - var final = block.FinalInstruction as LdLoc; - if (stloc == null || final == null || stloc.Variable != final.Variable || stloc.Variable.Kind != VariableKind.InitializerTarget) + if (block.Instructions.FirstOrDefault() is not StLoc stloc + || block.FinalInstruction is not LdLoc final + || stloc.Variable != final.Variable || stloc.Variable.Kind != VariableKind.InitializerTarget) + { throw new ArgumentException("given Block is invalid!"); + } + ILInstruction oldValue = stloc.Value; + if (stloc.Variable.Type.IsReferenceType == true) + { + // reference types must involve a $() call + Debug.Assert(TransformCollectionAndObjectInitializers.IsRecordCloneMethodCall((CallInstruction)stloc.Value)); + oldValue = ((CallInstruction)stloc.Value).Arguments.Single(); + } WithInitializerExpression withInitializerExpression = new WithInitializerExpression(); - withInitializerExpression.Expression = Translate(stloc.Value, stloc.Variable.Type); + withInitializerExpression.Expression = Translate(oldValue, stloc.Variable.Type); withInitializerExpression.Initializer = BuildArrayInitializerExpression(block, new InitializedObjectResolveResult(stloc.Variable.Type)); return withInitializerExpression.WithILInstruction(block) diff --git a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs index 20b58586f..9c5bd83f2 100644 --- a/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs +++ b/ICSharpCode.Decompiler/CSharp/OutputVisitor/CSharpOutputVisitor.cs @@ -1339,6 +1339,7 @@ namespace ICSharpCode.Decompiler.CSharp.OutputVisitor { StartNode(withInitializerExpression); withInitializerExpression.Expression.AcceptVisitor(this); + Space(); WriteKeyword("with"); withInitializerExpression.Initializer.AcceptVisitor(this); EndNode(withInitializerExpression); diff --git a/ICSharpCode.Decompiler/IL/Instructions/Block.cs b/ICSharpCode.Decompiler/IL/Instructions/Block.cs index af03ca0c7..b569bbe19 100644 --- a/ICSharpCode.Decompiler/IL/Instructions/Block.cs +++ b/ICSharpCode.Decompiler/IL/Instructions/Block.cs @@ -171,6 +171,7 @@ namespace ICSharpCode.Decompiler.IL break; case BlockKind.CollectionInitializer: case BlockKind.ObjectInitializer: + case BlockKind.WithInitializer: var final2 = finalInstruction as LdLoc; Debug.Assert(final2 != null); var initVar2 = final2!.Variable; @@ -178,30 +179,41 @@ namespace ICSharpCode.Decompiler.IL IType? type2 = null; bool condition = Instructions[0].MatchStLoc(final2.Variable, out var init2); Debug.Assert(condition); - Debug.Assert(init2 is NewObj - || init2 is DefaultValue - || (init2 is CallInstruction c && c.Method.FullNameIs("System.Activator", "CreateInstance") && c.Method.TypeArguments.Count == 1) - || (init2 is Block named && named.Kind == BlockKind.CallWithNamedArgs)); - switch (init2) + if (Kind == BlockKind.WithInitializer) { - case NewObj newObj: - type2 = newObj.Method.DeclaringType; - break; - case DefaultValue defaultValue: - type2 = defaultValue.Type; - break; - case Block callWithNamedArgs when callWithNamedArgs.Kind == BlockKind.CallWithNamedArgs: - type2 = ((CallInstruction)callWithNamedArgs.FinalInstruction).Method.ReturnType; - break; - case CallInstruction ci2 when TransformCollectionAndObjectInitializers.IsRecordCloneMethodCall(ci2): - type2 = ci2.Method.DeclaringType; - break; - case Call c2 when c2.Method.FullNameIs("System.Activator", "CreateInstance") && c2.Method.TypeArguments.Count == 1: - type2 = c2.Method.TypeArguments[0]; - break; - default: - Debug.Assert(false); - break; + if (initVar2.Type.IsReferenceType == true) + { + Debug.Assert(init2 is CallInstruction cloneCall && TransformCollectionAndObjectInitializers.IsRecordCloneMethodCall(cloneCall)); + } + type2 = initVar2.Type; + } + else + { + Debug.Assert(init2 is NewObj + || init2 is DefaultValue + || (init2 is CallInstruction c && c.Method.FullNameIs("System.Activator", "CreateInstance") && c.Method.TypeArguments.Count == 1) + || (init2 is Block named && named.Kind == BlockKind.CallWithNamedArgs)); + switch (init2) + { + case NewObj newObj: + type2 = newObj.Method.DeclaringType; + break; + case DefaultValue defaultValue: + type2 = defaultValue.Type; + break; + case Block callWithNamedArgs when callWithNamedArgs.Kind == BlockKind.CallWithNamedArgs: + type2 = ((CallInstruction)callWithNamedArgs.FinalInstruction).Method.ReturnType; + break; + case CallInstruction ci2 when TransformCollectionAndObjectInitializers.IsRecordCloneMethodCall(ci2): + type2 = ci2.Method.DeclaringType; + break; + case Call c2 when c2.Method.FullNameIs("System.Activator", "CreateInstance") && c2.Method.TypeArguments.Count == 1: + type2 = c2.Method.TypeArguments[0]; + break; + default: + Debug.Assert(false); + break; + } } for (int i = 1; i < Instructions.Count; i++) { diff --git a/ICSharpCode.Decompiler/IL/Transforms/ExpressionTransforms.cs b/ICSharpCode.Decompiler/IL/Transforms/ExpressionTransforms.cs index 1a29a00d9..277e6451d 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/ExpressionTransforms.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/ExpressionTransforms.cs @@ -318,13 +318,19 @@ namespace ICSharpCode.Decompiler.IL.Transforms { return; } - TransformAssignment.HandleCompoundAssign(inst, context); + if (TransformAssignment.HandleCompoundAssign(inst, context)) + return; + if (TransformCollectionAndObjectInitializers.HandleEmptyWith(inst, context)) + return; } protected internal override void VisitCallVirt(CallVirt inst) { base.VisitCallVirt(inst); - TransformAssignment.HandleCompoundAssign(inst, context); + if (TransformAssignment.HandleCompoundAssign(inst, context)) + return; + if (TransformCollectionAndObjectInitializers.HandleEmptyWith(inst, context)) + return; } protected internal override void VisitNewObj(NewObj inst) diff --git a/ICSharpCode.Decompiler/IL/Transforms/TransformCollectionAndObjectInitializers.cs b/ICSharpCode.Decompiler/IL/Transforms/TransformCollectionAndObjectInitializers.cs index 4f07d200e..0e23288c8 100644 --- a/ICSharpCode.Decompiler/IL/Transforms/TransformCollectionAndObjectInitializers.cs +++ b/ICSharpCode.Decompiler/IL/Transforms/TransformCollectionAndObjectInitializers.cs @@ -34,6 +34,32 @@ namespace ICSharpCode.Decompiler.IL.Transforms /// public class TransformCollectionAndObjectInitializers : IStatementTransform { + /// + /// Handles 'with' expressions with 0 initializers. + /// These do not start with the `stloc` that this transform typically looks for, + /// so they are handled separately as part of ExpressionTransforms. + /// + public static bool HandleEmptyWith(CallInstruction inst, ILTransformContext context) + { + if (!context.Settings.WithExpressions) + return false; + if (inst.Parent is StLoc { Parent: Block { Kind: BlockKind.WithInitializer } }) + return false; // already transformed + if (!IsRecordCloneMethodCall(inst)) + return false; + var parentChildren = inst.Parent!.Children; + int insertionIndex = inst.ChildIndex; + context.Step("$ to with-expression", inst); + var initializerBlock = new Block(BlockKind.WithInitializer); + ILVariable finalSlot = context.Function.RegisterVariable(VariableKind.InitializerTarget, inst.InferType(context.TypeSystem)); + initializerBlock.FinalInstruction = new LdLoc(finalSlot); + initializerBlock.Instructions.Add(new StLoc(finalSlot, inst)); + parentChildren[insertionIndex] = initializerBlock; + context.EndStep(inst); + // The with-initializer block might later get extended with additional initializers by Run(). + return true; + } + void IStatementTransform.Run(Block block, int pos, StatementTransformContext context) { if (!context.Settings.ObjectOrCollectionInitializers) @@ -93,10 +119,11 @@ namespace ICSharpCode.Decompiler.IL.Transforms instType = c.Method.TypeArguments[0]; blockKind = BlockKind.ObjectInitializer; break; - case CallInstruction ci when context.Settings.WithExpressions && IsRecordCloneMethodCall(ci): - instType = ci.Method.DeclaringType; + case Block { Kind: BlockKind.WithInitializer, Instructions: [StLoc { Value: var existingInitInst }] }: + // Extend existing with-initializer block that was created by HandleEmptyWith. + instType = existingInitInst.InferType(context.TypeSystem); blockKind = BlockKind.WithInitializer; - initInst = ci.Arguments.Single(); + initInst = existingInitInst; break; default: var typeDef = v.Type.GetDefinition();