initial commit: ai slop, not reviewed

This commit is contained in:
Emil Lerch 2026-07-17 15:07:34 -07:00
parent 8253766e0f
commit 58a94dd163
Signed by: lobo
GPG key ID: A7B62D657EF764F8
18 changed files with 3773 additions and 0 deletions

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Zig build artifacts
.zig-cache/
zig-out/
# Temporary files
.tmp/
# Editor files
*.swp
*~
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
coverage/

7
.mise.toml Normal file
View file

@ -0,0 +1,7 @@
[tools]
zig = "0.16.0"
zls = "0.16.0"
"github:j178/prek" = "0.4.1"
[tools."github:DonIsaac/zlint"]
version = "0.9.0"

51
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,51 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: local
hooks:
- id: forbid-ai-punctuation
name: Forbid smart punctuation (en/figure dash, minus, ellipsis, arrows, smart quotes)
language: pygrep
entry: '(–|‒|―|−|…|→|⇐|⇒|⇔|“|”|‘|’)'
files: '\.(zig|zon|md|srf|txt|toml|ya?ml)$'
exclude: '^\.pre-commit-config\.yaml$'
- id: forbid-prose-em-dash
name: Forbid prose em-dash (use ASCII hyphen); no-data sentinel glyphs are exempt
language: pygrep
entry: ' — '
files: '\.(zig|zon|md|srf|txt|toml|ya?ml)$'
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
- id: zig-fmt
- repo: local
hooks:
- id: zlint
name: Run zlint
# zlint accepts file paths only via stdin (-S); positional
# args are interpreted as directory names and silently
# produce no output. Pipe pre-commit's file list through
# bash to get the paths to zlint as stdin lines.
entry: bash -c 'printf "%s\n" "$@" | zlint --deny-warnings --fix -S' --
language: system
types: [zig]
- repo: https://github.com/batmac/pre-commit-zig
rev: v0.3.0
hooks:
- id: zig-build
- repo: local
hooks:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=80"]
language: system
types: [file]
pass_filenames: false

97
build.zig Normal file
View file

@ -0,0 +1,97 @@
const std = @import("std");
const Coverage = @import("build/Coverage.zig");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// -- Engine module (the calculation library) --
const engine_mod = b.addModule("engine", .{
.root_source_file = b.path("engine/src/engine.zig"),
.target = target,
.optimize = optimize,
});
// -- Engine static library (for linking into the main binary) --
const engine_lib = b.addLibrary(.{
.linkage = .static,
.name = "tally-engine",
.root_module = engine_mod,
});
b.installArtifact(engine_lib);
// -- Engine shared library (for Android JNI) --
const engine_shared = b.addLibrary(.{
.linkage = .dynamic,
.name = "tally",
.root_module = b.createModule(.{
.root_source_file = b.path("engine/src/c_api.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "engine", .module = engine_mod },
},
}),
});
b.installArtifact(engine_shared);
// -- Main binary (CLI + TUI in one) --
const exe = b.addExecutable(.{
.name = "tally",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "engine", .module = engine_mod },
},
}),
});
b.installArtifact(exe);
// -- Tests --
const engine_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("engine/src/engine.zig"),
.target = target,
.optimize = optimize,
}),
});
const cli_tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "engine", .module = engine_mod },
},
}),
});
const run_engine_tests = b.addRunArtifact(engine_tests);
const run_cli_tests = b.addRunArtifact(cli_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_engine_tests.step);
test_step.dependOn(&run_cli_tests.step);
// -- Coverage step (uses kcov, Linux x86_64/aarch64 only) --
{
var cov = Coverage.init(b);
const cov_mod = b.createModule(.{
.root_source_file = b.path("engine/src/engine.zig"),
.target = target,
.optimize = optimize,
});
_ = cov.addModule(cov_mod, "tally-engine");
}
// -- Run step --
const run_step = b.step("run", "Run tally");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
}

14
build.zig.zon Normal file
View file

@ -0,0 +1,14 @@
.{
.name = .tally,
.version = "0.1.0",
.minimum_zig_version = "0.16.0",
.fingerprint = 0xe8b6fe03a23dd13b,
.paths = .{
"build.zig",
"build.zig.zon",
"build",
"engine",
"src",
},
.dependencies = .{},
}

215
build/Coverage.zig Normal file
View file

@ -0,0 +1,215 @@
const builtin = @import("builtin");
const std = @import("std");
const Build = std.Build;
const Coverage = @This();
/// Initialize coverage infrastructure. Creates the "coverage" build step,
/// registers build options (-Dcoverage-threshold, -Dcoverage-dir),
/// and sets up the kcov download step. The kcov binary is downloaded into the
/// zig cache on first use and reused thereafter.
///
/// Use `zig build coverage --verbose` to see per-file coverage breakdown.
///
/// Call `addModule()` on the returned value to add the test module to the
/// coverage run.
///
/// Because addModule creates a new test executable from the root module provided,
/// if there are any linking steps being done to your test executable, those
/// must also be done to the test_exe returned by addModule.
pub fn init(b: *Build) Coverage {
const coverage_threshold = b.option(u7, "coverage-threshold", "Minimum coverage percentage required") orelse 0;
const coverage_dir = b.option([]const u8, "coverage-dir", "Coverage output directory") orelse
b.pathJoin(&.{ b.build_root.path orelse ".", "coverage" });
const coverage_step = b.step("coverage", "Generate test coverage report");
const arch_name = switch (builtin.cpu.arch) {
.x86_64 => "x86_64",
.aarch64 => "aarch64",
else => @tagName(builtin.cpu.arch),
};
const Algo = std.crypto.hash.sha2.Sha256;
var hasher = Algo.init(.{});
hasher.update("kcov-");
hasher.update(arch_name);
var cache_hash: [Algo.digest_length]u8 = undefined;
hasher.final(&cache_hash);
const cache_dir = b.pathJoin(&.{
b.cache_root.path.?,
"o",
b.fmt("{s}", .{std.fmt.bytesToHex(cache_hash, .lower)}),
});
const kcov_path = b.pathJoin(&.{ cache_dir, b.fmt("kcov-{s}", .{arch_name}) });
const download_exe = b.addExecutable(.{
.name = "download-kcov",
.root_module = b.createModule(.{
.root_source_file = b.path("build/download_kcov.zig"),
.target = b.resolveTargetQuery(.{}),
}),
});
const run_download = b.addRunArtifact(download_exe);
run_download.addArg(kcov_path);
run_download.addArg(arch_name);
return .{
.b = b,
.coverage_step = coverage_step,
.coverage_dir = coverage_dir,
.coverage_threshold = coverage_threshold,
.kcov_path = kcov_path,
.run_download = run_download,
};
}
/// Add a test module to the coverage run. Runs kcov on the test binary,
/// then reads the coverage JSON and prints a summary (with per-file
/// breakdown if --verbose). Fails if below -Dcoverage-threshold.
///
/// Returns the test executable so the caller can add any extra linking steps.
pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile {
const b = self.b;
const run_coverage = b.addSystemCommand(&.{self.kcov_path});
const include_path = b.pathJoin(&.{ b.build_root.path.?, "engine", "src" });
run_coverage.addArgs(&.{ "--include-path", include_path });
const css_file = b.pathJoin(&.{ b.build_root.path.?, "build", "bcov.css" });
run_coverage.addArg(b.fmt("--configure=css-file={s}", .{css_file}));
run_coverage.addArg(self.coverage_dir);
const test_exe = b.addTest(.{
.name = name,
.root_module = root_module,
.use_llvm = true,
});
run_coverage.addArtifactArg(test_exe);
run_coverage.step.dependOn(&test_exe.step);
run_coverage.step.dependOn(&self.run_download.step);
const check = b.allocator.create(Check) catch @panic("OOM");
check.* = .{
.step = Build.Step.init(.{
.id = .custom,
.name = "check coverage",
.owner = b,
.makeFn = make,
}),
.json_path = b.fmt("{s}/{s}/coverage.json", .{ self.coverage_dir, name }),
.threshold = self.coverage_threshold,
};
check.step.dependOn(&run_coverage.step);
self.coverage_step.dependOn(&check.step);
return test_exe;
}
// -- Fields --
b: *Build,
coverage_step: *Build.Step,
coverage_dir: []const u8,
coverage_threshold: u7,
kcov_path: []const u8,
run_download: *Build.Step.Run,
const Check = struct {
step: Build.Step,
json_path: []const u8,
threshold: u7,
};
const CoverageReport = struct {
files: []const CoverageFile,
};
const CoverageFile = struct {
file: []const u8,
covered_lines: usize,
total_lines: usize,
};
const File = struct {
file: []const u8,
percent_covered: f64,
covered_lines: usize,
total_lines: usize,
pub fn coverageLessThanDesc(_: void, lhs: File, rhs: File) bool {
return lhs.percent_covered > rhs.percent_covered;
}
};
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
_ = options;
const check: *Check = @fieldParentPtr("step", step);
const allocator = step.owner.allocator;
const io = step.owner.graph.io;
const file = std.Io.Dir.cwd().openFile(io, check.json_path, .{}) catch |err| {
return step.fail("Failed to open coverage report {s}: {}", .{ check.json_path, err });
};
defer file.close(io);
var file_reader = file.reader(io, &.{});
const content = try file_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024));
defer allocator.free(content);
const json = std.json.parseFromSlice(CoverageReport, allocator, content, .{
.ignore_unknown_fields = true,
}) catch |err| {
return step.fail("Failed to parse coverage JSON: {}", .{err});
};
defer json.deinit();
var total_covered: usize = 0;
var total_lines: usize = 0;
var file_list = std.ArrayList(File).empty;
defer file_list.deinit(allocator);
for (json.value.files) |f| {
const pct: f64 = if (f.total_lines > 0)
@as(f64, @floatFromInt(f.covered_lines)) / @as(f64, @floatFromInt(f.total_lines)) * 100.0
else
0;
try file_list.append(allocator, .{
.file = f.file,
.covered_lines = f.covered_lines,
.total_lines = f.total_lines,
.percent_covered = pct,
});
total_covered += f.covered_lines;
total_lines += f.total_lines;
}
std.mem.sort(File, file_list.items, {}, File.coverageLessThanDesc);
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_writer.interface;
if (step.owner.verbose) {
for (file_list.items) |f| {
try stdout.print(
"{d: >5.1}% {d: >5}/{d: <5}:{s}\n",
.{ f.percent_covered, f.covered_lines, f.total_lines, f.file },
);
}
}
const total_pct: f64 = if (total_lines > 0)
@as(f64, @floatFromInt(total_covered)) / @as(f64, @floatFromInt(total_lines)) * 100.0
else
0;
try stdout.print(
"Total test coverage: {d:.2}% ({d}/{d})\n",
.{ total_pct, total_covered, total_lines },
);
try stdout.flush();
if (@as(u7, @intFromFloat(@floor(total_pct))) < check.threshold)
return step.fail("Coverage {d:.2}% is below threshold {d}%", .{ total_pct, check.threshold });
}

45
build/bcov.css Normal file
View file

