diff --git a/engine/src/c_api.zig b/engine/src/c_api.zig index eeab813..3af7d07 100644 --- a/engine/src/c_api.zig +++ b/engine/src/c_api.zig @@ -1,38 +1,1439 @@ -//! C ABI exports for Tally engine. +//! The C ABI: the seam every non-Zig frontend calls through. //! -//! All string inputs use (pointer, length) pairs - no null-terminated strings. -//! Callers must free results via tally_result_free(). +//! Written for Android first (design 6.1-6.4) but named and shaped for any consumer: +//! Swift and C link the same library and call these functions directly, and the +//! Android-specific JNI translation is a separate layer on top of this one. +//! +//! ## Three rules a caller has to know +//! +//! 1. **A session holds the memory.** `tally_session_new` returns a handle that owns +//! the variables, the last answer, and the buffer results are written into. +//! `tally_session_free` releases all of it. There is no per-result free function, +//! and that absence is deliberate: a free that can be called with the wrong +//! allocator, or after its session has gone, is memory corruption rather than an +//! error. +//! 2. **A result is borrowed.** The bytes handed back live in the session's buffer and +//! stay valid until the next call on the same session. Copy them before calling +//! again. Every managed binding does that anyway: JNI into a `ByteArray`, Swift into +//! a `String`. +//! 3. **One session, one thread at a time.** Two sessions share nothing, so threads +//! can each hold their own. A single session used from two threads at once is the +//! caller's bug; this library takes no locks. +//! +//! ## Results are JSON +//! +//! A result is more than a number in several places - the multi-base rows, the units +//! catalogue - so the payload is JSON rather than a widening pile of out-parameters. +//! It is written by `std.json`, so escaping is not hand-rolled here. Errors come back +//! as `{"ok":false,"error":"..."}` using `engine.phrase`, which is the same wording the +//! CLI and TUI print: a frontend should not carry its own error table. -pub const CalcResult = extern struct { - json_ptr: ?[*]u8, - json_len: usize, - error_ptr: ?[*]u8, - error_len: usize, +const std = @import("std"); +const engine = @import("engine.zig"); +const Rational = @import("Rational.zig"); + +/// Bumped whenever a signature, a struct layout, a status code or the JSON shape +/// changes. Android ships a prebuilt library that can fall out of step with the code +/// calling it, and a silently changed layout is a crash in the field rather than an +/// error, so the version is readable separately from the product version. +const abi_version: u32 = 5; + +/// How a call ended. The evaluation itself failing is a normal outcome with a message +/// in the JSON, not an exceptional one. +pub const Status = enum(c_int) { + ok = 0, + /// The expression was read and could not be evaluated. The result JSON carries the + /// reason, so a caller can show it without a second call. + eval_error = 1, + /// No JSON: the result is an empty string, because there may be no memory to write + /// a message with. The session is still usable. + out_of_memory = 2, + /// The call was refused: a null session, a null out-parameter, an unknown mode, or + /// a saved state `tally_session_load` will not read. No result is produced, and + /// the session is unchanged. + invalid_argument = 3, }; -export fn tally_eval( - expr_ptr: [*]const u8, +/// Which evaluator runs. The CLI and TUI make the same choice per evaluation rather +/// than storing it, and so does this. +pub const Mode = enum(c_int) { + standard = 0, + programmer = 1, +}; + +/// Everything a frontend gets to decide about interpretation and display, set once on +/// the session rather than passed on every keystroke. +/// +/// The display fields are NFR-9.9: the engine holds no digit budget of its own, because +/// a phone screen, an 80-column terminal and a clipboard want different ones. These +/// defaults are a starting point, not the engine's opinion. +pub const Config = extern struct { + /// Programmer-mode width: 8, 16, 32, 64 or 128. Anything else is rejected. + bits: u16 = 64, + /// Read programmer values as two's complement. + is_signed: u8 = 1, + /// Byte order of the hex and ASCII rows. Display only; the value is unchanged. + big_endian: u8 = 1, + /// Thousands separators in rendered integers. + separators: u8 = 1, + /// Never abbreviate long integers to scientific notation, whatever + /// `max_integer_digits` says. This is what a clipboard wants. + never_abbreviate: u8 = 0, + /// Fractional digits a rendering may use. + fraction_digits: u16 = 20, + /// Integer digits shown before a value abbreviates to scientific notation. + max_integer_digits: u16 = 40, + /// Significant digits kept in the scientific form. + significant_digits: u16 = 17, + /// Fractional places past which a small value goes scientific instead of + /// spending the budget on leading zeros. + scientific_below_exponent: u16 = 15, + + fn formatOptions(self: Config) engine.Number.FormatOptions { + return .{ + .fraction_digits = self.fraction_digits, + .scientific_below_exponent = self.scientific_below_exponent, + .max_integer_digits = if (self.never_abbreviate != 0) null else self.max_integer_digits, + .significant_digits = self.significant_digits, + .separators = self.separators != 0, + }; + } + + fn programmerConfig(self: Config) ?engine.programmer.Config { + const width: engine.BitWidth = switch (self.bits) { + 8 => .bits8, + 16 => .bits16, + 32 => .bits32, + 64 => .bits64, + 128 => .bits128, + else => return null, + }; + return .{ + .width = width, + .signedness = if (self.is_signed != 0) .signed else .unsigned, + .display_endian = if (self.big_endian != 0) .big else .little, + }; + } +}; + +// -- The session -- + +/// A calculator that remembers things: variables, the last answer, and one buffer for +/// whatever it last said. +/// +/// Opaque to C. The allocator is a field rather than a global so the tests can inject +/// `std.testing.allocator` and hold this whole file to a leak check; only +/// `tally_session_new` picks a default. +/// +/// A value, built with `init` and released with `deinit`, like any Zig struct that +/// owns memory. Where it lives is the caller's choice: the tests keep one on the +/// stack, and `tally_session_new` puts one on the heap because C needs a pointer that +/// stays put. Not to be moved during a call: `arena.allocator()` points at the arena +/// inside this struct. Between calls nothing holds that pointer, so moving is safe. +pub const Session = struct { + allocator: std.mem.Allocator, + /// Variables and `Ans`. Clones into `allocator`, so they survive the arena reset + /// below - which is the reason this design works at all. + env: engine.Environment, + /// Everything one call allocates: the parse tree, intermediate rationals, rendered + /// text. Reset with `retain_capacity` after every call, so the second evaluation + /// and every one after it reuse the first one's pages. + /// + /// Nothing allocated from here is ever individually freed. The reset is the + /// teardown, and a `deinit` in the same scope would run after it. + arena: std.heap.ArenaAllocator, + /// The bytes the caller borrows, rebuilt per call and never reallocated smaller. + /// Always carries a NUL after the JSON, which `json_len` excludes: the length is + /// authoritative, and the terminator is a convenience for callers whose next step is + /// a C string (JNI's `NewStringUTF`, or a `printf`). + out: std.ArrayList(u8), + json_len: usize, + config: Config, + + /// Cannot fail: nothing here allocates until the first call. The only allocation a + /// new session needs is the one that puts it on the heap, and that belongs to + /// whoever wants it there. + pub fn init(allocator: std.mem.Allocator) Session { + return .{ + .allocator = allocator, + .env = engine.Environment.init(allocator), + .arena = std.heap.ArenaAllocator.init(allocator), + .out = .empty, + .json_len = 0, + .config = .{}, + }; + } + + /// Release everything the session owns. Not the session itself: it does not know + /// where it lives. + pub fn deinit(self: *Session) void { + self.out.deinit(self.allocator); + self.arena.deinit(); + self.env.deinit(); + self.* = undefined; + } + + /// The start of every call: forget the last result, and hand out the scratch arena. + /// + /// The caller defers `self.arena.reset(.retain_capacity)` itself, because a reset + /// deferred in here would run on the way out of this function. Every path out of a + /// call either writes new JSON or leaves none: never the last call's bytes, which + /// are still sitting in the buffer's spare capacity. + fn begin(self: *Session) std.mem.Allocator { + self.out.clearRetainingCapacity(); + self.json_len = 0; + return self.arena.allocator(); + } + + /// What a caller borrows after a call: the JSON it wrote, NUL-terminated, or an empty + /// NUL-terminated string when it wrote none (out of memory, or a refused call). + pub fn result(self: *const Session) [:0]const u8 { + if (self.json_len == 0) return ""; + return self.out.items[0..self.json_len :0]; + } + + /// Evaluate `source` and leave the JSON in `out`. + pub fn eval(self: *Session, source: []const u8, mode: Mode) Status { + return self.run(.commit, source, mode); + } + + /// The same answer `eval` would give, with nothing stored: no variable is + /// assigned and `Ans` is unchanged (design 6.5). + pub fn preview(self: *Session, source: []const u8, mode: Mode) Status { + return self.run(.preview, source, mode); + } + + const Effect = enum { commit, preview }; + + fn run(self: *Session, comptime effect: Effect, source: []const u8, mode: Mode) Status { + defer _ = self.arena.reset(.retain_capacity); + const scratch = self.begin(); + return switch (mode) { + .standard => self.evalStandard(effect, scratch, source), + // Programmer mode never touches the environment, so its preview is its + // evaluation. + .programmer => self.evalProgrammer(scratch, source), + }; + } + + /// The engine call for each effect. A preview goes through `previewStringInfo`, + /// which takes the environment as `const`. + fn evaluate(self: *Session, comptime effect: Effect, scratch: std.mem.Allocator, source: []const u8) engine.Error!engine.evaluator.EvalInfo { + return switch (effect) { + .commit => engine.evalStringInfo(&self.env, scratch, source), + .preview => engine.previewStringInfo(&self.env, scratch, source), + }; + } + + /// Standard mode, which includes unit conversion: a bare `100 km to mi` converts, + /// exactly as it does in the CLI and the TUI, because the trigger is the `to` + /// keyword rather than a mode (FR-4.1). + fn evalStandard(self: *Session, comptime effect: Effect, scratch: std.mem.Allocator, source: []const u8) Status { + if (engine.units.parseRequest(source)) |maybe_request| { + if (maybe_request) |request| return self.evalConversion(effect, scratch, request); + } else |err| return self.writeError(err); + + const info = self.evaluate(effect, scratch, source) catch |err| { + return self.writeError(err); + }; + const shown = info.value.render(scratch, self.config.formatOptions()) catch |err| { + return self.writeError(err); + }; + + // The multi-base rows standard mode adds when the expression was written in + // another base and the answer is a whole number that fits a pattern (FR-1.9). + var bases: ?Bases = null; + const as_float = info.value.toFloat(scratch); + if (info.has_nondecimal_literal and isDisplayableInt(as_float)) { + bases = basesOf(scratch, @intFromFloat(as_float)) catch |err| { + return self.writeError(err); + }; + } + + return self.writeJson(.{ + .ok = true, + .display = shown.text, + .exact = info.value == .exact, + .truncated = shown.truncated, + .bases = bases, + }); + } + + fn evalConversion( + self: *Session, + comptime effect: Effect, + scratch: std.mem.Allocator, + request: engine.units.ConversionRequest, + ) Status { + const info = self.evaluate(effect, scratch, request.value_text) catch |err| { + return self.writeError(err); + }; + const value = info.value; + const converted = engine.units.convertExactUnits(scratch, value, request.from, request.to) catch |err| { + return self.writeError(err); + }; + const options = self.config.formatOptions(); + const shown_in = value.render(scratch, options) catch |err| return self.writeError(err); + const shown_out = converted.render(scratch, options) catch |err| return self.writeError(err); + + return self.writeJson(.{ + .ok = true, + .display = shown_out.text, + .exact = converted == .exact, + .truncated = shown_out.truncated, + .conversion = .{ + .input = shown_in.text, + .from = request.from.name, + .to = request.to.name, + }, + }); + } + + /// Programmer mode: one value, every row the screen shows, rendered here so that + /// grouping and byte order are decided once (NFR-7) rather than in Kotlin. + fn evalProgrammer(self: *Session, scratch: std.mem.Allocator, source: []const u8) Status { + const config = self.config.programmerConfig() orelse return .invalid_argument; + const int = engine.evalProgrammerString(scratch, source, config) catch |err| { + return self.writeError(err); + }; + const rows = rowsOf(scratch, int, config) catch |err| return self.writeError(err); + + return self.writeJson(.{ + .ok = true, + .display = rows.dec_signed, + .exact = true, + .truncated = false, + .bits = config.width.bits(), + .rows = rows, + }); + } + + /// The units catalogue, so a picker is filled from the engine's tables rather than + /// from a list retyped in the frontend. + pub fn unitCatalog(self: *Session) Status { + defer _ = self.arena.reset(.retain_capacity); + const scratch = self.begin(); + + var categories: std.ArrayList(CategoryJson) = .empty; + for (std.enums.values(engine.UnitCategory)) |category| { + const table = engine.units.unitsIn(category); + var units: std.ArrayList(UnitJson) = .empty; + for (table) |unit| { + units.append(scratch, .{ + .name = unit.name, + .label = unit.label, + .aliases = unit.aliases, + .exact = unit.factor_text != null, + }) catch return .out_of_memory; + } + categories.append(scratch, .{ + .name = @tagName(category), + .label = category.label(), + .base_unit = category.baseUnit(), + .units = units.items, + }) catch return .out_of_memory; + } + + return self.writeJson(.{ .ok = true, .categories = categories.items }); + } + + /// One value, in every unit of its unit's category (design 6.6). + /// + /// What a converter screen shows: type a number once and read it in all the units + /// that make sense, rather than choosing a target first. The value is an + /// expression, evaluated as a preview, so `6*12` works and nothing is stored. A + /// conversion that fails for one unit - overflow, say - carries its own error + /// and leaves the rest of the list alone. + pub fn convertAll(self: *Session, value_source: []const u8, unit_name: []const u8) Status { + defer _ = self.arena.reset(.retain_capacity); + const scratch = self.begin(); + + const from = engine.units.findUnit(unit_name) orelse return self.writeError(error.UnknownUnit); + const info = engine.previewStringInfo(&self.env, scratch, value_source) catch |err| { + return self.writeError(err); + }; + const options = self.config.formatOptions(); + const shown_in = info.value.render(scratch, options) catch |err| return self.writeError(err); + + const table = engine.units.unitsIn(from.category); + const rows = scratch.alloc(ConvertedJson, table.len) catch return .out_of_memory; + for (table, rows) |to, *row| { + row.* = .{ .unit = to.name }; + const converted = engine.units.convertExactUnits(scratch, info.value, from, to) catch |err| { + if (err == error.OutOfMemory) return .out_of_memory; + row.@"error" = engine.phrase(err); + continue; + }; + const shown = converted.render(scratch, options) catch |err| { + if (err == error.OutOfMemory) return .out_of_memory; + row.@"error" = engine.phrase(err); + continue; + }; + row.display = shown.text; + row.exact = converted == .exact; + row.truncated = shown.truncated; + } + + return self.writeJson(.{ + .ok = true, + .input = shown_in.text, + .from = from.name, + .category = @tagName(from.category), + .results = rows, + }); + } + + // -- Saving and loading (design 6.7) -- + + /// The saved form's version, separate from the ABI's: the layout of the JSON can + /// change without a function changing, and a loader must refuse what it cannot read. + const saved_format: u32 = 1; + + /// A value as saved. Exactly one field is set. + const SavedValue = struct { + /// An exact value, as `toFractionString` writes it: every digit, no rounding. + exact: ?[]const u8 = null, + /// An inexact value's IEEE-754 bits as 16 hex digits - the float itself, so it + /// comes back as the same float rather than as a decimal near it. + bits: ?[]const u8 = null, + }; + + const SavedVariable = struct { name: []const u8, value: SavedValue }; + + const SavedState = struct { format: u32, ans: SavedValue, variables: []const SavedVariable }; + + /// The session's variables and `Ans` as JSON, exactly: what `load` needs to put the + /// session back as it was. The configuration is not included - it is the frontend's, + /// and it sets it. + /// + /// Variables are listed by name, so the same session always saves to the same bytes. + pub fn save(self: *Session) Status { + defer _ = self.arena.reset(.retain_capacity); + const scratch = self.begin(); + + const names = scratch.alloc([]const u8, self.env.variables.count()) catch return .out_of_memory; + var it = self.env.variables.keyIterator(); + var i: usize = 0; + while (it.next()) |key| : (i += 1) names[i] = key.*; + std.mem.sortUnstable([]const u8, names, {}, lessThan); + + const variables = scratch.alloc(SavedVariable, names.len) catch return .out_of_memory; + for (names, variables) |name, *saved| { + const value = self.env.variables.get(name).?; + saved.* = .{ .name = name, .value = encodeValue(scratch, value) catch return .out_of_memory }; + } + const ans = encodeValue(scratch, self.env.ans) catch return .out_of_memory; + return self.writeJson(SavedState{ .format = saved_format, .ans = ans, .variables = variables }); + } + + /// Replace the session's variables and `Ans` with what `save` wrote. + /// + /// All or nothing: the whole text is read and checked before anything is replaced, + /// so a rejected load leaves the session exactly as it was. Rejected: text that is + /// not a saved state, an unknown format, a name an assignment could not have made + /// (`1x`, `pi`), a name twice, and a value that is not exactly one well-formed form. + pub fn load(self: *Session, text: []const u8) Status { + defer _ = self.arena.reset(.retain_capacity); + const scratch = self.begin(); + + const saved = std.json.parseFromSliceLeaky(SavedState, scratch, text, .{ + .ignore_unknown_fields = true, + }) catch |err| return if (err == error.OutOfMemory) .out_of_memory else .invalid_argument; + if (saved.format != saved_format) return .invalid_argument; + + // Built beside the live environment and swapped in only once it is complete. + var next = engine.Environment.init(self.allocator); + var installed = false; + defer if (!installed) next.deinit(); + + for (saved.variables) |variable| { + if (!isVariableName(variable.name)) return .invalid_argument; + if (next.variables.contains(variable.name)) return .invalid_argument; + const value = decodeValue(scratch, variable.value) catch |err| return loadStatus(err); + next.setVar(variable.name, value) catch return .out_of_memory; + } + const ans = decodeValue(scratch, saved.ans) catch |err| return loadStatus(err); + next.setAns(ans) catch return .out_of_memory; + + self.env.deinit(); + self.env = next; + installed = true; + return .ok; + } + + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + + fn encodeValue(scratch: std.mem.Allocator, value: engine.Number) error{OutOfMemory}!SavedValue { + return switch (value) { + .exact => |r| .{ .exact = r.toFractionString(scratch) catch return error.OutOfMemory }, + .inexact => |f| .{ .bits = try std.fmt.allocPrint(scratch, "{x:0>16}", .{@as(u64, @bitCast(f))}) }, + }; + } + + const LoadError = error{ OutOfMemory, Malformed }; + + fn loadStatus(err: LoadError) Status { + return if (err == error.OutOfMemory) .out_of_memory else .invalid_argument; + } + + fn decodeValue(scratch: std.mem.Allocator, saved: SavedValue) LoadError!engine.Number { + if (saved.exact) |text| { + if (saved.bits != null) return error.Malformed; + const r = Rational.parseFraction(scratch, text) catch |err| { + return if (err == error.OutOfMemory) error.OutOfMemory else error.Malformed; + }; + return engine.Number.fromRational(scratch, r); + } + const hex = saved.bits orelse return error.Malformed; + if (hex.len != 16) return error.Malformed; + for (hex) |c| { + if (!std.ascii.isHex(c)) return error.Malformed; + } + const bits = std.fmt.parseInt(u64, hex, 16) catch return error.Malformed; + return engine.Number.fromFloat(@bitCast(bits)); + } + + /// A name an assignment could have stored: the tokenizer's identifier, and not one + /// of the names the environment answers itself. + fn isVariableName(name: []const u8) bool { + if (name.len == 0) return false; + if (!std.ascii.isAlphabetic(name[0]) and name[0] != '_') return false; + for (name) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '_') return false; + } + return !engine.Environment.isBuiltIn(name); + } + + // -- JSON payloads -- + // + // Structs rather than hand-written text: `std.json` escapes for us, and a field + // added here cannot forget its comma. Null optionals are omitted, so a standard + // result does not carry an empty `rows` object. + + const Bases = struct { hex: []const u8, oct: []const u8, bin: []const u8 }; + + const Rows = struct { + dec_signed: []const u8, + dec_unsigned: []const u8, + hex: []const u8, + oct: []const u8, + bin: []const u8, + ascii: []const u8, + }; + + const UnitJson = struct { name: []const u8, label: []const u8, aliases: []const []const u8, exact: bool }; + + /// One row of `convertAll`: the value in `unit`, or why it could not be had. + const ConvertedJson = struct { + unit: []const u8, + display: ?[]const u8 = null, + exact: bool = false, + truncated: bool = false, + @"error": ?[]const u8 = null, + }; + + const CategoryJson = struct { + name: []const u8, + label: []const u8, + base_unit: []const u8, + units: []const UnitJson, + }; + + fn basesOf(scratch: std.mem.Allocator, raw: u128) !Bases { + const int: engine.Integer = .{ + .raw = raw, + .width = engine.BitWidth.displayFor(raw), + .signedness = .unsigned, + }; + return .{ + .hex = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.hex, .{})}), + .oct = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.octal, .{})}), + .bin = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.binary, .{})}), + }; + } + + fn rowsOf(scratch: std.mem.Allocator, int: engine.Integer, config: engine.programmer.Config) !Rows { + const endian = config.display_endian; + return .{ + .dec_signed = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.decimal_signed, .{})}), + .dec_unsigned = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.decimal_unsigned, .{})}), + .hex = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.hex, .{ .endian = endian })}), + .oct = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.octal, .{})}), + .bin = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.binary, .{})}), + .ascii = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.ascii, .{ .endian = endian })}), + }; + } + + /// Write `payload` as the result. All or nothing: a write that fails partway leaves + /// no JSON rather than the fragment it got to. + fn writeJson(self: *Session, payload: anytype) Status { + var writer: std.Io.Writer.Allocating = .fromArrayList(self.allocator, &self.out); + const written = blk: { + std.json.Stringify.value(payload, .{ .emit_null_optional_fields = false }, &writer.writer) catch { + break :blk false; + }; + // A terminator past the reported length, so a caller that needs a C string + // does not have to copy the bytes to get one. JNI's `NewStringUTF` wants it. + writer.writer.writeByte(0) catch break :blk false; + break :blk true; + }; + self.out = writer.toArrayList(); + if (!written) { + self.out.clearRetainingCapacity(); + self.json_len = 0; + return .out_of_memory; + } + self.json_len = self.out.items.len - 1; + return .ok; + } + + /// An engine error as JSON, worded by `engine.phrase` so every frontend says the + /// same thing. Out of memory is a status rather than a message: there may be no + /// memory to write the message with. + fn writeError(self: *Session, err: engine.Error) Status { + if (err == error.OutOfMemory) return .out_of_memory; + const status = self.writeJson(.{ .ok = false, .@"error" = engine.phrase(err) }); + return if (status == .ok) .eval_error else status; + } +}; + +/// Whether an f64 names a whole number a `u128` pattern can hold, which is what the +/// multi-base rows need. The CLI has the same test for the same reason. +fn isDisplayableInt(value: f64) bool { + return value >= 0 and value == @trunc(value) and value < 340282366920938463463374607431768211456.0; +} + +// -- Exports -- + +/// Create a session. Returns null when there is no memory for one. +/// +/// `std.heap.page_allocator` is the default here and only here, and the choice is forced +/// by the loader rather than by taste (design 6.2). This library declares no +/// `DT_NEEDED`, so every symbol it references has to be one it defines itself. +/// `smp_allocator` keeps its per-thread state in a thread-local, which emits a TLS +/// segment and an undefined `__tls_get_addr` that only Bionic's libc can satisfy: it +/// links on a desktop and fails in `dlopen` on a device, before any code here runs. +/// +/// Page granularity is affordable because almost nothing comes straight from here: the +/// arena below buys pages in chunks and hands out bytes, and `out` grows geometrically. +/// What does hit it per allocation is an `Environment` variable clone, which rounds up +/// to a page - 16 KiB on a device configured that way. That is bounded by the number of +/// variables a person types. `std.heap.DebugAllocator` over this one is the upgrade if +/// that ever matters; it is also free of the symbols above, so the choice is open. +/// +/// Tests build a `Session` with `init` and their own allocator instead. +pub export fn tally_session_new() callconv(.c) ?*Session { + const allocator = std.heap.page_allocator; + const session = allocator.create(Session) catch return null; + session.* = .init(allocator); + return session; +} + +/// Release a session and everything it owns, including any result still borrowed from +/// it. Null is accepted so a caller's cleanup path needs no check. +pub export fn tally_session_free(session: ?*Session) callconv(.c) void { + const s = session orelse return; + // Read before `deinit`, which leaves the struct undefined. + const allocator = s.allocator; + s.deinit(); + allocator.destroy(s); +} + +/// Replace the session's configuration. Rejects a width that is not one of the five +/// supported, so a bad setting is reported here rather than on the next evaluation. +pub export fn tally_session_configure(session: ?*Session, config: ?*const Config) callconv(.c) Status { + const s = session orelse return .invalid_argument; + const c = config orelse return .invalid_argument; + if (c.programmerConfig() == null) return .invalid_argument; + s.config = c.*; + return .ok; +} + +/// Read the session's configuration, including the defaults a caller never set. +pub export fn tally_session_config(session: ?*Session, out: ?*Config) callconv(.c) Status { + const s = session orelse return .invalid_argument; + const o = out orelse return .invalid_argument; + o.* = s.config; + return .ok; +} + +/// The end of every export that returns a result: point the caller at it. +/// +/// One place, so no export can hand back the buffer without its length or the other way +/// round. When the call wrote no JSON - out of memory, or a refusal from inside the +/// session - that is an empty string, NUL-terminated like any result, never the previous +/// call's bytes. +fn handBack(s: *const Session, status: Status, out_ptr: *[*]const u8, out_len: *usize) Status { + const json = s.result(); + out_ptr.* = json.ptr; + out_len.* = json.len; + return status; +} + +/// Evaluate an expression. +/// +/// `ok` and `eval_error` leave `out_ptr` and `out_len` describing JSON borrowed from the +/// session until its next call. `out_of_memory` leaves them describing an empty string. +/// `invalid_argument` - a malformed call - may leave them untouched. +pub export fn tally_eval( + session: ?*Session, + 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 }; + out_ptr: ?*[*]const u8, + out_len: ?*usize, +) callconv(.c) Status { + return evalExport(.commit, session, expr_ptr, expr_len, mode, out_ptr, out_len); } -export fn tally_result_free(result: *CalcResult) callconv(.c) void { - _ = result; - // TODO: implement +/// What `tally_eval` would answer, changing nothing: an assignment is not stored and +/// `Ans` keeps the last committed answer. For a screen that answers as the user types +/// (design 6.5). Same arguments, same JSON, same borrowing rule. +pub export fn tally_preview( + session: ?*Session, + expr_ptr: ?[*]const u8, + expr_len: usize, + mode: c_int, + out_ptr: ?*[*]const u8, + out_len: ?*usize, +) callconv(.c) Status { + return evalExport(.preview, session, expr_ptr, expr_len, mode, out_ptr, out_len); } -export fn tally_version(out_len: *usize) callconv(.c) [*]const u8 { +/// The argument checking both evaluation exports share, so they cannot disagree about +/// what a malformed call is. +fn evalExport( + comptime effect: Session.Effect, + session: ?*Session, + expr_ptr: ?[*]const u8, + expr_len: usize, + mode: c_int, + out_ptr: ?*[*]const u8, + out_len: ?*usize, +) Status { + const s = session orelse return .invalid_argument; + const ptr = expr_ptr orelse return .invalid_argument; + const op = out_ptr orelse return .invalid_argument; + const ol = out_len orelse return .invalid_argument; + const m: Mode = switch (mode) { + 0 => .standard, + 1 => .programmer, + else => return .invalid_argument, + }; + + return handBack(s, s.run(effect, ptr[0..expr_len], m), op, ol); +} + +/// The unit categories and their units, as JSON borrowed like any other result. +pub export fn tally_unit_catalog( + session: ?*Session, + out_ptr: ?*[*]const u8, + out_len: ?*usize, +) callconv(.c) Status { + const s = session orelse return .invalid_argument; + const op = out_ptr orelse return .invalid_argument; + const ol = out_len orelse return .invalid_argument; + + return handBack(s, s.unitCatalog(), op, ol); +} + +/// A value in every unit of `unit`'s category, as JSON borrowed like any other result. +/// The value is an expression, previewed: nothing in the session changes. Added in +/// ABI version 3. +pub export fn tally_convert( + session: ?*Session, + value_ptr: ?[*]const u8, + value_len: usize, + unit_ptr: ?[*]const u8, + unit_len: usize, + out_ptr: ?*[*]const u8, + out_len: ?*usize, +) callconv(.c) Status { + const s = session orelse return .invalid_argument; + const vp = value_ptr orelse return .invalid_argument; + const up = unit_ptr orelse return .invalid_argument; + const op = out_ptr orelse return .invalid_argument; + const ol = out_len orelse return .invalid_argument; + + return handBack(s, s.convertAll(vp[0..value_len], up[0..unit_len]), op, ol); +} + +/// The session's variables and `Ans`, exactly, as JSON borrowed like any other result. +/// Hand it to `tally_session_load` - in this process or a later one - to put them back. +/// Added in ABI version 5. +pub export fn tally_session_save( + session: ?*Session, + out_ptr: ?*[*]const u8, + out_len: ?*usize, +) callconv(.c) Status { + const s = session orelse return .invalid_argument; + const op = out_ptr orelse return .invalid_argument; + const ol = out_len orelse return .invalid_argument; + + return handBack(s, s.save(), op, ol); +} + +/// Replace the session's variables and `Ans` with a saved state. All or nothing: on +/// anything but `ok` the session is unchanged. Any result still borrowed from the +/// session is invalidated, as by any call. Added in ABI version 5. +pub export fn tally_session_load( + session: ?*Session, + state_ptr: ?[*]const u8, + state_len: usize, +) callconv(.c) Status { + const s = session orelse return .invalid_argument; + const ptr = state_ptr orelse return .invalid_argument; + return s.load(ptr[0..state_len]); +} + +/// The product version, static and not to be freed. +pub export fn tally_version(out_len: ?*usize) callconv(.c) [*]const u8 { const version = "0.1.0"; - out_len.* = version.len; + if (out_len) |len| len.* = version.len; return version.ptr; } + +/// The ABI version, which moves independently of the product version. +pub export fn tally_abi_version() callconv(.c) u32 { + return abi_version; +} + +// -- Tests -- +// +// These call the exports the way a JNI or Swift binding will, and they construct +// sessions with `std.testing.allocator` so the whole seam is leak-checked: a session +// that forgets its arena, or a result buffer that is never freed, fails here rather +// than on a phone. + +const testing = std.testing; + +/// Evaluate and hand back the JSON, as a caller would read it. +fn evalJson(session: *Session, source: []const u8, mode: Mode) !struct { Status, []const u8 } { + // SAFETY: `tally_eval` writes both out-parameters on every status except + // `invalid_argument`, which the callers here do not produce. + var ptr: [*]const u8 = undefined; + var len: usize = 0; + const status = tally_eval(session, source.ptr, source.len, @intFromEnum(mode), &ptr, &len); + return .{ status, ptr[0..len] }; +} + +test "a session remembers variables and the last answer across calls" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + // The point of the handle: without one, the second call cannot see `x` and the + // third has no `Ans`. + { + const status, const json = try evalJson(&session, "x = 2^100", .standard); + try testing.expectEqual(Status.ok, status); + try testing.expect(std.mem.indexOf(u8, json, "1,267,650,600,228,229,401,496,703,205,376") != null); + } + { + const status, const json = try evalJson(&session, "x + 1", .standard); + try testing.expectEqual(Status.ok, status); + try testing.expect(std.mem.indexOf(u8, json, "1,267,650,600,228,229,401,496,703,205,377") != null); + } + { + const status, const json = try evalJson(&session, "Ans / 2", .standard); + try testing.expectEqual(Status.ok, status); + // Exactly half of an odd number, which only survives on the exact tier. + try testing.expect(std.mem.indexOf(u8, json, "633,825,300,114,114,700,748,351,602,688.5") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"exact\":true") != null); + } +} + +test "a result is borrowed until the next call on the same session" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + const first_status, const first = try evalJson(&session, "2 + 2", .standard); + try testing.expectEqual(Status.ok, first_status); + try testing.expect(std.mem.indexOf(u8, first, "\"display\":\"4\"") != null); + + // Reading `first` after this point is the caller's bug, which is why the contract + // is spelled out at the top of this file. What is asserted here is that the buffer + // is reused rather than reallocated per call, so a caller who copies immediately + // never pays for an allocation. + const second_status, const second = try evalJson(&session, "3 + 3", .standard); + try testing.expectEqual(Status.ok, second_status); + try testing.expect(std.mem.indexOf(u8, second, "\"display\":\"6\"") != null); + try testing.expectEqual(first.ptr, second.ptr); +} + +test "an evaluation error is a status plus the engine's own wording" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + const status, const json = try evalJson(&session, "1 / 0", .standard); + try testing.expectEqual(Status.eval_error, status); + try testing.expect(std.mem.indexOf(u8, json, "\"ok\":false") != null); + // The same phrase the CLI prints, from engine.phrase rather than a second table. + try testing.expect(std.mem.indexOf(u8, json, "division by zero") != null); + + // And the session still works afterwards. + const next_status, const next = try evalJson(&session, "2 * 3", .standard); + try testing.expectEqual(Status.ok, next_status); + try testing.expect(std.mem.indexOf(u8, next, "\"display\":\"6\"") != null); +} + +test "standard mode converts units and says which ones" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + const status, const json = try evalJson(&session, "100 km to mi", .standard); + try testing.expectEqual(Status.ok, status); + try testing.expect(std.mem.indexOf(u8, json, "62.137119") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"from\":\"km\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"to\":\"mi\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"input\":\"100\"") != null); + + // An exact conversion stays exact across the boundary. + const exact_status, const exact = try evalJson(&session, "12 in to ft", .standard); + try testing.expectEqual(Status.ok, exact_status); + try testing.expect(std.mem.indexOf(u8, exact, "\"display\":\"1\"") != null); + try testing.expect(std.mem.indexOf(u8, exact, "\"exact\":true") != null); +} + +test "a non-decimal literal brings the other bases with it" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + const status, const json = try evalJson(&session, "0xFF + 1", .standard); + try testing.expectEqual(Status.ok, status); + try testing.expect(std.mem.indexOf(u8, json, "\"display\":\"256\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"hex\":\"01 00\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"bin\":\"0000 0001 0000 0000\"") != null); + + // A decimal expression carries no rows at all rather than empty ones. + const plain_status, const plain = try evalJson(&session, "2 + 2", .standard); + try testing.expectEqual(Status.ok, plain_status); + try testing.expect(std.mem.indexOf(u8, plain, "bases") == null); +} + +test "programmer mode returns every row the screen shows, at the configured width" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + var config: Config = .{ .bits = 8, .is_signed = 1 }; + try testing.expectEqual(Status.ok, tally_session_configure(&session, &config)); + + const status, const json = try evalJson(&session, "0xFF", .programmer); + try testing.expectEqual(Status.ok, status); + // Eight bits, read as signed: 0xFF is -1, and unsigned it is 255. + try testing.expect(std.mem.indexOf(u8, json, "\"dec_signed\":\"-1\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"dec_unsigned\":\"255\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"bin\":\"1111 1111\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"bits\":8") != null); + + // Byte order is a display choice and it reaches the rows. + config = .{ .bits = 32, .big_endian = 0 }; + try testing.expectEqual(Status.ok, tally_session_configure(&session, &config)); + const le_status, const le = try evalJson(&session, "0xDEADBEEF", .programmer); + try testing.expectEqual(Status.ok, le_status); + try testing.expect(std.mem.indexOf(u8, le, "EF BE AD DE") != null); +} + +test "the configuration round-trips, and a width the engine has no name for is refused" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + var written: Config = .{ .bits = 128, .fraction_digits = 4, .separators = 0 }; + try testing.expectEqual(Status.ok, tally_session_configure(&session, &written)); + + // SAFETY: `tally_session_config` writes every field before this is read. + var read: Config = undefined; + try testing.expectEqual(Status.ok, tally_session_config(&session, &read)); + try testing.expectEqual(@as(u16, 128), read.bits); + try testing.expectEqual(@as(u16, 4), read.fraction_digits); + + // The display budget is the frontend's (NFR-9.9), and it is honoured: four + // fractional digits and no separators. + const status, const json = try evalJson(&session, "1000000/3", .standard); + try testing.expectEqual(Status.ok, status); + try testing.expect(std.mem.indexOf(u8, json, "\"display\":\"333333.3333\"") != null); + + var bad: Config = .{ .bits = 24 }; + try testing.expectEqual(Status.invalid_argument, tally_session_configure(&session, &bad)); + // The rejected setting did not take. + try testing.expectEqual(Status.ok, tally_session_config(&session, &read)); + try testing.expectEqual(@as(u16, 128), read.bits); +} + +test "the units catalogue is the engine's tables, not a list to retype" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + var ptr: [*]const u8 = undefined; + var len: usize = 0; + try testing.expectEqual(Status.ok, tally_unit_catalog(&session, &ptr, &len)); + const json = ptr[0..len]; + + // Every category the engine knows appears, with its base unit and its units. + for (std.enums.values(engine.UnitCategory)) |category| { + try testing.expect(std.mem.indexOf(u8, json, category.label()) != null); + } + try testing.expect(std.mem.indexOf(u8, json, "\"base_unit\":\"m\"") != null); + try testing.expect(std.mem.indexOf(u8, json, "\"name\":\"km\"") != null); + // Aliases come along, so a picker can search "kilometer" without its own table. + try testing.expect(std.mem.indexOf(u8, json, "kilometer") != null); + // Temperature has no exact factor for Fahrenheit's offset form, and the flag says so. + try testing.expect(std.mem.indexOf(u8, json, "\"exact\":true") != null); +} + +test "a malformed call is refused without touching the session" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + var ptr: [*]const u8 = undefined; + var len: usize = 0; + const source = "2 + 2"; + + try testing.expectEqual(Status.invalid_argument, tally_eval(null, source.ptr, source.len, 0, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_eval(&session, null, 0, 0, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_eval(&session, source.ptr, source.len, 0, null, &len)); + try testing.expectEqual(Status.invalid_argument, tally_eval(&session, source.ptr, source.len, 0, &ptr, null)); + // An unknown mode, which is what a stale caller would send after a new one is added. + try testing.expectEqual(Status.invalid_argument, tally_eval(&session, source.ptr, source.len, 99, &ptr, &len)); + + try testing.expectEqual(Status.invalid_argument, tally_session_configure(null, null)); + try testing.expectEqual(Status.invalid_argument, tally_session_config(&session, null)); + try testing.expectEqual(Status.invalid_argument, tally_unit_catalog(null, &ptr, &len)); + // Freeing nothing is allowed, so a caller's cleanup path needs no null check. + tally_session_free(null); +} + +test "a call that runs out of memory hands back an empty result, never the last one" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + var session: Session = .init(failing.allocator()); + defer session.deinit(); + + // A result in the buffer first: a stale hand-back would return these bytes. + try expectDisplay(try evalJson(&session, "6 * 7", .standard), "42"); + + // Each failure point of a call that needs a lot of new memory, in turn, until the + // call has enough. Every attempt short of that is out of memory with nothing in it. + var failures: usize = 0; + var point: usize = 0; + while (true) : (point += 1) { + failing.fail_index = failing.alloc_index + point; + var ptr: [*]const u8 = undefined; + var len: usize = 0; + const status = tally_unit_catalog(&session, &ptr, &len); + if (status == .ok) break; + failures += 1; + try testing.expectEqual(Status.out_of_memory, status); + try testing.expectEqual(@as(usize, 0), len); + // Still a C string, so a caller that ignores the status reads "" and not junk. + try testing.expectEqual(@as(u8, 0), ptr[0]); + } + try testing.expect(failures > 0); + + // The same through an evaluation, with an answer too large for the retained buffer. + point = 0; + while (true) : (point += 1) { + failing.fail_index = failing.alloc_index + point; + const status, const json = try evalJson(&session, "factorial(3000)", .standard); + if (status == .ok) break; + try testing.expectEqual(Status.out_of_memory, status); + try testing.expectEqualStrings("", json); + } + + // And the session is still usable, with what it already knew. + failing.fail_index = std.math.maxInt(usize); + try expectDisplay(try evalJson(&session, "Ans / factorial(2999)", .standard), "3,000"); +} + +test "the exported constructor works, allocator and all" { + // Every other test here builds a session with `std.testing.allocator` so the seam + // is leak-checked. This one goes through the export an app actually calls, which is + // the only path that touches `std.heap.page_allocator`: a default chosen in exactly + // one place is still a default that has to work. + const session = tally_session_new() orelse return error.SessionNotCreated; + defer tally_session_free(session); + + const status, const json = try evalJson(session, "6 * 7", .standard); + try testing.expectEqual(Status.ok, status); + try testing.expect(std.mem.indexOf(u8, json, "\"display\":\"42\"") != null); +} + +test "a result is NUL-terminated past its length, for callers that need a C string" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + var ptr: [*]const u8 = undefined; + var len: usize = 0; + try testing.expectEqual(Status.ok, tally_eval(&session, "2 + 2".ptr, 5, 0, &ptr, &len)); + + // The length excludes the terminator, and the terminator is there. This is what + // lets the JNI layer call `NewStringUTF` without copying the bytes first. + try testing.expectEqualStrings("{\"ok\":true,\"display\":\"4\",\"exact\":true,\"truncated\":false}", ptr[0..len]); + try testing.expectEqual(@as(u8, 0), ptr[len]); + try testing.expectEqual(len, std.mem.len(@as([*:0]const u8, @ptrCast(ptr)))); +} + +test "version and ABI version are separate facts" { + var len: usize = 0; + const version = tally_version(&len); + try testing.expectEqualStrings("0.1.0", version[0..len]); + // A caller that does not want the length may pass null. + _ = tally_version(null); + // 2 added `tally_preview`, 3 `tally_convert`, 4 the catalogue's `label`, 5 save and + // load. A library without what the app calls must be refused at startup, not + // discovered missing in use. + try testing.expectEqual(@as(u32, 5), tally_abi_version()); +} + +/// Save a session and hand back the JSON. +fn saveJson(session: *Session) ![]const u8 { + // SAFETY: written on every status but `invalid_argument`, which a live session + // and non-null out-parameters cannot produce. + var ptr: [*]const u8 = undefined; + var len: usize = 0; + try testing.expectEqual(Status.ok, tally_session_save(session, &ptr, &len)); + return ptr[0..len]; +} + +fn loadText(session: *Session, text: []const u8) Status { + return tally_session_load(session, text.ptr, text.len); +} + +test "a saved session loads back exactly, exact and inexact alike" { + var first: Session = .init(testing.allocator); + defer first.deinit(); + for ([_][]const u8{ "x = 2^100", "third = 1/3", "root = sqrt(2)", "big = factorial(500)", "1/7" }) |source| { + const status, _ = try evalJson(&first, source, .standard); + try testing.expectEqual(Status.ok, status); + } + // The bytes are borrowed until the next call, and the next session is a different + // one, so they stay valid for the load. + const saved = try saveJson(&first); + + var second: Session = .init(testing.allocator); + defer second.deinit(); + try testing.expectEqual(Status.ok, loadText(&second, saved)); + + try expectDisplay(try evalJson(&second, "x + 1", .standard), "1,267,650,600,228,229,401,496,703,205,377"); + // Exact stays exact: a third times three is exactly one, not 0.999... + { + const status, const json = try evalJson(&second, "third * 3", .standard); + try testing.expectEqual(Status.ok, status); + try expectContains(json, "\"display\":\"1\",\"exact\":true"); + } + // Inexact stays the same float: the bits, not a decimal near them. The same + // expression on both sessions renders identically. + { + _, const after = try evalJson(&second, "root * root - 2", .standard); + try expectContains(after, "\"exact\":false"); + const after_copy = try testing.allocator.dupe(u8, after); + defer testing.allocator.free(after_copy); + _, const before = try evalJson(&first, "root * root - 2", .standard); + try testing.expectEqualStrings(before, after_copy); + } + // Far past any literal limit, and still every digit. + try expectDisplay(try evalJson(&second, "big - factorial(500)", .standard), "0"); +} + +test "Ans is saved, and an inexact value comes back as the same float" { + var first: Session = .init(testing.allocator); + defer first.deinit(); + _ = try evalJson(&first, "r = sqrt(2)", .standard); + _ = try evalJson(&first, "1/7", .standard); + const saved = try testing.allocator.dupe(u8, try saveJson(&first)); + defer testing.allocator.free(saved); + try expectContains(saved, "\"ans\":{\"exact\":\"1/7\"}"); + // sqrt(2)'s bits, written out: 0x3FF6A09E667F3BCD. + try expectContains(saved, "{\"name\":\"r\",\"value\":{\"bits\":\"3ff6a09e667f3bcd\"}}"); + + var second: Session = .init(testing.allocator); + defer second.deinit(); + try testing.expectEqual(Status.ok, loadText(&second, saved)); + // The reloaded session saves to the same bytes: nothing was lost or reshaped. + try testing.expectEqualStrings(saved, try saveJson(&second)); + try expectDisplay(try evalJson(&second, "Ans * 7", .standard), "1"); +} + +test "a save lists variables by name, so the same session saves the same bytes" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + _ = try evalJson(&session, "zeta = 1", .standard); + _ = try evalJson(&session, "alpha = 2", .standard); + _ = try evalJson(&session, "mid = 3", .standard); + const json = try saveJson(&session); + const a = std.mem.indexOf(u8, json, "\"alpha\"").?; + const m = std.mem.indexOf(u8, json, "\"mid\"").?; + const z = std.mem.indexOf(u8, json, "\"zeta\"").?; + try testing.expect(a < m and m < z); +} + +test "a load replaces the session rather than merging into it" { + var source: Session = .init(testing.allocator); + defer source.deinit(); + _ = try evalJson(&source, "kept = 5", .standard); + const saved = try testing.allocator.dupe(u8, try saveJson(&source)); + defer testing.allocator.free(saved); + + var target: Session = .init(testing.allocator); + defer target.deinit(); + _ = try evalJson(&target, "gone = 9", .standard); + try testing.expectEqual(Status.ok, loadText(&target, saved)); + try expectDisplay(try evalJson(&target, "kept", .standard), "5"); + const status, _ = try evalJson(&target, "gone", .standard); + try testing.expectEqual(Status.eval_error, status); +} + +test "a bad saved state is refused and the session is left exactly as it was" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + _ = try evalJson(&session, "x = 42", .standard); + _ = try evalJson(&session, "6", .standard); + + const ans_ok = "\"ans\":{\"exact\":\"1\"}"; + const bad = [_][]const u8{ + "", + "not json", + "{}", + "{\"format\":2," ++ ans_ok ++ ",\"variables\":[]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"1x\",\"value\":{\"exact\":\"1\"}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"pi\",\"value\":{\"exact\":\"1\"}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"\",\"value\":{\"exact\":\"1\"}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"a\",\"value\":{\"exact\":\"1\"}},{\"name\":\"a\",\"value\":{\"exact\":\"2\"}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"a\",\"value\":{}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"a\",\"value\":{\"exact\":\"1\",\"bits\":\"3ff0000000000000\"}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"a\",\"value\":{\"exact\":\"1/0\"}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"a\",\"value\":{\"bits\":\"3ff\"}}]}", + "{\"format\":1," ++ ans_ok ++ ",\"variables\":[{\"name\":\"a\",\"value\":{\"bits\":\"+ff0000000000000\"}}]}", + "{\"format\":1,\"ans\":{\"exact\":\"x\"},\"variables\":[]}", + }; + for (bad) |text| { + try testing.expectEqual(Status.invalid_argument, loadText(&session, text)); + try expectDisplay(try evalJson(&session, "x", .standard), "42"); + // `x` itself just became Ans, so check Ans through what it was set to. + _ = try evalJson(&session, "6", .standard); + } + + // Fields this version does not know are ignored, so a newer save still loads. + try testing.expectEqual(Status.ok, loadText(&session, "{\"format\":1,\"ans\":{\"exact\":\"3\"},\"variables\":[],\"note\":\"later\"}")); + try expectDisplay(try evalJson(&session, "Ans", .standard), "3"); + + try testing.expectEqual(Status.invalid_argument, tally_session_load(null, "{}".ptr, 2)); + try testing.expectEqual(Status.invalid_argument, tally_session_load(&session, null, 0)); + var ptr: [*]const u8 = undefined; + var len: usize = 0; + try testing.expectEqual(Status.invalid_argument, tally_session_save(null, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_session_save(&session, null, &len)); + try testing.expectEqual(Status.invalid_argument, tally_session_save(&session, &ptr, null)); +} + +test "save and load release everything at any allocation failure" { + try testing.checkAllAllocationFailures(testing.allocator, struct { + fn run(a: std.mem.Allocator) !void { + var first: Session = .init(a); + defer first.deinit(); + for ([_][]const u8{ "x = 2^100", "y = sqrt(2)", "1/3" }) |source| { + if (first.eval(source, .standard) == .out_of_memory) return error.OutOfMemory; + } + if (first.save() == .out_of_memory) return error.OutOfMemory; + const saved = try a.dupe(u8, first.out.items[0..first.json_len]); + defer a.free(saved); + + var second: Session = .init(a); + defer second.deinit(); + if (second.eval("old = 1", .standard) == .out_of_memory) return error.OutOfMemory; + switch (second.load(saved)) { + .ok => {}, + .out_of_memory => return error.OutOfMemory, + else => return error.TestUnexpectedResult, + } + } + }.run, .{}); +} + +test "the catalogue carries each unit's display name" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + var ptr: [*]const u8 = undefined; + var len: usize = 0; + try testing.expectEqual(Status.ok, tally_unit_catalog(&session, &ptr, &len)); + const json = ptr[0..len]; + try expectContains(json, "{\"name\":\"m/s\",\"label\":\"Meter per second\","); + try expectContains(json, "{\"name\":\"C\",\"label\":\"Celsius\","); +} + +/// Convert and hand back the JSON. +fn convertJson(session: *Session, value: []const u8, unit: []const u8) !struct { Status, []const u8 } { + // SAFETY: as in `evalJson`. + var ptr: [*]const u8 = undefined; + var len: usize = 0; + const status = tally_convert(session, value.ptr, value.len, unit.ptr, unit.len, &ptr, &len); + return .{ status, ptr[0..len] }; +} + +fn expectContains(json: []const u8, needle: []const u8) !void { + try testing.expect(std.mem.indexOf(u8, json, needle) != null); +} + +test "one value comes back in every unit of its category" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + const status, const json = try convertJson(&session, "100", "C"); + try testing.expectEqual(Status.ok, status); + try expectContains(json, "\"from\":\"C\""); + try expectContains(json, "\"category\":\"temperature\""); + // Every temperature unit, the input's own included, each exact. + try expectContains(json, "{\"unit\":\"C\",\"display\":\"100\",\"exact\":true"); + try expectContains(json, "{\"unit\":\"F\",\"display\":\"212\",\"exact\":true"); + try expectContains(json, "{\"unit\":\"K\",\"display\":\"373.15\",\"exact\":true"); + try expectContains(json, "{\"unit\":\"R\",\"display\":\"671.67\",\"exact\":true"); +} + +test "a converted value is an expression, and converting it stores nothing" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + try expectDisplay(try evalJson(&session, "5", .standard), "5"); + { + const status, const json = try convertJson(&session, "6 * 2", "in"); + try testing.expectEqual(Status.ok, status); + try expectContains(json, "\"input\":\"12\""); + try expectContains(json, "{\"unit\":\"ft\",\"display\":\"1\",\"exact\":true"); + } + // Neither the value nor anything in it became `Ans`, and an assignment in it stored + // nothing. + _ = try convertJson(&session, "x = 3", "m"); + try expectDisplay(try evalJson(&session, "Ans", .standard), "5"); + const status, _ = try evalJson(&session, "x", .standard); + try testing.expectEqual(Status.eval_error, status); +} + +test "an inexact unit is marked inexact, row by row" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + const status, const json = try convertJson(&session, "180", "deg"); + try testing.expectEqual(Status.ok, status); + try expectContains(json, "{\"unit\":\"deg\",\"display\":\"180\",\"exact\":true"); + // Degrees to radians goes through pi, so it cannot be exact. + try expectContains(json, "{\"unit\":\"rad\",\"display\":\"3.14159265358979"); + try expectContains(json, "\"exact\":false"); +} + +test "a bad value or an unknown unit is an error, and a malformed call is refused" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + { + const status, const json = try convertJson(&session, "2 +", "km"); + try testing.expectEqual(Status.eval_error, status); + try expectContains(json, "\"ok\":false"); + } + { + const status, const json = try convertJson(&session, "1", "smoot"); + try testing.expectEqual(Status.eval_error, status); + try expectContains(json, "unknown unit"); + } + + var ptr: [*]const u8 = undefined; + var len: usize = 0; + try testing.expectEqual(Status.invalid_argument, tally_convert(null, "1".ptr, 1, "m".ptr, 1, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_convert(&session, null, 0, "m".ptr, 1, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_convert(&session, "1".ptr, 1, null, 0, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_convert(&session, "1".ptr, 1, "m".ptr, 1, null, &len)); + try testing.expectEqual(Status.invalid_argument, tally_convert(&session, "1".ptr, 1, "m".ptr, 1, &ptr, null)); +} + +/// Preview and hand back the JSON, as `evalJson` does for an evaluation. +fn previewJson(session: *Session, source: []const u8, mode: Mode) !struct { Status, []const u8 } { + // SAFETY: as in `evalJson`. + var ptr: [*]const u8 = undefined; + var len: usize = 0; + const status = tally_preview(session, source.ptr, source.len, @intFromEnum(mode), &ptr, &len); + return .{ status, ptr[0..len] }; +} + +fn expectDisplay(result: struct { Status, []const u8 }, display: []const u8) !void { + const status, const json = result; + try testing.expectEqual(Status.ok, status); + var buf: [128]u8 = undefined; + const needle = try std.fmt.bufPrint(&buf, "\"display\":\"{s}\"", .{display}); + try testing.expect(std.mem.indexOf(u8, json, needle) != null); +} + +test "a preview answers like an evaluation and changes nothing in the session" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + // Typing `x = 7` shows 7 and assigns nothing. + try expectDisplay(try previewJson(&session, "x = 7", .standard), "7"); + { + const status, const json = try previewJson(&session, "x", .standard); + try testing.expectEqual(Status.eval_error, status); + try testing.expect(std.mem.indexOf(u8, json, "unknown variable") != null); + } + + // `Ans` is the last committed answer, however many previews come after it. + try expectDisplay(try evalJson(&session, "6 * 7", .standard), "42"); + try expectDisplay(try previewJson(&session, "Ans + 1", .standard), "43"); + try expectDisplay(try previewJson(&session, "Ans + 1", .standard), "43"); + try expectDisplay(try evalJson(&session, "Ans", .standard), "42"); +} + +test "a previewed conversion leaves Ans alone too" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + // The conversion path evaluates its value text separately, so it needs the same + // guarantee by its own route. + try expectDisplay(try evalJson(&session, "5", .standard), "5"); + try expectDisplay(try previewJson(&session, "12 in to ft", .standard), "1"); + try expectDisplay(try evalJson(&session, "Ans", .standard), "5"); +} + +test "a preview reaches programmer mode and refuses the same malformed calls" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + { + const status, const json = try previewJson(&session, "0xFF and 0x0F", .programmer); + try testing.expectEqual(Status.ok, status); + try testing.expect(std.mem.indexOf(u8, json, "\"dec_unsigned\":\"15\"") != null); + } + + var ptr: [*]const u8 = undefined; + var len: usize = 0; + const source = "1"; + try testing.expectEqual(Status.invalid_argument, tally_preview(null, source.ptr, source.len, 0, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_preview(&session, null, 0, 0, &ptr, &len)); + try testing.expectEqual(Status.invalid_argument, tally_preview(&session, source.ptr, source.len, 99, &ptr, &len)); +} + +test "an empty expression is an evaluation error, not a crash" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + const status, const json = try evalJson(&session, "", .standard); + try testing.expectEqual(Status.eval_error, status); + try testing.expect(std.mem.indexOf(u8, json, "\"ok\":false") != null); +} + +test "the session survives a long run without growing its buffers" { + var session: Session = .init(testing.allocator); + defer session.deinit(); + + // A calculator is typed into. The arena is reset with retained capacity and the + // result buffer is cleared rather than freed, so a hundred evaluations settle at a + // steady footprint instead of climbing. + _ = try evalJson(&session, "1 + 1", .standard); + const settled_out = session.out.capacity; + for (0..100) |i| { + var buf: [32]u8 = undefined; + const src = try std.fmt.bufPrint(&buf, "{d} * {d} + 0.5", .{ i, i }); + const status, _ = try evalJson(&session, src, .standard); + try testing.expectEqual(Status.ok, status); + } + // Small results, so the buffer that served the first one serves all of them. + try testing.expectEqual(settled_out, session.out.capacity); +}