23. pyxc: Switch
What I Am Building
Chapter 22 added bitwise operators. Right now, multi-way branching on an integer value means a chain of if/elif. I'm adding switch:
extern def printd(x: float64)
def day_type(d: int) -> int:
var result: int = 0
switch d:
case 0, 6:
result = 2 # Sunday or Saturday
default:
result = 1 # weekday
return result
def main() -> int:
printd(float64(day_type(0) + day_type(3) + day_type(6)))
return 0
5.000000
switch runs the matching case and stops — no fallthrough. A case can list more than one value, so 0 and 6 share a body without two separate case lines.
Source Code
git clone --depth 1 https://github.com/alankarmisra/pyxc-llvm-tutorial
cd pyxc-llvm-tutorial/code/chapter-23
Grammar
I add switch-statement and its three sub-productions, and add it as a compound-statement alternative:
code/chapter-23/pyxc.ebnf
program = [ end-of-lines ]
[ top-level-item
{ end-of-lines top-level-item } ]
[ end-of-lines ] ;
end-of-lines = end-of-line { end-of-line } ;
top-level-item = function-definition
| external
| top-level-statement ;
function-definition = "def" function-signature [ "->" type ] ":"
( simple-statement
| end-of-lines block ) ;
external = "extern" "def" function-signature [ "->" type ] ;
top-level-statement = statement ;
function-signature = name "(" [ parameters ] ")" ;
parameters = typed-parameter { "," typed-parameter } ;
typed-parameter = name ":" type ;
if-statement = "if" expression ":" suite
{ [ end-of-lines ] "elif" expression ":" suite }
[ [ end-of-lines ] "else" ":" suite ] ;
while-statement = "while" expression ":" suite ;
do-while-statement = "do" ":" suite [ end-of-lines ]
"while" expression ;
+switch-statement = "switch" expression ":" end-of-lines
+ indent switch-body dedent ;
+switch-body = switch-case
+ { end-of-lines switch-case }
+ [ end-of-lines default-case ] ;
+switch-case = "case" switch-integer
+ { "," switch-integer } ":" suite ;
+default-case = "default" ":" suite ;
for-statement = "for" ( "var" name ":" type | name )
"=" expression ","
expression "," expression ":" suite ;
variable-statement = "var" variable-binding
{ "," variable-binding } ;
assignment-statement = lvalue "=" expression ;
simple-statement = return-statement
| break-statement
| continue-statement
| variable-statement
| assignment-statement
| expression ;
-compound-statement = if-statement
- | for-statement
- | while-statement
- | do-while-statement ;
+compound-statement = if-statement
+ | for-statement
+ | while-statement
+ | do-while-statement
+ | switch-statement ;
statement = simple-statement | compound-statement ;
suite = simple-statement
| compound-statement
| end-of-lines block ;
return-statement = "return" [ expression ] ;
break-statement = "break" ;
continue-statement = "continue" ;
statement-separator = end-of-lines | BLOCK_END ;
block = indent statement
{ statement-separator statement } dedent ;
expression = logical-or ;
logical-or = logical-and { "||" logical-and } ;
logical-and = bitwise-or { "&&" bitwise-or } ;
bitwise-or = bitwise-xor { "|" bitwise-xor } ;
bitwise-xor = bitwise-and { "^" bitwise-and } ;
bitwise-and = equality { "&" equality } ;
equality = relational { ("==" | "!=") relational } ;
relational = shift { ("<" | "<=" | ">" | ">=") shift } ;
shift = sum { ("<<" | ">>") sum } ;
sum = term { ("+" | "-") term } ;
term = factor { ("*" | "/" | "%") factor } ;
lvalue = name ;
variable-binding = name ":" type [ "=" expression ] ;
factor = ("-" | "!" | "~") factor | primary ;
primary = cast-expression
| name-expression
| number-expression
| boolean-literal
| parenthesized-expression ;
cast-expression = cast-type "(" expression ")" ;
name-expression = name | call-expression ;
call-expression = name "(" [ arguments ] ")" ;
arguments = expression { "," expression } ;
number-expression = number ;
parenthesized-expression = "(" expression ")" ;
indent = INDENT ;
dedent = DEDENT ;
name = (letter | "_")
{ letter | digit | "_" } ;
type = "int" | "int8" | "int16" | "int32"
| "int64" | "uint8" | "uint16"
| "uint32" | "uint64"
| "float" | "float32"
| "float64" | "bool" | "None" ;
cast-type = "int" | "int8" | "int16" | "int32"
| "int64" | "uint8" | "uint16"
| "uint32" | "uint64"
| "float" | "float32"
| "float64" | "bool" ;
+switch-integer = [ "-" ] digit { digit } ;
number = ( digit { digit } [ "." { digit } ]
| "." digit { digit } ) [ exponent ] ;
exponent = ( "e" | "E" ) [ "+" | "-" ]
digit { digit } ;
boolean-literal = "True" | "False" ;
letter = "A".."Z" | "a".."z" ;
digit = "0".."9" ;
end-of-line = "\r\n" | "\r" | "\n" ;
comment = "#" { comment-character } ;
comment-character = ? any character except "\r" and "\n" ? ;
whitespace = " " | "\t" | "\v" | "\f" ;
INDENT = ? synthetic token emitted by lexer when indentation increases ? ;
DEDENT = ? synthetic token emitted by lexer when indentation decreases ? ;
BLOCK_END = ? synthetic token injected into the stream by ParseBlock
immediately after it consumes DEDENT ? ;
New Tokens and Keywords
I add three new tokens:
tok_switch = -47,
tok_case = -48,
tok_default = -49,
And add them to the keyword table:
{"switch", tok_switch}, {"case", tok_case},
{"default", tok_default},
Representing switch in the AST
The node stores the condition, a list of (values, body) pairs, and an optional default body. A case can list more than one value — case 'a', 'e', 'i', 'o', 'u': — so I store a vector of values per case, not just one:
class SwitchStatementNode : public ExpressionNode {
unique_ptr<ExpressionNode> Condition;
vector<pair<vector<int64_t>, unique_ptr<ExpressionNode>>> Cases;
unique_ptr<ExpressionNode> DefaultCase;
public:
SwitchStatementNode(
unique_ptr<ExpressionNode> Condition,
vector<pair<vector<int64_t>, unique_ptr<ExpressionNode>>> Cases,
unique_ptr<ExpressionNode> DefaultCase)
: Condition(std::move(Condition)), Cases(std::move(Cases)),
DefaultCase(std::move(DefaultCase)) {
setType(ValueType::None);
}
bool shouldPrintValue() const override { return false; }
Value *codegen() override;
};
I store case values as int64_t — signed integer literals I parse at compile time.
Parse-Time Switch Depth
Same pattern as loop depth for break/continue: a counter and an RAII guard track whether I'm inside a switch:
static int ParseSwitchDepth = 0;
struct ParseSwitchGuard {
ParseSwitchGuard() { ++ParseSwitchDepth; }
~ParseSwitchGuard() { --ParseSwitchDepth; }
};
I update ParseBreakStatement to accept break inside a switch as well as a loop:
static unique_ptr<ExpressionNode> ParseBreakStatement() {
if (ParseLoopDepth <= 0 && ParseSwitchDepth <= 0)
return LogErrorExpression("'break' used outside of a loop or switch");
getNextToken();
return make_unique<BreakStatementNode>();
}
Parsing Case Literals
Case values are signed integer literals. I handle an optional leading - explicitly, before reading the number:
static bool ParseSwitchCaseValue(int64_t &Value) {
bool Negative = false;
if (CurrentToken == tok_minus) {
Negative = true;
getNextToken();
}
if (CurrentToken != tok_number || NumberIsFloat) {
LogErrorExpression("Switch case value must be an integer literal");
return false;
}
uint64_t Magnitude = 0;
for (char Digit : NumberLiteral) {
unsigned ValueOfDigit = static_cast<unsigned>(Digit - '0');
if (Magnitude >
(std::numeric_limits<uint64_t>::max() - ValueOfDigit) / 10) {
LogErrorExpression("Switch case value out of range");
return false;
}
Magnitude = Magnitude * 10 + ValueOfDigit;
}
getNextToken(); // eat integer
uint64_t NegativeLimit =
static_cast<uint64_t>(std::numeric_limits<int64_t>::max()) + 1;
if (Negative) {
if (Magnitude > NegativeLimit) {
LogErrorExpression("Switch case value out of range");
return false;
}
Value = Magnitude == NegativeLimit
? std::numeric_limits<int64_t>::min()
: -static_cast<int64_t>(Magnitude);
} else {
if (Magnitude >
static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
LogErrorExpression("Switch case value out of range");
return false;
}
Value = static_cast<int64_t>(Magnitude);
}
return true;
}
I accumulate the magnitude digit by digit rather than parsing the whole literal and checking after the fact, so overflow is caught mid-parse instead of after a uint64_t has already wrapped around silently. case -9223372036854775808: — the minimum int64_t — still parses correctly: its magnitude is exactly NegativeLimit (INT64_MAX + 1), one past what a positive int64_t can hold, which is exactly the boundary Magnitude == NegativeLimit exists to allow. This also means negative case values just work: case -1: is valid, no separate rule needed.
Parsing the switch Statement
I eat switch, check the condition is an integer type, then read an indented block of case and default clauses. A case reads one value, then keeps reading more as long as a , follows — that's how case 'a', 'e', 'i', 'o', 'u': ends up sharing one body across five values:
static unique_ptr<ExpressionNode> ParseSwitchStatement() {
getNextToken(); // eat 'switch'
auto Condition = ParseExpression();
if (!Condition)
return nullptr;
if (!IsIntType(Condition->getType()))
return LogErrorExpression("Switch condition must be an integer type");
if (CurrentToken != tok_colon)
return LogErrorExpression("Expected ':' after switch expression");
getNextToken(); // eat ':'
if (CurrentToken == tok_eol)
consumeNewlines();
if (CurrentToken != tok_indent)
return LogErrorExpression("Expected an indented switch body");
getNextToken(); // eat INDENT
ParseSwitchGuard Switch;
vector<pair<vector<int64_t>, unique_ptr<ExpressionNode>>> Cases;
set<int64_t> SeenValues;
unique_ptr<ExpressionNode> DefaultCase;
while (CurrentToken != tok_dedent && CurrentToken != tok_eof) {
if (CurrentToken == tok_case) {
if (DefaultCase)
return LogErrorExpression("Case cannot follow default");
getNextToken(); // eat 'case'
vector<int64_t> Values;
while (true) {
int64_t Value = 0;
if (!ParseSwitchCaseValue(Value))
return nullptr;
if (!SeenValues.insert(Value).second)
return LogErrorExpression("Duplicate switch case value");
Values.push_back(Value);
if (CurrentToken != tok_comma)
break;
getNextToken(); // eat ','
}
if (CurrentToken != tok_colon)
return LogErrorExpression("Expected ':' after case value");
getNextToken(); // eat ':'
auto Body = ParseSuite();
if (!Body)
return nullptr;
Cases.emplace_back(std::move(Values), std::move(Body));
} else if (CurrentToken == tok_default) {
if (DefaultCase)
return LogErrorExpression("Duplicate default case");
getNextToken(); // eat 'default'
if (CurrentToken != tok_colon)
return LogErrorExpression("Expected ':' after default");
getNextToken(); // eat ':'
DefaultCase = ParseSuite();
if (!DefaultCase)
return nullptr;
} else {
return LogErrorExpression("Expected 'case' or 'default' in switch body");
}
if (CurrentToken == tok_block_end)
getNextToken();
if (CurrentToken == tok_eol)
consumeNewlines();
}
if (CurrentToken != tok_dedent)
return LogErrorExpression("Expected dedent after switch body");
PendingTokens.push_front(tok_block_end);
getNextToken(); // eat DEDENT, then surface block-end
return make_unique<SwitchStatementNode>(
std::move(Condition), std::move(Cases), std::move(DefaultCase));
}
I reject duplicate case values at parse time with a set<int64_t>, checked as each value is read — so a repeat within one comma-separated list (case 1, 2, 1:) is caught the same way as a repeat across two separate case lines. I reject multiple default clauses the same way, and reject a case that comes after default at all — default has to be the last clause in the switch body. If nothing matches and there's no default, DefaultCase just stays null — execution falls through to after the switch with no action.
Non-integer switch condition:
switch x:
Error (Line 3, Column 11): Switch condition must be an integer type
switch x:
^~~~
Duplicate case value:
case 1:
return 1
case 1:
return 2
Error (Line 5, Column 11): Duplicate switch case value
case 1:
^~~~
Refactoring Break Targets
Chapter 14's LoopControlStack carries BreakTarget and ContinueTarget together. A switch needs to push a break target without disturbing continue, which still has to reach the enclosing loop — so I add a separate stack just for break:
static vector<BasicBlock *> BreakTargetStack;
I update the for and while codegens to push and pop BreakTargetStack alongside LoopControlStack:
// for loop:
BreakTargetStack.push_back(AfterBB);
if (!Body->codegen()) {
BreakTargetStack.pop_back();
return nullptr;
}
BreakTargetStack.pop_back();
// while loop:
BreakTargetStack.push_back(AfterBB);
// ...
BreakTargetStack.pop_back();
And I switch BreakStatementNode::codegen from LoopControlStack.back().BreakTarget to BreakTargetStack:
Value *BreakStatementNode::codegen() {
if (BreakTargetStack.empty())
return LogErrorV("'break' used outside of a loop or switch");
TheBuilder->CreateBr(BreakTargetStack.back());
return ConstantFP::get(*TheContext, APFloat(0.0));
}
continue doesn't need any of this — it still reads LoopControlStack.back().ContinueTarget directly, and a switch never touches that stack. continue inside a switch keeps meaning "continue the enclosing loop," while break inside a switch now means "exit the switch," not the loop.
switch Codegen
I use LLVM's own switch instruction — a real multi-way branch, not a chain of comparisons. The backend picks a jump table, binary search, or comparison chain depending on how many cases there are and how dense the values are; I don't have to choose. Each case gets one basic block, and since LLVM's switch already supports many values pointing at the same block, giving a case several values is just one addCase call per value, all targeting that case's block:
Value *SwitchStatementNode::codegen() {
Value *ConditionValue = Condition->codegen();
if (!ConditionValue)
return nullptr;
auto *ConditionType = dyn_cast<IntegerType>(LLVMTypeFor(Condition->getType()));
if (!ConditionType)
return LogErrorV("Switch condition must be an integer type");
Function *FunctionIR = TheBuilder->GetInsertBlock()->getParent();
BasicBlock *AfterBlock =
BasicBlock::Create(*TheContext, "switch.after", FunctionIR);
BasicBlock *DefaultBlock =
DefaultCase
? BasicBlock::Create(*TheContext, "switch.default", FunctionIR)
: AfterBlock;
unsigned CaseCount = 0;
for (const auto &Case : Cases)
CaseCount += Case.first.size();
auto *SwitchIR =
TheBuilder->CreateSwitch(ConditionValue, DefaultBlock, CaseCount);
vector<BasicBlock *> CaseBlocks;
for (const auto &Case : Cases) {
BasicBlock *CaseBlock =
BasicBlock::Create(*TheContext, "switch.case", FunctionIR);
CaseBlocks.push_back(CaseBlock);
for (int64_t Value : Case.first) {
auto *Constant = ConstantInt::get(ConditionType,
static_cast<uint64_t>(Value), true);
SwitchIR->addCase(Constant, CaseBlock);
}
}
BreakTargetStack.push_back(AfterBlock);
for (size_t Index = 0; Index < Cases.size(); ++Index) {
TheBuilder->SetInsertPoint(CaseBlocks[Index]);
if (!Cases[Index].second->codegen()) {
BreakTargetStack.pop_back();
return nullptr;
}
if (!TheBuilder->GetInsertBlock()->getTerminator())
TheBuilder->CreateBr(AfterBlock);
}
if (DefaultCase) {
TheBuilder->SetInsertPoint(DefaultBlock);
if (!DefaultCase->codegen()) {
BreakTargetStack.pop_back();
return nullptr;
}
if (!TheBuilder->GetInsertBlock()->getTerminator())
TheBuilder->CreateBr(AfterBlock);
}
BreakTargetStack.pop_back();
TheBuilder->SetInsertPoint(AfterBlock);
return ConstantFP::get(*TheContext, APFloat(0.0));
}
TheBuilder->CreateSwitch(ConditionValue, DefaultBlock, CaseCount) emits the switch instruction itself, with the default destination and a hint for how many cases to expect — CaseCount counts individual values, not case clauses, so case 0, 6: contributes two to the hint even though it's one clause with one body. When there's no default in the source, DefaultBlock is just AfterBlock — no matching value falls straight through to after the switch, same as a real default that does nothing. SwitchIR->addCase(Constant, CaseBlock) registers each value.
If a case body doesn't end in a terminator, I add an unconditional branch to switch.after myself. That's the whole no-fallthrough guarantee — every case exits to switch.after unless it already returned or broke somewhere else. There's no way to stack empty cases to share a body the way C does; if two values need the same code, list them on one case line instead.
I only allow compile-time integer literals as case values, not variables or expressions — that's what lets LLVM build a real jump table or binary search instead of a comparison chain.
Known Limitations
Case values must be compile-time integer literals. case x: or case a + 1: aren't accepted; only literal integers (optionally negative) are.
No fallthrough, and no way to opt into it. Every case implicitly branches to switch.after unless it already returns or breaks. There's no C-style case 1: case 2: stacking to share a body; comma-separated values on one case line are the only way to match several values with one body.
Only one default. A second default: clause is a parse-time error, same as a duplicate case value.
Build and Run
cd code/chapter-23
cmake -S . -B build && cmake --build build
llvm-lit -v test/
Try It
ready> def day_type(d: int) -> int:
switch d:
case 0, 6:
return 2
default:
return 1
ready> day_type(0)
2
ready> day_type(3)
1
ready> day_type(6)
2
ready>
What's Next
Chapter 24 adds struct declarations.
Need Help?
Build issues? Questions?
- GitHub Issues: Report problems
- Discussions: Ask questions
Include:
- Your OS and version
- Full error message
- Output of
cmake --version,ninja --version, andllvm-config --version
I'll help you figure it out.