@ -0,0 +1,45 @@
/* Based upon the lcov CSS style, style files can be reused - Dark Theme */
body { color: #e0e0e0; background-color: #1e1e1e; }
a:link { color: #6b9aff; text-decoration: underline; }
a:visited { color: #4dbb7a; text-decoration: underline; }
a:active { color: #ff6b8a; text-decoration: underline; }
td.title { text-align: center; padding-bottom: 10px; font-size: 20pt; font-weight: bold; }
td.ruler { background-color: #4a6ba8; }
td.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; }
td.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; }
td.versionInfo { text-align: center; padding-top: 2px; }
th.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; }
th.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; }
pre.source { font-family: monospace; white-space: pre; overflow: hidden; text-overflow: ellipsis; }
span.lineNum { background-color: #5a5a2a; }
span.lineNumLegend { background-color: #5a5a2a; width: 96px; font-weight: bold ;}
span.lineCov { background-color: #2d5a2d; }
span.linePartCov { background-color: #707000; }
span.lineNoCov { background-color: #762c2c; }
span.orderNum { background-color: #5a4a2a; float: right; width:5em; text-align: left; }
span.orderNumLegend { background-color: #5a4a2a; width: 96px; font-weight: bold ;}
span.coverHits { background-color: #4a4a2a; padding-left: 3px; padding-right: 1px; text-align: right; list-style-type: none; display: inline-block; width: 5em; }
span.coverHitsLegend { background-color: #4a4a2a; width: 96px; font-weight: bold; margin: 0 auto;}
td.tableHead { text-align: center; color: #e0e0e0; background-color: #4a6ba8; font-family: sans-serif; font-size: 120%; font-weight: bold; }
td.coverFile { text-align: left; padding-left: 10px; padding-right: 20px; color: #6b9aff; font-family: monospace; background-color: #3a3a3a; }
td.coverBar { padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; }
td.coverBarOutline { background-color: #4a4a4a; }
td.coverPer { text-align: left; padding-left: 10px; padding-right: 10px; font-weight: bold; background-color: #3a3a3a; color: #e0e0e0; }
td.coverPerLeftMed { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a5a00; font-weight: bold; color: #e0e0e0; }
td.coverPerLeftLo { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a2d2d; font-weight: bold; color: #e0e0e0; }
td.coverPerLeftHi { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #2d5a2d; font-weight: bold; color: #e0e0e0; }
td.coverNum { text-align: right; padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; color: #e0e0e0; }
.tablesorter-blue tbody > tr:hover > td,
.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr.even:hover > td,
.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td {
background: #4a4a4a;
}
.tablesorter-blue tbody > tr.odd:hover > td,
.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td {
background: #4a4a4a;
}

80
build/download_kcov.zig Normal file
View file

@ -0,0 +1,80 @@
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const allocator = init.arena.allocator();
const io = init.io;
const args = try init.minimal.args.toSlice(allocator);
if (args.len != 3) return error.InvalidArgs;
const kcov_path = args[1];
const arch_name = args[2];
// Check if file already exists
const stat = std.Io.Dir.cwd().statFile(io, kcov_path, .{}) catch |err| blk: {
if (err == error.FileNotFound) break :blk null else return err;
};
if (stat != null) return;
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_writer.interface;
try stdout.writeAll("Determining latest kcov version\n");
try stdout.flush();
var client = std.http.Client{ .allocator = allocator, .io = io };
defer client.deinit();
// Get redirect to find latest version
const list_uri = try std.Uri.parse("https://git.lerch.org/lobo/-/packages/generic/kcov/");
var req = try client.request(.GET, list_uri, .{ .redirect_behavior = .unhandled });
defer req.deinit();
try req.sendBodiless();
var redirect_buf: [1024]u8 = undefined;
const response = try req.receiveHead(&redirect_buf);
if (response.head.status != .see_other) return error.UnexpectedResponse;
const location = response.head.location orelse return error.NoLocation;
const version_start = std.mem.lastIndexOfScalar(u8, location, '/') orelse return error.InvalidLocation;
const version = location[version_start + 1 ..];
try stdout.print(
"Downloading kcov version {s} for {s} to {s}...",
.{ version, arch_name, kcov_path },
);
try stdout.flush();
const binary_url = try std.fmt.allocPrint(
allocator,
"https://git.lerch.org/api/packages/lobo/generic/kcov/{s}/kcov-{s}",
.{ version, arch_name },
);
const cache_dir = std.fs.path.dirname(kcov_path) orelse return error.InvalidPath;
std.Io.Dir.cwd().createDir(io, cache_dir, std.Io.File.Permissions.default_dir) catch |e| switch (e) {
error.PathAlreadyExists => {},
else => return e,
};
const uri = try std.Uri.parse(binary_url);
const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{});
defer file.close(io);
try file.setPermissions(io, @enumFromInt(0o755));
var buffer: [8192]u8 = undefined;
var writer = file.writer(io, &buffer);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.response_writer = &writer.interface,
});
if (result.status != .ok) return error.DownloadFailed;
try writer.interface.flush();
try stdout.writeAll("done\n");
try stdout.flush();
}

69
engine/src/ast.zig Normal file
View file

@ -0,0 +1,69 @@
//! AST node definitions for Tally expressions.
//!
//! The AST is arena-allocated: all nodes live in a single arena and are freed
//! together when the expression is no longer needed. No per-node deallocation.
const types = @import("types.zig");
const Base = types.Base;
/// A single expression node.
pub const Expr = union(enum) {
number: Number,
unary: Unary,
binary: Binary,
call: Call,
variable: []const u8,
assignment: Assignment,
pub const Number = struct {
float_value: f64,
/// If the number is a pure integer, stores the exact value.
int_value: ?u64,
base: Base,
};
pub const Unary = struct {
op: UnaryOp,
operand: *Expr,
};
pub const Binary = struct {
op: BinaryOp,
left: *Expr,
right: *Expr,
};
pub const Call = struct {
name: []const u8,
args: []const *Expr,
};
pub const Assignment = struct {
name: []const u8,
value: *Expr,
};
};
pub const UnaryOp = enum {
negate, // -x
bitwise_not, // ~x
};
pub const BinaryOp = enum {
add,
sub,
mul,
div,
mod,
pow, // ^ in standard, ** in programmer
// Bitwise (programmer mode)
bit_and,
bit_or,
bit_xor,
shift_left,
shift_right, // arithmetic >>
shift_right_logical, // >>>
rotate_left, // rol keyword
rotate_right, // ror keyword
};

38
engine/src/c_api.zig Normal file
View file

@ -0,0 +1,38 @@
//! C ABI exports for Tally engine.
//!
//! All string inputs use (pointer, length) pairs - no null-terminated strings.
//! Callers must free results via tally_result_free().
pub const CalcResult = extern struct {
json_ptr: ?[*]u8,
json_len: usize,
error_ptr: ?[*]u8,
error_len: usize,
};
export fn tally_eval(
expr_ptr: [*]const u8,
expr_len: usize,
mode: c_int,
config_ptr: ?[*]const u8,
config_len: usize,
) callconv(.c) CalcResult {
_ = expr_ptr;
_ = expr_len;
_ = mode;
_ = config_ptr;
_ = config_len;
// TODO: implement
return .{ .json_ptr = null, .json_len = 0, .error_ptr = null, .error_len = 0 };
}
export fn tally_result_free(result: *CalcResult) callconv(.c) void {
_ = result;
// TODO: implement
}
export fn tally_version(out_len: *usize) callconv(.c) [*]const u8 {
const version = "0.1.0";
out_len.* = version.len;
return version.ptr;
}

30
engine/src/engine.zig Normal file
View file

@ -0,0 +1,30 @@
//! Tally calculation engine.
//!
//! Pure computation library with no I/O. Provides expression parsing,
//! evaluation, programmer-mode bit manipulation, struct layout computation,
//! unit conversion, and financial calculations.
pub const types = @import("types.zig");
pub const tokenizer = @import("tokenizer.zig");
pub const ast = @import("ast.zig");
pub const parser = @import("parser.zig");
pub const evaluator = @import("evaluator.zig");
pub const programmer = @import("programmer.zig");
pub const formatter = @import("formatter.zig");
// Re-export primary types for convenience
pub const Value = types.Value;
pub const Mode = types.Mode;
pub const BitWidth = types.BitWidth;
pub const CalcError = types.CalcError;
pub const Parser = parser.Parser;
pub const Expr = ast.Expr;
pub const Environment = evaluator.Environment;
pub const evalString = evaluator.evalString;
pub const evalProgrammerString = programmer.evalProgrammerString;
test {
std.testing.refAllDecls(@This());
}
const std = @import("std");

530
engine/src/evaluator.zig Normal file
View file

@ -0,0 +1,530 @@
//! AST evaluator for Tally.
//!
//! Walks an AST and produces a Value. In standard mode, all computations
//! use f64 floating-point arithmetic. In programmer mode, integer operations
//! are exact (masked to bit width). The evaluator uses an Environment for
//! variable storage, history, and configuration.
const std = @import("std");
const math = std.math;
const Allocator = std.mem.Allocator;
const ast = @import("ast.zig");
const Expr = ast.Expr;
const BinaryOp = ast.BinaryOp;
const types = @import("types.zig");
const Mode = types.Mode;
const ProgrammerConfig = types.ProgrammerConfig;
const CalcError = types.CalcError;
const parser_mod = @import("parser.zig");
const Parser = parser_mod.Parser;
/// Evaluation environment holding variables, history, and config.
pub const Environment = struct {
allocator: Allocator,
mode: Mode,
programmer_config: ProgrammerConfig,
variables: std.StringHashMap(f64),
ans: f64,
history_len: usize,
pub fn init(allocator: Allocator, mode: Mode) Environment {
return .{
.allocator = allocator,
.mode = mode,
.programmer_config = .{},
.variables = std.StringHashMap(f64).init(allocator),
.ans = 0,
.history_len = 0,
};
}
pub fn deinit(self: *Environment) void {
self.variables.deinit();
}
/// Set a variable value.
pub fn setVar(self: *Environment, name: []const u8, value: f64) !void {
try self.variables.put(name, value);
}
/// Get a variable or constant value.
pub fn getVar(self: *const Environment, name: []const u8) ?f64 {
// Built-in constants
if (std.mem.eql(u8, name, "pi")) return math.pi;
if (std.mem.eql(u8, name, "e")) return math.e;
if (std.mem.eql(u8, name, "tau")) return math.tau;
if (std.mem.eql(u8, name, "Ans") or std.mem.eql(u8, name, "ans")) return self.ans;
return self.variables.get(name);
}
};
/// Evaluate a parsed expression in the given environment.
/// Returns the computed value as f64 for standard mode.
pub fn evaluate(env: *Environment, expr: *const Expr) CalcError!f64 {
switch (expr.*) {
.number => |n| return n.float_value,
.variable => |name| {
return env.getVar(name) orelse return CalcError.UnknownVariable;
},
.assignment => |a| {
const val = try evaluate(env, a.value);
env.setVar(a.name, val) catch return CalcError.OutOfMemory;
return val;
},
.unary => |u| {
const operand = try evaluate(env, u.operand);
return switch (u.op) {
.negate => -operand,
.bitwise_not => {
// In standard mode, bitwise not doesn't really make sense,
// but we'll compute it on the integer representation
const int_val: u64 = @bitCast(@as(i64, @intFromFloat(operand)));
const result = ~int_val & env.programmer_config.bit_width.mask();
return @floatFromInt(@as(i64, @bitCast(result)));
},
};
},
.binary => |b| {
const left = try evaluate(env, b.left);
const right = try evaluate(env, b.right);
return evalBinaryOp(b.op, left, right);
},
.call => |c| {
return evalFunction(env, c.name, c.args);
},
}
}
/// Evaluate a binary operation on two f64 values.
fn evalBinaryOp(op: BinaryOp, left: f64, right: f64) CalcError!f64 {
return switch (op) {
.add => left + right,
.sub => left - right,
.mul => left * right,
.div => if (right == 0) CalcError.DivisionByZero else left / right,
.mod => if (right == 0) CalcError.DivisionByZero else @mod(left, right),
.pow => math.pow(f64, left, right),
// Bitwise ops in standard mode operate on integer truncations
.bit_and => floatBitwise(left, right, bitwiseAnd),
.bit_or => floatBitwise(left, right, bitwiseOr),
.bit_xor => floatBitwise(left, right, bitwiseXor),
.shift_left => floatShift(left, right, true),
.shift_right, .shift_right_logical => floatShift(left, right, false),
.rotate_left, .rotate_right => {
// Rotations need bit width context; in standard mode, use 64-bit
const l: u64 = @bitCast(@as(i64, @intFromFloat(left)));
const r: u6 = @intFromFloat(@mod(right, 64.0));
const result = if (op == .rotate_left)
math.rotl(u64, l, r)
else
math.rotr(u64, l, r);
return @floatFromInt(@as(i64, @bitCast(result)));
},
};
}
fn bitwiseAnd(a: u64, b: u64) u64 {
return a & b;
}
fn bitwiseOr(a: u64, b: u64) u64 {
return a | b;
}
fn bitwiseXor(a: u64, b: u64) u64 {
return a ^ b;
}
fn floatBitwise(left: f64, right: f64, op: *const fn (u64, u64) u64) f64 {
const l: u64 = @bitCast(@as(i64, @intFromFloat(left)));
const r: u64 = @bitCast(@as(i64, @intFromFloat(right)));
const result = op(l, r);
return @floatFromInt(@as(i64, @bitCast(result)));
}
fn floatShift(left: f64, right: f64, is_left: bool) f64 {
const l: u64 = @bitCast(@as(i64, @intFromFloat(left)));
const shift_amt: u6 = @intFromFloat(@mod(right, 64.0));
const result = if (is_left) l << shift_amt else l >> shift_amt;
return @floatFromInt(@as(i64, @bitCast(result)));
}
/// Evaluate a built-in function call.
fn evalFunction(env: *Environment, name: []const u8, args: []const *Expr) CalcError!f64 {
// Single-argument functions
if (args.len == 1) {
const x = try evaluate(env, args[0]);
return evalSingleArgFn(name, x) orelse CalcError.UnknownFunction;
}
// Multi-argument functions
if (args.len == 2) {
const a = try evaluate(env, args[0]);
const b = try evaluate(env, args[1]);
if (std.mem.eql(u8, name, "max")) return @max(a, b);
if (std.mem.eql(u8, name, "min")) return @min(a, b);
if (std.mem.eql(u8, name, "atan2")) return math.atan2(a, b);
if (std.mem.eql(u8, name, "log")) {
// log(value, base)
if (b <= 0 or b == 1 or a <= 0) return CalcError.DomainError;
return @log(a) / @log(b);
}
}
// Zero-argument functions
if (args.len == 0) {
if (std.mem.eql(u8, name, "rand")) {
// Not truly random in a pure engine, but useful as placeholder
return 0.0;
}
}
return CalcError.UnknownFunction;
}
/// Evaluate a single-argument built-in function.
fn evalSingleArgFn(name: []const u8, x: f64) ?f64 {
if (std.mem.eql(u8, name, "sin")) return @sin(x);
if (std.mem.eql(u8, name, "cos")) return @cos(x);
if (std.mem.eql(u8, name, "tan")) return @tan(x);
if (std.mem.eql(u8, name, "asin")) {
if (x < -1 or x > 1) return null; // domain error
return math.asin(x);
}
if (std.mem.eql(u8, name, "acos")) {
if (x < -1 or x > 1) return null;
return math.acos(x);
}
if (std.mem.eql(u8, name, "atan")) return math.atan(x);
if (std.mem.eql(u8, name, "log")) return @log10(x);
if (std.mem.eql(u8, name, "log10")) return @log10(x);
if (std.mem.eql(u8, name, "ln")) return @log(x);
if (std.mem.eql(u8, name, "log2")) return @log2(x);
if (std.mem.eql(u8, name, "sqrt")) {
if (x < 0) return null;
return @sqrt(x);
}
if (std.mem.eql(u8, name, "cbrt")) return math.cbrt(x);
if (std.mem.eql(u8, name, "abs")) return @abs(x);
if (std.mem.eql(u8, name, "ceil")) return @ceil(x);
if (std.mem.eql(u8, name, "floor")) return @floor(x);
if (std.mem.eql(u8, name, "round")) return @round(x);
if (std.mem.eql(u8, name, "exp")) return @exp(x);
if (std.mem.eql(u8, name, "factorial")) {
if (x < 0 or x != @round(x) or x > 170) return null;
return factorial(@intFromFloat(x));
}
return null;
}
fn factorial(n: u64) f64 {
if (n <= 1) return 1.0;
var result: f64 = 1.0;
var i: u64 = 2;
while (i <= n) : (i += 1) {
result *= @floatFromInt(i);
}
return result;
}
/// High-level evaluate: parse a string and evaluate it.
/// Updates env.ans on success.
pub fn evalString(env: *Environment, allocator: Allocator, source: []const u8) CalcError!f64 {
var p = Parser.init(allocator, source, env.mode);
const expr = try p.parse();
const result = try evaluate(env, expr);
env.ans = result;
env.history_len += 1;
return result;
}
// -- Tests --
const testing = std.testing;
fn testEval(source: []const u8) !f64 {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .standard);
defer env.deinit();
return evalString(&env, alloc, source);
}
fn testEvalProgrammer(source: []const u8) !f64 {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .programmer);
defer env.deinit();
return evalString(&env, alloc, source);
}
test "eval simple number" {
const result = try testEval("42");
try testing.expectEqual(@as(f64, 42.0), result);
}
test "eval addition" {
const result = try testEval("2 + 3");
try testing.expectEqual(@as(f64, 5.0), result);
}
test "eval subtraction" {
const result = try testEval("10 - 7");
try testing.expectEqual(@as(f64, 3.0), result);
}
test "eval multiplication" {
const result = try testEval("6 * 7");
try testing.expectEqual(@as(f64, 42.0), result);
}
test "eval division" {
const result = try testEval("10 / 4");
try testing.expectEqual(@as(f64, 2.5), result);
}
test "eval division by zero" {
const result = testEval("1 / 0");
try testing.expectError(CalcError.DivisionByZero, result);
}
test "eval modulo" {
const result = try testEval("10 % 3");
try testing.expectApproxEqAbs(@as(f64, 1.0), result, 1e-10);
}
test "eval power" {
const result = try testEval("2^10");
try testing.expectEqual(@as(f64, 1024.0), result);
}
test "eval precedence" {
const result = try testEval("2 + 3 * 4");
try testing.expectEqual(@as(f64, 14.0), result);
}
test "eval parentheses" {
const result = try testEval("(2 + 3) * 4");
try testing.expectEqual(@as(f64, 20.0), result);
}
test "eval unary negation" {
const result = try testEval("-5 + 3");
try testing.expectEqual(@as(f64, -2.0), result);
}
test "eval nested parens" {
const result = try testEval("((2 + 3) * (4 - 1))");
try testing.expectEqual(@as(f64, 15.0), result);
}
test "eval pi constant" {
const result = try testEval("pi");
try testing.expectApproxEqAbs(math.pi, result, 1e-10);
}
test "eval e constant" {
const result = try testEval("e");
try testing.expectApproxEqAbs(math.e, result, 1e-10);
}
test "eval tau constant" {
const result = try testEval("tau");
try testing.expectApproxEqAbs(math.tau, result, 1e-10);
}
test "eval implicit mul: 2pi" {
const result = try testEval("2pi");
try testing.expectApproxEqAbs(2.0 * math.pi, result, 1e-10);
}
test "eval implicit mul: 3(4+5)" {
const result = try testEval("3(4+5)");
try testing.expectEqual(@as(f64, 27.0), result);
}
test "eval sin" {
const result = try testEval("sin(0)");
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
}
test "eval cos" {
const result = try testEval("cos(0)");
try testing.expectApproxEqAbs(@as(f64, 1.0), result, 1e-10);
}
test "eval sqrt" {
const result = try testEval("sqrt(144)");
try testing.expectEqual(@as(f64, 12.0), result);
}
test "eval abs" {
const result = try testEval("abs(-42)");
try testing.expectEqual(@as(f64, 42.0), result);
}
test "eval floor" {
const result = try testEval("floor(3.7)");
try testing.expectEqual(@as(f64, 3.0), result);
}
test "eval ceil" {
const result = try testEval("ceil(3.2)");
try testing.expectEqual(@as(f64, 4.0), result);
}
test "eval round" {
const result = try testEval("round(3.5)");
try testing.expectEqual(@as(f64, 4.0), result);
}
test "eval factorial" {
const result = try testEval("factorial(5)");
try testing.expectEqual(@as(f64, 120.0), result);
}
test "eval factorial 0" {
const result = try testEval("factorial(0)");
try testing.expectEqual(@as(f64, 1.0), result);
}
test "eval ln" {
const result = try testEval("ln(1)");
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
}
test "eval exp" {
const result = try testEval("exp(0)");
try testing.expectEqual(@as(f64, 1.0), result);
}
test "eval max" {
const result = try testEval("max(3, 7)");
try testing.expectEqual(@as(f64, 7.0), result);
}
test "eval min" {
const result = try testEval("min(3, 7)");
try testing.expectEqual(@as(f64, 3.0), result);
}
test "eval unknown function" {
const result = testEval("bogus(1)");
try testing.expectError(CalcError.UnknownFunction, result);
}
test "eval unknown variable" {
const result = testEval("xyz");
try testing.expectError(CalcError.UnknownVariable, result);
}
test "eval variable assignment and use" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .standard);
defer env.deinit();
const assign_result = try evalString(&env, alloc, "X = 42");
try testing.expectEqual(@as(f64, 42.0), assign_result);
const use_result = try evalString(&env, alloc, "X + 8");
try testing.expectEqual(@as(f64, 50.0), use_result);
}
test "eval Ans" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .standard);
defer env.deinit();
_ = try evalString(&env, alloc, "7 * 6");
const result = try evalString(&env, alloc, "Ans + 1");
try testing.expectEqual(@as(f64, 43.0), result);
}
test "eval complex expression" {
const result = try testEval("sin(pi/2) + cos(0)");
try testing.expectApproxEqAbs(@as(f64, 2.0), result, 1e-10);
}
test "eval 2^32 - 1" {
const result = try testEval("2^32 - 1");
try testing.expectEqual(@as(f64, 4294967295.0), result);
}
test "eval programmer XOR" {
const result = try testEvalProgrammer("0xF ^ 0x3");
try testing.expectEqual(@as(f64, 12.0), result);
}
test "eval programmer AND" {
const result = try testEvalProgrammer("0xFF & 0x0F");
try testing.expectEqual(@as(f64, 15.0), result);
}
test "eval programmer OR" {
const result = try testEvalProgrammer("0xF0 | 0x0F");
try testing.expectEqual(@as(f64, 255.0), result);
}
test "eval programmer shift left" {
const result = try testEvalProgrammer("1 << 8");
try testing.expectEqual(@as(f64, 256.0), result);
}
test "eval programmer shift right" {
const result = try testEvalProgrammer("256 >> 4");
try testing.expectEqual(@as(f64, 16.0), result);
}
test "eval number with underscores" {
const result = try testEval("1_000_000 + 1");
try testing.expectEqual(@as(f64, 1_000_001.0), result);
}
test "eval bitwise not in standard mode" {
const result = try testEval("~0");
// ~0 as i64 = -1
try testing.expectEqual(@as(f64, -1.0), result);
}
test "eval rotate left in standard mode" {
// 1 rol 4 = 16 (for 64-bit)
const result = try testEvalProgrammer("1 rol 4");
try testing.expectEqual(@as(f64, 16.0), result);
}
test "eval rotate right in standard mode" {
const result = try testEvalProgrammer("16 ror 4");
try testing.expectEqual(@as(f64, 1.0), result);
}
test "eval atan2" {
const result = try testEval("atan2(1, 1)");
try testing.expectApproxEqAbs(math.pi / 4.0, result, 1e-10);
}
test "eval log with base" {
const result = try testEval("log(100, 10)");
try testing.expectApproxEqAbs(@as(f64, 2.0), result, 1e-10);
}
test "eval log domain error" {
const result = testEval("log(-1, 10)");
try testing.expectError(CalcError.DomainError, result);
}
test "eval asin domain error" {
const result = testEval("asin(2)");
// asin(2) is domain error since |2| > 1
try testing.expectError(CalcError.UnknownFunction, result);
}
test "eval acos" {
const result = try testEval("acos(1)");
try testing.expectApproxEqAbs(@as(f64, 0.0), result, 1e-10);
}

487
engine/src/formatter.zig Normal file
View file

@ -0,0 +1,487 @@
//! Number display formatter for Tally.
//!
//! Produces two string representations for every value:
//! - `display`: human-readable with separators (commas, underscores, spaces)
//! - `raw`: clipboard-friendly without separators (but with base prefix)
//!
//! Formatting rules per the spec:
//! - Decimal: comma-separated groups of 3 (e.g. "4,294,967,295")
//! - Hex value view: underscore per 16-bit word (e.g. "0xFFFF_FFFF")
//! - Binary: space per nibble (e.g. "1111 1111")
//! - Octal: underscore per 3-digit group (e.g. "0o37_777_777_777")
//! - Scientific notation only when |value| > 10^15 or < 10^-15 or > 15 sig digits
const std = @import("std");
const types = @import("types.zig");
const BitWidth = types.BitWidth;
/// A formatted value with both display and clipboard representations.
pub const FormattedValue = struct {
display: []const u8,
raw: []const u8,
};
/// Format a floating-point value for display.
/// Uses comma grouping for integers, avoids scientific notation unless necessary.
pub fn formatFloat(buf: []u8, value: f64) FormattedValue {
// Check if value is an integer (no fractional part, within safe range)
const is_integer = value == @trunc(value) and @abs(value) < 9007199254740992.0; // 2^53
if (is_integer and @abs(value) < 1e15) {
// Format as integer with commas
const int_val: i64 = @intFromFloat(value);
const raw_len = writeSignedInt(buf, int_val);
const raw = buf[0..raw_len];
// Now write the display version (with commas) after the raw version
const display_start = raw_len;
const display_len = writeDecimalWithCommas(buf[display_start..], int_val);
const display = buf[display_start..][0..display_len];
return .{ .display = display, .raw = raw };
}
// Check if we should use scientific notation
const abs_val = @abs(value);
if (abs_val != 0 and (abs_val > 1e15 or abs_val < 1e-15)) {
// Scientific notation
const raw_len = (std.fmt.bufPrint(buf, "{e}", .{value}) catch return .{ .display = "ERR", .raw = "ERR" }).len;
return .{ .display = buf[0..raw_len], .raw = buf[0..raw_len] };
}
// Regular float formatting
const raw_len = (std.fmt.bufPrint(buf, "{d}", .{value}) catch return .{ .display = "ERR", .raw = "ERR" }).len;
return .{ .display = buf[0..raw_len], .raw = buf[0..raw_len] };
}
/// Format an integer for programmer mode hex display.
/// Display: "0xFFFF_FFFF" (underscore per 16-bit word)
/// Raw: "0xFFFFFFFF" (no separators)
pub fn formatHex(buf: []u8, value: u64, bit_width: BitWidth) FormattedValue {
const width = bit_width.bits();
const hex_digits: usize = @as(usize, width) / 4;
// Write raw first: "0x" + hex digits
buf[0] = '0';
buf[1] = 'x';
var pos: usize = 2;
var i: usize = 0;
while (i < hex_digits) : (i += 1) {
const shift_amt: u6 = @intCast((hex_digits - 1 - i) * 4);
const nibble: u4 = @intCast((value >> shift_amt) & 0xF);
buf[pos] = hexDigit(nibble);
pos += 1;
}
const raw = buf[0..pos];
// Write display after raw: "0x" + hex digits with _ every 4 digits (16-bit word boundary)
const display_start = pos;
buf[pos] = '0';
buf[pos + 1] = 'x';
pos += 2;
i = 0;
while (i < hex_digits) : (i += 1) {
if (i > 0 and i % 4 == 0) {
buf[pos] = '_';
pos += 1;
}
const shift_amt: u6 = @intCast((hex_digits - 1 - i) * 4);
const nibble: u4 = @intCast((value >> shift_amt) & 0xF);
buf[pos] = hexDigit(nibble);
pos += 1;
}
const display = buf[display_start..pos];
return .{ .display = display, .raw = raw };
}
/// Format an integer for programmer mode binary display.
/// Display: "1111 0000 1010 1100" (space per nibble)
/// Raw: "0b1111000010101100" (no separators)
pub fn formatBinary(buf: []u8, value: u64, bit_width: BitWidth) FormattedValue {
const width: usize = bit_width.bits();
// Write raw: "0b" + binary digits
buf[0] = '0';
buf[1] = 'b';
var pos: usize = 2;
var i: usize = 0;
while (i < width) : (i += 1) {
const shift_amt: u6 = @intCast(width - 1 - i);
const bit: u8 = @intCast((value >> shift_amt) & 1);
buf[pos] = '0' + bit;
pos += 1;
}
const raw = buf[0..pos];
// Write display: binary digits with space per nibble
const display_start = pos;
i = 0;
while (i < width) : (i += 1) {
if (i > 0 and i % 4 == 0) {
buf[pos] = ' ';
pos += 1;
}
const shift_amt: u6 = @intCast(width - 1 - i);
const bit: u8 = @intCast((value >> shift_amt) & 1);
buf[pos] = '0' + bit;
pos += 1;
}
const display = buf[display_start..pos];
return .{ .display = display, .raw = raw };
}
/// Format an integer for programmer mode octal display.
/// Display: "0o37_777_777_777" (underscore per 3-digit group)
/// Raw: "0o37777777777" (no separators)
pub fn formatOctal(buf: []u8, value: u64, bit_width: BitWidth) FormattedValue {
_ = bit_width; // Octal display shows all significant digits
// Write raw octal
buf[0] = '0';
buf[1] = 'o';
var pos: usize = 2;
if (value == 0) {
buf[pos] = '0';
pos += 1;
return .{ .display = buf[0..pos], .raw = buf[0..pos] };
}
// Calculate octal digits
var digits: [22]u8 = undefined; // max 22 octal digits for u64
var digit_count: usize = 0;
var v = value;
while (v > 0) : (v /= 8) {
digits[digit_count] = @intCast(v % 8);
digit_count += 1;
}
// Write raw (most significant first)
var j: usize = digit_count;
while (j > 0) {
j -= 1;
buf[pos] = '0' + digits[j];
pos += 1;
}
const raw = buf[0..pos];
// Write display with underscores every 3 digits from the right
const display_start = pos;
buf[pos] = '0';
buf[pos + 1] = 'o';
pos += 2;
j = digit_count;
var digits_written: usize = 0;
while (j > 0) {
j -= 1;
// Insert underscore when digits_written > 0 and j is a multiple of 3
// (j represents position from right, so j%3==0 means start of a new group)
if (digits_written > 0 and j % 3 == 2 and j + 1 < digit_count) {
// Actually let's just use digits_written from the left
}
buf[pos] = '0' + digits[j];
pos += 1;
digits_written += 1;
}
// That approach is wrong. Let me rewrite: figure out how many digits are in
// the leftmost group, then insert underscore every 3 after that.
// For 11 digits: leftmost group has 2 digits (11 % 3 = 2), then groups of 3.
pos = display_start;
buf[pos] = '0';
buf[pos + 1] = 'o';
pos += 2;
const first_group = if (digit_count % 3 == 0) @as(usize, 3) else digit_count % 3;
j = digit_count;
digits_written = 0;
while (j > 0) {
j -= 1;
if (digits_written > 0 and digits_written == first_group) {
buf[pos] = '_';
pos += 1;
} else if (digits_written > first_group and (digits_written - first_group) % 3 == 0) {
buf[pos] = '_';
pos += 1;
}
buf[pos] = '0' + digits[j];
pos += 1;
digits_written += 1;
}
const display = buf[display_start..pos];
return .{ .display = display, .raw = raw };
}
/// Format an unsigned integer as decimal for programmer mode.
/// Display: "4,294,967,295" (comma-separated)
/// Raw: "4294967295" (no separators)
pub fn formatDecimalUnsigned(buf: []u8, value: u64) FormattedValue {
const raw_len = writeUnsignedInt(buf, value);
const raw = buf[0..raw_len];
const display_start = raw_len;
const display_len = writeUnsignedWithCommas(buf[display_start..], value);
const display = buf[display_start..][0..display_len];
return .{ .display = display, .raw = raw };
}
/// Format a signed integer as decimal for programmer mode.
/// Display: "-1" or "4,294,967,295"
/// Raw: same without commas
pub fn formatDecimalSigned(buf: []u8, value: i64) FormattedValue {
const raw_len = writeSignedInt(buf, value);
const raw = buf[0..raw_len];
const display_start = raw_len;
const display_len = writeDecimalWithCommas(buf[display_start..], value);
const display = buf[display_start..][0..display_len];
return .{ .display = display, .raw = raw };
}
// -- Internal helpers --
fn hexDigit(nibble: u4) u8 {
if (nibble < 10) return '0' + @as(u8, nibble);
return 'A' + @as(u8, nibble) - 10;
}
fn writeUnsignedInt(buf: []u8, value: u64) usize {
if (value == 0) {
buf[0] = '0';
return 1;
}
var digits: [20]u8 = undefined;
var count: usize = 0;
var v = value;
while (v > 0) : (v /= 10) {
digits[count] = @intCast(v % 10);
count += 1;
}
var pos: usize = 0;
var i: usize = count;
while (i > 0) {
i -= 1;
buf[pos] = '0' + digits[i];
pos += 1;
}
return pos;
}
fn writeSignedInt(buf: []u8, value: i64) usize {
if (value < 0) {
buf[0] = '-';
const abs_val: u64 = @intCast(-value);
return 1 + writeUnsignedInt(buf[1..], abs_val);
}
return writeUnsignedInt(buf, @intCast(value));
}
fn writeUnsignedWithCommas(buf: []u8, value: u64) usize {
if (value == 0) {
buf[0] = '0';
return 1;
}
var digits: [20]u8 = undefined;
var count: usize = 0;
var v = value;
while (v > 0) : (v /= 10) {
digits[count] = @intCast(v % 10);
count += 1;
}
// digits[0] is least significant, digits[count-1] is most significant
// Write most significant first, inserting commas every 3 from the right
var pos: usize = 0;
var i: usize = count;
while (i > 0) {
i -= 1;
buf[pos] = '0' + digits[i];
pos += 1;
// Insert comma if there are more digits and position from right is multiple of 3
if (i > 0 and i % 3 == 0) {
buf[pos] = ',';
pos += 1;
}
}
return pos;
}
fn writeDecimalWithCommas(buf: []u8, value: i64) usize {
if (value < 0) {
buf[0] = '-';
const abs_val: u64 = @intCast(-value);
return 1 + writeUnsignedWithCommas(buf[1..], abs_val);
}
return writeUnsignedWithCommas(buf, @intCast(value));
}
// -- Tests --
const testing = std.testing;
test "formatFloat: integer value" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 42.0);
try testing.expectEqualStrings("42", result.display);
try testing.expectEqualStrings("42", result.raw);
}
test "formatFloat: large integer with commas" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 4294967295.0);
try testing.expectEqualStrings("4,294,967,295", result.display);
try testing.expectEqualStrings("4294967295", result.raw);
}
test "formatFloat: negative integer" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, -1234.0);
try testing.expectEqualStrings("-1,234", result.display);
try testing.expectEqualStrings("-1234", result.raw);
}
test "formatFloat: million" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1000000.0);
try testing.expectEqualStrings("1,000,000", result.display);
try testing.expectEqualStrings("1000000", result.raw);
}
test "formatFloat: zero" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 0.0);
try testing.expectEqualStrings("0", result.display);
try testing.expectEqualStrings("0", result.raw);
}
test "formatHex: 8-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xFF, .bits8);
try testing.expectEqualStrings("0xFF", result.display);
try testing.expectEqualStrings("0xFF", result.raw);
}
test "formatHex: 16-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xABCD, .bits16);
try testing.expectEqualStrings("0xABCD", result.display);
try testing.expectEqualStrings("0xABCD", result.raw);
}
test "formatHex: 32-bit with grouping" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xDEADBEEF, .bits32);
try testing.expectEqualStrings("0xDEAD_BEEF", result.display);
try testing.expectEqualStrings("0xDEADBEEF", result.raw);
}
test "formatHex: 64-bit with grouping" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xDEAD_BEEF_CAFE_BABE, .bits64);
try testing.expectEqualStrings("0xDEAD_BEEF_CAFE_BABE", result.display);
try testing.expectEqualStrings("0xDEADBEEFCAFEBABE", result.raw);
}
test "formatHex: zero 32-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0, .bits32);
try testing.expectEqualStrings("0x0000_0000", result.display);
try testing.expectEqualStrings("0x00000000", result.raw);
}
test "formatBinary: 8-bit" {
var buf: [256]u8 = undefined;
const result = formatBinary(&buf, 0xFF, .bits8);
try testing.expectEqualStrings("1111 1111", result.display);
try testing.expectEqualStrings("0b11111111", result.raw);
}
test "formatBinary: 8-bit mixed" {
var buf: [256]u8 = undefined;
const result = formatBinary(&buf, 0xA5, .bits8);
try testing.expectEqualStrings("1010 0101", result.display);
try testing.expectEqualStrings("0b10100101", result.raw);
}
test "formatBinary: 16-bit" {
var buf: [256]u8 = undefined;
const result = formatBinary(&buf, 0x000F, .bits16);
try testing.expectEqualStrings("0000 0000 0000 1111", result.display);
try testing.expectEqualStrings("0b0000000000001111", result.raw);
}
test "formatOctal: simple" {
var buf: [256]u8 = undefined;
const result = formatOctal(&buf, 511, .bits16);
try testing.expectEqualStrings("0o777", result.display);
try testing.expectEqualStrings("0o777", result.raw);
}
test "formatOctal: large with grouping" {
var buf: [256]u8 = undefined;
const result = formatOctal(&buf, 0xFFFF_FFFF, .bits32);
// 0xFFFFFFFF = 37777777777 octal (11 digits)
try testing.expectEqualStrings("0o37_777_777_777", result.display);
try testing.expectEqualStrings("0o37777777777", result.raw);
}
test "formatOctal: zero" {
var buf: [256]u8 = undefined;
const result = formatOctal(&buf, 0, .bits8);
try testing.expectEqualStrings("0o0", result.display);
try testing.expectEqualStrings("0o0", result.raw);
}
test "formatDecimalUnsigned: simple" {
var buf: [256]u8 = undefined;
const result = formatDecimalUnsigned(&buf, 255);
try testing.expectEqualStrings("255", result.display);
try testing.expectEqualStrings("255", result.raw);
}
test "formatDecimalUnsigned: large" {
var buf: [256]u8 = undefined;
const result = formatDecimalUnsigned(&buf, 4294967295);
try testing.expectEqualStrings("4,294,967,295", result.display);
try testing.expectEqualStrings("4294967295", result.raw);
}
test "formatDecimalSigned: negative" {
var buf: [256]u8 = undefined;
const result = formatDecimalSigned(&buf, -1);
try testing.expectEqualStrings("-1", result.display);
try testing.expectEqualStrings("-1", result.raw);
}
test "formatDecimalSigned: negative large" {
var buf: [256]u8 = undefined;
const result = formatDecimalSigned(&buf, -1234567);
try testing.expectEqualStrings("-1,234,567", result.display);
try testing.expectEqualStrings("-1234567", result.raw);
}
test "formatFloat: very large number uses scientific notation" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1.5e16);
// Should use scientific notation for values > 1e15
try testing.expect(std.mem.indexOf(u8, result.display, "e") != null or
std.mem.indexOf(u8, result.display, "E") != null);
}
test "formatFloat: very small number uses scientific notation" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 1.5e-16);
try testing.expect(std.mem.indexOf(u8, result.display, "e") != null or
std.mem.indexOf(u8, result.display, "E") != null);
}
test "formatFloat: regular float" {
var buf: [256]u8 = undefined;
const result = formatFloat(&buf, 3.14159);
try testing.expect(std.mem.indexOf(u8, result.display, "3.14") != null);
}

