A field, auto-property, or event initializer is written once at its
declaration, but in IL it runs in every instance constructor that does not
chain to this(...) (and static initializers run in the static constructor).
The decompiler lifts the initializer from a single constructor, so its
breakpoint was emitted only there and the other constructors had none.
Two causes are addressed:
- The lift discarded the initializer's copies in the other constructors.
They are now kept on MemberInitializerInOtherConstructorsAnnotation and
replayed by SequencePointBuilder, mapping the same source location onto
each constructor's IL.
- PortablePdbWriter only emitted methods that DebugInfoGenerator discovered
through declaration syntax, so a constructor whose declaration is omitted
from the output (implicit default ctor, implicit static ctor, primary
ctor) dropped its generated points. Those functions are now emitted by
walking the sequence-point map directly.
PdbGen fixtures cover single, multiple, this()-chained, implicit, static,
primary-constructor, and field-like event initializers, pinning the
reconstructed breakpoint map against the C# compiler's.
Assisted-by: Claude:claude-opus-4-8:Claude Code
ReadWin32Resources walked the PE resource directory tree with raw native pointer
arithmetic over attacker-controlled offsets, counts and sizes, with no bounds
checks, no recursion-depth limit and no cycle detection. The root section pointer
came from GetSectionData, whose length was read and then discarded, leaving every
dereference unbounded.
A crafted assembly could therefore turn merely opening it (the Save as project
feature reads these resources unconditionally) into an uncatchable process kill or
an out-of-bounds native read: a subdirectory entry pointing back at itself recursed
until the stack overflowed; an inflated entry count walked off the section end; and
a data entry whose Size was up to 4 GB made Buffer.MemoryCopy read far past the
section, faulting on an unmapped page or copying adjacent process memory into the
byte[] later written to app.ico/app.manifest on disk. None of this is containable,
since a StackOverflowException cannot be caught and the repo has no corrupted-state
exception handling. This is the sibling of the bundle signature fix in a154a7bbb.
Carry the section length alongside the root pointer and bounds-check every offset,
entry count, name-string length and data Size against it, cap recursion depth and
track visited directory offsets to break cycles. A hostile or truncated file now
yields a bounded, partial tree instead of a crash; well-formed resources parse
exactly as before. The parser no longer needs the whole PEReader, only a delegate
that resolves a data RVA to a bounded pointer, which is the seam the new tests drive
over a pinned buffer.
Assisted-by: Claude:claude-opus-4-8:Claude Code
SingleFileBundle.IsBundle scans for the 32-byte bundle signature and reads the
8-byte header offset stored immediately before it. That offset only exists in a
genuine bundle, where the signature sits near the end of the file. The scan
started at the first byte, so a crafted file with the signature at offset 0..7
made it read before the start of the buffer. On the production path that buffer
is a page-aligned memory-mapped view, so the read faults on the preceding
unmapped page with an AccessViolationException -- a corrupted-state exception
that bypasses the loader's catch and terminates the process merely from opening
the file. The bundle probe runs on every opened file, so this needs no user
action beyond open.
Skip the backward read unless the match is at least sizeof(long) bytes into the
buffer. While in the same file, bound ReadManifest's FileCount against the bytes
that remain before it pre-sizes the entry array, so a crafted manifest can no
longer request a multi-gigabyte allocation.
Assisted-by: Claude:claude-opus-4-8:Claude Code
ILFunction.IsAsync is derived from the method signature, so .NET 11
runtime-async methods (MethodImplAsync bit, no MoveNext state machine)
report IsAsync without AsyncAwaitDecompiler ever populating
AsyncDebugInfo. Its Awaits then stays an uninitialized ImmutableArray,
and PortablePdbWriter threw an NRE building the MethodSteppingInformation
blob from that default struct. Runtime-async methods have no yield/resume
offsets to record, so guard on Awaits.IsDefault and omit the blob,
matching the C# compiler, which emits no stepping information for them
either. A genuinely zero-await classic state machine keeps an
initialized empty Awaits and is unaffected.
Assisted-by: Claude:claude-opus-4-8:Claude Code
DebugInfoGenerator asserted that every local variable's decompiled type
is equivalent to the metadata local-signature type at its slot. That
holds at IL-read time but not afterwards: variable splitting gives one
slot several typed variables, pinned-region locals are modeled as
pointers (int* vs int& pinned), and generic-context type-parameter
identity differs. The assertion therefore aborted PDB generation (Debug
builds) for ordinary inputs such as any method with a fixed statement.
The type is never written to the PDB - only the slot index and name are -
so the mismatch cannot affect the debugging experience. The slot index,
the only emitted value, is correct by construction: it is the IL
ldloc/stloc operand, sourced from the signature slot when the variable is
created and copied verbatim by SplitVariables; it is never reassigned.
Keep only the index-bound check and document why the type is not verified.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Decompiler warnings (ILFunction.Warnings, e.g. the DetectPinnedRegions
block-duplication notice) are surfaced as an EmptyStatement carrying only
a comment. VisitEmptyStatement prints no semicolon for it, and
EmptyStatement derives its StartLocation/EndLocation from its Location
field, which is only set when that semicolon token is written. The
statement was therefore left without a text location, and
SequencePointBuilder then asserted on the empty start location while
generating PDB sequence points - aborting PDB generation for any assembly
whose decompilation emits such a warning (e.g. System.Net.Requests).
Point the empty statement at the comment it carries (already printed by
the time the node ends, so its location is known), falling back to the
collapsed end-of-last-token position. Every printed node then has a
location, so the SequencePointBuilder invariant holds without
special-casing, and the statement lines up with the text it represents.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A standalone portable PDB (or raw metadata stream) loads as a metadata-only
MetadataFile, but the tree node was left empty: LoadChildren early-returned for
any kind other than PortableExecutable/WebCIL, so the node showed an expander
over no children. This regressed in the WPF -> Avalonia port, which dropped the
old default switch arm; release/10.1 still populated such nodes with the
metadata table and heap nodes directly. Restore that behavior.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The pretty DynamicTests exercised arithmetic and relational binary operators on
dynamic operands, but never the bitwise and shift operators (& | ^ << >>). Add a
BitwiseAndShiftBinaryOperators case so this operator family is pinned by a
round-trip test alongside the others.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The pretty DynamicTests only exercised += -= *= /= on a dynamic target, leaving
%= &= |= ^= <<= >>= unverified even though VisitDynamicCompoundAssign and
GetAssignmentOperatorTypeFromExpressionType already map them. Add them so the
full set of dynamic compound-assignment operators is pinned by a round-trip
test.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The correctness DynamicTests only ran binary + - * / on a dynamic operand, so
no test recompiled and executed decompiled output for any unary operator. That
gap is why ~ on a dynamic value (issue #3820) shipped uncompilable output
undetected: a correctness case round-trips the decompilation through the
compiler, so it fails the moment the decompiler emits something that does not
recompile.
Add ~, -, +, and ! cases. They pin the runtime semantics of the dynamic unary
path and would have caught the OnesComplement regression directly.
Assisted-by: Claude:claude-opus-4-8:Claude Code
VisitDynamicUnaryOperatorInstruction handled every dynamic unary operator
except ExpressionType.OnesComplement, so ~x on a dynamic operand fell through
to the unsupported-opcode error expression and produced uncompilable output
(an incomplete cast that fails to parse). Map it to the bitwise-complement
operator, like the sibling unary cases.
Assisted-by: Copilot:claude-opus-4.8:GitHub Copilot CLI
The PDB sequence-point tests were missing real while-loop input, and their residual comparison treated breakpoint locations as an unordered multiset. Add coverage for while/do-while fixtures and compare residuals in sequence order so stepping-order changes are pinned.
Assisted-by: CodeAlta:gpt-5.5:CodeAlta
Extends the breakpoint-map comparison to hidden sequence points, anchoring
each hidden point to the visible point it follows so the descriptor stays
independent of the IL offsets the decompiler reconstructs. Adds PdbGen cases
spanning try/catch/finally, switch, async/await, yield, loops, LINQ, pattern
matching and more, pinning the known residuals where the decompiler folds a
compiler-hidden branch into an adjacent point.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The PdbGen tests compared the reconstructed PDB to the C# compiler's
byte-for-byte, so any non-trivial method failed on reconstructed IL
ranges, hidden sequence points and local scopes - none of which the
decompiler can reproduce exactly. That left four of seven fixtures
[Ignore]d and the suite with almost no coverage.
Compare only what a debugging user actually feels: the visible (non-hidden)
breakpoint map, parsed straight from the sequence-point blobs and keyed by
method-definition row (shared between the PDB and the PE it describes). IL
offsets, hidden points, local scopes and the embedded source are dropped.
The compiler's own PDB is the oracle, so the tests assert correct debugging
behavior rather than the decompiler's past output. Methods where the
decompiler legitimately diverges pin an auto-derived residual snapshot, the
same accept-the-diff workflow as the pretty tests; a separate oracle-free
check rejects duplicate or overlapping sequence points.
Un-ignores ForLoopTests, LambdaCapturing and Members (its source is
regenerated to match the decompiler's per-type output, collapsing ~50 lines
of indentation-induced coordinate noise to two genuine differences).
Assisted-by: Claude:claude-opus-4-8:Claude Code
The "Toggle folding" context-menu entry folded the block at the caret, so
right-clicking a fold on a different line than the caret toggled the wrong
block. Record the document offset under the pointer when the text-view menu
opens and expose it on TextViewContext as TextLocation; the folding entry acts
on that offset (falling back to the caret only when no click position was
recorded). The pointer handler now records the offset before the reference
lookup, so a document with foldings but no clickable references still gets a
position.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The analyzer tree set ShowLines="False" while the assembly tree uses "True", so
the Analyze panel was missing the connector lines the rest of the app shows. The
two panes already share the same SharpTreeView control and templates, and the
line geometry is driven entirely by the generic node Level/IsLast state, so the
analyzer hierarchy renders them correctly; the False was an unconsidered default
carried in when the pane was migrated to SharpTreeView. Align it with "True".
Assisted-by: Claude:claude-opus-4-8:Claude Code
The analyzer signature colours depend on the active theme, and the formatting
on the active language, but the highlighted RichText was built once and cached.
Toggling the theme or switching the decompiler language therefore left existing
analyzer results showing the old colours / syntax.
Key the node's RichText cache on the (theme, language) pair so it rebuilds when
either changes, and have the rich-text cell re-render rich rows on both
ThemeManager.ThemeChanged and the language settings' PropertyChanged (cleaned up
on Node change and on detach, like the existing subscriptions).
Assisted-by: Claude:claude-opus-4-8:Claude Code
The Analyze panel showed each signature as a flat string, so the type names
that carry the key information were hard to spot in long Used-By / Uses lists.
Render analyzer entity rows as syntax-highlighted rich text with the type-name
spans emboldened (issue #2164).
Replace Language.GetRichTextTooltip(entity) with a general
GetRichText(entity, conversionFlags, boldTypeNames): the hover tooltip is one
caller, the analyzer pane another. C# colours the signature via
CSharpHighlightingTokenWriter and bolds the type-name colour spans; IL renders
its disassembled header (its signature form), already lexically highlighted;
the base produces plain text. The IL method/type/field header is semantically
the same artifact as the C# signature, so it shares the one method.
Rendering is opt-in: nodes that want rich labels implement IRichTextNode, and
the single shared SharpTreeView cell renders its runs via the new RichNodeText
attached behaviour, falling back to the plain Text otherwise. SharpTreeNode.Text
stays the authoritative plain string, so search, copy and keyboard navigation
are unchanged; analyzer entity nodes derive Text from the same RichText so the
two never diverge.
Fixes#2164
Assisted-by: Claude:claude-opus-4-8:Claude Code
The right-click menu in the decompiled code read in a near-random order. The
shared entry registry orders entries by a single Order value and only uses
Category to place separators, so a few defects scrambled the result: the
"Decompile" reference entry had Order=10, jumping above Copy/Select all; the
"Decompile to new panel" entry sat in a misspelt "Navigate" category (vs the
"Navigation" the other entries use), adding a stray separator; and it tied
Copy at Order 100 while "Go to token" tied Analyze at 200, interleaving
categories on the discovery-order tiebreak.
Pull the three navigation entries into a contiguous 150-170 band so they form
one block between the Editor (100s) and Analyze (200s) bands, fixing the
spelling so they share a category. No provider change: the existing by-hundreds
category layout is left intact, so the tree / search / metadata menus that share
the registry stay coherent.
Assisted-by: Claude:claude-opus-4-8:Claude Code
F# emits tail calls pervasively, but the 'tail.' prefix was dropped
entirely at the C# output stage, so the information never reached the
decompiled text. Render it inline as '/*tail.*/' before the call,
mirroring the existing 'constrained.' prefix comment in CallBuilder.
Fix#3817
Assisted-by: Claude:claude-opus-4-8:Claude Code
The Simple theme sizes a menu row to its 16px icon plus margins (~24px), tighter
than the classic WPF menu. Raise MinHeight so the rows have comparable vertical
breathing room, and centre the icon, label and input-gesture text in the taller
row (the theme template leaves their VerticalAlignment unset, so a style reaches
them) instead of letting them sit at the top.
Assisted-by: Claude:claude-opus-4-8:Claude Code
* Migrate ILSpy.AddIn.VS2022 to SDK-style VSIX project
* Retire the legacy VS2017/2019 ILSpy add-in
* Build the VS extension via dotnet build on the slnx
* Inline ILSpy.AddIn.Shared into ILSpy.AddIn.VS2022
* Restore the VS add-in menus broken by the SDK-style migration
The "Open from NuGet feed" chooser rendered every result with the default
NuGet logo, even though the feed search already returns each package's own
IconUrl - it was fetched into NuGetPackageInfo and then ignored. The list
looked like a wall of identical icons, unlike the NuGet gallery.
NuGetPackageInfo is an immutable feed DTO bound straight into the ListBox, so
it can't raise PropertyChanged for an icon that arrives later. Rather than
bolt mutability and an Avalonia image type onto that DTO, introduce a thin
per-row view model (NuGetPackageViewModel) that owns an observable Icon
defaulting to the logo and swaps in the real icon once downloaded, plus an
INuGetIconLoader service that fetches and decodes off the UI thread. Icons
are decoded to 64px (rendered at 32) and cached by URL as the in-flight Task
so each URL downloads once and concurrent requests share it; the loader is
held for the command's lifetime so the cache survives reopening the dialog.
A per-search CancellationTokenSource stops a stale result set's downloads
from painting onto rows recycled by the next search. Any failure - non-http
URL, network error, decode error - collapses to null so a dead icon URL just
keeps the default.
Also disable horizontal scrolling on the results list. With it enabled
(the default), rows measured at unbounded width, so the star column stopped
filling the viewport and selecting a row brought the overflow into view -
the list jumped sideways and grew a scrollbar. The row layout is meant to
fit the width (the description wraps/trims), so horizontal scrolling was
never wanted.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The project listed 201 explicit <Compile Include> and 119 <None Include> items
with EnableDefaultItems=false. Many test-case sources had been marked <None>
only because the C# compiler available when they were written could not build
them; the current Roslyn compiles them fine. The hand-maintained lists had also
drifted: five committed fixtures (Operators.cs, Issue3751.cs, three ILPretty
.cs) were on disk with passing tests but in no item list.
Switch to the SDK default **/*.cs glob and exclude only the sources that still
cannot be compiled into the test assembly, determined empirically by building
with everything included and removing what failed:
- IL-pretty inputs that are not valid standalone C# and *.Expected.cs golden
outputs that reuse type names (compile errors / duplicate definitions).
- MetadataAttributes.cs, which applies assembly/module attributes that break
NUnit test discovery (zero tests found) when compiled into the assembly.
The excluded sources stay <None> for IDE visibility; the harness compiles them
from disk at test time regardless. This compiles 23 more fixtures than before
while keeping every previously-compiled file. Default None globbing stays off so
the non-C# inputs (.il/.vb/.fs) remain the authoritative list.
Operators.cs, now part of the build, is normalized to the repo's tab indentation
by the format hook (a CS110 block used spaces). Verified: the full suite still
reports 2257 passed, 0 failed, 35 skipped.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The project listed all 576 source files as explicit <Compile Include> with
EnableDefaultItems=false, so every new file had to be added by hand. Switch to
the SDK's default **/*.cs glob and keep only the exclusions that are actually
needed:
- Properties/DecompilerVersionInfo.cs is generated before build and absent when
the glob is evaluated on a clean checkout, so it is still included explicitly
(with a paired Remove to avoid a duplicate once a prior build produced it).
- DecompilerVersionInfo.template.cs is a placeholder template, never compiled.
Also delete the hand-written DecompilerAstNodeAttribute.cs: the source generator
emits this attribute (RegisterPostInitializationOutput), so the on-disk copy was
stale dead code -- never compiled, and parameterless where real usages pass
[DecompilerAstNode(hasPatternPlaceholder: true)]. Removing it drops what would
otherwise be a third exclusion.
Default None globbing stays off so the explicit None entries remain the
authoritative list. The evaluated Compile set is identical to the previous 576
files; verified by a clean build with the version file both present and absent.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A stackalloc whose result is a pointer is only valid C# as the initializer of a
pointer-typed local. The inliner moved a single-use pointer stackalloc into its
use, producing e.g. 'K.V(stackalloc int[3] { 1, 2, v })' or '*stackalloc ...';
in an expression position the stackalloc is typed as Span<T>, which does not
convert to a pointer, so the output did not compile. Keep such a stackalloc as a
separate local. Moving it into a local store (its declaration) and into the
Span<T>/ReadOnlySpan<T> constructor stay allowed, since those are the positions
where the pointer or span form is exactly what is wanted.
Found while exploring stackalloc-initializer coverage.
Assisted-by: Claude:claude-opus-4-8:Claude Code
HandleSequentialLocAllocInitializer formed a stackalloc initializer whenever the
explicit stores were contiguous from offset 0, even if they did not cover the
whole buffer. A 'stackalloc byte[16]' reinterpreted as int and written through
three of its four elements decompiled to 'stackalloc int[4] { 1, v, 3 }', whose
initializer has fewer elements than the declared length and does not compile.
Require every element to be written (from the constant data blob or an explicit
store) before forming the initializer; otherwise the buffer stays a plain
stackalloc with individual stores.
Found while exploring stackalloc-initializer coverage.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A constant-size stackalloc initializer stores its constant elements through a
data blob (cpblk from a <PrivateImplementationDetails> field). ReadElement
decoded every element by width, so a 4-byte float was read as Int32 and an
8-byte double as Int64. The resulting constant carried the raw bit pattern
(1f decoded as 1.0653532E+09f) and its stack type no longer matched the store,
tripping StObj.CheckInvariant. Dispatch on the element's type code so Single and
Double are read as floating-point, matching the heap-array decoder.
Found while exploring stackalloc-initializer coverage around the element-type
hint fix; floating-point element types had no test case.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A constant-size stackalloc initializer whose buffer is only passed to a static
call (never dereferenced as a typed pointer) threw "given Block is invalid!".
TranslateStackAllocInitializer recovers the element type from the surrounding
type hint, but that hint is unreliable for this shape: the constant allocation
size is folded to a byte count, so it can no longer be read off a
'count * sizeof(T)' expression, and the buffer is kept on the stack as a native
int instead of a T* local, leaving the hint a non-pointer. The guard only
repaired an incompatible pointer hint, so a non-pointer hint fell through to a
'byte' element type that is incompatible with the actual stores, and the
per-element check threw. Derive the element type from the type being stored
whenever the hint is not already a compatible pointer.
The regression is driven by the code shape, not the compiler version: in
optimized builds the buffer is kept on the stack as a native int whenever it is
passed to a static call without being dereferenced. This reproduces across
Roslyn versions, including the pinned test compiler, so the added fixture is red
without this fix in the optimized configurations.
Assisted-by: Claude:claude-opus-4-8:Claude Code
After the Role and TokenRole hierarchies were removed, the Roles class no
longer held any roles -- only the punctuation and keyword text the output
visitor writes (LPar, Arrow, ClassKeyword, ...). The name was a misleading
vestige of the deleted system. Rename it to Tokens, which is what it now
is; the constants and their values are unchanged.
Also refresh the CSharpSlotInfo doc, which still described slot identity as
the successor to the removed node.Role == Roles.X comparison.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A slot kind names one child position, so the generator emits a single
typed Slots.X constant for it. A kind used with two different child types
had to widen that constant to AstNode, and the typed GetChildren<T>
accessor then cast the live AstNodeCollection<Concrete> to
AstNodeCollection<AstNode> -- an InvalidCastException waiting for the
first caller (latent today, but the model permitted it).
Make the loose state unrepresentable instead of guarding it at runtime:
DSTG001 errors when a [Slot] kind is declared with more than one child
type. The six kinds that only coincidentally shared a name (Body, True,
False, Initializer, SwitchSection, Variable) are split into precisely
typed kinds; a genuinely either/or position (a lambda body) stays one
declared type, AstNode. Every Slots.X now carries its real element type,
so the GetChildren cast can no longer fail.
Output is unchanged: the Pretty suite stays byte-identical with
CheckInvariant green in DEBUG.
Assisted-by: Claude:claude-opus-4-8:Claude Code
These helpers are documented to return null when nothing matches, but
returned `null!`, hiding the very nullable warnings #nullable enable is
meant to surface: a miss handed back null typed as non-null and would
NRE downstream with no compile-time signal. Returning T? lets the
compiler enforce the guard at each call site (both existing callers
already null-check). GetVariable forwards the result, so it becomes
VariableInitializer? to match.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A single slot always occupies the same flattened index, so filling,
clearing, or replacing it moves no other child -- only the new child's own
index needs setting. The generated single-slot setters now pass that index
to SetChildNode (a compile-time constant when no collection precedes the
slot; SetChild forwards its argument), which assigns childIndex directly and
never invalidates -- mirroring the IL AST's SetChildInstruction(ref, value,
index). Previously a set-from-null could not know the index here and fell
back to invalidating, forcing a later O(children) EnsureChildIndices rebuild.
With the indices now kept current by construction, NextSibling/PrevSibling
inline the validity check and skip the (non-inlinable) EnsureChildIndices
call in the overwhelmingly common already-valid case.
Together these idle the renumber machinery on a System.Private.CoreLib
decompile: EnsureChildIndices calls 53.4M -> 1.6M, actual rebuilds
1.97M -> 183K, elements renumbered 3.40M -> 757K. Output is byte-identical
and the Pretty suite stays green with CheckInvariant validating the
directly-assigned indices after every transform.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Removing a collection element invalidated the parent's whole flattened
index set, so the next sibling navigation rebuilt it in O(children) -- and
that renumber, not the array shift, was the dominant cost: a reverse
(tail-first) removal, which shifts nothing, was still quadratic, isolating
EnsureChildIndices as the culprit.
When a node's only collection is also its last slot it owns the contiguous
range [base, base + Count) with nothing after it, so an element's flattened
index is just base + its local position (base is the slot's declaration
position, since the preceding slots are all single children). On that
fast-path -- which the generator now flags, passing the base index -- Add
indexes only the appended element, Insert/Remove renumber only the shifted
suffix, and IndexOf is base-relative O(1); none of them invalidate. Other
shapes (several collections, or a slot after the collection) keep the
invalidate-and-rebuild fallback. Tail and scattered removal, and removal
during traversal, no longer pay the per-operation renumber.
Microbenchmark, removing every element of an N-element block: tail-first at
N=32000 went 1493 ms -> 0.5 ms (now O(N)); front-first is ~1.6x faster and
no longer renumbers (its residual cost is the inherent array shift). Output
is byte-identical and the Pretty suite stays green with CheckInvariant
validating the maintained indices after every transform.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A node's children carry a cached flattened childIndex, rebuilt by
EnsureChildIndices after any structural mutation invalidates it. The
single-slot setter and the collection indexer invalidated the whole set on
every in-place replace, so the next sibling navigation (e.g. the visitor's
NextSibling walk) rebuilt all indices in O(children). A transform that
replaces each element of a block while traversing it was therefore O(N^2).
But replacing a child in place does not move anything: a single slot always
occupies the same flattened index, and a replaced collection element keeps
its position. So carry the old child's index to the new child and skip the
invalidation. Setting or clearing a slot still invalidates, since the new
child's index is not known locally; a stale carried value is corrected by
the next renumber anyway.
Microbenchmark (replace every statement in an N-statement block): at
N=32000, 2158 ms -> 1 ms. Output is byte-identical and the Pretty suite
stays green with CheckInvariant active.
Assisted-by: Claude:claude-opus-4-8:Claude Code
AstNodeCollection<T> created its List<T> eagerly in the constructor, and
the collection itself is created on first access of its slot property. So
every collection slot that is read but stays empty -- Attributes,
TypeArguments, type-parameter constraints and the like, which are absent on
the vast majority of nodes -- still allocated a List that never held an
element. Decompiling System.Private.CoreLib that was ~100 MB of short-lived
empty Lists churning gen0.
Make the list nullable and allocate it on first Add; an accessed-but-empty
collection now costs only the wrapper. The backing array was already lazy
(List defers it to the first add), so this drops the redundant List object
for the empty case. Output is byte-identical and the Pretty suite stays
green with CheckInvariant active.
Assisted-by: Claude:claude-opus-4-8:Claude Code
The generated GetChildNodes materialized a List<AstNode> (plus a boxed
List.Enumerator at every foreach) for each node, so AstNode.Children and
the visitor's per-node child walk allocated three objects per traversal.
Decompiling System.Private.CoreLib that came to ~1.7 GB of extra garbage,
roughly +7% over the linked-list model the slot tree replaced. A yield
iterator removes the List but trades it for an equally costly per-node
state machine, so it is not enough on its own.
Enumerate children through a by-value struct enumerator over the existing
FirstChild/NextSibling primitives, capturing each child's successor before
it is yielded so a transform may still remove or replace the current child
mid-traversal. AstNodeCollection<T> gets the same struct treatment for a
direct foreach. Child enumeration now allocates nothing, bringing total
allocations back to the linked-list baseline at byte-identical output
(full Pretty suite green with CheckInvariant active).
Assisted-by: Claude:claude-opus-4-8:Claude Code
InsertMissingTokensDecorator removes a pending node by value and uses whether it
was still present, so a HashSet keys that membership-removal on O(1); iteration
order is irrelevant because every pending node receives the same location. Rename
the field to nodesAwaitingStartLocation for clarity.
Assisted-by: Claude:claude-opus-4-8:Claude Code
A contributor guide next to the node classes: the [DecompilerAstNode]/[Slot]/
[ExcludeFromMatch] attributes, the slot-and-kind model (per-node CSharpSlotInfo
vs the canonical Slots constants), scalars and generated constructors,
CheckInvariant, and a step-by-step for adding a node.
Assisted-by: Claude:claude-opus-4-8:Claude Code
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
A declaration's attribute collection (AstNodeCollection<AttributeSection>) and an
attribute section's own attributes (AstNodeCollection<Attribute>) both carried
[Slot("Attribute")], so the kind mapped to two child types and the shared
Slots.Attribute widened to AstNode. Give the declaration-level slot its own
"AttributeSection" kind so every kind maps to a single type; EntityDeclaration's
Attributes accessor now reads through the typed Slots.AttributeSection. This was
the last child-access keyed on the SlotKind matching enum.
Assisted-by: Claude:claude-opus-4-8:Claude Code
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
GetChild/GetChildren/SetChild take a typed CSharpSlotInfo<T> and infer the child
type from the slot, delegating to the existing kind-based lookups. Call sites can
pass a node's static slot (e.g. node.GetChild(PropertyDeclaration.GetterSlot))
instead of an explicit type argument plus a SlotKind.
Assisted-by: Claude:claude-opus-4-8:Claude Code
Add a generic CSharpSlotInfo<T> (T is the child type, the element type for a
collection) and have the generator emit each per-node slot field as the typed
variant. This lets the typed child accessors infer the result type from the slot,
so the explicit type argument can drop out of the SlotKind-based child API in the
following steps. No behavior change -- the typed slots are still consumed through
the non-generic CSharpSlotInfo base.
Assisted-by: Claude:claude-opus-4-8:Claude Code
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
Completes the nullable migration for ICSharpCode.Decompiler/CSharp/Syntax: every
hand-written file now carries #nullable enable. The pattern-matching API
(INode/Pattern/PatternExtensions DoMatch/Match/IsMatch) takes a nullable 'other',
since matching legitimately compares a pattern against a missing child. Members
that genuinely return or accept null are annotated to match (AttributeSection's
target token, IAnnotatable.Annotation<T>, Statement/Expression.ReplaceWith,
SyntaxExtensions.GetNextStatement), and a latent null dereference in
Backreference.DoMatch is fixed.
Assisted-by: Claude:claude-opus-4-8:Claude Code