mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3807/head
1 changed files with 174 additions and 0 deletions
@ -0,0 +1,174 @@ |
|||||||
|
# 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 |
||||||
|
|
||||||
|
```csharp |
||||||
|
[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 (the `implicit operator` from `Pattern` and the nested |
||||||
|
`PatternPlaceholder`). 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](#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's `IsOptional`/`ChildType`. `node.GetChildSlotInfo(i)` returns these. |
||||||
|
- **Canonical kinds** -- the `Slots` holder (`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: |
||||||
|
|
||||||
|
```csharp |
||||||
|
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: |
||||||
|
|
||||||
|
```csharp |
||||||
|
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 |
||||||
|
|
||||||
|
1. Create `Foo.cs` in the appropriate sub-folder, with the license header and `#nullable enable`. |
||||||
|
2. Declare `[DecompilerAstNode] public sealed partial class Foo : <Base>` (or `abstract`, with |
||||||
|
`hasPatternPlaceholder: true` if it's a pattern-matchable base). |
||||||
|
3. Add a `[Slot("Kind")] public partial <Type> Child { get; set; }` for each child (or |
||||||
|
`AstNodeCollection<T> Children { get; }` for a collection; `string Name` for a name). Mark optional |
||||||
|
children nullable. Declare them in the order they print. |
||||||
|
4. Add any scalar auto-properties. Add a `Visit Foo` to the hand-written visitor base classes if your |
||||||
|
node needs custom visiting beyond the generated dispatch. |
||||||
|
5. Build. Inspect the generated half if needed by building with |
||||||
|
`-p:EmitCompilerGeneratedFiles=true` (output under `obj/.../generated/`). |
||||||
|
6. Pretty-print it in `CSharpOutputVisitor`, add a `VisitFoo`, and add a fixture under |
||||||
|
`ICSharpCode.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 `Slots` constant to `AstNode`. 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 a `null`/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 bare |
||||||
|
`dotnet build` prunes the lock files (see the root `CLAUDE.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}/Debug` and 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.** `CheckInvariant` is `[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** |
||||||
|
exercises `CheckInvariant` across every node shape -- a green run is the real signal, not a green |
||||||
|
build. |
||||||
|
- **A `required slot '<Name>' on <Node> must not be empty` assertion** 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. |
||||||
Loading…
Reference in new issue