567
engine/src/parser.zig Normal file
View file

@ -0,0 +1,567 @@
//! 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 (-, ~)
//! - Function calls: identifier(args...)
//! - Implicit multiplication: 2pi, 3(4+5), (2)(3)
//! - Mode-dependent ^ (power in standard, XOR in programmer)
//! - 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, // |
bit_xor = 3, // ^ (programmer mode)
bit_and = 4, // &
shift = 5, // << >> >>> rol ror
additive = 6, // + -
multiplicative = 7, // * / %
power = 8, // ^ (standard mode) or **
unary = 9, // - ~ (prefix)
call = 10, // function calls, implicit mul
};
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.
pub fn parse(self: *Parser) CalcError!*Expr {
const expr = try self.parseExpr(.none);
if (self.current.kind != .eof) {
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();
while (true) {
// Check for implicit multiplication first:
// If current token starts a value and isn't an operator, insert mul.
if (self.isImplicitMul()) {
const mul_prec = Prec.multiplicative;
if (@intFromEnum(mul_prec) <= @intFromEnum(min_prec)) break;
const right = try self.parseExpr(mul_prec);
left = try self.makeNode(.{ .binary = .{
.op = .mul,
.left = left,
.right = right,
} });
continue;
}
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,
} });
},
.identifier => {
self.advance();
const name = tok.text(self.source);
// Check for assignment: identifier = expr
if (self.current.kind == .equals) {
self.advance();
const value = try self.parseExpr(.none);
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);
if (self.current.kind != .right_paren) {
const first_arg = try self.parseExpr(.none);
args.append(self.allocator, first_arg) catch return CalcError.OutOfMemory;
while (self.current.kind == .comma) {
self.advance(); // consume ,
const arg = try self.parseExpr(.none);
args.append(self.allocator, arg) catch 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;
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) {
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);
return self.makeNode(.{ .unary = .{
.op = .negate,
.operand = operand,
} });
},
.tilde => {
self.advance();
const operand = try self.parseExpr(.unary);
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)
if (tok.kind == .identifier) {
const name = tok.text(self.source);
if (std.mem.eql(u8, name, "rol") or std.mem.eql(u8, name, "ror")) {
self.advance();
const op: BinaryOp = if (std.mem.eql(u8, name, "rol")) .rotate_left else .rotate_right;
const right = try self.parseExpr(prec);
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);
return self.makeNode(.{ .binary = .{
.op = op,
.left = left,
.right = right,
} });
}
/// Determine if the current position represents an implicit multiplication.
/// This happens when the current token could start a new value expression
/// and the previous token ended a value expression, with no operator between.
fn isImplicitMul(self: *Parser) bool {
// Implicit mul only happens when infixPrecedence returns .none
// (meaning the current token is NOT a recognized infix operator)
// but IS the start of a value expression.
if (self.infixPrecedence(self.current.kind) != .none) return false;
return switch (self.current.kind) {
.number, .left_paren => true,
.identifier => {
// Don't treat keyword operators as implicit mul
const name = self.current.text(self.source);
if (std.mem.eql(u8, name, "rol") or std.mem.eql(u8, name, "ror") or
std.mem.eql(u8, name, "to"))
{
return false;
}
return true;
},
else => false,
};
}
/// Get the infix precedence of a token kind.
fn infixPrecedence(self: *Parser, kind: TokenKind) Prec {
return switch (kind) {
.pipe => .bit_or,
.caret => if (self.mode == .programmer) .bit_xor else .power,
.ampersand => .bit_and,
.shift_left, .shift_right, .shift_right_logical => .shift,
.plus, .minus => .additive,
.star, .slash, .percent => .multiplicative,
.star_star => .power,
.identifier => blk: {
// "rol" and "ror" are infix keyword operators at shift precedence
const name = self.currentText(kind);
if (name != null) {
if (std.mem.eql(u8, name.?, "rol") or std.mem.eql(u8, name.?, "ror")) {
break :blk .shift;
}
}
break :blk .none;
},
else => .none,
};
}
fn currentText(self: *Parser, kind: TokenKind) ?[]const u8 {
if (kind == .identifier) {
return self.current.text(self.source);
}
return null;
}
/// Map a token kind to a binary operator.
fn tokenToBinaryOp(self: *Parser, kind: TokenKind) ?BinaryOp {
return switch (kind) {
.plus => .add,
.minus => .sub,
.star => .mul,
.slash => .div,
.percent => .mod,
.caret => if (self.mode == .programmer) .bit_xor else .pow,
.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;
// Use an arena for tests so error paths don't leak
var test_arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator);
fn testParse(source: []const u8, mode: Mode) !*Expr {
// Reset arena between test calls isn't needed since each test is independent
// and we use testing.allocator for successful parses (with manual free),
// but for error cases we need an arena.
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();
}
fn freeExpr(allocator: Allocator, expr: *Expr) void {
switch (expr.*) {
.number => {},
.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 implicit mul: number identifier" {
// 2pi should parse as 2 * pi
const expr = try testParse("2pi", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.mul, expr.binary.op);
try testing.expectEqual(@as(f64, 2.0), expr.binary.left.number.float_value);
try testing.expectEqualStrings("pi", expr.binary.right.variable);
}
test "parse implicit mul: number paren" {
// 3(4+5) should parse as 3 * (4+5)
const expr = try testParse("3(4+5)", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.mul, expr.binary.op);
try testing.expectEqual(@as(f64, 3.0), expr.binary.left.number.float_value);
try testing.expectEqual(BinaryOp.add, expr.binary.right.binary.op);
}
test "parse implicit mul: paren paren" {
// (2)(3) should parse as (2) * (3)
const expr = try testParse("(2)(3)", .standard);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.mul, 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 caret as XOR in programmer mode" {
const expr = try testParse("0xF ^ 0x3", .programmer);
defer freeExpr(testing.allocator, expr);
try testing.expectEqual(BinaryOp.bit_xor, expr.binary.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);
}

374
engine/src/programmer.zig Normal file
View file

@ -0,0 +1,374 @@
//! Programmer mode evaluator for Tally.
//!
//! All operations use exact integer arithmetic (u64 storage), with results
//! masked to the configured bit width. No floating-point involved.
//! Produces Integer values with signed/unsigned interpretation.
const std = @import("std");
const Allocator = std.mem.Allocator;
const ast = @import("ast.zig");
const Expr = ast.Expr;
const BinaryOp = ast.BinaryOp;
const types = @import("types.zig");
const Integer = types.Integer;
const ProgrammerConfig = types.ProgrammerConfig;
const CalcError = types.CalcError;
const parser_mod = @import("parser.zig");
const Parser = parser_mod.Parser;
/// Evaluate an AST in programmer mode, producing an exact integer result.
pub fn evalProgrammer(config: ProgrammerConfig, expr: *const Expr) CalcError!Integer {
const raw = try evalExpr(config, expr);
return .{
.raw = raw & config.bit_width.mask(),
.bit_width = config.bit_width,
.signedness = config.signedness,
};
}
/// Recursively evaluate an expression to a raw u64.
fn evalExpr(config: ProgrammerConfig, expr: *const Expr) CalcError!u64 {
switch (expr.*) {
.number => |n| {
if (n.int_value) |int_val| {
return int_val & config.bit_width.mask();
}
// Float in programmer mode: truncate to integer
if (n.float_value < 0) {
const neg: i64 = @intFromFloat(n.float_value);
return @as(u64, @bitCast(neg)) & config.bit_width.mask();
}
const val: u64 = @intFromFloat(n.float_value);
return val & config.bit_width.mask();
},
.variable => {
// Programmer mode doesn't support named variables (yet)
// Could add register-style variables later
return CalcError.UnknownVariable;
},
.assignment => {
return CalcError.InvalidOperandType;
},
.unary => |u| {
const operand = try evalExpr(config, u.operand);
return switch (u.op) {
.negate => blk: {
// Two's complement negation: ~x + 1, masked to width
const negated = (~operand +% 1) & config.bit_width.mask();
break :blk negated;
},
.bitwise_not => (~operand) & config.bit_width.mask(),
};
},
.binary => |b| {
const left = try evalExpr(config, b.left);
const right = try evalExpr(config, b.right);
return evalBinaryOp(config, b.op, left, right);
},
.call => {
// No function calls in programmer mode
return CalcError.UnknownFunction;
},
}
}
/// Evaluate a binary operation on two u64 values, masked to bit width.
fn evalBinaryOp(config: ProgrammerConfig, op: BinaryOp, left: u64, right: u64) CalcError!u64 {
const mask = config.bit_width.mask();
const width = config.bit_width.bits();
const result: u64 = switch (op) {
.add => (left +% right) & mask,
.sub => (left -% right) & mask,
.mul => (left *% right) & mask,
.div => blk: {
if (right == 0) return CalcError.DivisionByZero;
break :blk (left / right) & mask;
},
.mod => blk: {
if (right == 0) return CalcError.DivisionByZero;
break :blk (left % right) & mask;
},
.pow => blk: {
// Integer exponentiation
var base = left;
var exp = right;
var acc: u64 = 1;
while (exp > 0) : (exp >>= 1) {
if (exp & 1 != 0) acc = (acc *% base) & mask;
base = (base *% base) & mask;
}
break :blk acc;
},
.bit_and => left & right,
.bit_or => left | right,
.bit_xor => left ^ right,
.shift_left => blk: {
const shift_amt: u6 = if (right >= width)
@intCast(width - 1)
else
@intCast(right);
break :blk (left << shift_amt) & mask;
},
.shift_right => blk: {
// Arithmetic right shift: preserves sign bit
const shift_amt: u6 = if (right >= width)
@intCast(width - 1)
else
@intCast(right);
// Sign-extend, shift, then mask
const sign_bit: u64 = @as(u64, 1) << @intCast(width - 1);
if (left & sign_bit != 0) {
// Negative: fill with 1s from the top
const extended = left | ~mask;
const shifted: u64 = @bitCast(@as(i64, @bitCast(extended)) >> shift_amt);
break :blk shifted & mask;
}
break :blk (left >> shift_amt) & mask;
},
.shift_right_logical => blk: {
// Logical right shift: always fills with 0s
const shift_amt: u6 = if (right >= width)
@intCast(width - 1)
else
@intCast(right);
break :blk (left >> shift_amt) & mask;
},
.rotate_left => blk: {
const amt: u6 = @intCast(@mod(right, width));
if (amt == 0) break :blk left;
const anti: u6 = @intCast(width - amt);
break :blk ((left << amt) | (left >> anti)) & mask;
},
.rotate_right => blk: {
const amt: u6 = @intCast(@mod(right, width));
if (amt == 0) break :blk left;
const anti: u6 = @intCast(width - amt);
break :blk ((left >> amt) | (left << anti)) & mask;
},
};
return result;
}
/// High-level: parse and evaluate a string in programmer mode.
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: ProgrammerConfig) CalcError!Integer {
var p = Parser.init(allocator, source, .programmer);
const expr = try p.parse();
return evalProgrammer(config, expr);
}
// -- Tests --
const testing = std.testing;
fn testProg(source: []const u8) !Integer {
return testProgWith(source, .{});
}
fn testProgWith(source: []const u8, config: ProgrammerConfig) !Integer {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
return evalProgrammerString(arena.allocator(), source, config);
}
test "prog: simple number" {
const result = try testProg("42");
try testing.expectEqual(@as(u64, 42), result.unsignedValue());
}
test "prog: hex number" {
const result = try testProg("0xFF");
try testing.expectEqual(@as(u64, 255), result.unsignedValue());
}
test "prog: binary number" {
const result = try testProg("0b1010");
try testing.expectEqual(@as(u64, 10), result.unsignedValue());
}
test "prog: addition" {
const result = try testProg("10 + 20");
try testing.expectEqual(@as(u64, 30), result.unsignedValue());
}
test "prog: subtraction wrapping" {
// 5 - 10 in 8-bit unsigned wraps
const result = try testProgWith("5 - 10", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 251), result.unsignedValue()); // 256 - 5
try testing.expectEqual(@as(i64, -5), result.signedValue());
}
test "prog: multiplication" {
const result = try testProg("6 * 7");
try testing.expectEqual(@as(u64, 42), result.unsignedValue());
}
test "prog: multiplication overflow 8-bit" {
const result = try testProgWith("200 * 2", .{ .bit_width = .bits8 });
// 400 & 0xFF = 144
try testing.expectEqual(@as(u64, 144), result.unsignedValue());
}
test "prog: division" {
const result = try testProg("100 / 4");
try testing.expectEqual(@as(u64, 25), result.unsignedValue());
}
test "prog: division by zero" {
const result = testProg("10 / 0");
try testing.expectError(CalcError.DivisionByZero, result);
}
test "prog: modulo" {
const result = try testProg("10 % 3");
try testing.expectEqual(@as(u64, 1), result.unsignedValue());
}
test "prog: power" {
const result = try testProg("2 ** 10");
try testing.expectEqual(@as(u64, 1024), result.unsignedValue());
}
test "prog: bitwise AND" {
const result = try testProg("0xFF & 0x0F");
try testing.expectEqual(@as(u64, 0x0F), result.unsignedValue());
}
test "prog: bitwise OR" {
const result = try testProg("0xF0 | 0x0F");
try testing.expectEqual(@as(u64, 0xFF), result.unsignedValue());
}
test "prog: bitwise XOR" {
const result = try testProg("0xFF ^ 0x0F");
try testing.expectEqual(@as(u64, 0xF0), result.unsignedValue());
}
test "prog: bitwise NOT 8-bit" {
const result = try testProgWith("~0x0F", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0xF0), result.unsignedValue());
}
test "prog: bitwise NOT 16-bit" {
const result = try testProgWith("~0x00FF", .{ .bit_width = .bits16 });
try testing.expectEqual(@as(u64, 0xFF00), result.unsignedValue());
}
test "prog: bitwise NOT 32-bit" {
const result = try testProgWith("~0", .{ .bit_width = .bits32 });
try testing.expectEqual(@as(u64, 0xFFFF_FFFF), result.unsignedValue());
}
test "prog: shift left" {
const result = try testProg("1 << 8");
try testing.expectEqual(@as(u64, 256), result.unsignedValue());
}
test "prog: shift left overflow 8-bit" {
const result = try testProgWith("1 << 8", .{ .bit_width = .bits8 });
// Shifting by width or more in 8-bit: shift_amt clamped to 7
try testing.expectEqual(@as(u64, 128), result.unsignedValue());
}
test "prog: logical shift right" {
const result = try testProgWith("0x80 >>> 4", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0x08), result.unsignedValue());
}
test "prog: arithmetic shift right (sign bit preserved)" {
// 0x80 in 8-bit is -128; >> 1 should give 0xC0 (-64)
const result = try testProgWith("0x80 >> 1", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0xC0), result.unsignedValue());
try testing.expectEqual(@as(i64, -64), result.signedValue());
}
test "prog: arithmetic shift right (positive)" {
// 0x40 in 8-bit is positive; >> 1 should give 0x20
const result = try testProgWith("0x40 >> 1", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0x20), result.unsignedValue());
}
test "prog: rotate left 8-bit" {
// 0x81 rol 1 in 8-bit: bit 7 wraps to bit 0 -> 0x03
const result = try testProgWith("0x81 rol 1", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0x03), result.unsignedValue());
}
test "prog: rotate right 8-bit" {
// 0x81 ror 1 in 8-bit: bit 0 wraps to bit 7 -> 0xC0
const result = try testProgWith("0x81 ror 1", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0xC0), result.unsignedValue());
}
test "prog: negation two's complement" {
const result = try testProgWith("-1", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0xFF), result.unsignedValue());
try testing.expectEqual(@as(i64, -1), result.signedValue());
}
test "prog: negation 16-bit" {
const result = try testProgWith("-42", .{ .bit_width = .bits16 });
try testing.expectEqual(@as(i64, -42), result.signedValue());
}
test "prog: complex expression" {
// (0xFF & 0x0F) | (1 << 4) = 0x0F | 0x10 = 0x1F
const result = try testProg("(0xFF & 0x0F) | (1 << 4)");
try testing.expectEqual(@as(u64, 0x1F), result.unsignedValue());
}
test "prog: precedence AND before OR" {
// 0xF0 | 0xFF & 0x0F = 0xF0 | (0xFF & 0x0F) = 0xF0 | 0x0F = 0xFF
const result = try testProg("0xF0 | 0xFF & 0x0F");
try testing.expectEqual(@as(u64, 0xFF), result.unsignedValue());
}
test "prog: chained shifts" {
const result = try testProg("1 << 4 << 2");
// Left-associative: (1 << 4) << 2 = 16 << 2 = 64
try testing.expectEqual(@as(u64, 64), result.unsignedValue());
}
test "prog: mask applied to input" {
// 0x1FF in 8-bit mode should be masked to 0xFF
const result = try testProgWith("0x1FF", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(u64, 0xFF), result.unsignedValue());
}
test "prog: 32-bit operations" {
const result = try testProgWith("0xFFFF_FFFF + 1", .{ .bit_width = .bits32 });
try testing.expectEqual(@as(u64, 0), result.unsignedValue());
}
test "prog: 64-bit max" {
const result = try testProgWith("~0", .{ .bit_width = .bits64 });
try testing.expectEqual(@as(u64, 0xFFFF_FFFF_FFFF_FFFF), result.unsignedValue());
}
test "prog: negative float input" {
// -5.0 as a float in programmer mode should become two's complement
const result = try testProgWith("-5", .{ .bit_width = .bits8 });
try testing.expectEqual(@as(i64, -5), result.signedValue());
}
test "prog: variable reference errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "x", .{});
try testing.expectError(CalcError.UnknownVariable, result);
}
test "prog: assignment errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "X = 5", .{});
try testing.expectError(CalcError.InvalidOperandType, result);
}
test "prog: function call errors" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "sin(1)", .{});
try testing.expectError(CalcError.UnknownFunction, result);
}

