Reconstruct nested deconstruction designations - #3950
Conversation
773733f to
555e364
Compare
554f0cf to
f962833
Compare
Code reviewWhat this does
The IL-side design is sound: depth-first leaf indexing is exactly the order VerificationBeyond reading the diff (macOS,
Test coverage is considerably better than the description advertises ("9 Pretty fixtures ... plus class-inner and depth-3 Correctness cases"): there are ~18 of each, including member hiding pinning FindingsNothing blocking; no correctness defect found. Comments left at the relevant lines.
Minor: ConventionsCommit messages, |
159eaf0 to
63585d1
Compare
7b67a41 to
72b880f
Compare
b93b51e to
59f8bfc
Compare
1f6efcc to
33c0e98
Compare
There was a problem hiding this comment.
Pull request overview
This PR enhances ILSpy’s decompiler pipeline by reconstructing nested deconstruction syntax (both custom Deconstruct-call chains and nested tuple designations) into a single structured DeconstructInstruction, allowing the C# statement/expression builders to emit sugared nested patterns like var (x, (a, b)) = ...; instead of flattening into explicit follow-up calls/element statements.
Changes:
- Extend
DeconstructionTransformto consume chained/nestedDeconstructcalls and nested tuple-designation temporaries into recursive match patterns with depth-first leaf indexing. - Improve pointer-target deconstruction detection by fixing
DeconstructInstruction.IsAssignmentexpected-type inference forstobjinto pointer targets (including stack-slot-erased pointer types). - Fix
TupleType.FromUnderlyingTypeto avoidNullReferenceExceptionon non-tuple input by correctly handling default/empty tuple element arrays.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ICSharpCode.Decompiler/TypeSystem/TupleType.cs | Prevents NRE in FromUnderlyingType by using IsDefaultOrEmpty on tuple element arrays. |
| ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs | Core change: matches and builds nested deconstruction patterns (call chains + tuple nesting) and integrates conversions/assignments. |
| ICSharpCode.Decompiler/IL/Instructions/DeconstructInstruction.cs | Improves assignment recognition for pointer-target stobj by falling back to stobj.Type. |
| ICSharpCode.Decompiler.Tests/TestCases/Pretty/DeconstructionTests.cs | Adds Pretty fixtures covering nested deconstruction shapes, tuple nesting, unrelated-assignment termination, and pointer targets. |
| ICSharpCode.Decompiler.Tests/TestCases/Correctness/DeconstructionTests.cs | Adds runtime-pinned correctness cases validating evaluation order and nested tuple/custom deconstruction behavior. |
Suppressed comments (2)
ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs:602
- ElementAtOrDefault(pos) on block.Instructions will enumerate via LINQ. A simple bounds check with indexing avoids the overhead and keeps this matching loop allocation-free.
while (MatchTupleElementStore(block.Instructions.ElementAtOrDefault(pos),
out var temp, out var container, out var containerType, out int index))
ICSharpCode.Decompiler/IL/Transforms/DeconstructionTransform.cs:782
- MatchAssignments uses ElementAtOrDefault(pos) in a loop; that LINQ call can be avoided by directly indexing with a bounds check.
while (MatchAssignment(block.Instructions.ElementAtOrDefault(pos), out var targetType, out var valueInst, out var addAssignment))
{
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Code review summary (high-effort multi-agent review)Reviewed Correctness (recompilation / behavior)
Sugar-loss regressions (output quality)
Efficiency
Refuted during verification: the concern that break-and-commit on unrelated assignments could leave unconsumed Deconstruct results ill-formed -- the Seven further low-severity confirmed findings (minor duplication and micro-inefficiencies) were dropped under the report cap. 🤖 Generated with Claude Code |
The transform is about to be extended substantially; annotating it first keeps the null contracts of the matcher explicit, where "no match" is expressed by a null out-argument throughout. The matching state fields are non-null only while a match is in progress, which the codebase's null! idiom expresses; MatchConversion additionally gets the null check its caller's ElementAtOrDefault already implies. Assisted-by: Claude:claude-opus-5:Claude Code
GetTupleElementTypes returns a default ImmutableArray when the type is not tuple-compatible, so reading Length threw NullReferenceException instead of taking the documented return-null path. Assisted-by: Claude:claude-fable-5:Claude Code
C# only accepts System.ValueTuple as a tuple when it is a struct, so a class of that name is an unrelated type and rendering it with tuple syntax describes it as something it is not. It also made a tuple appear to contain itself, which no struct can, and the deconstruction transform then registered the same variable as a node of its tuple tree twice and threw ArgumentException, failing the whole method instead of leaving the statements alone. The check has accepted classes since tuples were added to the type system, alongside a name comparison against "ValueType" that was corrected later. Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Deconstruction into a pointer target ((*p, value) = tuple;) stayed an explicit Deconstruct call: a store through a pointer (or through a target whose pointer type got erased in a stack slot) does not infer a ByReferenceType, so IsAssignment reported an unknown expected type and the transform's conversion check rejected the assignment. The type of the store itself is just as precise, so use it as the expected type. Of the three IsAssignment call sites only the transform's MatchAssignment consumes the expected type; CheckInvariant and GetAssignmentIndex discard it, so this widens what the transform accepts without weakening the invariant check. Assisted-by: Claude:claude-opus-5:Claude Code
An assignment whose value is not one of the deconstruction's elements used to reject the whole match, so a custom deconstruction followed by any unrelated assignment stayed an explicit Deconstruct call. For a pattern rooted in a Deconstruct call the element list is fixed by the call's out-arguments, so such an assignment simply ends the pattern and stays after the deconstruct instruction. Tuple-rooted patterns keep rejecting: their element list is discovered from the assignments, so ending early would misread a suffix of the assignments as the whole pattern and fabricate discards for the elements before it. Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation, var (x, (a, b)) = o;, is lowered to a chain of Deconstruct calls - the inner call taking the outer call's out-argument as its target, through a defensive copy where the element is a struct - and decompiled as a flat deconstruction followed by an explicit Deconstruct call. The IL pattern node, its invariants and the C# builders already support nested patterns; only the transform never built them. MatchDeconstruction now consumes the chain into a tree of match patterns. The leaves get flat indices in depth-first order, which is the order in which StatementBuilder and ExpressionBuilder pair pattern variables with assignments, so the conversion and assignment matching runs unchanged on top of a nested pattern. Two matching rules follow from the chain being consumed: a call pattern no longer needs a matched assignment, because single-use leaves are covered by the forwarding fixup in MatchAssignments; and a pattern is not rooted on an element of an enclosing deconstruction, because blocks are processed back to front, so the inner call is visited first and would otherwise consume the pattern piecemeal, starving the outer call. That guard runs the enclosing match as a dry run, which is precise: a barrier statement between the calls or an element with further uses makes it fail, and the inner deconstruction is then still transformed on its own. Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation rebinds Deconstruct on the element's static type when the output is recompiled, while the explicit call it replaces is bound at the call site. Where a derived element type declares a Deconstruct of the same arity as the called method, and the source deconstructs through a base-typed view, the two bindings differ, so the sugared output calls the wrong method - a divergence the runtime fixture demonstrates on optimized builds, where copy propagation elides the view. Nesting is therefore only applied when the method the call binds to is the one a designation would rebind to; otherwise the call stays explicit, where its receiver cast preserves the binding. Assisted-by: Claude:claude-opus-5:Claude Code
Element index resolution serves both pattern roots: a registered result of a Deconstruct call, or an element read of a tuple, which it discovers on first sight and then owns. In an attempt rooted in a Deconstruct call the tuple branch must not engage - it overwrites the call's result bookkeeping and rewires the element read to a fresh variable that the pattern never defines. The shape that reaches it is a tuple whose element is custom-deconstructed with discarded leaves, followed by an unrelated assignment: the tuple-rooted attempt fails, the call-rooted one runs at the element's position, and, now that an unrelated assignment ends a call pattern instead of rejecting it, the mixed match is no longer rejected on the way out. Assisted-by: Claude:claude-opus-5:Claude Code
A nested designation over tuples, var (x, (a, b)) = t;, is lowered to one temporary per nested designation - parents before children - followed by the element reads in depth-first leaf order, and decompiled as a flat deconstruction plus separate element statements. The temporaries are now consumed into a tree of tuple nodes before the conversions and assignments are matched, and the leaves get the same flat depth-first indices the Deconstruct-call chain hands out, so conversion and assignment matching runs unchanged. Two properties of the lowered IL shape the matcher to it: earlier transforms rewrite non-escaping element reads from ldloca to ldloc, and the temporaries are stack slots whose type is imprecise, so the container's element type is authoritative and the match variable is retyped to keep the tuple pattern's invariant. An element that escapes the deconstruction - used after the statement, so the pattern cannot consume all its reads - demotes back to a designator leaf and the match is retried, which restores the flat deconstruction the escaping read needs. The guard against consuming a pattern piecemeal extends to the new shape: an element read whose container is stored by an earlier element read defers to the match starting at that store. Assisted-by: Claude:claude-opus-5:Claude Code
Deferring an inner deconstruction to its enclosing one used to be decided by matching the enclosing pattern in full, once per inner statement of the same pattern, discarding everything but the end position. The same decisions are available without it. A nested Deconstruct call can only be consumed by an enclosing one that is the immediately preceding statement, looking through the defensive copy of a struct element; anything else in between is a barrier that stops the enclosing from reaching this position, so it matches here instead. That leaves the case where the enclosing call is adjacent but cannot match anyway, which is decided by the constraint MatchDeconstructionCall already places on its out-parameters. The tuple-designation branch no longer needs the position the enclosing run starts at, so the backward walk that searched for it is gone with it. The added fixtures pin reconstruction across adjacent deconstructions, whose element stores that walk used to step through. Assisted-by: Claude:claude-opus-5[1m]:Claude Code Only defer to an enclosing designation that can reach this position The temporaries and element reads of a nested tuple designation are stored back to back, so a statement of any other kind between the temporary and a read of it stops the enclosing pattern from consuming that read. Deferring anyway lost the deconstruction entirely: the enclosing attempt fails and the back-to-front walk does not return to the position that stepped aside for it, so the reads were left as the plain element accesses they came from, which master reconstructs. Assisted-by: Claude:claude-opus-5[1m]:Claude Code
33c0e98 to
0007566
Compare
Re-review of the updated branchFollow-up to the review from this morning, checked against the current head (0007566). Verification: built Verified fixed
Accepted as argued
Withdrawn
Still open (1)
This is an AI-generated re-review (Claude), posted on Christoph's behalf. |
0007566 to
266ac8a
Compare
A nested designation whose temporary is still read elsewhere is retried with that variable demoted to a designator leaf. The check that the first tuple element must be assigned ran before that retry, and every leaf of a wrongly nested first element precedes the assigned ones, so the pattern looked like it started mid-way and was rejected before the retry could restore it. The flat deconstruction was lost for a shape that has one. Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Builds on #3949 (merged), rebased onto current master.
After #3949, nested deconstruction decompiled without crashing but unsugared:
var (x, (a, b)) = o;came out as a flat deconstruction followed by an explicitinner.Deconstruct(out var a, out var b);call. The IL pattern node (MatchInstructionwithIsDeconstructCallsub-patterns), its invariants, and the statement/expression builders all already support nested patterns — onlyDeconstructionTransformnever built them.Change (two commits — the chain-matching mechanism, then the rule changes that let chains nest): in
DeconstructionTransform,MatchDeconstructionnow consumes chainedDeconstructcalls — including the defensive copy Roslyn emits for struct elements — into nested match patterns, recursively. Leaves get flat indices in depth-first order, which is exactly the orderStatementBuilder/ExpressionBuilderpair assignments with designators, so conversion/assignment matching runs unchanged on top. Three matching-rule adjustments follow from the chain being consumed: a call pattern no longer needs a matched assignment (single-use leaves are covered by the existing forwarding fixup); an unrelated assignment ends a call pattern instead of rejecting it (tuple patterns still reject — their element list is discovered from the assignments, ending early would misread a suffix); and a pattern is not rooted on an element of an enclosing deconstruction (blocks are processed back to front — the inner call is visited first and would otherwise sugar piecemeal, starving the outer call).The unrelated-assignment rule is keyed on the pattern being call-rooted, not on nesting, so it also fixes flat custom deconstructions:
var (a, b) = GetSource(...);followed by any unrelated assignment previously decompiled as an explicitDeconstructcall and now sugars (pinned by theLocalVariable_NoConversion_Custom_UnrelatedAssignmentAfterfixture).One production change lands outside the transform, as its own commit carrying its two pointer-target Pretty fixtures:
DeconstructInstruction.IsAssignmentfalls back tostobj.Typewhen a pointer target was materialized through a stack slot (whose type erases to unknown during target evaluation). This independently enables deconstruction into pointer targets —(*p, value) = tuple;— including plain tuples with no nesting at all.Nested tuples too: the second half of the series extends the nesting to pure tuple chains (fixtures, then the temporary-consuming mechanism, then the deferral guard) —
var (x, (a, b)) = GetTuple<int, (int, int)>();, which Roslyn lowers to one temporary per nested designation (parents before children) plus element reads in depth-first leaf order, previously decompiled as a flat deconstruction plus separate element statements. The matcher now consumes the temporaries into a tuple-node tree with flat depth-first leaf indices (the same design as the call nesting). An element variable that escapes the deconstruction (used after the statement) demotes back to a designator leaf via a blacklist-and-retry, and a dry-run guard — the tuple analogue of the call path's — defers inner flat matches to the enclosing attempt. Two IL realities drove the matcher details: earlier transforms rewrite non-escaping element reads fromldlocatoldloc, and stack-slot temporaries carry imprecise types (the container's element type is authoritative; the match variable is retyped so theIsDeconstructTupleinvariant holds). One production change again lands outside the transform, in its own commit:TupleType.FromUnderlyingTypethrewNullReferenceExceptionon non-tuple input instead of returning null as documented.Scope: call-under-call and tuple-in-tuple nesting, any depth, assignment + foreach forms, discards, conversions on leaves. Mixed containers (a call pattern absorbing tuple elements, or a custom-deconstructed element inside a tuple pattern) intentionally stay flat, as do by-ref extension receivers (
Deconstruct(this in S, ...)).Tests: each feature's fixtures land ahead of its implementation and are red until it. For the call nesting: 20 Pretty fixtures (the flat unrelated-assignment form, struct/class inners, both-elements-nested, depth 3, inner discard, nested/typed/nullable conversions,
System.Tuplesources, the two bail-out shapesElementDeconstructedAfterBarrier/OuterElementUsedTwice, two foreach forms incl.KeyValuePairextension Deconstruct; the two pointer-target forms ride in the pointer commit) and 18 Correctness cases with printedDeconstructcalls pinning evaluation order at runtime, including member hiding (NestedDeconstruction_HiddenDeconstructMethodpins theBindsOnElementTypegate), side-effecting LHS targets, checked/nullable conversions,in-parameter, conditional and generic-constraint sources, and the tuple-with-custom-element corruption repro. For the tuple nesting: 6 Pretty fixtures (depth 2 and 3, both elements nested, conversions on inner leaves, the escaping-element case pinned per config, foreach) and 5 Correctness cases with runtime-value pins. Every commit in the series builds, and at every point the only failing tests are the not-yet-implemented spec fixtures (verified per intermediate). Full decompiler suite at the head: 3332 tests, 0 failures.Output polish for #3803/#3388-adjacent cases; closes nothing by itself.
🤖 Generated with Claude Code