9.7 KiB
The C# syntax tree and its source generator
The classes in this folder make up the C# abstract syntax tree (AST) that the decompiler builds and
then pretty-prints. Each node is a slot-based tree node: its children live in numbered slots,
the same model the IL AST (ICSharpCode.Decompiler/IL) uses.
Almost all of the mechanical per-node code -- the visitor plumbing, structural pattern matching, the
child-index API, constructors, and slot metadata -- is generated by a Roslyn source generator
(ICSharpCode.Decompiler.Generators/DecompilerSyntaxTreeGenerator.cs). You declare a node and its
children with a few attributes and partial properties; the generator fills in the rest.
A node at a glance
[DecompilerAstNode]
public sealed partial class BinaryOperatorExpression : Expression
{
[Slot("Left")]
public partial Expression? Left { get; set; } // a child slot (optional: nullable)
public BinaryOperatorType Operator { get; set; } // a scalar -- a plain auto-property
[Slot("Right")]
public partial Expression? Right { get; set; } // another child slot
}
From this the generator emits the AcceptVisitor overloads, DoMatch, the backing fields and bodies
for Left/Right, the flattened child-index dispatch (GetChildCount/GetChild/SetChild/...),
constructors (new BinaryOperatorExpression(left, right)), the LeftSlot/RightSlot metadata, and
the clone logic. The hand-written file only carries what is genuinely node-specific.
The attributes
[DecompilerAstNode]
Marks a class for generation. The class must be partial (the generator emits the other half).
[DecompilerAstNode]-- a normal node.[DecompilerAstNode(hasPatternPlaceholder: true)]-- in addition, emit the pattern-placeholder machinery so the type can appear in a pattern (theimplicit operatorfromPatternand the nestedPatternPlaceholder). Used on the bases that pattern matching substitutes into (AstNode,Expression,Statement,AstType, ...).
A concrete node whose direct base is abstract also gets the three AcceptVisitor overloads and a
Visit<NodeName> entry on the generated IAstVisitor interfaces.
[Slot("Kind")]
Declares one child position. Put it on a partial property; the property type decides the shape,
and the string is the slot kind (see Slots and kinds):
| Property type | Meaning |
|---|---|
Expression Foo (any AstNode subtype) |
a single required child |
Expression? Foo |
a single optional child (may be empty / null) |
AstNodeCollection<Parameter> Foo { get; } |
a child collection (get-only; the generator owns the body) |
string Name |
a name: a convenience string over a generated backing Identifier token slot |
string? Name |
an optional name (reads as null when absent) |
The kind string is shared across node types: every slot tagged [Slot("Expression")] has the same
kind, so node.Slot.Kind == Slots.Expression works polymorphically. Pick an existing kind name when
the position is the same logical role as on other nodes; pick a new one when it differs. Keep a kind
mapping to a single child type
where you can -- if one kind is used with several child types its typed Slots constant widens to
AstNode (still usable, but you lose inference for that kind; see the de-aliasing note below).
[ExcludeFromMatch]
Drops a property from the generated DoMatch. Use it where structural equality should ignore a slot
-- e.g. a constructor's NameToken (its name is just the declaring type's name, matched elsewhere).
Slots and kinds
A slot is described at runtime by CSharpSlotInfo (Name, ChildType, IsCollection,
IsOptional, and Kind). The generic CSharpSlotInfo<T> additionally carries the child type as a
type parameter so the typed accessors can infer it.
There are two flavours of CSharpSlotInfo instance, both generated:
- Per-node slots --
BinaryOperatorExpression.LeftSlot, etc. One per slot per node type; carries that node'sIsOptional/ChildType.node.GetChildSlotInfo(i)returns these. - Canonical kinds -- the
Slotsholder (Slots.Left,Slots.Expression, ...). One per distinct kind across the whole AST. These are the shared identity, matched by reference.
A per-node slot's Kind points at its canonical Slots constant; a canonical constant is its own
kind. A kind is an object -- the Slots constant -- matched by reference identity:
node.GetChild(Slots.Left) // typed access; T inferred from Slots.Left (-> Expression?)
node.GetChildren(Slots.Parameter) // a collection, element type inferred
node.Slot.Kind == Slots.Initializer // "is this node in an Initializer-kind slot?", polymorphic
Scalars and constructors
A settable property that is not a [Slot] -- a bool, an enum, etc. -- is just a normal
auto-property and is not a child. A settable enum-typed scalar is treated as part of construction,
so it becomes a constructor parameter alongside the child slots, in source-declaration order.
If a scalar has an invariant (e.g. it must be non-negative), express it as a CheckInvariant override
instead of a throwing setter (see below). Pure-value nodes with no [Slot] (e.g.
PrimitiveExpression, whose state is an opaque literal) keep hand-written constructors.
CheckInvariant
AstNode.CheckInvariant() (DEBUG only) runs before the transform pipeline and after every
transform (CSharpDecompiler.RunTransforms), the analog of ILInstruction.CheckInvariant. The base
implementation recursively checks the slot structure: required (non-optional) single slots are filled,
each child's Parent points back, the flattened index matches, and the runtime type fits the slot.
A node type overrides it (calling base) to assert its own scalar invariants -- constraints on a
scalar property that the slot structure cannot express:
internal override void CheckInvariant()
{
base.CheckInvariant();
Debug.Assert(PointerRank >= 0, "ComposedType.PointerRank must not be negative");
}
So a transform that corrupts the tree fails at the exact transform, not as a downstream output diff.
Adding a new node
- Create
Foo.csin the appropriate sub-folder, with the license header and#nullable enable. - Declare
[DecompilerAstNode] public sealed partial class Foo : <Base>(orabstract, withhasPatternPlaceholder: trueif it's a pattern-matchable base). - Add a
[Slot("Kind")] public partial <Type> Child { get; set; }for each child (orAstNodeCollection<T> Children { get; }for a collection;string Namefor a name). Mark optional children nullable. Declare them in the order they print. - Add any scalar auto-properties. Add a
Visit Footo the hand-written visitor base classes if your node needs custom visiting beyond the generated dispatch. - Build. Inspect the generated half if needed by building with
-p:EmitCompilerGeneratedFiles=true(output underobj/.../generated/). - Pretty-print it in
CSharpOutputVisitor, add aVisitFoo, and add a fixture underICSharpCode.Decompiler.Tests/TestCases/Pretty.
Conventions and gotchas
- The
[Slot]string is the bare kind name ("Body", "Expression"), not a dotted role expression. - A kind reused with two different child types widens its
Slotsconstant toAstNode. If you need a precise type, give the position its own kind name -- as the declaration-level attribute slot does ([Slot("AttributeSection")]) versus an attribute section's own[Slot("Attribute")]. - Optional names read back as
null(not an empty string) and anull/empty assignment clears the backing token. - Don't hand-write what the generator emits (visitors,
DoMatch, child dispatch, constructors, slot metadata, clone). If you find yourself doing so, the node is probably missing a[Slot].
Working on the generator (notes for agents and tooling)
A few things that are easy to get wrong when editing the generator or the nodes automatically:
- Build the way the repo expects. Use the root pwsh scripts, and the OpenSSL env var is required:
OPENSSL_ENABLE_SHA1_SIGNATURES=1 pwsh ./build.ps1 -Configuration Debug --no-restore. A baredotnet buildprunes the lock files (see the rootCLAUDE.md). - The generator is a cached analyzer assembly. Editing a node file re-runs the generator (node
files are its input). Editing the generator's own source may not re-run it on an incremental
build -- the consuming project keeps using the cached analyzer. Force a fresh run by clearing the
generator's outputs:
rm -rf ICSharpCode.Decompiler.Generators/{bin,obj}/Debugand rebuild. - Don't verify generation by reading
obj/.../generated/*.g.cs. Those files are written only when the build sets-p:EmitCompilerGeneratedFiles=true, so they are usually a stale snapshot and will mislead you. To confirm the generator emits what you expect, either build with that flag, or just reference the expected symbol (e.g.Slots.Foo) in code and let the build fail or pass. - Validate with the DEBUG test run.
CheckInvariantis[Conditional("DEBUG")]and the test host runs DEBUG, so after any AST or transform change run the Pretty suite in DEBUG:OPENSSL_ENABLE_SHA1_SIGNATURES=1 dotnet test --solution ILSpy.sln --report-trx --no-build -c Debug --filter "FullyQualifiedName~PrettyTestRunner". It checks the output is byte-identical and exercisesCheckInvariantacross every node shape -- a green run is the real signal, not a green build. - A
required slot '<Name>' on <Node> must not be emptyassertion means a slot is declared non-nullable but is legitimately empty at runtime. The fix is almost always to make that[Slot]property nullable (the position is optional), not to relax the check.