644
engine/src/tokenizer.zig Normal file
View file

@ -0,0 +1,644 @@
//! Expression tokenizer for Tally.
//!
//! Converts an input string into a sequence of tokens for the parser.
//! Supports multiple number bases (decimal, hex 0x, octal 0o, binary 0b),
//! operators, identifiers (functions/variables), and implicit multiplication.
//!
//! The `TokenStream` wraps the raw `Tokenizer` and inserts synthetic `star`
//! tokens for implicit multiplication (e.g. `2pi`, `3(4+5)`, `)(` ).
const std = @import("std");
const types = @import("types.zig");
const Mode = types.Mode;
const Base = types.Base;
pub const TokenKind = enum {
// Literals
number,
// Identifiers (function names, variable names, keywords like "to", "rol", "ror")
identifier,
// Operators
plus,
minus,
star,
slash,
percent,
caret, // ^ (power in standard, XOR in programmer)
star_star, // ** (power in programmer)
ampersand, // &
pipe, // |
tilde, // ~
shift_left, // <<
shift_right, // >> (arithmetic)
shift_right_logical, // >>>
// Delimiters
left_paren,
right_paren,
comma,
semicolon,
equals, // = (assignment)
// Special
eof,
invalid,
/// Returns true if this token can be the last token before an implicit
/// multiplication (i.e. it produces a value).
pub fn isValueEnd(self: TokenKind) bool {
return switch (self) {
.number, .identifier, .right_paren => true,
else => false,
};
}
/// Returns true if this token can be the first token after an implicit
/// multiplication (i.e. it starts a value).
pub fn isValueStart(self: TokenKind) bool {
return switch (self) {
.number, .identifier, .left_paren, .tilde => true,
else => false,
};
}
};
pub const Token = struct {
kind: TokenKind,
/// Byte offset into the source where this token starts.
start: usize,
/// Byte length of this token in the source.
len: usize,
/// Extract the token's text from the source.
pub fn text(self: Token, source: []const u8) []const u8 {
return source[self.start..][0..self.len];
}
};
/// Parsed number value from a token.
pub const NumberValue = struct {
float: f64,
/// If the number is a pure integer (no decimal point, no exponent), this
/// holds the exact integer value.
int_value: ?u64,
base: Base,
};
/// Parse a number token's text into a value.
/// Handles 0x (hex), 0o (octal), 0b (binary), decimal integers, and floats.
/// Underscores are ignored as digit separators.
pub fn parseNumber(token_text: []const u8) !NumberValue {
// Strip underscores for parsing
var buf: [128]u8 = undefined;
var buf_len: usize = 0;
for (token_text) |c| {
if (c != '_') {
if (buf_len >= buf.len) return error.InvalidNumber;
buf[buf_len] = c;
buf_len += 1;
}
}
const clean = buf[0..buf_len];
if (clean.len == 0) return error.InvalidNumber;
// Check base prefix
if (clean.len >= 2 and clean[0] == '0') {
switch (clean[1]) {
'x', 'X' => {
const digits = clean[2..];
if (digits.len == 0) return error.InvalidNumber;
const val = std.fmt.parseInt(u64, digits, 16) catch return error.InvalidNumber;
return .{ .float = @floatFromInt(val), .int_value = val, .base = .hex };
},
'o', 'O' => {
const digits = clean[2..];
if (digits.len == 0) return error.InvalidNumber;
const val = std.fmt.parseInt(u64, digits, 8) catch return error.InvalidNumber;
return .{ .float = @floatFromInt(val), .int_value = val, .base = .octal };
},
'b', 'B' => {
const digits = clean[2..];
if (digits.len == 0) return error.InvalidNumber;
const val = std.fmt.parseInt(u64, digits, 2) catch return error.InvalidNumber;
return .{ .float = @floatFromInt(val), .int_value = val, .base = .binary };
},
else => {},
}
}
// Check if it's a pure integer (no '.', no 'e'/'E')
var is_integer = true;
for (clean) |c| {
if (c == '.' or c == 'e' or c == 'E') {
is_integer = false;
break;
}
}
if (is_integer) {
const val = std.fmt.parseInt(u64, clean, 10) catch {
// Could be too large for u64, try as float
const f = std.fmt.parseFloat(f64, clean) catch return error.InvalidNumber;
return .{ .float = f, .int_value = null, .base = .decimal };
};
return .{ .float = @floatFromInt(val), .int_value = val, .base = .decimal };
}
// Float
const f = std.fmt.parseFloat(f64, clean) catch return error.InvalidNumber;
return .{ .float = f, .int_value = null, .base = .decimal };
}
/// Token stream that inserts synthetic `star` tokens for implicit multiplication.
pub const ImplicitMulStream = struct {
tokenizer: Tokenizer,
source: []const u8,
buffered: ?Token,
prev_kind: TokenKind,
pub fn init(source: []const u8, mode: Mode) ImplicitMulStream {
return .{
.tokenizer = Tokenizer.init(source, mode),
.source = source,
.buffered = null,
.prev_kind = .eof,
};
}
pub fn next(self: *ImplicitMulStream) Token {
// If we have a buffered token from a previous implicit mul detection
if (self.buffered) |buf| {
self.buffered = null;
self.prev_kind = buf.kind;
return buf;
}
const tok = self.tokenizer.next();
// Check for implicit multiplication:
// prev is value-end AND current is value-start
if (self.prev_kind.isValueEnd() and tok.kind.isValueStart()) {
// Buffer this token for the next call, emit star now
self.buffered = tok;
self.prev_kind = .star;
return .{ .kind = .star, .start = tok.start, .len = 0 };
}
self.prev_kind = tok.kind;
return tok;
}
/// Peek at the source text for a given token.
pub fn tokenText(self: *const ImplicitMulStream, tok: Token) []const u8 {
return tok.text(self.source);
}
};
// -- Raw Tokenizer --
pub const Tokenizer = struct {
source: []const u8,
pos: usize,
mode: Mode,
pub fn init(source: []const u8, mode: Mode) Tokenizer {
return .{
.source = source,
.pos = 0,
.mode = mode,
};
}
pub fn next(self: *Tokenizer) Token {
self.skipWhitespace();
if (self.pos >= self.source.len) {
return .{ .kind = .eof, .start = self.pos, .len = 0 };
}
const start = self.pos;
const c = self.source[self.pos];
switch (c) {
'+' => return self.singleChar(.plus, start),
'-' => return self.singleChar(.minus, start),
'/' => return self.singleChar(.slash, start),
'%' => return self.singleChar(.percent, start),
'&' => return self.singleChar(.ampersand, start),
'|' => return self.singleChar(.pipe, start),
'~' => return self.singleChar(.tilde, start),
'(' => return self.singleChar(.left_paren, start),
')' => return self.singleChar(.right_paren, start),
',' => return self.singleChar(.comma, start),
';' => return self.singleChar(.semicolon, start),
'=' => return self.singleChar(.equals, start),
'^' => return self.singleChar(.caret, start),
'*' => {
self.pos += 1;
if (self.pos < self.source.len and self.source[self.pos] == '*') {
self.pos += 1;
return .{ .kind = .star_star, .start = start, .len = 2 };
}
return .{ .kind = .star, .start = start, .len = 1 };
},
'<' => {
self.pos += 1;
if (self.pos < self.source.len and self.source[self.pos] == '<') {
self.pos += 1;
return .{ .kind = .shift_left, .start = start, .len = 2 };
}
return .{ .kind = .invalid, .start = start, .len = 1 };
},
'>' => {
self.pos += 1;
if (self.pos < self.source.len and self.source[self.pos] == '>') {
self.pos += 1;
if (self.pos < self.source.len and self.source[self.pos] == '>') {
self.pos += 1;
return .{ .kind = .shift_right_logical, .start = start, .len = 3 };
}
return .{ .kind = .shift_right, .start = start, .len = 2 };
}
return .{ .kind = .invalid, .start = start, .len = 1 };
},
'0'...'9' => return self.readNumber(start),
'a'...'z', 'A'...'Z', '_' => return self.readIdentifier(start),
'.' => {
// Could be start of a decimal number like .5
if (self.pos + 1 < self.source.len and
self.source[self.pos + 1] >= '0' and self.source[self.pos + 1] <= '9')
{
return self.readNumber(start);
}
self.pos += 1;
return .{ .kind = .invalid, .start = start, .len = 1 };
},
else => {
self.pos += 1;
return .{ .kind = .invalid, .start = start, .len = 1 };
},
}
}
fn singleChar(self: *Tokenizer, kind: TokenKind, start: usize) Token {
self.pos += 1;
return .{ .kind = kind, .start = start, .len = 1 };
}
fn skipWhitespace(self: *Tokenizer) void {
while (self.pos < self.source.len) {
switch (self.source[self.pos]) {
' ', '\t', '\r', '\n' => self.pos += 1,
else => break,
}
}
}
fn readNumber(self: *Tokenizer, start: usize) Token {
// Check for base prefix: 0x, 0o, 0b
if (self.source[self.pos] == '0' and self.pos + 1 < self.source.len) {
const next_ch = self.source[self.pos + 1];
switch (next_ch) {
'x', 'X' => {
self.pos += 2;
self.consumeDigits(isHexDigit);
return .{ .kind = .number, .start = start, .len = self.pos - start };
},
'o', 'O' => {
self.pos += 2;
self.consumeDigits(isOctalDigit);
return .{ .kind = .number, .start = start, .len = self.pos - start };
},
'b', 'B' => {
// Disambiguate: 0b... is binary only if followed by 0 or 1
if (self.pos + 2 < self.source.len and
(self.source[self.pos + 2] == '0' or self.source[self.pos + 2] == '1'))
{
self.pos += 2;
self.consumeDigits(isBinaryDigit);
return .{ .kind = .number, .start = start, .len = self.pos - start };
}
// Otherwise fall through to decimal
},
else => {},
}
}
// Decimal number (possibly floating point)
self.consumeDigits(isDecDigit);
// Fractional part
if (self.pos < self.source.len and self.source[self.pos] == '.') {
if (self.pos + 1 < self.source.len and
self.source[self.pos + 1] >= '0' and self.source[self.pos + 1] <= '9')
{
self.pos += 1; // consume '.'
self.consumeDigits(isDecDigit);
} else if (self.source[start] == '.') {
// Number started with '.', e.g. ".5"
self.pos += 1;
self.consumeDigits(isDecDigit);
}
}
// Exponent part (e or E)
if (self.pos < self.source.len and
(self.source[self.pos] == 'e' or self.source[self.pos] == 'E'))
{
self.pos += 1;
if (self.pos < self.source.len and
(self.source[self.pos] == '+' or self.source[self.pos] == '-'))
{
self.pos += 1;
}
self.consumeDigits(isDecDigit);
}
return .{ .kind = .number, .start = start, .len = self.pos - start };
}
fn consumeDigits(self: *Tokenizer, predicate: *const fn (u8) bool) void {
while (self.pos < self.source.len) {
const ch = self.source[self.pos];
if (predicate(ch)) {
self.pos += 1;
} else if (ch == '_') {
// Digit separator
self.pos += 1;
} else {
break;
}
}
}
fn readIdentifier(self: *Tokenizer, start: usize) Token {
while (self.pos < self.source.len) {
const ch = self.source[self.pos];
if ((ch >= 'a' and ch <= 'z') or
(ch >= 'A' and ch <= 'Z') or
(ch >= '0' and ch <= '9') or
ch == '_')
{
self.pos += 1;
} else {
break;
}
}
return .{ .kind = .identifier, .start = start, .len = self.pos - start };
}
fn isHexDigit(c: u8) bool {
return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F');
}
fn isOctalDigit(c: u8) bool {
return c >= '0' and c <= '7';
}
fn isBinaryDigit(c: u8) bool {
return c == '0' or c == '1';
}
fn isDecDigit(c: u8) bool {
return c >= '0' and c <= '9';
}
};
// -- Tests --
const testing = std.testing;
test "tokenize simple arithmetic" {
var tok = Tokenizer.init("2 + 3 * 4", .standard);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.plus, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.star, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.eof, tok.next().kind);
}
test "tokenize hex number" {
var tok = Tokenizer.init("0xFF", .programmer);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0xFF", t.text("0xFF"));
}
test "tokenize binary number" {
var tok = Tokenizer.init("0b1010", .programmer);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0b1010", t.text("0b1010"));
}
test "tokenize octal number" {
var tok = Tokenizer.init("0o777", .programmer);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0o777", t.text("0o777"));
}
test "tokenize shift operators" {
var tok = Tokenizer.init("x << 3 >> 1 >>> 2", .programmer);
try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.shift_left, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.shift_right, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.shift_right_logical, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.eof, tok.next().kind);
}
test "tokenize star_star" {
var tok = Tokenizer.init("2**10", .programmer);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.star_star, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
}
test "tokenize number with underscores" {
var tok = Tokenizer.init("1_000_000", .standard);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("1_000_000", t.text("1_000_000"));
}
test "tokenize hex with underscores" {
var tok = Tokenizer.init("0xFF_FF", .programmer);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("0xFF_FF", t.text("0xFF_FF"));
}
test "tokenize function call" {
var tok = Tokenizer.init("sin(3.14)", .standard);
try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.left_paren, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
try testing.expectEqual(TokenKind.right_paren, tok.next().kind);
}
test "tokenize floating point with exponent" {
var tok = Tokenizer.init("1.5e10", .standard);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("1.5e10", t.text("1.5e10"));
}
test "tokenize negative exponent" {
var tok = Tokenizer.init("2.5e-3", .standard);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings("2.5e-3", t.text("2.5e-3"));
}
test "tokenize number starting with dot" {
var tok = Tokenizer.init(".5", .standard);
const t = tok.next();
try testing.expectEqual(TokenKind.number, t.kind);
try testing.expectEqualStrings(".5", t.text(".5"));
}
test "tokenize assignment" {
var tok = Tokenizer.init("X = 42", .standard);
try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.equals, tok.next().kind);
try testing.expectEqual(TokenKind.number, tok.next().kind);
}
test "tokenize all bitwise ops" {
var tok = Tokenizer.init("a & b | c ^ ~d", .programmer);
try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.ampersand, tok.next().kind);
try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.pipe, tok.next().kind);
try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.caret, tok.next().kind);
try testing.expectEqual(TokenKind.tilde, tok.next().kind);
try testing.expectEqual(TokenKind.identifier, tok.next().kind);
try testing.expectEqual(TokenKind.eof, tok.next().kind);
}
test "tokenize empty string" {
var tok = Tokenizer.init("", .standard);
try testing.expectEqual(TokenKind.eof, tok.next().kind);
}
test "tokenize whitespace only" {
var tok = Tokenizer.init(" \t\n ", .standard);
try testing.expectEqual(TokenKind.eof, tok.next().kind);
}
// -- parseNumber tests --
test "parseNumber decimal integer" {
const result = try parseNumber("42");
try testing.expectEqual(@as(f64, 42.0), result.float);
try testing.expectEqual(@as(?u64, 42), result.int_value);
try testing.expectEqual(Base.decimal, result.base);
}
test "parseNumber decimal with underscores" {
const result = try parseNumber("1_000_000");
try testing.expectEqual(@as(?u64, 1_000_000), result.int_value);
}
test "parseNumber hex" {
const result = try parseNumber("0xFF");
try testing.expectEqual(@as(?u64, 255), result.int_value);
try testing.expectEqual(Base.hex, result.base);
}
test "parseNumber binary" {
const result = try parseNumber("0b1010");
try testing.expectEqual(@as(?u64, 10), result.int_value);
try testing.expectEqual(Base.binary, result.base);
}
test "parseNumber octal" {
const result = try parseNumber("0o777");
try testing.expectEqual(@as(?u64, 511), result.int_value);
try testing.expectEqual(Base.octal, result.base);
}
test "parseNumber float" {
const result = try parseNumber("3.14");
try testing.expectApproxEqAbs(@as(f64, 3.14), result.float, 1e-10);
try testing.expectEqual(@as(?u64, null), result.int_value);
try testing.expectEqual(Base.decimal, result.base);
}
test "parseNumber float with exponent" {
const result = try parseNumber("1.5e10");
try testing.expectEqual(@as(f64, 1.5e10), result.float);
try testing.expectEqual(@as(?u64, null), result.int_value);
}
test "parseNumber hex with underscores" {
const result = try parseNumber("0xFF_FF");
try testing.expectEqual(@as(?u64, 0xFFFF), result.int_value);
try testing.expectEqual(Base.hex, result.base);
}
// -- ImplicitMulStream tests --
test "implicit mul: number followed by identifier (2pi)" {
var stream = ImplicitMulStream.init("2pi", .standard);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.star, stream.next().kind); // synthetic
try testing.expectEqual(TokenKind.identifier, stream.next().kind);
try testing.expectEqual(TokenKind.eof, stream.next().kind);
}
test "implicit mul: number followed by paren 3(4+5)" {
var stream = ImplicitMulStream.init("3(4+5)", .standard);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.star, stream.next().kind); // synthetic
try testing.expectEqual(TokenKind.left_paren, stream.next().kind);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.plus, stream.next().kind);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.right_paren, stream.next().kind);
try testing.expectEqual(TokenKind.eof, stream.next().kind);
}
test "implicit mul: paren followed by paren )()" {
var stream = ImplicitMulStream.init("(2)(3)", .standard);
try testing.expectEqual(TokenKind.left_paren, stream.next().kind);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.right_paren, stream.next().kind);
try testing.expectEqual(TokenKind.star, stream.next().kind); // synthetic
try testing.expectEqual(TokenKind.left_paren, stream.next().kind);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.right_paren, stream.next().kind);
try testing.expectEqual(TokenKind.eof, stream.next().kind);
}
test "implicit mul: identifier followed by paren (function call) does NOT insert star" {
// "sin(x)" - identifier followed by ( is a function call, not implicit mul.
// However, at the tokenizer level, identifier IS a value-end and ( IS a value-start,
// so the ImplicitMulStream WILL insert a star. The PARSER is responsible for
// recognizing "identifier * (" as a function call pattern (when the star is
// synthetic/zero-length) and handling it correctly.
//
// This test documents the actual stream behavior:
var stream = ImplicitMulStream.init("sin(x)", .standard);
try testing.expectEqual(TokenKind.identifier, stream.next().kind);
try testing.expectEqual(TokenKind.star, stream.next().kind); // synthetic
try testing.expectEqual(TokenKind.left_paren, stream.next().kind);
try testing.expectEqual(TokenKind.identifier, stream.next().kind);
try testing.expectEqual(TokenKind.right_paren, stream.next().kind);
try testing.expectEqual(TokenKind.eof, stream.next().kind);
}
test "no implicit mul: number + operator + number" {
var stream = ImplicitMulStream.init("2 + 3", .standard);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.plus, stream.next().kind);
try testing.expectEqual(TokenKind.number, stream.next().kind);
try testing.expectEqual(TokenKind.eof, stream.next().kind);
}

