tally/engine/src/parser.zig
2026-07-29 07:43:12 -07:00

717 lines
27 KiB
Zig

//! Pratt parser for Tally expressions.
//!
//! Uses top-down operator precedence (Pratt parsing) to convert a token
//! stream into an AST. Handles:
//! - Operator precedence and associativity
//! - Unary prefix operators (-, ~, not)
//! - Function calls: identifier(args...)
//! - Keyword operators: and, or, xor, rol, ror
//! - ^ and ** are always exponentiation (XOR is the `xor` keyword)
//! - Variable assignment: X = expr
const std = @import("std");
const Allocator = std.mem.Allocator;
const ast = @import("ast.zig");
const Expr = ast.Expr;
const BinaryOp = ast.BinaryOp;
const UnaryOp = ast.UnaryOp;
const tokenizer_mod = @import("tokenizer.zig");
const Tokenizer = tokenizer_mod.Tokenizer;
const TokenKind = tokenizer_mod.TokenKind;
const Token = tokenizer_mod.Token;
const parseNumber = tokenizer_mod.parseNumber;
const types = @import("types.zig");
const Mode = types.Mode;
const CalcError = types.CalcError;
/// Precedence levels (higher = tighter binding).
const Prec = enum(u8) {
none = 0,
assignment = 1, // =
bit_or = 2, // | or
bit_xor = 3, // xor
bit_and = 4, // & and
shift = 5, // << >> >>> rol ror
additive = 6, // + -
multiplicative = 7, // * / %
power = 8, // ^ ** (always exponentiation)
unary = 9, // - ~ not (prefix)
call = 10, // function calls
};
pub const Parser = struct {
source: []const u8,
tokenizer: Tokenizer,
current: Token,
previous: Token,
mode: Mode,
allocator: Allocator,
had_error: bool,
error_pos: ?usize,
pub fn init(allocator: Allocator, source: []const u8, mode: Mode) Parser {
var tok = Tokenizer.init(source, mode);
const first = tok.next();
return .{
.source = source,
.tokenizer = tok,
.current = first,
.previous = .{ .kind = .eof, .start = 0, .len = 0 },
.mode = mode,
.allocator = allocator,
.had_error = false,
.error_pos = null,
};
}
/// Parse a complete expression. Returns error if parsing fails.
///
/// On success the caller owns the tree and must release it with `freeExpr`.
/// On failure nothing is returned and nothing is left allocated: every error
/// path below frees what it built. Without that, a single typo in an
/// interactive session leaks the partial tree, which is exactly what the TUI
/// does on every keystroke-completed expression.
pub fn parse(self: *Parser) CalcError!*Expr {
const expr = try self.parseExpr(.none);
if (self.current.kind != .eof) {
// Trailing tokens: the tree parsed so far is unreachable.
freeExpr(self.allocator, expr);
self.had_error = true;
self.error_pos = self.current.start;
return CalcError.UnexpectedToken;
}
return expr;
}
/// Parse an expression with the given minimum precedence.
fn parseExpr(self: *Parser, min_prec: Prec) CalcError!*Expr {
var left = try self.parsePrefix();
// Each successful parseInfix returns a node that has adopted `left`, so
// this errdefer always covers the whole tree built so far.
errdefer freeExpr(self.allocator, left);
while (true) {
const prec = self.infixPrecedence(self.current.kind);
if (@intFromEnum(prec) <= @intFromEnum(min_prec)) break;
left = try self.parseInfix(left, prec);
}
return left;
}
/// Parse a prefix expression (number, identifier, unary op, parenthesized).
fn parsePrefix(self: *Parser) CalcError!*Expr {
const tok = self.current;
switch (tok.kind) {
.number => {
self.advance();
const text = tok.text(self.source);
const num = parseNumber(text) catch return CalcError.InvalidNumber;
return self.makeNode(.{ .number = .{
.float_value = num.float,
.int_value = num.int_value,
.base = num.base,
.text = text,
} });
},
.string_literal => {
self.advance();
const text = tok.text(self.source);
// Strip quotes: 'abc' -> abc
if (text.len < 2) return CalcError.InvalidNumber;
const content = text[1 .. text.len - 1];
return self.makeNode(.{ .string_literal = content });
},
.identifier => {
self.advance();
const name = tok.text(self.source);
// "not" prefix keyword = bitwise NOT
if (std.mem.eql(u8, name, "not")) {
const operand = try self.parseExpr(.unary);
errdefer freeExpr(self.allocator, operand);
return self.makeNode(.{ .unary = .{
.op = .bitwise_not,
.operand = operand,
} });
}
// Check for assignment: identifier = expr
if (self.current.kind == .equals) {
self.advance();
const value = try self.parseExpr(.none);
errdefer freeExpr(self.allocator, value);
return self.makeNode(.{ .assignment = .{
.name = name,
.value = value,
} });
}
// Check for function call: identifier(args)
if (self.current.kind == .left_paren) {
self.advance(); // consume (
var args = std.ArrayList(*Expr).empty;
defer args.deinit(self.allocator);
// Arguments parsed before the failure still own their trees.
errdefer for (args.items) |arg| freeExpr(self.allocator, arg);
if (self.current.kind != .right_paren) {
const first_arg = try self.parseExpr(.none);
args.append(self.allocator, first_arg) catch {
freeExpr(self.allocator, first_arg);
return CalcError.OutOfMemory;
};
while (self.current.kind == .comma) {
self.advance(); // consume ,
const arg = try self.parseExpr(.none);
args.append(self.allocator, arg) catch {
freeExpr(self.allocator, arg);
return CalcError.OutOfMemory;
};
}
}
if (self.current.kind != .right_paren) {
self.had_error = true;
self.error_pos = self.current.start;
return CalcError.UnmatchedParen;
}
self.advance(); // consume )
const args_slice = self.allocator.dupe(*Expr, args.items) catch
return CalcError.OutOfMemory;
errdefer self.allocator.free(args_slice);
return self.makeNode(.{ .call = .{
.name = name,
.args = args_slice,
} });
}
// Check for keyword operators (rol, ror) - these are identifiers
// that act as infix operators, handled in parseInfix via infixPrecedence
// Only reach here if it's a plain variable reference.
return self.makeNode(.{ .variable = name });
},
.left_paren => {
self.advance(); // consume (
const inner = try self.parseExpr(.none);
if (self.current.kind != .right_paren) {
freeExpr(self.allocator, inner);
self.had_error = true;
self.error_pos = self.current.start;
return CalcError.UnmatchedParen;
}
self.advance(); // consume )
return inner;
},
.minus => {
self.advance();
const operand = try self.parseExpr(.unary);
errdefer freeExpr(self.allocator, operand);
return self.makeNode(.{ .unary = .{
.op = .negate,
.operand = operand,
} });
},
.tilde => {
self.advance();
const operand = try self.parseExpr(.unary);
errdefer freeExpr(self.allocator, operand);
return self.makeNode(.{ .unary = .{
.op = .bitwise_not,
.operand = operand,
} });
},
.eof => {
self.had_error = true;
self.error_pos = tok.start;
return CalcError.UnexpectedEnd;
},
else => {
self.had_error = true;
self.error_pos = tok.start;
return CalcError.UnexpectedToken;
},
}
}
/// Parse an infix expression given the left-hand side and precedence.
fn parseInfix(self: *Parser, left: *Expr, prec: Prec) CalcError!*Expr {
const tok = self.current;
// Handle keyword operators (rol, ror, and, or, xor)
if (tok.kind == .identifier) {
const name = tok.text(self.source);
if (keywordBinaryOp(name)) |op| {
self.advance();
const right = try self.parseExpr(prec);
// `left` belongs to the caller's errdefer until makeNode adopts
// it, so only the right operand is released here.
errdefer freeExpr(self.allocator, right);
return self.makeNode(.{ .binary = .{
.op = op,
.left = left,
.right = right,
} });
}
}
self.advance();
const op = self.tokenToBinaryOp(tok.kind) orelse {
self.had_error = true;
self.error_pos = tok.start;
return CalcError.UnexpectedToken;
};
// Right-associative for power
const next_prec: Prec = if (op == .pow)
@enumFromInt(@intFromEnum(prec) - 1)
else
prec;
const right = try self.parseExpr(next_prec);
errdefer freeExpr(self.allocator, right);
return self.makeNode(.{ .binary = .{
.op = op,
.left = left,
.right = right,
} });
}
/// Map a keyword identifier to a binary operator, if it is one.
fn keywordBinaryOp(name: []const u8) ?BinaryOp {
if (std.mem.eql(u8, name, "rol")) return .rotate_left;
if (std.mem.eql(u8, name, "ror")) return .rotate_right;
if (std.mem.eql(u8, name, "and")) return .bit_and;
if (std.mem.eql(u8, name, "or")) return .bit_or;
if (std.mem.eql(u8, name, "xor")) return .bit_xor;
return null;
}
/// Precedence of a keyword infix operator, if the name is one.
fn keywordPrec(name: []const u8) ?Prec {
if (std.mem.eql(u8, name, "rol") or std.mem.eql(u8, name, "ror")) return .shift;
if (std.mem.eql(u8, name, "and")) return .bit_and;
if (std.mem.eql(u8, name, "or")) return .bit_or;
if (std.mem.eql(u8, name, "xor")) return .bit_xor;
return null;
}
/// Get the infix precedence of a token kind.
fn infixPrecedence(self: *Parser, kind: TokenKind) Prec {
return switch (kind) {
.pipe => .bit_or,
.caret => .power, // always exponentiation
.ampersand => .bit_and,
.shift_left, .shift_right, .shift_right_logical => .shift,
.plus, .minus => .additive,
.star, .slash, .percent => .multiplicative,
.star_star => .power,
.identifier => keywordPrec(self.current.text(self.source)) orelse .none,
else => .none,
};
}
/// Map a token kind to a binary operator.
fn tokenToBinaryOp(self: *Parser, kind: TokenKind) ?BinaryOp {
_ = self;
return switch (kind) {
.plus => .add,
.minus => .sub,
.star => .mul,
.slash => .div,
.percent => .mod,
.caret => .pow, // always exponentiation
.star_star => .pow,
.ampersand => .bit_and,
.pipe => .bit_or,
.shift_left => .shift_left,
.shift_right => .shift_right,
.shift_right_logical => .shift_right_logical,
else => null,
};
}
fn advance(self: *Parser) void {
self.previous = self.current;
self.current = self.tokenizer.next();
}
fn makeNode(self: *Parser, expr: Expr) CalcError!*Expr {
const node = self.allocator.create(Expr) catch return CalcError.OutOfMemory;
node.* = expr;
return node;
}
};
// -- Tests --
const testing = std.testing;
// Error-path tests used to need an arena because a failed parse leaked its
// partial tree. They no longer do (the parser cleans up after itself), but the
// arena helper is kept for the tests already written against it.
var test_arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator);
fn testParse(source: []const u8, mode: Mode) !*Expr {
var parser = Parser.init(testing.allocator, source, mode);
return parser.parse();
}
fn testParseArena(source: []const u8, mode: Mode) CalcError!*Expr {
const alloc = test_arena_instance.allocator();
var p = Parser.init(alloc, source, mode);
return p.parse();
}
/// Release a parsed tree.
///
/// The parser hands ownership of the tree to the caller, so every successful
/// `parse` needs a matching `freeExpr`. Error paths inside the parser clean up
/// after themselves, so a failed parse leaves nothing to free.
pub fn freeExpr(allocator: Allocator, expr: *Expr) void {
switch (expr.*) {
.number => {},
.string_literal => {},
.variable => {},
.unary => |u| freeExpr(allocator, u.operand),
.binary => |b| {
freeExpr(allocator, b.left);
freeExpr(allocator, b.right);
},
.call => |c| {
for (c.args) |arg| freeExpr(allocator, arg);
allocator.free(c.args);
},
.assignment => |a| freeExpr(allocator, a.value),
}
allocator.destroy(expr);
}
test "parse simple number" {
const expr = try testParse("42", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(@as(f64, 42.0), expr.number.float_value);
try testing.expectEqual(@as(?u64, 42), expr.number.int_value);
}
test "parse hex number" {
const expr = try testParse("0xFF", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(@as(?u64, 255), expr.number.int_value);
}
test "parse addition" {
const expr = try testParse("2 + 3", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value);
try testing.expectEqual(@as(f64, 3.0), expr.binary.right.number.float_value);
}
test "parse precedence: mul before add" {
// 2 + 3 * 4 should parse as 2 + (3 * 4)
const expr = try testParse("2 + 3 * 4", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value);
try testing.expectEqual(BinaryOp.mul, expr.binary.right.binary.op);
}
test "parse precedence: power right-associative" {
// 2^3^4 should parse as 2^(3^4)
const expr = try testParse("2^3^4", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op);
try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value);
try testing.expectEqual(BinaryOp.pow, expr.binary.right.binary.op);
}
test "parse unary negation" {
const expr = try testParse("-5", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(UnaryOp.negate, expr.unary.op);
try testing.expectEqual(@as(f64, 5.0), expr.unary.operand.number.float_value);
}
test "parse negation in expression" {
// -2 + 3 should be (-2) + 3
const expr = try testParse("-2 + 3", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqual(UnaryOp.negate, expr.binary.left.unary.op);
}
test "parse parentheses" {
// (2 + 3) * 4
const expr = try testParse("(2 + 3) * 4", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.mul, expr.binary.op);
try testing.expectEqual(BinaryOp.add, expr.binary.left.binary.op);
}
test "parse function call" {
const expr = try testParse("sin(3.14)", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("sin", expr.call.name);
try testing.expectEqual(@as(usize, 1), expr.call.args.len);
try testing.expectApproxEqAbs(@as(f64, 3.14), expr.call.args[0].number.float_value, 1e-10);
}
test "parse multi-arg function call" {
const expr = try testParse("max(1, 2, 3)", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("max", expr.call.name);
try testing.expectEqual(@as(usize, 3), expr.call.args.len);
}
test "parse variable" {
const expr = try testParse("pi", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("pi", expr.variable);
}
test "parse assignment" {
const expr = try testParse("X = 42", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("X", expr.assignment.name);
try testing.expectEqual(@as(f64, 42.0), expr.assignment.value.number.float_value);
}
test "parse adjacent number and identifier is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("2pi", .standard);
try testing.expectError(CalcError.UnexpectedToken, result);
}
test "parse adjacent number and paren is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("3(4+5)", .standard);
try testing.expectError(CalcError.UnexpectedToken, result);
}
test "parse adjacent paren paren is an error (no implicit mul)" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("(2)(3)", .standard);
try testing.expectError(CalcError.UnexpectedToken, result);
}
test "parse caret is power in programmer mode (not XOR)" {
const expr = try testParse("0xF ^ 0x3", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op);
}
test "parse xor keyword is XOR" {
const expr = try testParse("0xF xor 0x3", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.bit_xor, expr.binary.op);
}
test "parse and keyword" {
const expr = try testParse("0xF and 0x3", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.bit_and, expr.binary.op);
}
test "parse or keyword" {
const expr = try testParse("0xF or 0x3", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.bit_or, expr.binary.op);
}
test "parse not prefix keyword" {
const expr = try testParse("not 0xFF", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(UnaryOp.bitwise_not, expr.unary.op);
}
test "parse caret as power in standard mode" {
const expr = try testParse("2 ^ 10", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op);
}
test "parse ** as power in programmer mode" {
const expr = try testParse("2 ** 10", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.pow, expr.binary.op);
}
test "parse bitwise operators" {
const expr = try testParse("0xF & 0x3 | 0x1", .programmer);
defer freeExpr(testing.allocator, expr);
// | has lowest precedence of these, so: (0xF & 0x3) | 0x1
try testing.expectEqual(BinaryOp.bit_or, expr.binary.op);
try testing.expectEqual(BinaryOp.bit_and, expr.binary.left.binary.op);
}
test "parse shift operators" {
const expr = try testParse("1 << 4", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.shift_left, expr.binary.op);
}
test "parse bitwise not" {
const expr = try testParse("~0xFF", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(UnaryOp.bitwise_not, expr.unary.op);
}
test "parse error: unmatched paren" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("(2 + 3", .standard);
try testing.expectError(CalcError.UnmatchedParen, result);
}
test "parse error: unexpected token" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("+ +", .standard);
// + at start is not a valid prefix
try testing.expectError(CalcError.UnexpectedToken, result);
}
test "parse error: empty expression" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("", .standard);
try testing.expectError(CalcError.UnexpectedEnd, result);
}
test "parse complex expression" {
// sin(2*pi) + 1
const expr = try testParse("sin(2*pi) + 1", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.add, expr.binary.op);
try testing.expectEqualStrings("sin", expr.binary.left.call.name);
}
test "parse nested function calls" {
const expr = try testParse("max(sin(1), cos(2))", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqualStrings("max", expr.call.name);
try testing.expectEqual(@as(usize, 2), expr.call.args.len);
try testing.expectEqualStrings("sin", expr.call.args[0].call.name);
try testing.expectEqualStrings("cos", expr.call.args[1].call.name);
}
test "parse error: unmatched paren in function call args" {
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("max(1, 2", .standard);
try testing.expectError(CalcError.UnmatchedParen, result);
}
test "parse error: identifier in infix position (not a keyword op)" {
// "5 foo": 5 parses as prefix, then foo is an identifier that is not
// a keyword operator, so it has .none precedence. The loop stops and
// parse() reports the leftover token as unexpected.
defer _ = test_arena_instance.reset(.retain_capacity);
const result = testParseArena("5 foo", .standard);
try testing.expectError(CalcError.UnexpectedToken, result);
}
// -- Ownership on the error paths --
//
// These run on testing.allocator rather than an arena, so a partial tree left
// behind by a failed parse fails the test. Before the parser cleaned up after
// itself, every one of these inputs leaked, which mattered in the TUI: a typo
// at the prompt leaked the tree parsed up to that point.
test "a failed parse leaves nothing allocated" {
const bad = [_][]const u8{
"2 +", // missing right operand
"1 2", // trailing token after a complete expression
"(1 + 2", // unmatched paren
"(1 + (2 * 3)", // unmatched outer paren, nested tree built
"max(1, 2", // unterminated argument list
"max(1, 2,", // trailing comma then end
"sin(", // call with nothing in it
"-", // unary with no operand
"~", // bitwise not with no operand
"not", // keyword not with no operand
"x =", // assignment with no value
"y = 1 +", // assignment whose value fails to parse
"*", // operator in prefix position
"", // empty input
"1 + 2 3", // trailing token after a binary expression
"-(1 + ", // nested failure under a unary
};
for (bad) |source| {
var parser = Parser.init(testing.allocator, source, .standard);
if (parser.parse()) |expr| {
freeExpr(testing.allocator, expr);
std.debug.print("expected a parse error for \"{s}\"\n", .{source});
return error.TestUnexpectedResult;
} else |_| {}
}
}
test "a failed parse in programmer mode also leaves nothing allocated" {
const bad = [_][]const u8{ "0xFF and", "1 rol", "not", "0b1010 xor (1", "1 << " };
for (bad) |source| {
var parser = Parser.init(testing.allocator, source, .programmer);
if (parser.parse()) |expr| {
freeExpr(testing.allocator, expr);
std.debug.print("expected a parse error for \"{s}\"\n", .{source});
return error.TestUnexpectedResult;
} else |_| {}
}
}
test "a successful parse hands over exactly one tree to free" {
// The mirror of the above: freeing once must be enough and must not
// double-free any shared node.
const good = [_][]const u8{
"1 + 2 * 3",
"-(4 + 5)",
"max(1, min(2, 3))",
"x = 2 ^ 3 ^ 4",
"not 0xFF",
"sqrt(2) + factorial(5)",
"cagr(10000, 25000, 5)",
"tvm_pmt(360, 0.5, 200000, 0)",
};
for (good) |source| {
var parser = Parser.init(testing.allocator, source, .standard);
const expr = try parser.parse();
freeExpr(testing.allocator, expr);
}
}
test "an allocation failure mid-parse frees whatever was built" {
// The cleanup added for the error paths above is mostly reachable only when
// an allocation fails partway through, so it is swept rather than assumed.
// The wrapped testing.allocator reports any node the parser abandons.
const sources = [_][]const u8{
"1 + 2 * 3",
"-(1 + 2)",
"~5",
"not 7",
"max(1, 2, 3)",
"min(max(1, 2), 3)",
"x = 1 + 2",
"1 rol 2",
"(((1)))",
"cagr(10000, 25000, 5)",
};
for (sources) |source| {
var fail_index: usize = 0;
while (fail_index < 64) : (fail_index += 1) {
var failing = std.testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index });
const allocator = failing.allocator();
var parser = Parser.init(allocator, source, .standard);
if (parser.parse()) |expr| {
// Past the last allocation this input makes, so nothing is left
// to fail; the tree itself must still be well formed.
freeExpr(allocator, expr);
break;
} else |err| {
try testing.expectEqual(CalcError.OutOfMemory, err);
}
} else {
return error.AllocationSweepNeverCompleted;
}
}
}