Look at the signatures we've been accumulating:
fn parseExpression(
source: [*]const u8,
pos: *usize,
names: *[256][]const u8,
values: *[256]i64,
count: *usize,
) i64 { ... }
Five parameters. Every parser function has them. Every recursive call passes them all. Every testCase ends with &varNames, &varValues, &varCount. It's silly.
And it's about to get worse. Functions need a function table, a return flag, a return value, and a stack to save/restore the caller's variables. That would add another seven or eight parameters.
So now we make a Context struct. One pointer replaces all of it. This is the first and only struct we're going to use in the whole language -- we put it off until the argument lists genuinely hurt. Now they do.
const Context = struct {
source: [*]const u8 = undefined,
pos: usize = 0,
varNames: [256][]const u8 = undefined,
varValues: [256]i64 = undefined,
varCount: usize = 0,
// more fields coming in the next few problems:
// function table, return flag, call-save stack.
};
Field access on a pointer auto-dereferences, so ctx.pos works even though ctx is *Context.
Rewrite every parser, executor, and variable function to take ctx: *Context as their only argument. The small text-only helpers (skipSpaces, readNumber, readIdentifier, isSpace, etc.) keep their existing (source, pos) signature -- they don't need the whole context. Call them as skipSpaces(ctx.source, &ctx.pos).
parseFactor:
fn parseFactor(ctx: *Context) i64 {
if (ctx.source[ctx.pos] == '(') {
ctx.pos += 1;
skipSpaces(ctx.source, &ctx.pos);
const val = parseExpression(ctx);
if (ctx.source[ctx.pos] == ')') {
ctx.pos += 1;
skipSpaces(ctx.source, &ctx.pos);
}
return val;
}
if (isAlpha(ctx.source[ctx.pos])) {
const name = readIdentifier(ctx.source, &ctx.pos);
return lookupVariable(ctx, name);
}
return readNumber(ctx.source, &ctx.pos);
}
The rest of the parser chain follows the same shape -- parseTerm(ctx), parseAddSub(ctx), parseExpression(ctx).
lookupVariable and assignVariable now take ctx:
fn lookupVariable(ctx: *Context, name: []const u8) i64 {
var i: usize = ctx.varCount;
while (i > 0) {
i -= 1;
if (stringsEqual(ctx.varNames[i], name)) {
return ctx.varValues[i];
}
}
return 0;
}
fn assignVariable(ctx: *Context, name: []const u8, val: i64) void {
var i: usize = 0;
while (i < ctx.varCount) {
if (stringsEqual(ctx.varNames[i], name)) {
ctx.varValues[i] = val;
return;
}
i += 1;
}
ctx.varNames[ctx.varCount] = name;
ctx.varValues[ctx.varCount] = val;
ctx.varCount += 1;
}
executeStatement, executeBlock, runProgram, skipBlock all take ctx. (skipBlock only uses ctx.source and ctx.pos.)
eval sets up the source and kicks off runProgram:
fn eval(input: []const u8, ctx: *Context) i64 {
ctx.source = input.ptr;
ctx.pos = 0;
skipSpaces(ctx.source, &ctx.pos);
return runProgram(ctx);
}
testCase and main:
fn testCase(input: []const u8, expected: i64, ctx: *Context) void {
const got = eval(input, ctx);
if (got == expected) {
print("{s} = {d} ok\n", .{ input, got });
} else {
print("{s} = {d} FAIL want {d}\n", .{ input, got, expected });
}
}
pub fn main() void {
var ctx = Context{};
testCase("var x: i64 = 42; x", 42, &ctx);
testCase("var x: i64 = 5; x + 1", 6, &ctx);
testCase("var x: i64 = 5; x = x + 1; x", 6, &ctx);
}
Notice var ctx = Context{}; -- the struct's defaults (undefined for the arrays, 0 for pos and varCount) take care of initialization. Every call site is now (ctx) or (input, ctx). One pointer. The struct will grow silently as we add function-related fields; call sites won't change.