172
engine/src/types.zig Normal file
View file

@ -0,0 +1,172 @@
//! Core types shared across the Tally engine.
const std = @import("std");
/// Calculation mode determines parsing and evaluation behavior.
pub const Mode = enum {
standard,
programmer,
financial,
};
/// Configurable integer bit width for programmer mode.
pub const BitWidth = enum(u7) {
bits8 = 8,
bits16 = 16,
bits32 = 32,
bits64 = 64,
/// Returns the mask for this bit width (all bits set within width).
pub fn mask(self: BitWidth) u64 {
return switch (self) {
.bits8 => 0xFF,
.bits16 => 0xFFFF,
.bits32 => 0xFFFF_FFFF,
.bits64 => 0xFFFF_FFFF_FFFF_FFFF,
};
}
/// Returns the number of bits as a plain integer.
pub fn bits(self: BitWidth) u7 {
return @intFromEnum(self);
}
};
/// Signedness interpretation for programmer mode values.
pub const Signedness = enum {
signed,
unsigned,
};
/// Byte ordering for display purposes.
pub const Endianness = enum {
little,
big,
};
/// Number base for input/output.
pub const Base = enum {
decimal,
hex,
octal,
binary,
};
/// An integer value in programmer mode.
/// Raw bits stored in u64; interpretation depends on bit_width and signedness.
pub const Integer = struct {
raw: u64,
bit_width: BitWidth,
signedness: Signedness,
/// Apply the bit width mask, truncating to the configured width.
pub fn masked(self: Integer) u64 {
return self.raw & self.bit_width.mask();
}
/// Interpret as a signed value (sign-extend from bit_width).
pub fn signedValue(self: Integer) i64 {
const m = self.masked();
const width = self.bit_width.bits();
const sign_bit: u64 = @as(u64, 1) << @intCast(width - 1);
if (m & sign_bit != 0) {
// Sign extend: fill upper bits with 1s
const extension = ~self.bit_width.mask();
return @bitCast(m | extension);
}
return @intCast(m);
}
/// Interpret as an unsigned value (just mask).
pub fn unsignedValue(self: Integer) u64 {
return self.masked();
}
};
/// Result of any calculation.
pub const Value = union(enum) {
integer: Integer,
float: f64,
boolean: bool,
};
/// Programmer mode configuration.
pub const ProgrammerConfig = struct {
bit_width: BitWidth = .bits64,
signedness: Signedness = .signed,
display_endian: Endianness = .little,
};
/// All possible engine errors.
pub const CalcError = error{
// Parser errors
UnexpectedToken,
UnmatchedParen,
InvalidNumber,
UnknownFunction,
UnknownVariable,
UnexpectedEnd,
InvalidExpression,
// Evaluation errors
DivisionByZero,
Overflow,
InvalidOperandType,
DomainError,
// Struct layout errors
InvalidType,
InvalidFieldName,
DuplicateFieldName,
StructTooLarge,
// Financial errors
InsufficientParameters,
ConvergenceFailure,
// Unit conversion errors
UnknownUnit,
IncompatibleUnits,
// System
OutOfMemory,
};
/// Detailed error information with source position.
pub const ErrorInfo = struct {
code: CalcError,
message: []const u8,
/// Character position in input where the error occurred (0-indexed).
position: ?usize = null,
};
test "BitWidth.mask" {
try std.testing.expectEqual(@as(u64, 0xFF), BitWidth.bits8.mask());
try std.testing.expectEqual(@as(u64, 0xFFFF), BitWidth.bits16.mask());
try std.testing.expectEqual(@as(u64, 0xFFFF_FFFF), BitWidth.bits32.mask());
try std.testing.expectEqual(@as(u64, 0xFFFF_FFFF_FFFF_FFFF), BitWidth.bits64.mask());
}
test "Integer.signedValue" {
// 0xFF in 8-bit signed = -1
const i8_neg1 = Integer{ .raw = 0xFF, .bit_width = .bits8, .signedness = .signed };
try std.testing.expectEqual(@as(i64, -1), i8_neg1.signedValue());
// 0x7F in 8-bit signed = 127
const i8_127 = Integer{ .raw = 0x7F, .bit_width = .bits8, .signedness = .signed };
try std.testing.expectEqual(@as(i64, 127), i8_127.signedValue());
// 0x80 in 8-bit signed = -128
const i8_neg128 = Integer{ .raw = 0x80, .bit_width = .bits8, .signedness = .signed };
try std.testing.expectEqual(@as(i64, -128), i8_neg128.signedValue());
// 0xFFFF_FFFF in 32-bit signed = -1
const i32_neg1 = Integer{ .raw = 0xFFFF_FFFF, .bit_width = .bits32, .signedness = .signed };
try std.testing.expectEqual(@as(i64, -1), i32_neg1.signedValue());
}
test "Integer.unsignedValue masks correctly" {
// Extra bits beyond width are masked off
const val = Integer{ .raw = 0x1FF, .bit_width = .bits8, .signedness = .unsigned };
try std.testing.expectEqual(@as(u64, 0xFF), val.unsignedValue());
}

