469 Commits
Author SHA1 Message Date
Siegfried Pammer 8e50cadcc7 Make debug step recording opt in
Non-UI decompilation runs do not consume debug steps, but debug builds still allocated steppers and step nodes unless each context opted out manually. Keep step contexts empty unless the caller enables RecordSteps, while preserving the shared stepper path for the UI debug-step view.

Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
2026-10-10 21:18:52 +02:00
Siegfried Pammer 098de864b9 Avoid cloning settings for every method
Most methods use the decompiler settings read-only. Clone only for the WinForms InitializeComponent special case that temporarily overrides settings before running the method pipeline.

Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
2026-10-10 15:15:52 +02:00
Siegfried Pammer aa18f6c075 Merge pull request #4222 from icsharpcode/fix/2199-vb-closure
Fix/2199 vb closure
2026-10-08 20:55:56 +02:00
Siegfried Pammer 565e1a3442 Handle legacy VB delegate wrappers
VB can lower event-handler closures through generated wrapper methods, closure copy constructors, and immediately invoked VB anonymous delegate helpers. Recognizing and unwrapping those shapes leaves the expected lambda form and lets captured locals be scalar-replaced instead of exposing generated closure artifacts.

Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
2026-10-08 17:52:13 +02:00
Siegfried Pammer ddcac25c2b Repair private virtual member output
Metadata can contain private virtual members that C# cannot declare directly. Adjusting the emitted accessibility in the decompiler keeps such members compilable without pushing the policy into the general AST builder.

Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
2026-10-03 14:52:35 +02:00
Siegfried Pammer e528a358ed Preserve VB WithEvents backing fields
AccessedThroughProperty-backed controls need to remain addressable as fields so WinForms initialization can use compiler-generated backing storage without hiding the setter that maintains WithEvents subscriptions.

Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
2026-10-03 14:52:07 +02:00
Siegfried Pammer f386a148b0 Merge pull request #4160 from icsharpcode/refactor/extension-method-syntax-in-callbuilder
Decide extension method syntax in CallBuilder
2026-09-29 07:45:01 +02:00
Siegfried Pammer 2a838a4f02 Detect generated closure cache types
Compiler-generated cache holder types such as <>c should be treated consistently with other closure-like types so the decompiler can classify them before the broader fallback.

Assisted-by: OpenCode:openai/gpt-5.5:opencode
2026-09-28 07:36:37 +02:00
Siegfried Pammer 453028f50f Include member context in decompilation error comments
The command-line summary already names the failing token and member, but the generated code only asked users to report a generic exception. Put the same member context into the emitted comment so copied snippets identify the failing method.

Assisted-by: OpenAI:gpt-5.5:OpenCode
2026-09-25 20:30:55 +02:00
Siegfried Pammer 9b16bf3edb Decide extension method syntax in CallBuilder
Every other decision about how a member reference is spelled is made
while the expression is built, behind Disambiguator, and CallBuilder
already writes target.ExtensionMethod for delegate references and method
groups. Extension calls were the last spelling decided afterwards, by an
AST transform that had to rebuild a resolver from a UsingScope
annotation on the tree root and re-derive every argument's resolve
result from AST annotations.

The check now runs against the resolver ExpressionBuilder already owns,
whose using scope is the namespace superset the DecompileRun collected
from the IL. That superset is wider than the imports actually printed,
so it can only refuse infix syntax where the transform allowed it, never
bind a call to a different overload.

Two consequences had to be handled. The declaring type is no longer
named anywhere once the call is infix, so IntroduceUsingDeclarations
would not have imported its namespace and the output would not compile;
it now imports from the invocation itself. And an extension GetEnumerator
in infix form is spelled like an instance call, so the foreach matcher
can no longer read a plain match as an instance method.

Writing the receiver as the target means skipping an argument, which the
argument list only supported where there were neither names nor optional
arguments: it skipped before trimming the optional tail, so a skip past
an omitted argument took one argument too many, and it gave up entirely
on names. Both now skip after trimming, which also settles a latent
off-by-one on the collection-initializer path.

