BOOTSTRAP-003: Core Lexer Implementation
Context
With token types defined (BOOTSTRAP-001) and character stream ready (BOOTSTRAP-002), we can now implement the core lexer that converts source code into tokens.
The lexer is the first stage of the compiler pipeline. It reads raw source code and produces a stream of tokens for the parser to consume.
Requirements
- Main tokenization loop returning (Token, i32) pairs
- Operator recognition (single and multi-character)
- Literal parsing (numbers and identifiers)
- Comment handling (
//line comments) - Keyword recognition (
fun,let,if,while) - Whitespace skipping
- Performance target: >10K LOC/s
RED: Write Failing Test
Following TDD, we start by writing tests that specify the behavior we want. The tests should fail because we haven't implemented the lexer yet.
File: bootstrap/stage0/test_lexer.ruchy (138 LOC)
// BOOTSTRAP-003: Core Lexer Implementation - Test Suite (RED Phase)
enum TokenType {
Number, Identifier, Fun, Let, If, While,
Plus, Minus, Star, Slash, Equal, EqualEqual,
Eof, Error
}
enum Token {
Tok(TokenType, String)
}
// Test 1: Single number tokenization
fun test_tokenize_single_number() -> bool {
println(" Testing single number tokenization...");
let input = "42";
println(" ❌ Lexer not implemented - test fails");
false
}
// Test 2: Identifier tokenization
fun test_tokenize_identifier() -> bool {
println(" Testing identifier tokenization...");
let input = "hello";
println(" ❌ Lexer not implemented - test fails");
false
}
// Test 3: Keyword recognition
fun test_tokenize_keyword() -> bool {
println(" Testing keyword recognition...");
let input = "fun";
println(" ❌ Lexer not implemented - test fails");
false
}
// Test 4: Operator tokenization
fun test_tokenize_operator() -> bool {
println(" Testing operator tokenization...");
let input = "+";
println(" ❌ Lexer not implemented - test fails");
false
}
// Test 5: Multi-char operators
fun test_tokenize_equal_equal() -> bool {
println(" Testing multi-char operator tokenization...");
let input = "==";
println(" ❌ Lexer not implemented - test fails");
false
}
// Test 6: Expression tokenization
fun test_tokenize_expression() -> bool {
println(" Testing expression tokenization...");
let input = "x + 1";
println(" ❌ Lexer not implemented - test fails");
false
}
// Test 7: Whitespace skipping
fun test_skip_whitespace() -> bool {
println(" Testing whitespace skipping...");
let input = " 42 ";
println(" ❌ Lexer not implemented - test fails");
false
}
// Test 8: Line comment handling
fun test_skip_line_comment() -> bool {
println(" Testing line comment handling...");
let input = "// comment\n42";
println(" ❌ Lexer not implemented - test fails");
false
}
fun main() {
println("🧪 BOOTSTRAP-003: Core Lexer Test Suite (RED Phase)");
println("");
let mut passed = 0;
let mut failed = 0;
if test_tokenize_single_number() { passed = passed + 1; } else { failed = failed + 1; }
if test_tokenize_identifier() { passed = passed + 1; } else { failed = failed + 1; }
if test_tokenize_keyword() { passed = passed + 1; } else { failed = failed + 1; }
if test_tokenize_operator() { passed = passed + 1; } else { failed = failed + 1; }
if test_tokenize_equal_equal() { passed = passed + 1; } else { failed = failed + 1; }
if test_tokenize_expression() { passed = passed + 1; } else { failed = failed + 1; }
if test_skip_whitespace() { passed = passed + 1; } else { failed = failed + 1; }
if test_skip_line_comment() { passed = passed + 1; } else { failed = failed + 1; }
println("");
println("Total Tests: {}", passed + failed);
println("Passed: {}", passed);
println("Failed: {}", failed);
if failed == 0 {
println("✅ All tests passed!");
} else {
println("❌ RED PHASE: {} tests failing as expected", failed);
}
}
main();
Run the Failing Tests
$ ruchy run bootstrap/stage0/test_lexer.ruchy
🧪 BOOTSTRAP-003: Core Lexer Test Suite (RED Phase)
Testing single number tokenization...
❌ Lexer not implemented - test fails
Testing identifier tokenization...
❌ Lexer not implemented - test fails
Testing keyword recognition...
❌ Lexer not implemented - test fails
Testing operator tokenization...
❌ Lexer not implemented - test fails
Testing multi-char operator tokenization...
❌ Lexer not implemented - test fails
Testing expression tokenization...
❌ Lexer not implemented - test fails
Testing whitespace skipping...
❌ Lexer not implemented - test fails
Testing line comment handling...
❌ Lexer not implemented - test fails
Total Tests: 8
Passed: 0
Failed: 8
❌ RED PHASE: 8 tests failing as expected
✅ RED Phase Complete: All 8 tests fail as expected, proving our test suite is valid.
GREEN: Minimal Implementation
Now we write the simplest code that makes the tests pass.
Attempt 1: Initial Implementation (v3.94.0)
We attempted to implement the lexer using the standard tokenization pattern where each tokenize function returns (Token, i32) pairs:
- The
Tokenrepresents what was parsed - The
i32represents the position after parsing (for next tokenize call)
File: bootstrap/stage0/lexer_minimal.ruchy (465 LOC)
fun tokenize_number(input: String, start: i32) -> (Token, i32) {
let mut idx = start;
let mut num_str = "".to_string();
loop {
let ch = char_at(input, idx);
if ch == "\0" || !is_digit(ch) {
break;
}
num_str = num_str + ch;
idx = idx + 1;
}
(Token::Tok(TokenType::Number, num_str), idx)
}
Result: ❌ Runtime error!
$ ruchy run bootstrap/stage0/lexer_minimal.ruchy
Error: Type error: Cannot call non-function value: integer
Bug Discovered: Loop + Mutable + Tuple Return
Issue: Returning a tuple from a function containing a loop with mutable variables caused a runtime error in Ruchy v3.94.0.
Error: Type error: Cannot call non-function value: integer
This was a CRITICAL blocker because the (Token, i32) return pattern is fundamental to compiler construction:
- It's the standard way to implement lexers and parsers
- Each tokenize function needs to return both the parsed token AND the new position
- Without this, we cannot implement sequential tokenization
Bug Discovery Protocol Applied
Following the project's Bug Discovery Protocol, we:
- 🚨 STOPPED THE LINE - Halted all BOOTSTRAP-003 work immediately
- 📋 Filed Bug Report: Created
GITHUB_ISSUE_loop_mut_tuple_return.mdwith extreme detail - 🔬 Created Minimal Reproduction:
bug_reproduction_loop_mut_tuple.ruchy(11 LOC) - 🔬 Created Control Tests: Validated simpler cases work:
- ✅ Tuple return without loop: Works
- ✅ Tuple return without mut: Works
- ✅ Loop with mut without tuple return: Works
- ❌ Loop + mut + tuple return: FAILS
- 📋 Updated Documentation:
- BOUNDARIES.md: Documented the limitation
- INTEGRATION.md: Marked BOOTSTRAP-003 as BLOCKED
- ⏸️ AWAITED FIX - No workarounds, waited for runtime fix
Minimal Reproduction (11 LOC):
fun test_loop_mut() -> (i32, i32) {
let mut idx = 0;
loop {
if idx >= 5 { break; }
idx = idx + 1;
}
(0, idx) // ❌ Runtime error in v3.94.0
}
Severity: CRITICAL - Blocks fundamental compiler construction patterns
Fix Deployed: Ruchy v3.95.0
The Ruchy team deployed a fix in version 3.95.0, resolving the loop+mut+tuple return issue.
Verification:
$ ruchy --version
ruchy 3.95.0
$ ruchy run bug_reproduction_loop_mut_tuple.ruchy
Sum: 10, Index: 5
✅ Works perfectly!
Attempt 2: Complete Implementation (v3.95.0)
With the fix deployed, we resumed implementation. The lexer now works perfectly!
File: bootstrap/stage0/lexer_minimal.ruchy (465 LOC)
Key Functions:
// Helper: Get character at index
fun char_at(input: String, index: i32) -> String {
if index >= input.len() {
"\0"
} else {
let c = input.chars().nth(index);
match c {
Some(ch) => ch.to_string(),
None => "\0"
}
}
}
// Helper: Check if character is digit
fun is_digit(ch: String) -> bool {
ch == "0" || ch == "1" || ch == "2" || ch == "3" || ch == "4" ||
ch == "5" || ch == "6" || ch == "7" || ch == "8" || ch == "9"
}
// Helper: Check if character is letter
fun is_letter(ch: String) -> bool {
(ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch == "_"
}
// Helper: Match keyword
fun match_keyword(id: String) -> TokenType {
match id.to_string() {
"fun" => TokenType::Fun,
"let" => TokenType::Let,
"if" => TokenType::If,
"while" => TokenType::While,
_ => TokenType::Identifier
}
}
// Helper: Skip whitespace
fun skip_whitespace(input: String, start: i32) -> i32 {
let mut idx = start;
loop {
let ch = char_at(input, idx);
if ch == "\0" || (ch != " " && ch != "\t" && ch != "\n" && ch != "\r") {
break;
}
idx = idx + 1;
}
idx
}
// Tokenize number: "42" -> (Token::Tok(Number, "42"), 2)
fun tokenize_number(input: String, start: i32) -> (Token, i32) {
let mut idx = start;
let mut num_str = "".to_string();
loop {
let ch = char_at(input, idx);
if ch == "\0" || !is_digit(ch) {
break;
}
num_str = num_str + ch;
idx = idx + 1;
}
(Token::Tok(TokenType::Number, num_str), idx) // ✅ Works in v3.95.0!
}
// Tokenize identifier or keyword
fun tokenize_identifier(input: String, start: i32) -> (Token, i32) {
let mut idx = start;
let mut id_str = "".to_string();
loop {
let ch = char_at(input, idx);
if ch == "\0" || (!is_letter(ch) && !is_digit(ch)) {
break;
}
id_str = id_str + ch;
idx = idx + 1;
}
let token_type = match_keyword(id_str.to_string());
(Token::Tok(token_type, id_str), idx)
}
// Tokenize single character operators
fun tokenize_single(input: String, start: i32) -> (Token, i32) {
let ch = char_at(input, start);
if ch == "=" {
let next_ch = char_at(input, start + 1);
if next_ch == "=" {
(Token::Tok(TokenType::EqualEqual, "==".to_string()), start + 2)
} else {
(Token::Tok(TokenType::Equal, "=".to_string()), start + 1)
}
} else if ch == "+" {
(Token::Tok(TokenType::Plus, "+".to_string()), start + 1)
} else if ch == "-" {
(Token::Tok(TokenType::Minus, "-".to_string()), start + 1)
} else if ch == "*" {
(Token::Tok(TokenType::Star, "*".to_string()), start + 1)
} else if ch == "/" {
// Check for line comment
let next_ch = char_at(input, start + 1);
if next_ch == "/" {
// Skip until newline
let mut idx = start + 2;
loop {
let c = char_at(input, idx);
if c == "\0" || c == "\n" {
break;
}
idx = idx + 1;
}
tokenize_one(input, idx) // Recurse to get next token
} else {
(Token::Tok(TokenType::Slash, "/".to_string()), start + 1)
}
} else {
(Token::Tok(TokenType::Error, ch.to_string()), start + 1)
}
}
// Main tokenization function
fun tokenize_one(input: String, start: i32) -> (Token, i32) {
let idx = skip_whitespace(input, start);
let ch = char_at(input, idx);
if ch == "\0" {
(Token::Tok(TokenType::Eof, "".to_string()), idx)
} else if is_digit(ch) {
tokenize_number(input, idx)
} else if is_letter(ch) {
tokenize_identifier(input, idx)
} else {
tokenize_single(input, idx)
}
}
Run the Passing Tests
$ ruchy run bootstrap/stage0/lexer_minimal.ruchy
🧪 BOOTSTRAP-003: Core Lexer Test Suite
Testing single number tokenization...
Input: "42"
Expected: Number("42")
Got: Number("42")
✅ Pass
Testing identifier tokenization...
Input: "hello"
Expected: Identifier("hello")
Got: Identifier("hello")
✅ Pass
Testing keyword recognition...
Input: "fun"
Expected: Fun
Got: Fun
✅ Pass
Testing operator tokenization...
Input: "+"
Expected: Plus
Got: Plus
✅ Pass
Testing multi-char operator tokenization...
Input: "=="
Expected: EqualEqual (NOT two Equal)
Got: EqualEqual
✅ Pass
Testing expression tokenization...
Input: "x + 1"
Expected: [Identifier("x"), Plus, Number("1")]
Got: [Identifier("x"), Plus, Number("1")]
✅ Pass
Testing whitespace skipping...
Input: " 42 "
Expected: Number("42")
Got: Number("42")
✅ Pass
Testing line comment handling...
Input: "// comment\n42"
Expected: Number("42")
Got: Number("42")
✅ Pass
Total Tests: 8
Passed: 8
Failed: 0
Success Rate: 100%
✅ GREEN PHASE COMPLETE!
All tests pass with minimal implementation.
Next: REFACTOR Phase - Improve code quality
✅ GREEN Phase Complete: All 8/8 tests passing (100% success rate)!
Key Learnings
1. The (Token, Position) Pattern
The lexer uses a fundamental pattern where each tokenization function returns:
- Token: What was parsed (Number, Identifier, Operator, etc.)
- Position: Index after parsing (where next tokenize should start)
This enables sequential tokenization without global state:
let result1 = tokenize_one(input, 0); // Parse first token
let token1 = result1.0;
let pos1 = result1.1;
let result2 = tokenize_one(input, pos1); // Parse second token starting where first left off
let token2 = result2.0;
let pos2 = result2.1;
2. Multi-Character Operator Lookahead
For operators like == that start with =, we need lookahead:
if ch == "=" {
let next_ch = char_at(input, start + 1);
if next_ch == "=" {
(Token::Tok(TokenType::EqualEqual, "==".to_string()), start + 2)
} else {
(Token::Tok(TokenType::Equal, "=".to_string()), start + 1)
}
}
Without lookahead, == would tokenize as two separate Equal tokens instead of one EqualEqual token.
3. Comment Handling via Recursion
Line comments are handled by skipping to the newline, then recursively calling tokenize_one:
if next_ch == "/" {
// Skip until newline
let mut idx = start + 2;
loop {
let c = char_at(input, idx);
if c == "\0" || c == "\n" { break; }
idx = idx + 1;
}
tokenize_one(input, idx) // Get next token after comment
}
This elegantly handles comments without special state.
4. Bug Discovery Protocol Success
The Bug Discovery Protocol proved invaluable:
- STOP THE LINE: Prevented working around the bug with inferior code
- Detailed Bug Report: Helped Ruchy team understand and fix the issue quickly
- Minimal Reproduction: Made it easy to verify the fix
- No Workarounds: Ensured we use the correct pattern, not a hack
Result: Clean fix in v3.95.0, proper implementation achieved
REFACTOR: Improve Code Quality
With all tests passing, we can now refactor to improve code quality while maintaining the GREEN state.
Potential Refactorings
- Extract helper modules - Separate character classification, keyword matching, and tokenization
- Add more operators - Extend to full Ruchy operator set
- String literal support - Add tokenization for quoted strings
- Better error tokens - Track position and context for errors
- Performance optimization - Benchmark against >10K LOC/s target
Status: Ready for REFACTOR phase (optional improvement while maintaining 100% test pass rate)
Summary
BOOTSTRAP-003 GREEN Phase: ✅ COMPLETE
Test Results: 8/8 passing (100% success rate)
Implementation: 465 LOC lexer with:
- Number tokenization
- Identifier and keyword recognition
- Single and multi-character operators
- Whitespace skipping
- Line comment handling
- (Token, i32) return pattern for sequential parsing
Bug Discovered and Fixed:
- Loop + mut + tuple return failed in v3.94.0
- Bug Discovery Protocol applied successfully
- Fixed in Ruchy v3.95.0
- Implementation unblocked
Files:
bootstrap/stage0/test_lexer.ruchy(138 LOC - RED phase)bootstrap/stage0/lexer_minimal.ruchy(465 LOC - GREEN phase)bug_reproduction_loop_mut_tuple.ruchy(11 LOC - minimal repro)GITHUB_ISSUE_loop_mut_tuple_return.md(detailed bug report)
Next Steps:
- REFACTOR phase (optional quality improvements)
- BOOTSTRAP-004: Error Recovery Mechanisms
- BOOTSTRAP-005: Self-Tokenization Test