336
src/main.zig Normal file
View file

@ -0,0 +1,336 @@
const std = @import("std");
const engine = @import("engine");
/// Result of CLI evaluation - pure data, no I/O.
pub const CliResult = struct {
output: []const u8,
is_error: bool,
};
/// Parse CLI args and determine the expression and mode.
/// Returns the joined expression and mode, or an error/help output.
pub const ParsedArgs = union(enum) {
expression: struct {
text: []const u8,
mode: engine.Mode,
},
output: struct {
text: []const u8,
is_error: bool,
},
};
pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedArgs {
var mode: engine.Mode = .standard;
var expr_parts = std.ArrayList([]const u8).empty;
defer expr_parts.deinit(allocator);
for (args) |arg| {
if (std.mem.eql(u8, arg, "-p") or std.mem.eql(u8, arg, "--programmer")) {
mode = .programmer;
} else if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) {
return .{ .output = .{
.text = help_text,
.is_error = false,
} };
} else if (std.mem.eql(u8, arg, "--version")) {
return .{ .output = .{ .text = "tally 0.1.0\n", .is_error = false } };
} else {
expr_parts.append(allocator, arg) catch {
return .{ .output = .{ .text = "error: out of memory\n", .is_error = true } };
};
}
}
if (expr_parts.items.len == 0) {
return .{ .output = .{ .text = "error: no expression provided\n", .is_error = true } };
}
const expression = std.mem.join(allocator, " ", expr_parts.items) catch {
return .{ .output = .{ .text = "error: out of memory\n", .is_error = true } };
};
return .{ .expression = .{ .text = expression, .mode = mode } };
}
/// Evaluate an expression and format the result as a string.
pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: engine.Mode, buf: []u8) CliResult {
if (mode == .programmer) {
const config = engine.types.ProgrammerConfig{};
const result = engine.evalProgrammerString(allocator, expression, config) catch |err| {
return .{ .output = errorMessage(err), .is_error = true };
};
return formatProgrammerResult(buf, result, config);
}
var env = engine.Environment.init(allocator, .standard);
defer env.deinit();
const result = engine.evalString(&env, allocator, expression) catch |err| {
return .{ .output = errorMessage(err), .is_error = true };
};
const formatted = engine.formatter.formatFloat(buf, result);
return .{ .output = formatted.display, .is_error = false };
}
fn formatProgrammerResult(buf: []u8, result: engine.types.Integer, config: engine.types.ProgrammerConfig) CliResult {
const value = result.unsignedValue();
const signed = result.signedValue();
// Format into sections of buf
// We'll build the output string piece by piece
var hex_buf: [256]u8 = undefined;
var dec_buf: [256]u8 = undefined;
var sdec_buf: [256]u8 = undefined;
var bin_buf: [512]u8 = undefined;
const hex = engine.formatter.formatHex(&hex_buf, value, config.bit_width);
const dec = engine.formatter.formatDecimalUnsigned(&dec_buf, value);
const sdec = engine.formatter.formatDecimalSigned(&sdec_buf, signed);
const bin = engine.formatter.formatBinary(&bin_buf, value, config.bit_width);
const output = std.fmt.bufPrint(buf,
\\ dec(signed): {s}
\\ dec(unsigned): {s}
\\ hex: {s}
\\ bin: {s}
\\
, .{ sdec.display, dec.display, hex.display, bin.display }) catch {
return .{ .output = "error: buffer overflow\n", .is_error = true };
};
return .{ .output = output, .is_error = false };
}
fn errorMessage(err: engine.CalcError) []const u8 {
return switch (err) {
engine.CalcError.DivisionByZero => "error: division by zero\n",
engine.CalcError.UnknownFunction => "error: unknown function\n",
engine.CalcError.UnknownVariable => "error: unknown variable\n",
engine.CalcError.UnmatchedParen => "error: unmatched parenthesis\n",
engine.CalcError.UnexpectedToken => "error: unexpected token\n",
engine.CalcError.UnexpectedEnd => "error: unexpected end of expression\n",
engine.CalcError.InvalidNumber => "error: invalid number\n",
engine.CalcError.InvalidExpression => "error: invalid expression\n",
engine.CalcError.DomainError => "error: domain error\n",
engine.CalcError.Overflow => "error: overflow\n",
else => "error: evaluation error\n",
};
}
const help_text =
\\tally - a cross-platform calculator
\\
\\Usage: tally [OPTIONS] <expression>
\\
\\Options:
\\ -p, --programmer Programmer mode (^ = XOR, ** = power)
\\ -h, --help Show this help
\\ --version Show version
\\
;
const usage_text =
\\tally - a cross-platform calculator
\\
\\Usage:
\\ tally "<expression>" Evaluate expression
\\ tally -p "<expression>" Programmer mode
\\ tally --help Show help
\\
\\Examples:
\\ tally "2^32 - 1"
\\ tally -p "0xFF & 0x0F"
\\ tally "sin(pi/2) + 1"
\\
;
// -- Entry point: thin I/O shell over pure logic --
pub fn main(init: std.process.Init) u8 {
const allocator = init.arena.allocator();
const io = init.io;
const args = init.minimal.args.toSlice(allocator) catch {
write(io, std.Io.File.stderr(), "error: failed to read arguments\n");
return 1;
};
if (args.len <= 1) {
write(io, std.Io.File.stdout(), usage_text);
return 0;
}
const parsed = parseArgs(allocator, args[1..]);
switch (parsed) {
.output => |out| {
const file = if (out.is_error) std.Io.File.stderr() else std.Io.File.stdout();
write(io, file, out.text);
return if (out.is_error) @as(u8, 1) else 0;
},
.expression => |expr| {
var buf: [4096]u8 = undefined;
const result = evaluate(allocator, expr.text, expr.mode, &buf);
const file = if (result.is_error) std.Io.File.stderr() else std.Io.File.stdout();
write(io, file, result.output);
if (!result.is_error) write(io, std.Io.File.stdout(), "\n");
return if (result.is_error) @as(u8, 1) else 0;
},
}
}
fn write(io: std.Io, file: std.Io.File, msg: []const u8) void {
var buf: [4096]u8 = undefined;
var writer = file.writer(io, &buf);
writer.interface.writeAll(msg) catch return;
writer.interface.flush() catch return;
}
// -- Unit tests --
const testing = std.testing;
test "parseArgs: simple expression" {
const parsed = parseArgs(testing.allocator, &.{"2+2"});
switch (parsed) {
.expression => |e| {
try testing.expectEqualStrings("2+2", e.text);
try testing.expectEqual(engine.Mode.standard, e.mode);
testing.allocator.free(e.text);
},
.output => unreachable,
}
}
test "parseArgs: multi-arg expression joins with spaces" {
const parsed = parseArgs(testing.allocator, &.{ "2", "+", "2" });
switch (parsed) {
.expression => |e| {
try testing.expectEqualStrings("2 + 2", e.text);
testing.allocator.free(e.text);
},
.output => unreachable,
}
}
test "parseArgs: programmer flag" {
const parsed = parseArgs(testing.allocator, &.{ "-p", "0xFF" });
switch (parsed) {
.expression => |e| {
try testing.expectEqual(engine.Mode.programmer, e.mode);
try testing.expectEqualStrings("0xFF", e.text);
testing.allocator.free(e.text);
},
.output => unreachable,
}
}
test "parseArgs: --programmer long flag" {
const parsed = parseArgs(testing.allocator, &.{ "--programmer", "0xF0", "|", "0x0F" });
switch (parsed) {
.expression => |e| {
try testing.expectEqual(engine.Mode.programmer, e.mode);
try testing.expectEqualStrings("0xF0 | 0x0F", e.text);
testing.allocator.free(e.text);
},
.output => unreachable,
}
}
test "parseArgs: --help" {
const parsed = parseArgs(testing.allocator, &.{"--help"});
switch (parsed) {
.output => |out| {
try testing.expect(!out.is_error);
try testing.expect(std.mem.startsWith(u8, out.text, "tally"));
},
.expression => unreachable,
}
}
test "parseArgs: --version" {
const parsed = parseArgs(testing.allocator, &.{"--version"});
switch (parsed) {
.output => |out| {
try testing.expect(!out.is_error);
try testing.expectEqualStrings("tally 0.1.0\n", out.text);
},
.expression => unreachable,
}
}
test "parseArgs: no expression" {
const parsed = parseArgs(testing.allocator, &.{"-p"});
switch (parsed) {
.output => |out| {
try testing.expect(out.is_error);
try testing.expectEqualStrings("error: no expression provided\n", out.text);
},
.expression => unreachable,
}
}
test "evaluate: standard arithmetic" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "2 + 2", .standard, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("4", result.output);
}
test "evaluate: large number has commas" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "2^32 - 1", .standard, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("4,294,967,295", result.output);
}
test "evaluate: function" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "sin(pi/2) + 1", .standard, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("2", result.output);
}
test "evaluate: implicit mul" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "3(4+5)", .standard, &buf);
try testing.expect(!result.is_error);
try testing.expectEqualStrings("27", result.output);
}
test "evaluate: programmer mode" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "0xFF & 0x0F", .programmer, &buf);
try testing.expect(!result.is_error);
try testing.expect(std.mem.indexOf(u8, result.output, "15") != null);
try testing.expect(std.mem.indexOf(u8, result.output, "0x0000_0000_0000_000F") != null);
}
test "evaluate: division by zero error" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "1/0", .standard, &buf);
try testing.expect(result.is_error);
try testing.expectEqualStrings("error: division by zero\n", result.output);
}
test "evaluate: unknown variable error" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
var buf: [4096]u8 = undefined;
const result = evaluate(arena.allocator(), "xyz + 1", .standard, &buf);
try testing.expect(result.is_error);
try testing.expectEqualStrings("error: unknown variable\n", result.output);
}