A hand-built Expression.Equal whose operands are a type parameter has no
lambda to decompile to: `v == other` is CS0019 for a type parameter, and
boxing both operands compiles but compares box identity where the tree
compares values once the parameter is a value type. The conversion now
declines, leaving the Expression calls that built the tree.
Found in EF Core's BoolToTwoValuesConverter<TProvider>.ToBool, which
decompiled to code that does not compile.
Assisted-by: Claude:claude-opus-5:Claude Code
The block addresses its allocation through the pointer localloc returns, and
only its result is a Span<T>. Retyping the initializer variable to the span
made every element store ask for a pointer it no longer had, which was papered
over with a conv from the span; once Obj and VT became distinct stack types
that conv had no conversion kind left and the whole method failed to decompile.
The span constructor becomes the block's final instruction instead, so the
element stores keep the pointer they were written against and the block still
evaluates to the span.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The Span<T>/ReadOnlySpan<T> initializer patterns replaced their call with an
array initializer block, so an array stood where a span was expected: the
enclosing leave and any call taking the result saw StackType.Obj against the
StackType.VT the span type demands. The block now ends in the implicit
conversion the C# compiler applies, which is the one shape besides a bare
ldloc that an array initializer may take; the expression builder keeps the
conversion out of the output but not out of the expression's type.
Naming that operator wants a method looked up by signature rather than by a
predicate over the type's members, so MetadataModule grows a ResolveMethod
overload for it, sharing its signature matching with the metadata path.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
The Span<T>/ReadOnlySpan<T> initializer patterns replaced their call with an
array initializer block, so an array stood where a span was expected: the
enclosing leave and any call taking the result saw StackType.Obj against the
StackType.VT the span type demands. The conversion the C# compiler applies is
the implicit operator, and it has to wrap the block rather than sit inside it,
because an ArrayInitializer block must keep ldloc as its final instruction.
Without the operator the conversion cannot be expressed at all, so the
original call is left untransformed instead.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A conversion of a small integer type to Int32 returns its operand unchanged,
because such values already occupy an I4 stack slot. The two operands of
`(short a, int b) => a + b` are therefore Int16 and Int32, and requiring them
to be equal rejected the conversion; the whole expression tree was then left
untransformed, or worse, aborted the enclosing method. What
BinaryNumericInstruction requires of its operands is a common stack type.
TryConvertExpressionTree also has to cope with a builder that fails, rather
than dereferencing the lambda it did not get.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
An unconstrained type parameter has no known IsReferenceType, and since the
stack types were split it takes StackType.VT, so a call on it reached
CallInstruction with a VT 'this' argument where Obj was expected. It might be
a value type at runtime, so it needs the same box a known value type gets.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
An expression tree leaves the boxing of a value-type receiver implicit: it
carries no Convert node for it, because the boxing follows from the method
being declared on a reference type. Enum.HasFlag invoked on an enum value is
the common case, and it reached CallInstruction with an I4 'this' argument
where Obj was expected, which aborts a debug build outright.
Deciding by the target's own type also retires the StackType.VT arm, which
ExpectedTypeForThisPointer never returns. Since box records the type of what
it boxes, an expression-tree cast to that same type in front of it is dropped.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A conversion from a value type to a reference type produced an opaque cast, so
no box instruction appeared anywhere in the converted tree, while the same C#
compiled as a plain lambda yields box T. The operand type is what box takes,
and it is available from the converted operand.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Compiling the same C# twice, once as Expression<Func<...>> and once as a plain
Func<...>, and diffing the two ILFunction bodies exposes where the conversion
reconstructs something the IL reader would never build. Three such cases:
The sign is part of the opcode only for the checked add/sub/mul and for
div/rem/shr; ILReader leaves it at Sign.None elsewhere, while the conversion
took it from the operand type unconditionally.
Expression.MemberInit is an object initializer, not a collection initializer.
Expression.Convert's three-argument overload carries the user-defined
conversion operator - which is how the decimal conversions are encoded - and
that argument was read by nothing, so every such conversion collapsed into an
opaque cast that dropped the method. Emitting the call matches what the
transform already does for decimal arithmetic.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Every converter now states the Expression.* call it matches and the ILAst it
produces, and the argument-count switches label the factory overload each case
stands for. The shapes were read off ILAst dumps of compiled expression trees
rather than from the factory signatures; two branches are documented as
unreachable, since no arithmetic or logical factory declares the four-argument
(left, right, liftToNull, method) overload their case matches.
Also drops the result-type local left in ConvertField, which BuildField
re-derives from the field and the type hint.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
ConvertConstant is its only caller, and with the result type gone the
out parameter that reconstructed it - a switch over LdNull/LdStr/Ldc* -
has no consumer. What remains is a match condition: the two-argument
Expression.Constant overload must pass its type as typeof(T), while the
one-argument overload legacy csc emits for display-class instances has
nothing to check.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Every converter handed back a (Func<ILInstruction>, IType) pair, and the
IType was consumed by matchers that ran before the builder. Now that the
ILAst instructions carry their own types, InferType() on the built operand
answers the same questions, so the type-dependent decisions move into the
builders and the pair collapses to the builder alone. Builders that could
already fail (ConvertArrayIndex) set the precedent for returning null from
inside one; ConvertInstruction propagates that.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
ConvertBind is the only expression-tree converter whose IType nobody reads:
ConvertMemberInit, its sole caller, takes Item1 and discards the rest, and
the member type is recoverable from the Call/StObj it builds anyway.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
System.Linq.Expressions resolves the user-defined operator behind a binary
factory by metadata name (Expression.Add looks up op_Addition), and the
checked factories reuse the unchecked names: AddChecked also looks up
op_Addition, never op_CheckedAddition. Recording that name at the call site
keeps the mapping next to the factory it belongs to.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Roslyn passes a display class into a local function by ref, and a local
function that only forwards that parameter to a sibling has no closure
variable of its own. The closure analysis therefore found nothing to
anchor it and fell back to the root method body, which put it out of
reach of the callees it forwards to; CallBuilder then hit the assert
guarding a local function reference it cannot resolve and emitted the
raw metadata name of the target instead.
The constructor path also mixed use-site containers into a scope the
closure analysis had already determined; when the use-sites live in
separate function bodies there is no common container, and resetting to
the constructor body threw that scope away.
Assisted-by: Claude:claude-opus-5:Claude Code
Change InferType() to an abstract method and implement for every ILInstruction.
With this change, we now always have enough information to create a variable of an appropriate type to store the result of evaluating the instruction.
This previously was not the case for instructions producing "other value type", for which the stacktype-based fallback incorrectly produced `object`.
Invariants that involve types (stack type of a variable against its IType,
the element type of an array access, the operand types of a comparison)
need a type system to resolve them against, and the only correct one is the
type system the instruction tree was decoded with. Until now CheckInvariant
took only the phase, so such a check had no compilation to use:
DeconstructInstruction.CheckInvariant called IsAssignment with a null type
system, which only held up because the targets it sees are ldloc, whose
InferType never touches the compilation; a ldflda-wrapped or pointer target
would have failed inside the invariant instead of reporting a violation.
Every call site already has that type system in scope: the ILReader's
compilation, the ILTransformContext of the running transform, or the
decompiler's own IDecompilerTypeSystem. It is now passed explicitly and the
base implementation asserts it is present, so a future invariant can rely
on it without re-plumbing the callers.
Assisted-by: Claude:claude-fable-5:Claude Code
A local function nested in a lambda can capture closures at two depths:
csc emits it as an instance method on the enclosing method's display
class that takes the lambda's display class as a parameter. Combining
those two capture scopes with FindCommonAncestorInstruction picked the
enclosing method, moving the function out of the lambda that owns the
deeper closure; the variables captured there were then unreachable and
the display-class parameter survived into the output as an undeclared
identifier. Nested capture scopes resolve to the innermost instead,
which a local function can always see - it reaches the outer closure
through the display class it is declared on.
Assisted-by: Claude:claude-fable-5:Claude Code
HandleConditionalOperator collapses `if (c) a = x; else a = y;` into a
conditional operator, innermost first, and keeps going for as long as the
chain does. A source else-if ladder therefore comes back as one expression,
however long it was: the sample in #2027 decompiles to a single
2095-character statement, and one NLog method to 2230 characters nested 29
brackets deep.
ExpandNestedConditionals undoes that past one level, so a statement keeps at
most a single conditional operator.
It runs at the end of the pipeline rather than inside ExpressionTransforms,
because every transform that needs its input to be a single expression has
to see the collapsed form first: object and collection initializers, `with`,
switch expressions, interpolated string handlers, and the query lambdas the
C# stage later rewrites into clauses. Cutting the chain earlier leaves an
if-else between the statements they pattern-match on and they silently stop
matching - an object initializer assigning an init-only member then does not
even compile. The same reasoning ReduceNestingTransform gives for walking
back ConditionDetection's aggressive else-inlining once the structure is
settled.
A chain already stored to a variable is expanded into that variable, so
nothing has to be decided: the variable carries its own type. A chain in any
other position - an argument, a return value, a field store - has nothing to
expand into, and ILExtraction can give it one. The temporary ILExtraction
creates is typed from the stack type though, where `I4` is `int`, `bool`,
`char` and every enum at once, so extracting on that basis turned a bool
into `int num` with `if (num == 0)` and an enum into
`dbType = (IsFixedLength ? 22 : 0)`.
InferExpectedType is the counterpart to InferType that answers this: where
InferType asks what a value is, it asks what the position the value flows
into says it should be - a parameter, a return type, a field, all of which
carry their type in metadata. Extraction is done only where that question
has an answer, and the temporary is typed from it. The receiver of a call
then reads `XPathNavigator xPathNavigator`, not `object obj` with a cast
back, and a field store keeps its enum's member names.
The Pretty fixture covers what must NOT change: an array initializer, a
query lambda, a ref local, a switch expression, an object initializer with
an init-only member, a `with` expression, a catch-when filter and both
constructor-initializer forms. The positive case is an ILPretty test,
because a Pretty fixture is its own input and expected output, and a chain
that round-trips through collapse and expansion has no fixed point there.
The PdbGen test records the cost in breakpoints: the compiler's single
sequence point for the collapsed statement becomes one per expanded
statement, which is inherent to splitting a statement in two.
#2027
Assisted-by: Claude:claude-opus-5:Claude Code
A dynamic call site that names a type passes typeof(T) as its target. When
a later argument contains control flow, the compiler stores that typeof in
a temporary. ILInlining does not undo this: it inlines a store only into
the immediately following instruction, so an intervening statement leaves
the typeof behind, and the expression builder printed the temporary instead
of the type. Substituting the typeof back into the target slot is not an
option either, because a call there has side effects and blocks inlining of
the remaining arguments.
The type is therefore stored on the instruction. Object creation already
did this, but kept the field private, so the expression builder re-derived
the type from the target argument and failed on the spilled form. Member
invocation gets the same field. Both drop the target from Arguments, so the
dead-argument handling removes the store; ArgumentInfo keeps its entry for
the target, because the invocation symbol is built from it.
The type of an object creation is not nullable: the transform returns
before constructing the instruction when it cannot match the typeof, and
substitutes the unknown type otherwise. An unresolvable type therefore
reaches the expression builder as the unresolved type it is, and prints as
`?` like any other, rather than being indistinguishable from a missing one.
Those two are also the only binder method kinds that ever see
CSharpArgumentInfoFlags.IsStaticType. Every other way of naming a type as
the receiver binds statically and leaves only a conversion of the dynamic
operand behind.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
FindRefStructParameters dropped generic instantiations, so a parameter
typed 'ref <>c__DisplayClass0_0<T>' never reached RefStructTypes. Both
consumers therefore missed local functions whose declaring type or method
is generic: the signature test for an obfuscated local function, and
LocalFunctionNeedsAccessibilityChange, which left such a function internal
while its closure struct stayed private - the recompiled output then fails
with CS0051.
Cross-module signatures still drop out, because the generic type part of an
instantiation goes through GetTypeFromReference, which returns nil.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A Roslyn local function name is "<caller>g__name|x_y", where the trailing digits
are a synthetic disambiguator that SplitName has to strip before the scope-local
renumbering can run. An obfuscated name has no such suffix, so running it through
the same path renames "smethod_1" to "smethod_" -- a needless second mangling on
top of what the obfuscator already did.
#3202
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Obfuscators strip the CompilerGeneratedAttribute and rewrite the
"<caller>g__name|x_y" name, which is all IsLocalFunctionMethod had to go on.
The method then stays an ordinary static method, its display struct escapes by
ref into a plain call, and TransformDisplayClassUsage correctly refuses to
scalar-replace it -- so the closure fields leak into the output as
"<>c__DisplayClass29_0_.iid" (issue #3202).
The one marker an obfuscator cannot remove is the signature: Roslyn emits
struct closures exclusively for local functions, and no hand-written C# can
name a "<>c__DisplayClass" type, so a by-ref parameter of one identifies the
method regardless of what it is called.
#3202
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Below C# 7 ref locals are unavailable, so CopyPropagation is allowed to copy
LdFlda/LdElema. When such a copy lands in a StObj target slot whose value is
impure, it violates the invariant checked by StObj.CheckTargetSlot: C# computes
the value to be stored before dereferencing the target, so the exception moves.
ILInlining resolves the same conflict by marking the address as delayed rather
than falling back to a ref local; copy propagation now does the same, which
keeps the generated code unchanged and only repairs the IL.
Unlike inlining, copy propagation has no third arm to fall back to: by the time
DoPropagate runs, the defining store is about to disappear, so every load has to
be replaced and refusing the copy is no longer an option. That decision can only
be made up front, which is what CanPerformCopyPropagation does when ref locals
are requested. The assertion covers the remaining hole, the public Propagate()
entry point, which bypasses that check -- AsyncAwaitDecompiler copies an ldflda
of the builder field through it irrespective of the setting.
Propagating an address also un-inlines its arguments into fresh stack slots, and
those stores are copy-propagation candidates in their own right. They are
inserted before the store being replaced, so the block scan used to step right
past them: a slot loaded more than once could never be inlined back and survived
into the output as a ref local -- for `s.ShortField >>>= 5` the copied ldflda
left behind `stloc C_0(ldloca s)`, printed as `ref CustomStruct reference = ref
s`. Rewinding the scan to the first of those stores lets them propagate too.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
Both types are consumed well outside the C# output layer - DecompileRun
carries the using scope, and the IL transforms build a resolve context from
it - so living in ICSharpCode.Decompiler.CSharp.TypeSystem misrepresented
where they belong and forced a C#-specific namespace import on every
consumer.
Assisted-by: Claude:claude-opus-5[1m]:Claude Code
A writable ref-struct argument can receive narrower values through regular ref/out calls, and a ref-return can expose the same storage for field mutation. Treat those paths like receiver captures so inferred declarations remain compilable.
Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5d30b7a7-983d-4efa-8d99-fbface5828dc
Local scopedness is erased from IL and PDBs, so it can only be recovered
from the body. Compare each declaration initializer with later assignments,
field stores, and receiver captures using the C# 11 ref/value escape rules,
and emit scoped only when a later operation is strictly narrower.
Assisted-by: Copilot:gpt-5.6-sol:GitHub Copilot CLI
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720
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
`<P>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
Under UseLambdaSyntax, anonymous functions became lambdas only when an
expression body was possible; statement-bodied ones kept C# 2 delegate
syntax. Now every anonymous function whose parameter shape a lambda can
express uses lambda syntax; delegate syntax remains for ref/out/in and
params parameters and for pre-C# 3 language profiles.
Two latent issues surfaced by the wider lambda coverage: DeclareVariables
assumed an insertion point directly under a LambdaExpression is an
expression body it must convert to a block, which block-bodied lambdas
now violate; and anonymous methods declared without a parameter list
carry compiler-generated parameter names like '<p0>' that are not valid
identifiers, so the lambda's mandatory parameter list regenerates such
names from the parameter type: (object obj, EventArgs e) => ...
A side effect visible in fixtures: an explicit parameter list can make
a delegate-creation cast redundant that bare 'delegate' syntax needed
for overload resolution, e.g. new Thread((ThreadStart)delegate { })
becomes new Thread(() => { }).
Assisted-by: Claude:claude-fable-5:Claude Code
Element 8+ of a long tuple is read through the Rest field, which Roslyn
loads by value; ILSpy turned that into an addressof over the loaded
copy, hiding the tuple field chain from every downstream matcher. Elide
the copy when the enclosing expression only reads through it - the read
then goes directly through the original address and folds into the
usual Item_N chain. The deconstruction transform's use-shape guard also
learns to walk that chain; whether each read really is a consumable
element access remains the job of MatchTupleElementRead and the escape
check.
Assisted-by: Claude:claude-fable-5:Claude Code
Deconstruction assignment copies the right-hand side into a temporary
before calling Deconstruct. When the RHS is a call, inlining folds that
temporary away, but for a local or parameter it survived into the
output as a separate assignment statement. Consume the copy into the
deconstruction pattern; rendering the copied value as the RHS
recompiles to the identical temporary. Because blocks are processed
back to front, the call-position match defers to the attempt starting
at the copy, mirroring the existing nested-deconstruction defer guard.
The new fixture also covers deconstruction assignment to locals
captured by a lambda in an async method (issue #3037's crash shape,
already fixed earlier).
Assisted-by: Claude:claude-fable-5:Claude Code
C# has no ref-returning switch expression. Skip the transform for StackType.Ref and cover the statement form in RefLocalsAndReturns.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0dd407b6-9410-48df-add5-761ca4a8dec0
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
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
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
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 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
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