AST Design
AXSLC uses an unconventional but highly efficient AST representation.
Flat Array with Integer IDs
The AST is not a tree of heap-allocated node pointers. It is a flat std::vector<ASTNode>, and all cross-references between nodes use NodeID - a uint32_t index into that vector.
using NodeID = uint32_t;
struct ASTNode {
NodeID id;
SourceLocation location;
TypeRef type; // unused by default
ASTNodeData data; // std::variant<LiteralExpr, BinaryExpr, ...>
};Traditional Tree vs Flat Array
Node Reference by Index
This represents the expression: 5 + (10 * x)
Benefits
This design has several advantages:
No Heap Allocation Per Node
The vector may reallocate, but all references are stable indices, not pointers. No need to manage individual node lifetimes.
Trivial Serialization
ASTs from included files can be merged by simply appending vectors and rebasing indices.
High Memory Locality
Sequential traversal benefits from cache coherency since nodes are stored contiguously.
Simple Merging
The visit_offset() pattern makes AST composition straightforward.
Trade-offs
No Compile-Time Safety
Accessing m_nodes[id] and then std::get-ing the wrong variant type is a runtime error, not a compile-time error.
No Parent Pointers
Traversal is always top-down from a known root ID. You can't navigate upward in the tree.
Node Types
All possible node payloads are described by ASTNodeData, a std::variant of 33 types:
Expressions
LiteralExpr, IdentifierExpr, UnresolvedRef, BinaryExpr, UnaryExpr, TernaryExpr, CallExpr, IndexExpr, FieldExpr, ConstructExpr, AssignExpr
Statements
BlockStmt, IfStmt, ForStmt, WhileStmt, ReturnStmt, ExprStmt, DeclStmt, BreakStmt, ContinueStmt, DiscardStmt
Declarations
VarDecl, ParamDecl, FuncDecl, StructDecl, FieldDecl, UniformDecl, BufferDecl, InterfaceDecl, InterfaceBlock, InterfaceRef
Top-level
StageBlock, Program
The visit_offset() Pattern
Every node type implements a visit_offset(NodeID off) method. It increments all child NodeID fields by off. This is used during include merging:
When an included file's parsed nodes are appended to the main node vector at offset N, all NodeIDs inside those nodes must be bumped by N so they continue to point to the correct entries.
static void offset_node_ids(std::vector<ASTNode> &nodes, NodeID offset) {
for (auto &node : nodes) {
node.id += offset;
std::visit([offset](auto &d) { d.visit_offset(offset); }, node.data);
}
}Leaf nodes (nodes with no child NodeIDs) implement visit_offset as a no-op.
Include Merging Example
TypeRef
struct TypeRef {
TokenKind kind; // TokenKind::TypeVec3, TokenKind::Identifier, etc.
std::string name; // only populated when kind == TokenKind::Identifier
};For built-in types, kind holds the specific token kind and name is empty. For user-defined types (struct names), kind is TokenKind::Identifier and name holds the type string.
UnresolvedRef
During parsing, when the parser encounters a function call foo(...) where foo has not been declared yet, it initially emits an IdentifierExpr. At call-site disambiguation time (still in the parser), if foo is not in the declared set, the node is rewritten to UnresolvedRef{foo}.
The linker then validates all UnresolvedRef nodes against its final declared set. A reference that is still unresolved after link is an error.
Example Traversal
void print_expr(const std::vector<ASTNode>& nodes, NodeID id) {
const auto& node = nodes[id];
if (auto* bin = std::get_if<BinaryExpr>(&node.data)) {
std::cout << "(";
print_expr(nodes, bin->left);
std::cout << " " << op_to_string(bin->op) << " ";
print_expr(nodes, bin->right);
std::cout << ")";
} else if (auto* lit = std::get_if<LiteralExpr>(&node.data)) {
std::cout << lit->value;
}
// ... handle other node types
}