2. pyxc: The Parser and Syntax Tree
Next: turn the token stream into structure.
Chapter 1 can recognize the pieces of:
def add(a, b): a + b
but it does not know that add is a function, a and b are parameters, or a + b is the function body.
Add the next compiler boundary:
tokens -> abstract syntax tree
Work in:
cd code/chapter-02
2.1 Write the Grammar Before the Parser
Use this expression grammar:
program = [ end-of-lines ]
[ top-level-item { end-of-lines top-level-item } ]
[ end-of-lines ] ;
top-level-item = function-definition | top-level-expression ;
function-definition = "def" function-signature ":"
[ end-of-lines ] expression ;
function-signature = name "(" [ parameters ] ")" ;
parameters = name { "," name } ;
top-level-expression = expression ;
expression = sum ;
sum = term { "+" term } ;
term = primary ;
primary = name-expression
| number-expression
| parenthesized-expression ;
name-expression = name | call-expression ;
call-expression = name "(" [ arguments ] ")" ;
arguments = expression { "," expression } ;
parenthesized-expression = "(" expression ")" ;
Each parser function will implement one rule. This is a recursive-descent parser because grammar rules call the functions for their child rules directly.
2.2 Add the AST Base Class
Add:
class ExpressionNode {
public:
virtual ~ExpressionNode() = default;
};
Use unique_ptr for ownership. Each parent node owns its children, so deleting the root deletes the complete tree.
2.3 Add Leaf Expression Nodes
A number stores its value:
class NumberExpressionNode : public ExpressionNode {
double Value;
public:
NumberExpressionNode(double Value) : Value(Value) {}
};
A name stores its spelling:
class NameExpressionNode : public ExpressionNode {
string Name;
public:
NameExpressionNode(const string &Name) : Name(Name) {}
};
The AST keeps semantic structure and discards punctuation. A NumberExpressionNode does not need to remember which source characters surrounded it.
2.4 Add Compound Expression Nodes
Represent a + b with an operator and two owned operands:
class BinaryExpressionNode : public ExpressionNode {
int Operator;
unique_ptr<ExpressionNode> Left, Right;
public:
BinaryExpressionNode(int Operator, unique_ptr<ExpressionNode> Left,
unique_ptr<ExpressionNode> Right)
: Operator(Operator), Left(std::move(Left)),
Right(std::move(Right)) {}
};
Represent a call with a callee name and zero or more arguments:
class CallExpressionNode : public ExpressionNode {
string Callee;
vector<unique_ptr<ExpressionNode>> Arguments;
public:
CallExpressionNode(const string &Callee,
vector<unique_ptr<ExpressionNode>> Arguments)
: Callee(Callee), Arguments(std::move(Arguments)) {}
};
For example:
add(1, 2)
becomes:
CallExpression "add"
├── NumberExpression 1
└── NumberExpression 2
2.5 Add Function Nodes
Keep the signature separate from the body:
class FunctionSignatureNode {
string Name;
vector<string> Parameters;
public:
FunctionSignatureNode(const string &Name, vector<string> Parameters)
: Name(Name), Parameters(std::move(Parameters)) {}
};
Then combine a signature and expression body:
class FunctionDefinitionNode {
unique_ptr<FunctionSignatureNode> Signature;
unique_ptr<ExpressionNode> Body;
public:
FunctionDefinitionNode(unique_ptr<FunctionSignatureNode> Signature,
unique_ptr<ExpressionNode> Body)
: Signature(std::move(Signature)), Body(std::move(Body)) {}
};
This creates one clear unit:
function definition -> signature + body expression
2.6 Add One Token of Parser Lookahead
The lexer already provides getToken(). Add:
static int CurrentToken;
static int getNextToken() {
return CurrentToken = getToken();
}
CurrentToken is the token the parser is currently deciding how to use. Parser functions consume it only when they have confirmed it belongs to their grammar rule.
2.7 Add Parser Error Helpers
Add helpers that report an error and return nullptr:
static unique_ptr<ExpressionNode> LogErrorExpression(const char *Message) {
fprintf(stderr, "Error: %s\n", Message);
return nullptr;
}
static unique_ptr<FunctionSignatureNode>
LogErrorSignature(const char *Message) {
LogErrorExpression(Message);
return nullptr;
}
This lets parser code use the normal failure pattern:
if (CurrentToken != tok_rparen)
return LogErrorExpression("Expected ')'");
2.8 Parse Numbers
Add:
static unique_ptr<ExpressionNode> ParseNumberExpression() {
auto Result = make_unique<NumberExpressionNode>(NumberValue);
getNextToken(); // eat number
return std::move(Result);
}
The lexer has already placed the value in NumberValue. Copy it into the AST before consuming the next token.
2.9 Parse Names and Calls Together
A leading name is ambiguous until you inspect the following token:
x -> name expression
add(...) -> call expression
Implement:
static unique_ptr<ExpressionNode> ParseNameExpression() {
string ParsedName = Name;
getNextToken(); // eat name
if (CurrentToken != tok_lparen)
return make_unique<NameExpressionNode>(ParsedName);
getNextToken(); // eat '('
vector<unique_ptr<ExpressionNode>> Arguments;
if (CurrentToken != tok_rparen) {
while (true) {
auto Argument = ParseExpression();
if (!Argument)
return nullptr;
Arguments.push_back(std::move(Argument));
if (CurrentToken == tok_rparen)
break;
if (CurrentToken != tok_comma)
return LogErrorExpression("Expected ')' or ',' in argument list");
getNextToken(); // eat ','
}
}
getNextToken(); // eat ')'
return make_unique<CallExpressionNode>(ParsedName,
std::move(Arguments));
}
Parse each argument as a complete expression. That allows nested calls and arithmetic inside arguments without special call-parser logic.
2.10 Parse Parentheses
Add:
static unique_ptr<ExpressionNode> ParseParenthesizedExpression() {
getNextToken(); // eat '('
auto Expression = ParseExpression();
if (!Expression)
return nullptr;
if (CurrentToken != tok_rparen)
return LogErrorExpression("Expected ')'");
getNextToken(); // eat ')'
return Expression;
}
The parentheses affect grouping but do not need their own AST node. Return the expression they contain.
2.11 Dispatch Primary Expressions
Implement the grammar alternatives with a switch:
static unique_ptr<ExpressionNode> ParsePrimary() {
switch (CurrentToken) {
case tok_name:
return ParseNameExpression();
case tok_number:
return ParseNumberExpression();
case tok_lparen:
return ParseParenthesizedExpression();
default:
return LogErrorExpression("Unexpected token in expression");
}
}
This function answers one question:
which primary begins with CurrentToken?
2.12 Parse Addition Left to Right
For this chapter, term is just primary:
static unique_ptr<ExpressionNode> ParseTerm() {
return ParsePrimary();
}
Build sum with a loop:
static unique_ptr<ExpressionNode> ParseSum() {
auto Left = ParseTerm();
if (!Left)
return nullptr;
while (CurrentToken == tok_plus) {
getNextToken(); // eat '+'
auto Right = ParseTerm();
if (!Right)
return nullptr;
Left = make_unique<BinaryExpressionNode>(
tok_plus, std::move(Left), std::move(Right));
}
return Left;
}
Then make expression delegate to sum:
static unique_ptr<ExpressionNode> ParseExpression() {
return ParseSum();
}
Repeated addition folds left:
1 + 2 + 3 -> ((1 + 2) + 3)
2.13 Parse Function Signatures
After the driver consumes def, parse the name, parentheses, and comma-separated parameters:
static unique_ptr<FunctionSignatureNode> ParseFunctionSignature() {
if (CurrentToken != tok_name)
return LogErrorSignature("Expected function name");
string FunctionName = Name;
getNextToken(); // eat name
if (CurrentToken != tok_lparen)
return LogErrorSignature("Expected '(' in function signature");
getNextToken(); // eat '('
vector<string> Parameters;
if (CurrentToken != tok_rparen) {
while (true) {
if (CurrentToken != tok_name)
return LogErrorSignature("Expected parameter name");
Parameters.push_back(Name);
getNextToken(); // eat parameter
if (CurrentToken == tok_rparen)
break;
if (CurrentToken != tok_comma)
return LogErrorSignature("Expected ')' or ',' in signature");
getNextToken(); // eat ','
}
}
getNextToken(); // eat ')'
return make_unique<FunctionSignatureNode>(FunctionName,
std::move(Parameters));
}
2.14 Parse Function Definitions
Combine the signature with its body:
static unique_ptr<FunctionDefinitionNode> ParseFunctionDefinition() {
getNextToken(); // eat 'def'
auto Signature = ParseFunctionSignature();
if (!Signature)
return nullptr;
if (CurrentToken != tok_colon)
return nullptr;
getNextToken(); // eat ':'
while (CurrentToken == tok_eol)
getNextToken();
auto Body = ParseExpression();
if (!Body)
return nullptr;
return make_unique<FunctionDefinitionNode>(
std::move(Signature), std::move(Body));
}
Allow the body on the same line or after one or more newlines.
2.15 Add the Mini Driver
The top-level driver has three jobs:
newline -> consume it
def -> parse a function definition
other -> parse a top-level expression
Use handlers that report successful parsing:
static void HandleFunctionDefinition() {
if (ParseFunctionDefinition())
fprintf(stderr, "Parsed a function definition.\n");
else
getNextToken();
}
static void HandleTopLevelExpression() {
if (ParseExpression())
fprintf(stderr, "Parsed a top-level expression.\n");
else
getNextToken();
}
Then loop:
static void MainLoop() {
while (true) {
fprintf(stderr, "ready> ");
switch (CurrentToken) {
case tok_eof:
return;
case tok_eol:
getNextToken();
break;
case tok_def:
HandleFunctionDefinition();
break;
default:
HandleTopLevelExpression();
break;
}
}
}
Prime the lookahead once in main() before entering MainLoop().
The recovery step consumes at least one token after failure. Without it, the driver would retry the same invalid token forever.
2.16 Build and Run
cmake -S . -B build
cmake --build build
./build/pyxc
Try:
ready> def add(a, b): a + b
ready> add(1, 2)
Expected:
Parsed a function definition.
Parsed a top-level expression.
The parser does not execute either tree yet. Success means the token sequence matched the grammar and produced owned AST nodes.
Run the suite:
llvm-lit -v test/
What you built is the next clean boundary:
one grammar rule -> one parser function -> one AST node or error
Next: Chapter 3 splits expressions into precedence tiers.
2.17 Need Help?
Build issues? Questions?
Include:
- Your operating system and version
- The chapter number
- The exact command you ran
- The complete error message
- The output of
c++ --versionandcmake --version - The output of
llvm-config --versionfor Chapter 6 and later