Assisted-by: Claude:claude-opus-5:Claude Code
2026-09-21 16:58:00 +02:00
Siegfried Pammer cd212ea05f Pass the compilation to ILInstruction.CheckInvariant
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
2026-08-30 12:43:40 +02:00
Siegfried Pammer d22d4301e4 Expand nested conditional operators back into if-else
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
2026-08-30 00:20:51 +02:00
Siegfried Pammer b8d68d9143 Give the builder output a step of its own
The state the ExpressionBuilder and StatementBuilder leave behind was only
reachable as "state before the first AST transform" - an entry that names a
transform rather than the state, and that sits below every member's group in a
tree with one group per member. It is now a top-level step at the seam, where
the whole type is converted and nothing has transformed it yet.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-27 09:45:49 +02:00
Siegfried Pammer 44116503d9 Take the halted function from the stepper
Two extra pieces of state existed to reconstruct what the stepper already
records: which member a halt belongs to. The step the limit stopped on answers
it, except on a member's opening step, which is where the previous member's
state ends - and there the last step actually recorded answers it instead.

IL group openers gain an anchor so their position resolves. The member opener
deliberately keeps none: anchoring it to the member the pipeline is about to
start is exactly the misattribution the fallback exists to avoid.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-27 09:45:49 +02:00
Siegfried Pammer 0b4b2a3f08 Record the whole pipeline or none of it
Recording only gated the IL half, so a run with it off still numbered the C# AST
transforms into the shared stepper. That gave one pipeline two numbering scales,
and a step index is only meaningful against the scale it was recorded on: a tree
captured under one and replayed under the other selects a different step. It
also let the crashed-member attribution fire on a counter that had never moved -
a limit of zero matched at every throwing transform and rendered an unrelated
member's ILAst.

The flag now gates both halves, so steps exist exactly when recording is on, and
it lives on the pane instance rather than a static the background decompile read
across threads.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-27 09:45:28 +02:00
Siegfried Pammer a5bd230e14 Walk the whole decompiler pipeline in the Debug Steps pane
The pane used to split the pipeline across two languages: the ILAst language
stepped the IL transforms, the C# language stepped the AST transforms, and
nothing showed the seam between them, so a step index meant a different thing
depending on which language happened to be selected. Recording both halves into
one Stepper makes an index replayable across the whole pipeline; a limit that
lands in the IL phase has no C# to print, so the halted function is rendered as
ILAst instead.

Which function that is takes some care, because a member group's EndStep is the
next member's first step: a halt standing on a member's opening step belongs to
the member that just finished, a transform that throws where the limit was aimed
has to hand over the ILAst it half-transformed (what the ILAst language showed
as "ILAst after the crash"), and a step recorded on a helper function the
pipeline has not attached yet belongs to that function's own tree.

Retention stays opt-in twice over: the decompiler records IL steps only when
asked to, and the pane asks only while its view is on screen. Every kept step
pins the ILAst it captured, which for one type runs to tens of thousands of
nodes, so a closed pane would be paying for a tree nobody displays.

What is left of the ILAst language is its typed-IL dump, which runs no
transforms at all. That stays, as TypedILLanguage. IDebugStepProvider was down
to a single implementation and is removed.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-26 22:41:08 +02:00
Daniel Grunwald 07b2649fdf Merge pull request #3888 from sailro/scoped-local-inference-v2
Fix #3853: infer scoped locals from ref safety
2026-08-15 22:57:58 +02:00
Siegfried Pammer 48fcf0313e Move UsingScope and CSharpTypeResolveContext into the type system
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
2026-08-15 11:03:10 +02:00
Sebastien LebretonandCopilot aab8b9b288 Fix #3853: infer scoped locals from ref safety
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 <[email protected]>
Copilot-Session: 86d2918e-5a24-48b4-9a86-41d331ec3720
2026-08-14 16:58:36 +02:00
Siegfried Pammer 3da129632e Fix #3624: decompile semi-auto properties with the C# 14 field keyword
Backing-field references inside a property's own get/set/init accessors
are emitted as the `field` keyword at IL-to-AST translation time
(ExpressionBuilder.ConvertField), so arbitrary accessor bodies become
expressible and no separate rewrite pass is needed. Compiler-generated
trivial accessors then collapse individually to `get;`/`set;`, which
also handles mixed shapes like `{ get; set { ... field ... } }`; the
backing-field declaration is removed with its remaining attributes
re-hosted as `field:` sections, and constructor stores become property
initializers (or property assignments, for setter-less properties).
Implicit zero-stores that auto-default struct constructors emit for
unassigned backing fields are dropped rather than lifted.

