From 58a94dd163949642f157dc1f9f9e5e1bd6c5238f Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 17 Jul 2026 15:07:34 -0700 Subject: [PATCH] initial commit: ai slop, not reviewed --- .gitignore | 17 + .mise.toml | 7 + .pre-commit-config.yaml | 51 +++ build.zig | 97 ++++++ build.zig.zon | 14 + build/Coverage.zig | 215 +++++++++++++ build/bcov.css | 45 +++ build/download_kcov.zig | 80 +++++ engine/src/ast.zig | 69 ++++ engine/src/c_api.zig | 38 +++ engine/src/engine.zig | 30 ++ engine/src/evaluator.zig | 530 +++++++++++++++++++++++++++++++ engine/src/formatter.zig | 487 ++++++++++++++++++++++++++++ engine/src/parser.zig | 567 +++++++++++++++++++++++++++++++++ engine/src/programmer.zig | 374 ++++++++++++++++++++++ engine/src/tokenizer.zig | 644 ++++++++++++++++++++++++++++++++++++++ engine/src/types.zig | 172 ++++++++++ src/main.zig | 336 ++++++++++++++++++++ 18 files changed, 3773 insertions(+) create mode 100644 .gitignore create mode 100644 .mise.toml create mode 100644 .pre-commit-config.yaml create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 build/Coverage.zig create mode 100644 build/bcov.css create mode 100644 build/download_kcov.zig create mode 100644 engine/src/ast.zig create mode 100644 engine/src/c_api.zig create mode 100644 engine/src/engine.zig create mode 100644 engine/src/evaluator.zig create mode 100644 engine/src/formatter.zig create mode 100644 engine/src/parser.zig create mode 100644 engine/src/programmer.zig create mode 100644 engine/src/tokenizer.zig create mode 100644 engine/src/types.zig create mode 100644 src/main.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..073a8f3 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..bd31894 --- /dev/null +++ b/.mise.toml @@ -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" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d2fc35b --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..8f1764e --- /dev/null +++ b/build.zig @@ -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); + } +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..16f9fc4 --- /dev/null +++ b/build.zig.zon @@ -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 = .{}, +} diff --git a/build/Coverage.zig b/build/Coverage.zig new file mode 100644 index 0000000..cebd734 --- /dev/null +++ b/build/Coverage.zig @@ -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 }); +} diff --git a/build/bcov.css b/build/bcov.css new file mode 100644 index 0000000..d869903 --- /dev/null +++ b/build/bcov.css @@ -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; +} diff --git a/build/download_kcov.zig b/build/download_kcov.zig new file mode 100644 index 0000000..d7fafde --- /dev/null +++ b/build/download_kcov.zig @@ -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(); +} diff --git a/engine/src/ast.zig b/engine/src/ast.zig new file mode 100644 index 0000000..abdd298 --- /dev/null +++ b/engine/src/ast.zig @@ -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 +}; diff --git a/engine/src/c_api.zig b/engine/src/c_api.zig new file mode 100644 index 0000000..eeab813 --- /dev/null +++ b/engine/src/c_api.zig @@ -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; +} diff --git a/engine/src/engine.zig b/engine/src/engine.zig new file mode 100644 index 0000000..9ea034f --- /dev/null +++ b/engine/src/engine.zig @@ -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"); diff --git a/engine/src/evaluator.zig b/engine/src/evaluator.zig new file mode 100644 index 0000000..43da003 --- /dev/null +++ b/engine/src/evaluator.zig @@ -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); +} diff --git a/engine/src/formatter.zig b/engine/src/formatter.zig new file mode 100644 index 0000000..29fba34 --- /dev/null +++ b/engine/src/formatter.zig @@ -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); +} diff --git a/engine/src/parser.zig b/engine/src/parser.zig new file mode 100644 index 0000000..66750b4 --- /dev/null +++ b/engine/src/parser.zig @@ -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); +} diff --git a/engine/src/programmer.zig b/engine/src/programmer.zig new file mode 100644 index 0000000..8943136 --- /dev/null +++ b/engine/src/programmer.zig @@ -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); +} diff --git a/engine/src/tokenizer.zig b/engine/src/tokenizer.zig new file mode 100644 index 0000000..6737ea4 --- /dev/null +++ b/engine/src/tokenizer.zig @@ -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); +} diff --git a/engine/src/types.zig b/engine/src/types.zig new file mode 100644 index 0000000..b4f34bc --- /dev/null +++ b/engine/src/types.zig @@ -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()); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..af4fb2b --- /dev/null +++ b/src/main.zig @@ -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] + \\ + \\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 "" Evaluate expression + \\ tally -p "" 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); +}