Reduction Procedures
Table of Contents
- Overview
- How Procedures Work
- AST Generation Requirement
- Explicit Hook Annotations
- Implicit / Automatic Hooks
- Hook Execution Order
- Writing Hook Functions in Zig
Overview
Reduction procedures in Galley are user-defined semantic hooks written in Zig (procedures.zig). They execute during parsing when the parser matches and reduces grammar rules, allowing you to manipulate the Abstract Syntax Tree (AST), inspect parsed symbols, track state, or perform custom semantic validation.
How Procedures Work
- Table Generation: The grammar generator parses your grammar file (
ll.grmorlr.grm) and outputs a serialized parser file (e.g._ll-parser.zig). - Binding: For every hook reference (explicit or implicit), the generator checks if a public declaration with that name is exported by
languages/<name>/procedures.zig. - Execution: During runtime, when the parser shifts or reduces the marked symbols, it calls the corresponding hook function, passing a mutable context.
AST Generation Requirement
IMPORTANT
Hooks are strictly tied to AST node generation. If AST construction is disabled (using the --no-ast generator flag), all reduction procedures are automatically disabled and compiled out to maximize raw syntax validation speed.
Furthermore, for a specific symbol to trigger a hook, an AST node must be generated for it:
- Capitalized variables (PascalCase) generate AST nodes by default, so they can always trigger hooks.
- Helper variables starting with an underscore (e.g.
_OptionalBlank) do not generate AST nodes, so hooks attached to them will not execute. - Terminals do not generate AST nodes unless the
--ast-for-terminalsgenerator flag is active.
Explicit Hook Annotations
You can explicitly bind a procedure to a grammar symbol by appending @procedure_name:
1. LHS Variable Hooks
Attaches directly to the left-hand-side variable name. The procedure executes whenever this variable is reduced anywhere in the grammar:
Value@dropChildren
| Object OptionalBlank
| Array OptionalBlank2. RHS Symbol Hooks
Attaches to a specific symbol on the right-hand side of a production (which can be a variable, or a terminal symbol if --ast-for-terminals is enabled). The procedure executes only when that symbol is matched in that particular position:
Parent
| Value Child@validateChild "]"
ObjectMember
| String OptionalBlank ":"@myColonHook OptionalBlank Value
Number
| digit@myDigitHook _PositiveIntegerNumberTail3. Production Hooks
Attaches to the left-hand-side variable for a specific right-hand-side production by placing the hook immediately after the initial pipe (|). The procedure executes on the resulting left-hand-side node only when that particular production is reduced:
FloatTail
|@normalizeFraction "." PositiveIntegerNumber
|Chaining Multiple Hooks
Chaining is not a separate hook kind. It applies multiple procedures to the same symbol or production by appending them sequentially (e.g., @hook1@hook2).
When multiple hooks are chained, they are executed in left-to-right order (the leftmost hook executes first). This acts like function composition, where the leftmost hook operates on the raw match first before passing the result to the next hook to the right:
Expr
| "+" Number@firstHook@secondHookIn the example above, firstHook runs first, followed immediately by secondHook.
Implicit / Automatic Hooks
Alongside the three explicit hook placements, Galley provides a fourth family of automatic reduction hooks. They require no grammar annotations: the generator binds them by name when they are exported by your procedures.zig:
| Procedure Name | Execution Trigger |
|---|---|
reduction_<SymbolName>_<RhsIndex> | Executes when the zero-based right-hand-side production <RhsIndex> of <SymbolName> is reduced (e.g. reduction_Expr_0 runs only for the first Expr production). Indices follow the consecutive ` |
reduction_<SymbolName> | Executes whenever <SymbolName> produces an AST node, either by reducing a variable or matching an AST-enabled terminal. |
reduction | Executes as the general hook for every eligible variable reduction and AST-enabled terminal match. |
Hook Execution Order
For each eligible variable reduction, hooks execute from the most specific context to the most general:
- Hooks attached to that variable's occurrence in its parent's right-hand side, in left-to-right chain order.
- Hooks attached after the initial pipe of the selected production, in left-to-right chain order.
- The automatic production hook
reduction_<SymbolName>_<RhsIndex>, if exported. - Hooks attached to the variable's left-hand-side declaration, in left-to-right chain order.
- The automatic symbol hook
reduction_<SymbolName>, if exported. - The general
reductionhook, if exported.
Each phase receives the node resulting from the preceding phase. An RHS occurrence hook belongs to the child variable's reduction and runs only when that child is reached through the annotated parent position. A child completes this sequence before its parent variable is reduced. The start variable has no parent RHS occurrence, and reduction runs once and last for each eligible reduction.
For an AST-enabled terminal match, only the applicable phases run:
- Hooks attached to that terminal occurrence, in left-to-right chain order.
- The automatic terminal hook
reduction_<SymbolName>, if exported. - The general
reductionhook, if exported.
Variable hooks receive the selected variable rule in args.rule. Terminals do not have a reduction rule, so terminal hooks receive args.rule = null. All phases share the same mutable args.node; dropping or replacing it is visible to every later hook.
An LR parser must know the parent occurrence when a variable reduces or terminal matches. If the active LR state and lookahead correspond to multiple occurrences with different hook chains, generation fails with error.AmbiguousProcedureHooks rather than running a hook for the wrong position. Identical chains may share the action.
Writing Hook Functions in Zig
All custom procedures are defined inside your language's procedures.zig.
Function Signature
Every hook function must match the following signature:
const data_structures = @import("galley").data_structures;
const ProcedureArguments = data_structures.ProcedureArguments;
pub fn myHook(args: *ProcedureArguments) !void {
// If the node was allocated, inspect or modify it
if (args.node) |node_address| {
const node = args.context.node_allocator.at(node_address);
// Inspect the node or update its custom payload.
}
}Standard Helper Procedures
Many language implementations leverage standard tree-cleanup procedures:
dropChildren: Discards all child nodes of the current node to save memory:zigpub fn dropChildren(args: *ProcedureArguments) !void { if (args.node) |node_address| { _ = try data_structures.ASTNode.cleanChildren(node_address, args.context.node_allocator); } }rightRecursiveReductionandleftRecursiveReduction: Flatten one level of a recursive node when its edge child has the same grammar variable:zigpub const reduction_ItemsTail_0 = standard_procedures.rightRecursiveReduction;dropSelf: Discards the current node itself by setting it tonull:zigpub fn dropSelf(args: *ProcedureArguments) !void { args.node = null; }dropIfEmpty: Discards the current node when it has no children. This is useful for optional recursive tails:zigpub const dropIfEmpty = standard_procedures.dropIfEmpty;replaceWithChildren: Discards the current parent node's structure and replaces it with its first child in the AST hierarchy:zigpub fn replaceWithChildren(args: *ProcedureArguments) !void { if (args.node) |node_address| { args.node = data_structures.ASTNode.promoteChildrenOverWrapper( node_address, args.context.node_allocator, ); } }
Custom AST Node Payload
You can export a public Payload struct from procedures.zig to add language-specific data to every AST node. Each newly allocated node initializes its own payload using the struct's default field values:
pub const Payload = struct {
nesting_depth: u32 = 0,
variable_count: u32 = 0,
};Within a hook, access the payload through the current AST node:
pub fn countVariable(args: *ProcedureArguments) void {
if (args.node) |node_address| {
const node = args.context.node_allocator.at(node_address);
node.payload.variable_count += 1;
}
}Payload is node-local storage, not per-parse context state. Data shared by an entire parse must be managed separately rather than through args.context.payload, which does not exist.