Recognition stays AST/metadata-based rather than mirroring the ILAst
analysis used for automatic events: events must prove compiler-generated
bodies before discarding them, while the field keyword discards nothing,
so name association plus the accessor context is sufficient.

Below C# 14 (or with the new FieldKeyword setting off), the field
declaration survives under its metadata name, so the UI keeps showing
the truth; EscapeInvalidIdentifiers - the transform the compilable-output
flows (project export, VS, tests) already add - now maps
`<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
2026-08-13 13:15:21 +02:00
Siegfried Pammer 26145d6f17 Enqueue referenced members by definition in the decompile worklist
In generic types, resolve results reference members specialized by the
type's own type parameters. The worklist dedupe and entityMap in
DoDecompile(ITypeDefinition) are keyed by definition, so a hidden member
re-added through the worklist (e.g. a property backing field referenced
from an accessor) was decompiled under a key the output pass never looks
up, silently dropping the declaration while keeping its uses.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-08-13 13:15:21 +02:00
Siegfried Pammer 2efd871abc Fix #3451: qualify an explicit implementation as the type implements it
Tuple element names and nullability are not part of a type's identity, so an
interface resolved through one of its members carries neither. Naming an
explicit implementation from that type produced `void I<(int, int)>.M()` on a
type declared as `I<(int A, int B)>`, which the C# compiler rejects outright
with CS0540 - the decompiled source did not build. The nullable case was
already recorded as a TODO in the NullableRefTypes fixture, where the mismatch
costs a CS8643 warning rather than an error.

The implementing type's base-type list is the only place those annotations are
recorded, so the qualifier is looked up there. Three call sites derived it
independently - the AST builder for all five member kinds, the ambience used
for tooltips and tree labels, and the forwarders synthesized for MethodImpls -
so they now share one helper rather than repeating the rule twice more.

Matching while ignoring tuple names and nullability cannot be ambiguous:
implementing two interfaces that differ only in those is itself an error
(CS8140, CS8645).

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-11 06:21:29 +02:00
Siegfried Pammer 3cb27b0dcc Fix #3510: keep exporting a project when a member cannot be decompiled
One member the decompiler could not handle aborted the whole export, so a
single unsupported method in a large assembly left the user with nothing: no
sources, no .csproj, no way around it. Recovering silently would trade that
for a worse outcome - broken output nobody knows is broken - so every failure
is recorded, written where the content would have gone, and pointed at the
issue tracker.

The recovery has to hold for anything the export touches, not just method
bodies: a file that cannot be created, a resource that cannot be decoded, an
output visitor that throws mid-type. Each of those costs its own unit and
nothing else, and the units behind a failure are still produced - dropping
them would make the export look complete when it is not.

Consumers that relied on the exception keep their failure signal: ilspycmd
exits non-zero and lists the failures, the PowerShell cmdlets raise an error
record per failure, and the round-trip suite asserts the export reported none
- otherwise a crash on a method its own tests never call would ship green.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-10 21:00:47 +02:00
Siegfried Pammer 247cc1f3ed Declare VB anonymous types that C# anonymous types cannot express
A C# anonymous type is immutable and compares every member. VB's are neither
unless every property is declared 'Key': otherwise the properties are settable
and only the 'Key' ones take part in Equals and GetHashCode. Writing such a
type as 'new { ... }' silently gave it value equality and made any assignment
to one of its properties fail to compile, so only an anonymous type with no
settable property is treated as one; the rest keep their own declaration.

Those declarations carry the shape VB gave them, so the round-trip preserves
both mutability and 'Key' equality. Their names are the remaining obstacle,
since the VB compiler separates the parts with '$': the type, its backing
fields and any local named after it are renamed to use '_' instead, and a
comment on the declaration says why the type is spelled out.

Generated variable names are now rejected when they would not be legal C#
identifiers, which also stops a display class from lending its unspeakable
name to a local in the NoLocalFunctions output.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-08-07 12:04:25 +02:00
Siegfried Pammer 0e841d2a6d Recognize VB transparent identifiers in query decompilation
The VB compiler carries the range variables of a query in $VB$It, $VB$It1,
$VB$It2 and $VB$ItAnonymous, its counterpart to C#'s <>h__TransparentIdentifier.
Unrecognized, they were left in place by CombineQueryExpressions, and since '$'
is not legal in a C# identifier every VB query with more than one range
variable decompiled to code that cannot be recompiled.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-08-07 12:04:25 +02:00
Siegfried Pammer d9c60f08b0 Fix crash decompiling reference assemblies with stripped method bodies
The accessor forwarding-stub matcher bounded the IL body from above but not
from below. Reference assemblies keep the method RVA while stripping the body
to zero bytes, so an explicit interface accessor there sailed past the size
check and the first opcode read ran off the end of the blob, aborting the
whole type with a BadImageFormatException. The sibling matcher in
TransformDisplayClassUsage already guards its lower bound; this one did not.

Found by fuzzing nuget.org: every package resolving to the
Microsoft.NETFramework.ReferenceAssemblies packs was affected.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
2026-08-07 06:09:09 +02:00
Siegfried Pammer 17e6649ba8 Fix #3954: name the entry point of a top-level program 'Main'
Roslyn compiles top-level statements into a synthesized Program class
whose entry point is called '<Main>$', a name that cannot be declared
in C#. Decompiled output kept it verbatim (escaped to
_003CMain_003E_0024 when exporting a project), and since C# accepts
only a method called 'Main' as an entry point, the exported executable
did not compile (CS5001).

Give that method the name 'Main'. Per the decision recorded in #829 we
do not reconstruct top-level statements, so this is the level of
support the output needs to compile.

An async top-level program needs the name in a different place: it
compiles to '<Main>$' holding the statements plus a '<Main>' entry
point that only awaits it. That wrapper carries the .entrypoint marker
but is hidden from the output, so the name goes to the method it
awaits instead - unless AsyncAwait is off, when the wrapper is
emitted and keeps the name itself.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-08-05 12:56:18 +02:00
Siegfried Pammer a1726a9dbf Fix #3938: nested-type members must not inherit ExtensionInfo
The .NET 10 BCL ships static [Extension] classes that contain ordinary
nested types (e.g. XDocumentExtensions.XDocumentNavigable). Decompiling
such a nested type's member in isolation resolved the enclosing
container's ExtensionInfo, and DecompileBody then dereferenced the
missing extension-member mapping. A container without any extension
blocks now reports no ExtensionInfo at all, and ResolveExtensionInfo
applies a container's info only to members that actually belong to one
of its extension blocks.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-07-31 13:50:00 +02:00
Siegfried Pammer 4c04e01882 Cover virtual, overloaded, and renamed-Implements parameterized properties
The renamed-Implements case exposed a gap: the accessor-method
declarations did not include the explicit-interface-implementation
forwarders generated from .override directives, so decompiled types did
not implement their interfaces and same-name implementations lost their
interface mapping. DecompileParameterizedProperty now emits the same
forwarder stubs as the ordinary method path.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-07-27 16:28:34 +02:00
Siegfried Pammer 832090c08d Emit parameterized-property accessors as ordinary methods
C# cannot declare a named property with parameters: only the type's
default member gets indexer syntax, and reusing it via [IndexerName]
collapses for types with several differently-named indexed properties,
static properties, or explicit interface implementations. Emitting the
accessors as ordinary methods is the only fully general compilable
form, matches how C# consumes such properties (Roslyn exposes the
accessors of properties it cannot bind as regular methods, the same
pattern C# 14 made user-facing for extension-member disambiguation),
and round-trips call sites to identical IL. Call sites already lower
to direct accessor calls.

The property-level attributes are kept on the first accessor under the
'property:' attribute target: it is not valid on methods, so csc emits
nothing for it (CS0657 warning) and recompilation neither loses the
attributes from the source nor misapplies them to the accessor. A
comment on the first accessor documents the deliberate deviation.
Visual Studio's metadata-as-source view drops such properties'
attributes entirely.

The assembly tree and tooltips are unaffected: they keep rendering the
property node with its parameter list. Known limitation, inherent to
any C# projection: recompiling the output produces plain methods, so
VB.NET consumers of the recompiled assembly lose property syntax.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-07-27 16:28:34 +02:00
Christoph Wille c8474419c4 Redesign DecompilerEventSource with Start/Stop tracing and keyword gating
The events introduced in #2519 timed only the five per-entity DoDecompile
overloads, allocated a Stopwatch and the member's FullName even when no
trace session was attached, and reported whole milliseconds, which rounds
almost every member to zero. Flat one-shot events also gave PerfView and
dotnet-trace no way to show durations or nesting, and the actually
expensive stages (type system initialization, assembly resolution probing,
the IL/AST transform pipelines, whole-project decompilation) were not
instrumented at all.

Start/Stop event pairs let trace viewers derive duration and nesting from
event timestamps, keywords let a session enable only the areas of
interest, and every call site is gated on IsEnabled() so tracing costs a
branch when disabled. Per-transform events are Verbose because of their
volume; unlike the STEP/Stepper mechanism they work in Release builds.
EventSource is in-box for netstandard2.0 and flows over both ETW and
EventPipe, so this stays cross-platform with no new dependency.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-07-22 21:10:41 +02:00
Siegfried Pammer fddd62286b Beautify enum declarations by referencing previously declared members
Enum members whose value duplicates an earlier member now reference it
(Item2B = Item2A), [Flags] members combine earlier single-bit members
(All = Item1 | Item2 | Item3) or their complement (NotItem1 = ~Item1)
instead of showing a bare number.

Several guardrails keep the output faithful to how such enums are
written by hand: only previously declared members are referenced (field
row order); a multi-bit value lying entirely within a larger, earlier
member is a field encoding inside that mask, not a flag union, and
stays numeric, as do zero-valued members of [Flags] enums, which
routinely have several unrelated zero members. The ~X form is
suppressed in byte/ushort enum declarations, where the initializer
constant folds in int and would not compile. Enums with unusual
underlying types (bool, native int) keep the plain constant conversion.
With these rules, decompiling System.Private.CoreLib reproduces the
hand-written declarations of TypeAttributes, MethodAttributes,
AttributeTargets and FileAttributes almost verbatim.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-07-22 18:03:33 +02:00
Siegfried Pammer 7554c1b004 Remove the syntactic automatic-event patterns
With recognition on the ILAst and reference substitution during
translation, the four accessor-body patterns in
PatternStatementTransform were only reachable as a fallback, and any
divergence between them and the AutoEventDecompiler verdict produced
inconsistent output. A compiler shape the ILAst matchers do not know now
degrades to explicit accessors with the backing field kept in the
output, which stays compilable. Bodyless events (abstract, extern,
interface members) previously relied on the patterns' no-body clause to
become field-like; since C# cannot express bodyless custom accessors,
DoDecompile now chooses the field-like form for them directly. Also
deletes the orphaned IsEventBackingFieldName helper; the name
association lives in PropertyAndEventBackingFieldLookup.

Assisted-by: Claude:claude-fable-5:Claude Code
Claude-Session: https://claude.ai/code/session_01Btdypgm8utyxqt1Etn2BDi
2026-07-18 21:56:16 +02:00
Siegfried Pammer 461df88572 Replace automatic-event backing-field references during translation
PatternStatementTransform renamed backing-field identifiers to the event
after the fact, keyed on the metadata name association alone: references
belonging to an event that is not actually automatic were still renamed,
binding them to a custom event that is unusable as a value (the same
defect class as #3858), and the rename bypassed the resolver checks, so
qualifiers were computed for the hidden field instead of the printed
event. ExpressionBuilder.ConvertField now performs the substitution,
keyed on the AutoEventDecompiler verdict whose memo moves into
DecompileRun so that member hiding, the event declaration, and reference
translation all decide from one analysis. Checking the verdict's field
identity also keeps same-typed sibling events apart (#3575), and the
qualifier logic running against the event drops spurious this./type
qualifiers from raise sites.

mcs 2.x accesses a sibling automatic event's backing field directly
inside custom accessors instead of calling the accessor, so the fixture
expects the resulting Delegate.Combine form there.

Assisted-by: Claude:claude-fable-5:Claude Code
Claude-Session: https://claude.ai/code/session_01Btdypgm8utyxqt1Etn2BDi
2026-07-18 21:56:16 +02:00
Siegfried Pammer 198639be0d Emit the backing field when a name-associated event is not automatic
MemberIsHidden decides field hiding from the metadata name association
alone, which over-approximates: an event whose accessors fail the ILAst
validation is decompiled with explicit accessors, and while a referenced
backing field is re-added through the work list, an unreferenced one was
silently dropped from the output. The type-definition member loop now
consults the same memoized verdict as the event declaration, so the two
decisions agree by construction.

Assisted-by: Claude:claude-fable-5:Claude Code
Claude-Session: https://claude.ai/code/session_01Btdypgm8utyxqt1Etn2BDi
2026-07-18 21:56:16 +02:00
Siegfried Pammer 1b9131905d Fix #3858: Detect automatic events on the ILAst instead of the C# AST
The syntactic accessor-body patterns in PatternStatementTransform sit
downstream of every settings-dependent transform, so each new compiler
shape or settings combination silently broke recognition: with
AggressiveInlining enabled, static events inline the Delegate.Combine
call into CompareExchange positionally, which none of the four patterns
matched, while call sites were still rewritten to the event name from
metadata alone - producing uncompilable output (CS0079).

Recognition now happens in DoDecompile(IEvent) by structurally matching
the ILAst of the accessors, decompiled with a fixed set of settings the
same way RecordDecompiler analyzes method bodies. This makes detection
independent of the user-visible settings by construction. Events that
are not recognized fall back to the classic path unchanged, including
the existing AST patterns.

mcs 2.x compiles the accessors as a compound assignment, evaluating
'this' once via IL 'dup'; the simple-combine matcher accepts that
stack-slot alias.

Assisted-by: Claude:claude-fable-5:Claude Code
2026-07-18 21:56:16 +02:00
Siegfried Pammer bbac02a79f Extract DecompileBodyForAnalysis from RecordDecompiler
Structural recognition of compiler-generated code needs method bodies as
ILAst decompiled with a fixed set of settings, so that recognition does
not depend on user-visible options. RecordDecompiler had this pipeline
as a private helper; hoist it to CSharpDecompiler so other recognizers
can share it, deriving the generic context from the method's declaring
type instead of a captured type definition.

Assisted-by: Claude:claude-fable-5:Claude Code
Claude-Session: https://claude.ai/code/session_01Btdypgm8utyxqt1Etn2BDi
2026-07-18 21:56:16 +02:00
Sebastien Lebreton b0e446618f Fix #3850: recover ReadOnlySpan<T> array literals from the legacy lazy cache
Roslyn caches a ReadOnlySpan<T> created from an array literal in a
<PrivateImplementationDetails> field on target frameworks without
RuntimeHelpers.CreateSpan (e.g. .NET Framework / netstandard2.0 + System.Memory):

    object obj = <PrivateImplementationDetails>.cache;
    if (obj == null) {
        obj = new char[] { '\r', '\n' };
        <PrivateImplementationDetails>.cache = (char[])obj;
    }
    ... new ReadOnlySpan<char>((char[])obj) ...

The decompiled output referenced the compiler-synthesized
<PrivateImplementationDetails> type, whose escaped name is not expressible in C#
and is never declared, so the output failed to recompile (CS0400).

The modern RuntimeHelpers.CreateSpan form was already handled
(TransformRuntimeHelpersCreateSpanInitialization); this adds the analogous
handling for the legacy lazy-cache form, mirroring CachedDelegateInitialization
(which collapses the same lazy-static-field cache for anonymous-method delegates).
Once the cache is collapsed, the existing array-initializer transforms recover
the array literal, so the <PrivateImplementationDetails> reference disappears.

Test: ILPretty/CachedReadOnlySpanInitialization.
2026-07-13 11:12:10 +02:00
Siegfried Pammer d35c082616 Move debug-step stepping to a neutral namespace
The step recorder is shared by C# AST and ILAst replay, so keep the public infrastructure out of IL.Transforms and pass language-specific node navigation through neutral node metadata.

Assisted-by: OpenCode:openai/gpt-5.5:OpenCode
2026-07-02 01:08:51 +02:00
Siegfried Pammer d3517739ff Add fine-grained C# AST debug steps
Record AST transform groups and mutation steps through the C# pipeline, replay selected steps with the stepper, and carry modified-node ranges through output so the Debug Steps pane can highlight the selected mutation without replacing its full step tree.

Assisted-by: CodeAlta:gpt-5.5:CodeAlta
2026-07-02 01:08:51 +02:00
Siegfried Pammer ed61b8a14e Replace the SlotKind enum with object-identity Slots kinds
The slot kind is now the canonical typed CSharpSlotInfo<T> in Slots, matched by
object identity (the IL SlotInfo model), instead of a parallel SlotKind enum.
CSharpSlotInfo.Kind points at the canonical shared slot; a Slots constant is its
own kind (null Kind, never read -- only per-node slots are asked for their kind),
so there is no self-reference. Child access (GetChild/GetChildren/SetChild,
GetCollectionByKind, AddChild/InsertChild) and the polymorphic
node.Slot.Kind == Slots.X comparisons key on the canonical reference; the
generated SlotKind enum is removed. No behavior change.

Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer b23499503c Generate typed Slots; key 1:1-kind child access on them
The generator emits a Slots holder of typed CSharpSlotInfo<T> kind constants (T is
the child type, widened to AstNode for the few kinds reused with several child
types). Call sites whose kind maps to a single type switch from
GetChildByRole<T>(SlotKind.X) to GetChild(Slots.X), inferring the result type from
the slot. The ByRole methods and SlotKind remain for the multi-type Attribute
accessor and the internal add/insert plumbing, removed in following steps.

Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer 0a670d96bd Add AstNode.CheckInvariant to validate the AST after each transform
A DEBUG-only structural check, the analog of ILInstruction.CheckInvariant, run
after every AST transform in RunTransforms. It recursively asserts that each
required (non-optional) single slot holds a child, every child's Parent points
back, the stored flattened index matches the slot position, and the runtime type
fits the slot, so a transform that corrupts the tree fails at that transform
instead of as a downstream output diff. CSharpSlotInfo gains an IsOptional flag
(emitted by the generator) so the check can tell required from optional slots.

Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer f5b00ac234 Model optional AST names as nullable; fold [NameSlot] into [Slot]
The C# syntax tree's name accessors (ParameterDeclaration.Name, GotoStatement.Label,
catch variable, query continuations, ...) are convenience strings over a backing Identifier
token. Names that may be absent are now typed string? and read as null when absent, instead of
a non-null empty-string sentinel; required names stay non-null string. The backing token slot's
nullability follows, and an empty or null assignment clears the token (empty == absent).

Because the property type now carries optionality, the separate [NameSlot] attribute and its
nullOnEmpty flag are redundant: a [Slot] on a string property is a name (child slots are
AstNode-typed, so the type disambiguates), and the generator infers optionality from the
declared nullable annotation -- which a string? declaration already requires #nullable for. The
repeated empty-to-null setter body becomes Identifier.CreateIfNotEmpty. To make the annotation
readable, #nullable enable is turned on across the syntax node files (the directive the inferred
optionality depends on), with the attendant local nullability fixups. Consumers that feed a name
into a non-null slot assert it where the variable structurally has a name.

Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer 33b6813834 Tighten AST child-access API and trim output-path overhead
GetChildByRole<T> returned default! while it can legitimately yield null (empty slot
or a kind the node does not declare); it now returns T?. Callers that already null-checked
are unchanged; the few that rely on a structural guarantee (name tokens, label identifier,
property accessors) now state it with an explicit !, and MethodDeclaration drops a redundant
cast that the lie required.

Also removes two members left dead by the Role removal: the AddAnnotation override that
only forwarded to the base, and GetCSharpNodeBefore, which ignored 'this' and had no callers.

On the output path, GetChildNodes() allocates a snapshot List per call; the read-only
.Children traversals that never mutate the tree now walk the live FirstChild/NextSibling chain
(O(n), allocation-free, since child indices are cached), an EmptyStatement child check uses
HasChildren, and the label-statement sibling scan breaks on the first match instead of walking
to the end of the block. The generated GetChildNodes snapshot is kept where traversal may mutate.

Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer 2ea569b005 Enable nullable reference types across the C# AST consumers
Turn on #nullable enable across the AST consumer layer: the output visitor, the
IL-to-C# builders (statement, call and expression builders, CSharpDecompiler,
TypeSystemAstBuilder), the translation-result wrappers, the sequence-point and
required-namespace collectors, and the annotation helpers. Optional inputs,
fields and returns are typed nullable, detector out-parameters use
[NotNullWhen(true)], and structurally-guaranteed dereferences use the
null-forgiving operator. A few public parameters that already tolerate null are
widened to match their downstream callers. The annotations emit no IL, so the
Pretty suite stays byte-identical.
Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer 9285a7328d Delete the Role hierarchy; key the child API on SlotKind
Slot identity already lived in node.Slot/CSharpSlotInfo/SlotKind after
the storage flip, so the parallel Role/Role<T> child model was redundant.
The Role-keyed mutation/query API is reexpressed over SlotKind, and the
role-index packing on AstNode flags is gone.

Because a node's Slot is now derived from its index in its parent rather
than stored on the child, the located-AST reattach in
InsertMissingTokensDecorator must capture the child's slot kind before
Remove() detaches it.

Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer 9aa0ddd37b Replace AST null-objects with nullable reference types
With every optional slot nullable, the null-object pattern is dead. Generated
non-nullable getters return the backing field directly, which surfaced a last
tier of slots the decompiler legitimately leaves empty (omitted range operands,
an implicitly-typed array creation, unnamed parameters, an unbound generic
argument, and others) and flips them to nullable too. The machinery is then
removed entirely: the per-node null classes, the .Null statics and
VisitNullNode, AstNode.IsNull, the role null object, and Identifier.Null.
AcceptVisitor becomes unconditionally generated, and consumers move from
.IsNull to is null and from unconditional visits to ?.AcceptVisitor.
Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer 1f4fd68b3f Make optional AST slots nullable
Optional single-child slots return T? with a real null instead of a role
null-object, taking the C# grammar as the oracle for which slots are optional.
The generator emits the property type as T? and matches it with MatchOptional,
and consumers move from .IsNull to is null / ?.. This covers the optional
statement, member, try-catch, creation-initializer and pattern slots and the
optional NameSlot tokens. A few slots the grammar marks required but the
decompiler legitimately leaves empty (the implicit-element-access target, an
implicitly-typed lambda parameter's type) are flipped to nullable as well.
Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00
Siegfried Pammer 89e7f5b662 Expose node.Slot and key child positions on SlotKind
Introduce the successor to node.Role for child-slot identity: the generator
emits a CSharpSlotInfo per [Slot], exposed as node.Slot, plus a shared SlotKind
enum for the polymorphic "is this node in an embedded-statement / condition /
base-type slot?" comparisons a per-node identity cannot express. Migrate the
printer and transform position checks from node.Role to node.Slot and
node.Slot.Kind, and read identifier children and role-keyed writes through the
typed properties. Role is still present and is removed later; output is
unchanged.
Assisted-by: Claude:claude-opus-4-8:Claude Code
2026-06-23 07:43:42 +02:00