begin human review - rename Integer, remove types.zig

This commit is contained in:
Emil Lerch 2026-07-30 08:21:09 -07:00
parent 7121dd9dea
commit cb7da0fe9e
Signed by: lobo
GPG key ID: A7B62D657EF764F8
21 changed files with 800 additions and 646 deletions

View file

@ -61,18 +61,29 @@ build.zig (workspace root)
| Module | Responsibility |
|--------|---------------|
| `parser.zig` | Tokenizer + Pratt parser -> AST |
| `errors.zig` | `CalcError` and the one table of error wording |
| `Integer.zig` | A fixed-width integer (file-as-struct), plus `IntType`, `BitWidth`, `Signedness` |
| `rational.zig` | Exact rationals over big integers |
| `number.zig` | The exact/inexact numeric model (section 2.7) |
| `tokenizer.zig` | Lexer, and `Base` for literals |
| `ast.zig` | AST node definitions |
| `parser.zig` | Pratt parser -> AST |
| `evaluator.zig` | Walk AST, produce results |
| `programmer.zig` | Integer operations, base conversion, bit manipulation |
| `bitwise.zig` | The fixed-width operators (`& \| xor ~ << >> >>> rol ror`), shared by both modes |
| `struct_layout.zig` | Struct DSL parser, layout computation, ABI profiles |
| `programmer.zig` | Programmer-mode evaluation, its `Config`, wrapping arithmetic |
| `formatter.zig` | Display and clipboard strings for every base |
| `float_interp.zig` | IEEE 754 bit-level interpretation |
| `units.zig` | Unit conversion tables and resolver |
| `financial.zig` | CAGR, TVM, compound interest, amortization |
| `types.zig` | Shared types (Value, Error, etc.) |
| `engine.zig` | Public API surface (Zig-native) |
| `engine.zig` | Public API surface (Zig-native). Imports and re-exports only; defines nothing |
| `c_api.zig` | `extern "C"` wrappers for JNI/FFI consumers |
There is deliberately no `types.zig`. It existed, and being named after a language
feature rather than a concept, it accumulated four unrelated groups: the error
vocabulary, the fixed-width integer model, `Base` (used only by the lexer and the
AST), and `Mode` (which the engine stored and never read). Each has gone to the
module that owns it. `struct_layout.zig` is still unimplemented (Phase 4).
### 2.2 Core Data Types
```zig
@ -212,23 +223,44 @@ The evaluator maintains an `Environment`:
```zig
pub const Environment = struct {
allocator: Allocator,
mode: Mode,
/// Variables hold `Number`, so an assignment keeps whatever exactness its
/// expression had: `X = 0.1` stores exactly one tenth.
variables: std.StringHashMap(Number),
ans: ?Number,
programmer_config: ProgrammerConfig,
};
pub const ProgrammerConfig = struct {
bit_width: BitWidth = .bits64,
signedness: Signedness = .signed,
/// Big-endian by default so the HEX row reads as the number itself; see FR-2.8.
display_endian: Endianness = .big,
ans: Number,
};
```
Standard mode evaluates to `Number`, the exact/inexact union of section 2.7. Programmer mode evaluates to `Integer` (exact, truncated to bit width). Financial functions compute in `f64` and enter the expression language as inexact `Number` values (section 5.6).
It holds no mode and no programmer configuration. It used to hold both and read
neither: the caller picks `evalString` or `evalProgrammerString`, so which evaluator
runs is decided at the call rather than by state. The TUI was writing a mode into
that field on every mode change and nothing ever consulted it. `Mode` has left the
engine entirely; each frontend has its own, and they differ (the CLI has two, the TUI
has four including Convert, and the engine had a `financial` member it could never
act on because financial calculations are ordinary functions in expressions).
Programmer mode's configuration belongs to programmer mode:
```zig
// programmer.zig
pub const Config = struct {
int_type: IntType = .{},
/// Big-endian by default so the HEX row reads as the number itself; see FR-2.8.
display_endian: std.builtin.Endian = .big,
};
// Integer.zig: one concept, previously reassembled in three places
pub const IntType = struct {
width: BitWidth = .bits64,
signedness: Signedness = .signed,
};
```
`Integer.zig` is TitleCase because the file is the type: its top-level fields are
`raw` and `int_type`, and `IntType`, `BitWidth` and `Signedness` are declared inside
it. Byte order is `std.builtin.Endian` rather than an engine enum of the same two
members.
Standard mode evaluates to `Number`, the exact/inexact union of section 2.7. Programmer mode evaluates to `Integer`, which is a `u128` pattern plus the `IntType` that interprets it. Financial functions compute in `f64` and enter the expression language as inexact `Number` values (section 5.6).
### 2.6 Number Display Formatting

View file

@ -756,6 +756,50 @@ Remaining subcommands deferred until their engine modules exist.
- NOT DONE: mouse wheel scrolling for history
- Verify: help overlay works, mouse interactions work, looks reasonable in 80x24 terminal
### Task 5.12: Break up types.zig
`types.zig` was named after a language feature rather than a concept, so it
accumulated four unrelated groups with almost no overlap in who used them. Found by
asking, during review, why the file existed at all. Each group went to the module
that owns it and the file is gone:
- `engine/src/errors.zig`: `CalcError` and `errorPhrase`, the most widely depended-on
thing in the old file (nine engine modules, all three frontend files).
- `engine/src/Integer.zig`: the fixed-width integer model, TitleCase because the file
is the type (`raw` and `int_type` are its top-level fields). It declares `IntType`
(width plus signedness), `BitWidth` and `Signedness`. `IntType` replaced three
shapes of the same pair: `Integer{raw, bit_width, signedness}`,
`ProgrammerConfig{bit_width, signedness, display_endian}` and the `Domain` added to
`bitwise.zig` during the operator unification. `Integer` is now a pattern plus its
`IntType`, and `bitwise.zig` takes an `IntType` directly. The engine's own
`Endianness` enum is gone in favour of `std.builtin.Endian`, which has the same two
members.
- `Base` into `tokenizer.zig`: a lexical property with two users, the lexer and the
AST.
- `ProgrammerConfig` into `programmer.zig` as `Config`, holding an `IntType` and the
one display preference.
- `Mode` deleted from the engine. The frontends each own theirs, and they differ: the
CLI has two members, the TUI four (including Convert), and the engine's had a
`financial` member it could never act on.
Two more pieces of dead state fell out, both the same pattern as the `Parser` and
`Tokenizer` fields removed in the de-duplication pass, and both hidden because a
frontend was writing to them:
- `Environment.mode`, assigned in `init` and never read. `src/tui.zig setMode` was
writing it on every mode change.
- `Environment.programmer_config`, dead as of the operator unification, when `~`
stopped being the one operator that read a configured width in standard mode.
`Environment.init` now takes only an allocator.
`engine.zig` defines nothing and only re-exports, which is why the error set did not
move there: every engine module would have had to import the root to name its own
error type.
- Verify: 942 tests pass, fmt and zlint clean, `integer.zig` and `bitwise.zig` at
100% line coverage, CLI output unchanged across arithmetic, programmer flags,
conversion and the new shift semantics.
### Task 5.11: CLI programmer-mode flags and subcommand help
The CLI could reach programmer mode with `-p` but not configure it, so the width,

179
engine/src/Integer.zig Normal file
View file

@ -0,0 +1,179 @@
//! A fixed-width integer: a two's complement bit pattern plus the type that says
//! how to read it.
//!
//! Programmer mode computes on patterns of a chosen width rather than on the exact
//! rationals `number.zig` provides, and standard mode drops into the same
//! representation for the bitwise operators (FR-2.12). This file is that
//! representation; `bitwise.zig` is the operations on it.
//!
//! There used to be three shapes of the same idea: `Integer{raw, bit_width,
//! signedness}`, `ProgrammerConfig{bit_width, signedness, display_endian}`, and a
//! `Domain{bit_width, signedness}` inside `bitwise.zig`. `IntType` is the one
//! concept each was carrying a copy of: a value pairs it with bits, and a
//! configuration pairs it with a display preference.
const std = @import("std");
const Integer = @This();
/// The bit pattern. May carry bits above the width; every reader masks.
raw: u128,
/// How to read `raw`.
int_type: IntType,
pub fn init(raw: u128, int_type: IntType) Integer {
return .{ .raw = raw, .int_type = int_type };
}
/// Apply the width mask, truncating to the configured width.
pub fn masked(self: Integer) u128 {
return self.raw & self.int_type.mask();
}
/// Interpret as a signed value, sign-extended from the width.
pub fn signedValue(self: Integer) i128 {
return self.int_type.signExtend(self.raw);
}
/// Interpret as an unsigned value, which is just the mask.
pub fn unsignedValue(self: Integer) u128 {
return self.masked();
}
/// A fixed-width integer type: how wide, and whether the top bit is a sign.
///
/// Everything that interprets a pattern needs exactly this pair, which is why it
/// was being reassembled in three places. The default is 64-bit signed, which is
/// also standard mode's fixed type.
pub const IntType = struct {
width: BitWidth = .bits64,
signedness: Signedness = .signed,
pub fn mask(self: IntType) u128 {
return self.width.mask();
}
pub fn bits(self: IntType) u8 {
return self.width.bits();
}
/// The top bit's position, whether or not this type treats it as a sign.
pub fn topBit(self: IntType) u128 {
return @as(u128, 1) << @intCast(self.bits() - 1);
}
/// True when the pattern denotes a negative number in this type. An unsigned
/// type has no negative values, so `>>` is a zero fill there and a large shift
/// distance is large rather than negative.
pub fn isNegative(self: IntType, value: u128) bool {
return self.signedness == .signed and (value & self.mask()) & self.topBit() != 0;
}
/// Sign-extend the pattern to a full i128.
pub fn signExtend(self: IntType, value: u128) i128 {
const m = value & self.mask();
if (self.isNegative(m)) return @bitCast(m | ~self.mask());
return @intCast(m);
}
};
/// Configurable integer bit width. The tag is the bit count.
pub const BitWidth = enum(u8) {
bits8 = 8,
bits16 = 16,
bits32 = 32,
bits64 = 64,
bits128 = 128,
/// All bits set within the width. Shifts a full mask down, because
/// `(1 << bits) - 1` overflows at 128.
pub fn mask(self: BitWidth) u128 {
const shift: u7 = @intCast(128 - @as(u16, self.bits()));
return @as(u128, std.math.maxInt(u128)) >> shift;
}
/// Returns the number of bits as a plain integer.
pub fn bits(self: BitWidth) u8 {
return @intFromEnum(self);
}
};
/// Whether the top bit of a pattern is a sign.
pub const Signedness = enum {
signed,
unsigned,
};
// -- Tests --
const testing = std.testing;
test "BitWidth.mask" {
try testing.expectEqual(@as(u128, 0xFF), BitWidth.bits8.mask());
try testing.expectEqual(@as(u128, 0xFFFF), BitWidth.bits16.mask());
try testing.expectEqual(@as(u128, 0xFFFF_FFFF), BitWidth.bits32.mask());
try testing.expectEqual(@as(u128, 0xFFFF_FFFF_FFFF_FFFF), BitWidth.bits64.mask());
try testing.expectEqual(@as(u128, 0xFFFF_FFFF_FFFF_FFFF_FFFF_FFFF_FFFF_FFFF), BitWidth.bits128.mask());
}
test "BitWidth.mask: exactly `bits` low bits are set, at every width" {
for (std.enums.values(BitWidth)) |bw| {
try testing.expectEqual(@as(u8, bw.bits()), @popCount(bw.mask()));
try testing.expectEqual(@as(u128, 1), bw.mask() & 1);
}
}
test "IntType: signExtend reads the top bit only when the type is signed" {
const i8_type: IntType = .{ .width = .bits8, .signedness = .signed };
const u8_type: IntType = .{ .width = .bits8, .signedness = .unsigned };
try testing.expectEqual(@as(i128, -1), i8_type.signExtend(0xFF));
try testing.expectEqual(@as(i128, 255), u8_type.signExtend(0xFF));
try testing.expectEqual(@as(i128, -128), i8_type.signExtend(0x80));
try testing.expectEqual(@as(i128, 127), i8_type.signExtend(0x7F));
try testing.expect(i8_type.isNegative(0x80));
try testing.expect(!u8_type.isNegative(0x80));
// A 128-bit type has no bits above the width to fill.
const i128_type: IntType = .{ .width = .bits128, .signedness = .signed };
try testing.expectEqual(@as(i128, -1), i128_type.signExtend(i128_type.mask()));
}
test "IntType: the default is 64-bit signed, which standard mode uses" {
const default: IntType = .{};
try testing.expectEqual(BitWidth.bits64, default.width);
try testing.expectEqual(Signedness.signed, default.signedness);
try testing.expectEqual(@as(i128, -1), default.signExtend(default.mask()));
}
test "bits above the width never reach an interpretation" {
const i8_type: IntType = .{ .width = .bits8 };
// The high bits are noise from a wider computation.
try testing.expectEqual(@as(i128, -1), i8_type.signExtend(0xDEAD_00FF));
try testing.expectEqual(@as(u128, 0xFF), init(0xDEAD_00FF, i8_type).unsignedValue());
}
test "signedValue" {
const i8_type: IntType = .{ .width = .bits8 };
try testing.expectEqual(@as(i128, -1), init(0xFF, i8_type).signedValue());
try testing.expectEqual(@as(i128, 127), init(0x7F, i8_type).signedValue());
try testing.expectEqual(@as(i128, -128), init(0x80, i8_type).signedValue());
const i32_type: IntType = .{ .width = .bits32 };
try testing.expectEqual(@as(i128, -1), init(0xFFFF_FFFF, i32_type).signedValue());
}
test "unsignedValue masks correctly" {
const u8_type: IntType = .{ .width = .bits8, .signedness = .unsigned };
try testing.expectEqual(@as(u128, 0xFF), init(0x1FF, u8_type).unsignedValue());
}
test "the same bits read two ways, which is why the type travels with the value" {
const bits: u128 = 0xFF;
try testing.expectEqual(@as(i128, -1), init(bits, .{ .width = .bits8 }).signedValue());
try testing.expectEqual(
@as(i128, 255),
init(bits, .{ .width = .bits8, .signedness = .unsigned }).signedValue(),
);
}

View file

@ -11,8 +11,7 @@
//! deallocation"), which is how both eval entry points ended up leaking the whole
//! tree on every call.
const types = @import("types.zig");
const Base = types.Base;
const Base = @import("tokenizer.zig").Base;
/// A single expression node.
pub const Expr = union(enum) {

View file

@ -15,21 +15,21 @@
//! `~`.
//!
//! FR-2.12 promises that every operator means the same thing in both modes, so
//! there is one implementation, parameterised by a `Domain` (width plus
//! there is one implementation, parameterised by an `IntType` (width plus
//! signedness). Standard mode is fixed at 64-bit signed; a different width is what
//! programmer mode is for (FR-2.3).
//!
//! Values are carried as a `u128` holding the two's complement bit pattern masked
//! to the width, which is the same representation `types.Integer` uses.
//! to the width, which is the same representation `Integer` uses.
const std = @import("std");
const ast = @import("ast.zig");
const BinaryOp = ast.BinaryOp;
const types = @import("types.zig");
const BitWidth = types.BitWidth;
const Signedness = types.Signedness;
const ProgrammerConfig = types.ProgrammerConfig;
const CalcError = types.CalcError;
const Integer = @import("Integer.zig");
const BitWidth = Integer.BitWidth;
const Signedness = Integer.Signedness;
const IntType = Integer.IntType;
const CalcError = @import("errors.zig").CalcError;
/// The operators this module implements.
///
@ -67,48 +67,6 @@ pub fn fromBinaryOp(op: BinaryOp) ?Op {
};
}
/// The integer type an operation is carried out in: how wide, and whether the top
/// bit is a sign.
pub const Domain = struct {
bit_width: BitWidth,
signedness: Signedness,
/// Standard mode: 64-bit two's complement. Fixed, not configurable; the
/// programmer-mode width setting does not reach standard mode.
pub const standard: Domain = .{ .bit_width = .bits64, .signedness = .signed };
pub fn fromConfig(config: ProgrammerConfig) Domain {
return .{ .bit_width = config.bit_width, .signedness = config.signedness };
}
pub fn mask(self: Domain) u128 {
return self.bit_width.mask();
}
pub fn bits(self: Domain) u8 {
return self.bit_width.bits();
}
/// The sign bit position, whether or not this domain treats it as a sign.
pub fn topBit(self: Domain) u128 {
return @as(u128, 1) << @intCast(self.bits() - 1);
}
/// True when the pattern denotes a negative number in this domain. An unsigned
/// domain has no negative values, so `>>` is a zero fill there and a large
/// shift distance is large rather than negative.
pub fn isNegative(self: Domain, value: u128) bool {
return self.signedness == .signed and (value & self.mask()) & self.topBit() != 0;
}
/// Sign-extend the pattern to a full i128.
pub fn signExtend(self: Domain, value: u128) i128 {
const m = value & self.mask();
if (self.isNegative(m)) return @bitCast(m | ~self.mask());
return @intCast(m);
}
};
/// How far a shift moves, once the distance has been checked.
const Distance = union(enum) {
/// Shorter than the width, so some bits survive.
@ -121,10 +79,10 @@ const Distance = union(enum) {
///
/// A negative distance is a domain error rather than a very large one. Standard
/// mode used to reduce it modulo 64, so `8 >> -1` quietly became `8 >> 63`.
fn distance(domain: Domain, right: u128) CalcError!Distance {
if (domain.isNegative(right)) return CalcError.DomainError;
const value = right & domain.mask();
if (value >= domain.bits()) return .past_width;
fn distance(int_type: IntType, right: u128) CalcError!Distance {
if (int_type.isNegative(right)) return CalcError.DomainError;
const value = right & int_type.mask();
if (value >= int_type.bits()) return .past_width;
return .{ .within = @intCast(value) };
}
@ -132,14 +90,14 @@ fn distance(domain: Domain, right: u128) CalcError!Distance {
///
/// Rotation is cyclic, so a distance beyond the width is reduced rather than
/// saturated: rotating a 64-bit value by 65 is rotating it by 1.
fn rotation(domain: Domain, right: u128) CalcError!u7 {
if (domain.isNegative(right)) return CalcError.DomainError;
return @intCast((right & domain.mask()) % domain.bits());
fn rotation(int_type: IntType, right: u128) CalcError!u7 {
if (int_type.isNegative(right)) return CalcError.DomainError;
return @intCast((right & int_type.mask()) % int_type.bits());
}
/// Apply a fixed-width operation. Operands and result are masked bit patterns.
pub fn apply(domain: Domain, op: Op, left_in: u128, right_in: u128) CalcError!u128 {
const mask = domain.mask();
pub fn apply(int_type: IntType, op: Op, left_in: u128, right_in: u128) CalcError!u128 {
const mask = int_type.mask();
const left = left_in & mask;
const right = right_in & mask;
@ -149,59 +107,59 @@ pub fn apply(domain: Domain, op: Op, left_in: u128, right_in: u128) CalcError!u1
.bit_xor => left ^ right,
// Bits shifted past the end of the width are discarded, not wrapped:
// `0b1000 << 1` is 16. Wrapping is what `rol` and `ror` are for.
.shift_left => switch (try distance(domain, right)) {
.shift_left => switch (try distance(int_type, right)) {
.past_width => 0,
.within => |amt| (left << amt) & mask,
},
.shift_right_logical => switch (try distance(domain, right)) {
.shift_right_logical => switch (try distance(int_type, right)) {
.past_width => 0,
.within => |amt| (left >> amt) & mask,
},
.shift_right => blk: {
const negative = domain.isNegative(left);
switch (try distance(domain, right)) {
const negative = int_type.isNegative(left);
switch (try distance(int_type, right)) {
// Shifting a negative value all the way out leaves the sign fill,
// which is every bit set; a non-negative one leaves zero.
.past_width => break :blk if (negative) mask else 0,
.within => |amt| {
if (!negative) break :blk (left >> amt) & mask;
// Shift in the signed domain so the sign bit is the fill.
const extended = domain.signExtend(left);
const extended = int_type.signExtend(left);
break :blk @as(u128, @bitCast(extended >> amt)) & mask;
},
}
},
.rotate_left => blk: {
const amt = try rotation(domain, right);
const amt = try rotation(int_type, right);
if (amt == 0) break :blk left;
const anti: u7 = @intCast(domain.bits() - amt);
const anti: u7 = @intCast(int_type.bits() - amt);
break :blk ((left << amt) | (left >> anti)) & mask;
},
.rotate_right => blk: {
const amt = try rotation(domain, right);
const amt = try rotation(int_type, right);
if (amt == 0) break :blk left;
const anti: u7 = @intCast(domain.bits() - amt);
const anti: u7 = @intCast(int_type.bits() - amt);
break :blk ((left >> amt) | (left << anti)) & mask;
},
};
}
/// Bitwise complement within the width.
pub fn not(domain: Domain, value: u128) u128 {
return ~value & domain.mask();
pub fn not(int_type: IntType, value: u128) u128 {
return ~value & int_type.mask();
}
/// Two's complement negation within the width.
pub fn negate(domain: Domain, value: u128) u128 {
return (~value +% 1) & domain.mask();
pub fn negate(int_type: IntType, value: u128) u128 {
return (~value +% 1) & int_type.mask();
}
// -- Tests --
const testing = std.testing;
const w8: Domain = .{ .bit_width = .bits8, .signedness = .signed };
const u8_domain: Domain = .{ .bit_width = .bits8, .signedness = .unsigned };
const i8_type: IntType = .{ .width = .bits8 };
const u8_type: IntType = .{ .width = .bits8, .signedness = .unsigned };
test "fromBinaryOp: every fixed-width operator maps, no arithmetic one does" {
// The compiler enforces the total mapping; this pins which side each lands on.
@ -219,14 +177,14 @@ test "fromBinaryOp: every fixed-width operator maps, no arithmetic one does" {
test "arithmetic right shift fills with the sign bit" {
// -8 in 8 bits is 0b1111_1000; one place right is 0b1111_1100, which is -4.
const result = try apply(w8, .shift_right, 0b1111_1000, 1);
const result = try apply(i8_type, .shift_right, 0b1111_1000, 1);
try testing.expectEqual(@as(u128, 0b1111_1100), result);
try testing.expectEqual(@as(i128, -4), w8.signExtend(result));
try testing.expectEqual(@as(i128, -4), i8_type.signExtend(result));
}
test "logical right shift fills with zeros" {
// The same bits, shifted the other way: 0b0111_1100 is 124.
const result = try apply(w8, .shift_right_logical, 0b1111_1000, 1);
const result = try apply(i8_type, .shift_right_logical, 0b1111_1000, 1);
try testing.expectEqual(@as(u128, 124), result);
}
@ -236,8 +194,8 @@ test "the two right shifts agree on non-negative values" {
var amt: u128 = 0;
while (amt < 8) : (amt += 1) {
try testing.expectEqual(
try apply(w8, .shift_right, value, amt),
try apply(w8, .shift_right_logical, value, amt),
try apply(i8_type, .shift_right, value, amt),
try apply(i8_type, .shift_right_logical, value, amt),
);
}
}
@ -247,60 +205,60 @@ test "an unsigned domain has no sign to extend" {
// 0xFF is 255 here, not -1, so the arithmetic shift is a zero fill too. The
// old programmer-mode implementation looked at the top bit regardless of the
// configured signedness and gave 0xFF.
try testing.expectEqual(@as(u128, 0x7F), try apply(u8_domain, .shift_right, 0xFF, 1));
try testing.expectEqual(@as(u128, 0x7F), try apply(u8_domain, .shift_right_logical, 0xFF, 1));
try testing.expectEqual(@as(u128, 0x7F), try apply(u8_type, .shift_right, 0xFF, 1));
try testing.expectEqual(@as(u128, 0x7F), try apply(u8_type, .shift_right_logical, 0xFF, 1));
}
test "shifts run to completion instead of wrapping or clamping the distance" {
// Standard mode reduced the distance modulo the width, so `1 << 64` was 1;
// programmer mode clamped it to width - 1, so 8-bit `0xFF >>> 20` was 1.
const standard_domain = Domain.standard;
try testing.expectEqual(@as(u128, 0), try apply(standard_domain, .shift_left, 1, 64));
try testing.expectEqual(@as(u128, 0), try apply(standard_domain, .shift_left, 1, 1000));
try testing.expectEqual(@as(u128, 0), try apply(w8, .shift_right_logical, 0xFF, 20));
try testing.expectEqual(@as(u128, 0), try apply(w8, .shift_left, 0xFF, 8));
const i64_type: IntType = .{};
try testing.expectEqual(@as(u128, 0), try apply(i64_type, .shift_left, 1, 64));
try testing.expectEqual(@as(u128, 0), try apply(i64_type, .shift_left, 1, 1000));
try testing.expectEqual(@as(u128, 0), try apply(i8_type, .shift_right_logical, 0xFF, 20));
try testing.expectEqual(@as(u128, 0), try apply(i8_type, .shift_left, 0xFF, 8));
// A negative value shifted all the way out is all sign bits, not zero.
try testing.expectEqual(@as(u128, 0xFF), try apply(w8, .shift_right, 0b1111_1000, 8));
try testing.expectEqual(@as(u128, 0xFF), try apply(w8, .shift_right, 0b1111_1000, 100));
try testing.expectEqual(@as(u128, 0xFF), try apply(i8_type, .shift_right, 0b1111_1000, 8));
try testing.expectEqual(@as(u128, 0xFF), try apply(i8_type, .shift_right, 0b1111_1000, 100));
// A non-negative one is zero.
try testing.expectEqual(@as(u128, 0), try apply(w8, .shift_right, 0b0100_0000, 8));
try testing.expectEqual(@as(u128, 0), try apply(i8_type, .shift_right, 0b0100_0000, 8));
}
test "shifting by one less than the width still keeps a bit" {
// The boundary the clamping rule used to hide.
try testing.expectEqual(@as(u128, 0b1000_0000), try apply(w8, .shift_left, 1, 7));
try testing.expectEqual(@as(u128, 1), try apply(w8, .shift_right_logical, 0b1000_0000, 7));
try testing.expectEqual(@as(u128, 0xFF), try apply(w8, .shift_right, 0b1000_0000, 7));
try testing.expectEqual(@as(u128, 0b1000_0000), try apply(i8_type, .shift_left, 1, 7));
try testing.expectEqual(@as(u128, 1), try apply(i8_type, .shift_right_logical, 0b1000_0000, 7));
try testing.expectEqual(@as(u128, 0xFF), try apply(i8_type, .shift_right, 0b1000_0000, 7));
}
test "a negative shift distance is a domain error, not a huge one" {
const neg_one: u128 = 0xFF; // -1 in 8-bit signed
try testing.expectError(CalcError.DomainError, apply(w8, .shift_left, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(w8, .shift_right, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(w8, .shift_right_logical, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(w8, .rotate_left, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(w8, .rotate_right, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_left, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_right, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .shift_right_logical, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .rotate_left, 1, neg_one));
try testing.expectError(CalcError.DomainError, apply(i8_type, .rotate_right, 1, neg_one));
// The same pattern in an unsigned domain is 255, a distance past the width.
try testing.expectEqual(@as(u128, 0), try apply(u8_domain, .shift_left, 1, neg_one));
try testing.expectEqual(@as(u128, 0), try apply(u8_type, .shift_left, 1, neg_one));
}
test "rotation is cyclic and reduces the distance" {
try testing.expectEqual(@as(u128, 0b0000_0011), try apply(w8, .rotate_left, 0b1000_0001, 1));
try testing.expectEqual(@as(u128, 0b1100_0000), try apply(w8, .rotate_right, 0b1000_0001, 1));
try testing.expectEqual(@as(u128, 0b0000_0011), try apply(i8_type, .rotate_left, 0b1000_0001, 1));
try testing.expectEqual(@as(u128, 0b1100_0000), try apply(i8_type, .rotate_right, 0b1000_0001, 1));
// Rotating by the width is the identity, and by width + 1 is by 1.
try testing.expectEqual(@as(u128, 0b1000_0001), try apply(w8, .rotate_left, 0b1000_0001, 8));
try testing.expectEqual(@as(u128, 0b0000_0011), try apply(w8, .rotate_left, 0b1000_0001, 9));
try testing.expectEqual(@as(u128, 0b1000_0001), try apply(i8_type, .rotate_left, 0b1000_0001, 8));
try testing.expectEqual(@as(u128, 0b0000_0011), try apply(i8_type, .rotate_left, 0b1000_0001, 9));
}
test "rotate left and rotate right are inverses at every distance and width" {
for ([_]BitWidth{ .bits8, .bits16, .bits32, .bits64, .bits128 }) |bw| {
const domain: Domain = .{ .bit_width = bw, .signedness = .unsigned };
const value: u128 = 0x1234_5678_9ABC_DEF0 & domain.mask();
const int_type: IntType = .{ .width = bw, .signedness = .unsigned };
const value: u128 = 0x1234_5678_9ABC_DEF0 & int_type.mask();
var amt: u128 = 0;
while (amt < domain.bits()) : (amt += 1) {
const there = try apply(domain, .rotate_left, value, amt);
const back = try apply(domain, .rotate_right, there, amt);
while (amt < int_type.bits()) : (amt += 1) {
const there = try apply(int_type, .rotate_left, value, amt);
const back = try apply(int_type, .rotate_right, there, amt);
try testing.expectEqual(value, back);
}
}
@ -308,41 +266,42 @@ test "rotate left and rotate right are inverses at every distance and width" {
test "results stay inside the width" {
for ([_]BitWidth{ .bits8, .bits16, .bits32, .bits64, .bits128 }) |bw| {
const domain: Domain = .{ .bit_width = bw, .signedness = .signed };
const all_ones = domain.mask();
const int_type: IntType = .{ .width = bw, .signedness = .signed };
const all_ones = int_type.mask();
inline for (@typeInfo(Op).@"enum".fields) |field| {
const op = @field(Op, field.name);
// 1 is a safe distance for the shifts and a legal operand for the rest.
const result = try apply(domain, op, all_ones, 1);
try testing.expectEqual(result, result & domain.mask());
const result = try apply(int_type, op, all_ones, 1);
try testing.expectEqual(result, result & int_type.mask());
}
}
}
test "not and negate stay inside the width" {
try testing.expectEqual(@as(u128, 0xFF), not(w8, 0));
try testing.expectEqual(@as(u128, 0), not(w8, 0xFF));
try testing.expectEqual(@as(u128, 0xFF), negate(w8, 1));
try testing.expectEqual(@as(u128, 1), negate(w8, 0xFF));
try testing.expectEqual(@as(u128, 0xFF), not(i8_type, 0));
try testing.expectEqual(@as(u128, 0), not(i8_type, 0xFF));
try testing.expectEqual(@as(u128, 0xFF), negate(i8_type, 1));
try testing.expectEqual(@as(u128, 1), negate(i8_type, 0xFF));
// Negating the most negative value gives itself back, as two's complement does.
try testing.expectEqual(@as(u128, 0x80), negate(w8, 0x80));
try testing.expectEqual(@as(u128, 0x80), negate(i8_type, 0x80));
}
test "signExtend reads the top bit only when the domain is signed" {
try testing.expectEqual(@as(i128, -1), w8.signExtend(0xFF));
try testing.expectEqual(@as(i128, 255), u8_domain.signExtend(0xFF));
try testing.expectEqual(@as(i128, -128), w8.signExtend(0x80));
try testing.expectEqual(@as(i128, 127), w8.signExtend(0x7F));
try testing.expectEqual(@as(i128, -1), i8_type.signExtend(0xFF));
try testing.expectEqual(@as(i128, 255), u8_type.signExtend(0xFF));
try testing.expectEqual(@as(i128, -128), i8_type.signExtend(0x80));
try testing.expectEqual(@as(i128, 127), i8_type.signExtend(0x7F));
// A 128-bit domain has no bits above the width to fill.
const w128: Domain = .{ .bit_width = .bits128, .signedness = .signed };
try testing.expectEqual(@as(i128, -1), w128.signExtend(w128.mask()));
const i128_type: IntType = .{ .width = .bits128 };
try testing.expectEqual(@as(i128, -1), i128_type.signExtend(i128_type.mask()));
}
test "standard mode is 64-bit signed" {
try testing.expectEqual(BitWidth.bits64, Domain.standard.bit_width);
try testing.expectEqual(Signedness.signed, Domain.standard.signedness);
test "the default type, which standard mode uses, is 64-bit signed" {
const i64_type: IntType = .{};
try testing.expectEqual(BitWidth.bits64, i64_type.width);
try testing.expectEqual(Signedness.signed, i64_type.signedness);
// -8 >> 1 is -4 there, which is the case that used to differ between modes.
const minus_eight: u128 = @bitCast(@as(i128, -8) & @as(i128, @bitCast(Domain.standard.mask())));
const shifted = try apply(Domain.standard, .shift_right, minus_eight, 1);
try testing.expectEqual(@as(i128, -4), Domain.standard.signExtend(shifted));
const minus_eight: u128 = @bitCast(@as(i128, -8) & @as(i128, @bitCast(i64_type.mask())));
const shifted = try apply(i64_type, .shift_right, minus_eight, 1);
try testing.expectEqual(@as(i128, -4), i64_type.signExtend(shifted));
}

View file

@ -1,23 +1,31 @@
//! 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.
//! Pure computation library with no I/O. Provides expression parsing, evaluation,
//! programmer-mode bit manipulation, unit conversion, and financial calculations.
//!
//! This file is a facade: it imports and re-exports, and defines nothing. Anything
//! defined here would have to be imported back by the modules below, which is how a
//! root file becomes a dependency of its own leaves.
pub const types = @import("types.zig");
// Vocabulary, lowest first.
pub const errors = @import("errors.zig");
pub const Integer = @import("Integer.zig");
// Exact numeric model (design.md 2.7). The evaluator computes in these.
pub const rational = @import("rational.zig");
pub const number = @import("number.zig");
// Language layer.
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");
// Fixed-width integer operations, shared by both modes.
pub const bitwise = @import("bitwise.zig");
pub const programmer = @import("programmer.zig");
// Domains and display.
pub const formatter = @import("formatter.zig");
pub const float_interp = @import("float_interp.zig");
pub const units = @import("units.zig");
pub const financial = @import("financial.zig");
// Exact numeric model (design.md 2.7). The evaluator computes in these.
pub const rational = @import("rational.zig");
pub const number = @import("number.zig");
// The modules above are the engine's surface: a caller writes `engine.units.convert`
// or `engine.financial.solveTvm`. The aliases below exist only for the handful of
@ -25,9 +33,8 @@ pub const number = @import("number.zig");
// curated re-export of nearly every public declaration, which drifted: two thirds
// of it had no callers, and `Value` was re-exported after the type it named had
// stopped being the engine's result type.
pub const Mode = types.Mode;
pub const BitWidth = types.BitWidth;
pub const CalcError = types.CalcError;
pub const CalcError = errors.CalcError;
pub const BitWidth = Integer.BitWidth;
pub const Environment = evaluator.Environment;
pub const evalString = evaluator.evalString;
pub const evalStringInfo = evaluator.evalStringInfo;

136
engine/src/errors.zig Normal file
View file

@ -0,0 +1,136 @@
//! The engine's error vocabulary, and the one place its wording lives.
//!
//! Every engine function returns `CalcError!T`. Errors carry no position or
//! context: an `ErrorInfo` with a source position was once specified and declared,
//! but nothing ever constructed one, and the parser fields that would have fed it
//! were written and never read. Adding position reporting means threading it
//! through the returns, which is a change to make deliberately.
const std = @import("std");
/// 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,
};
/// The human-readable phrase for an error, with no prefix and no newline.
///
/// The single source of these strings. The CLI and the TUI each had their own
/// switch over the same error set, differing only in punctuation and in what they
/// had forgotten: the TUI was missing `InsufficientParameters`, `ConvergenceFailure`
/// and `InvalidExpression` and rendered all three as "evaluation error". Callers add
/// their own decoration ("error: " and a newline for the CLI, "error: " for the
/// TUI), and a view with better context can still override individual cases, as the
/// financial form does.
///
/// The switch has no `else`, so an error added to the set is a compile error here
/// rather than a silent fallback to a vague phrase.
pub fn errorPhrase(err: CalcError) []const u8 {
return switch (err) {
// Parser
CalcError.UnexpectedToken => "unexpected token",
CalcError.UnmatchedParen => "unmatched parenthesis",
CalcError.InvalidNumber => "invalid number",
CalcError.UnknownFunction => "unknown function",
CalcError.UnknownVariable => "unknown variable",
CalcError.UnexpectedEnd => "unexpected end of expression",
CalcError.InvalidExpression => "invalid expression",
// Evaluation
CalcError.DivisionByZero => "division by zero",
CalcError.Overflow => "overflow",
CalcError.InvalidOperandType => "invalid operand type",
CalcError.DomainError => "domain error",
// Struct layout
CalcError.InvalidType => "invalid type",
CalcError.InvalidFieldName => "invalid field name",
CalcError.DuplicateFieldName => "duplicate field name",
CalcError.StructTooLarge => "struct too large",
// Financial
CalcError.InsufficientParameters => "these values do not determine an answer",
CalcError.ConvergenceFailure => "no solution found",
// Units
CalcError.UnknownUnit => "unknown unit",
CalcError.IncompatibleUnits => "incompatible units (different categories)",
// System
CalcError.OutOfMemory => "out of memory",
};
}
// -- Tests --
const testing = std.testing;
test "errorPhrase: every error in the set has its own phrase" {
// Exhaustive by construction: the switch in errorPhrase has no else branch, so
// adding an error to CalcError without a phrase is a compile error rather than a
// silent fallback. This walks the set to prove the phrases are distinct and
// non-empty.
const fields = @typeInfo(CalcError).error_set.?;
var seen: [fields.len][]const u8 = undefined;
inline for (fields, 0..) |field, i| {
const phrase = errorPhrase(@field(CalcError, field.name));
try testing.expect(phrase.len > 0);
// No prefix and no newline: decoration belongs to the caller.
try testing.expect(!std.mem.startsWith(u8, phrase, "error"));
try testing.expect(std.mem.indexOfScalar(u8, phrase, '\n') == null);
seen[i] = phrase;
}
for (seen, 0..) |phrase, i| {
for (seen[i + 1 ..]) |other| {
if (std.mem.eql(u8, phrase, other)) {
std.debug.print("two errors share the phrase \"{s}\"\n", .{phrase});
return error.TestUnexpectedResult;
}
}
}
}
test "errorPhrase: usable at comptime, which is how frontends decorate it" {
const decorated = comptime "error: " ++ errorPhrase(CalcError.DivisionByZero);
try testing.expectEqualStrings("error: division by zero", decorated);
}
test "errorPhrase: the cases the TUI table used to lose" {
try testing.expectEqualStrings("invalid expression", errorPhrase(CalcError.InvalidExpression));
try testing.expectEqualStrings("no solution found", errorPhrase(CalcError.ConvergenceFailure));
try testing.expectEqualStrings(
"these values do not determine an answer",
errorPhrase(CalcError.InsufficientParameters),
);
}

View file

@ -11,10 +11,9 @@ 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 Integer = @import("Integer.zig");
const IntType = Integer.IntType;
const CalcError = @import("errors.zig").CalcError;
const parser_mod = @import("parser.zig");
const Parser = parser_mod.Parser;
const number_mod = @import("number.zig");
@ -22,23 +21,24 @@ const Number = number_mod.Number;
const bitwise = @import("bitwise.zig");
const financial = @import("financial.zig");
/// Evaluation environment holding variables, history, and config.
/// Evaluation environment holding variables and the last answer.
///
/// Variables and `Ans` are stored as `Number`, so an assignment keeps whatever
/// exactness its expression had: `X = 0.1` stores exactly one tenth rather than
/// a binary approximation of it.
///
/// It holds no mode and no programmer configuration. It used to hold both and read
/// neither: the caller chooses between `evalString` and `evalProgrammerString`, and
/// standard mode's integer type is fixed (`standard_int_type`). The TUI was writing
/// a mode into this on every mode change, into a field nothing consulted.
pub const Environment = struct {
allocator: Allocator,
mode: Mode,
programmer_config: ProgrammerConfig,
variables: std.StringHashMap(Number),
ans: Number,
pub fn init(allocator: Allocator, mode: Mode) Environment {
pub fn init(allocator: Allocator) Environment {
return .{
.allocator = allocator,
.mode = mode,
.programmer_config = .{},
.variables = std.StringHashMap(Number).init(allocator),
// Starts inexact so that `init` cannot fail; the first evaluation
// replaces it.
@ -173,7 +173,7 @@ fn evalExact(env: *Environment, scratch: Allocator, expr: *const Expr) CalcError
// setting entirely.
.bitwise_not => blk: {
const bits = try toFixedWidthBits(operand.toFloat(scratch));
break :blk fromFixedWidthBits(bitwise.not(standard_domain, bits));
break :blk fromFixedWidthBits(bitwise.not(standard_int_type, bits));
},
};
},
@ -232,7 +232,7 @@ fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) C
const l = try toFixedWidthBits(left.toFloat(scratch));
const r = try toFixedWidthBits(right.toFloat(scratch));
const result = try bitwise.apply(
standard_domain,
standard_int_type,
comptime bitwise.fromBinaryOp(fixed_op).?,
l,
r,
@ -242,12 +242,13 @@ fn evalBinaryOp(scratch: Allocator, op: BinaryOp, left: Number, right: Number) C
};
}
/// Standard mode's integer domain: 64-bit two's complement, fixed (FR-2.3).
/// Standard mode's integer type: 64-bit two's complement, fixed (FR-2.3), which is
/// also `IntType`'s default.
///
/// Standard mode does not consult the programmer-mode width. A width other than 64
/// is what programmer mode is for, and pretending otherwise is how `~` came to
/// honour the setting while the shifts beside it did not.
const standard_domain = bitwise.Domain.standard;
const standard_int_type: IntType = .{};
/// Project a float onto the integer domain the bitwise operators work in.
///
@ -269,7 +270,7 @@ fn toFixedWidthBits(value: f64) CalcError!u128 {
/// Read a result pattern back as a number, signed, since standard mode is signed.
fn fromFixedWidthBits(bits: u128) Number {
return Number.fromFloat(@floatFromInt(standard_domain.signExtend(bits)));
return Number.fromFloat(@floatFromInt(standard_int_type.signExtend(bits)));
}
/// Evaluate a built-in function call.
@ -585,7 +586,7 @@ 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);
var env = Environment.init(alloc);
defer env.deinit();
const result = try evalString(&env, alloc, source);
return result.toFloat(alloc);
@ -595,7 +596,7 @@ 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);
var env = Environment.init(alloc);
defer env.deinit();
const result = try evalString(&env, alloc, source);
return result.toFloat(alloc);
@ -765,7 +766,7 @@ 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);
var env = Environment.init(alloc);
defer env.deinit();
const assign_result = try evalString(&env, alloc, "X = 42");
@ -779,7 +780,7 @@ 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);
var env = Environment.init(alloc);
defer env.deinit();
_ = try evalString(&env, alloc, "7 * 6");
@ -873,8 +874,7 @@ test "standard mode: the fixed-width operators use one implementation with progr
for (shared) |source| {
const standard = try testEval(source);
const prog = try programmer_mod.evalProgrammerString(alloc, source, .{
.bit_width = .bits64,
.signedness = .signed,
.int_type = .{ .width = .bits64, .signedness = .signed },
});
try testing.expectEqual(@as(i128, @intFromFloat(standard)), prog.signedValue());
}
@ -918,13 +918,13 @@ test "standard mode: rotation is cyclic, not clamped" {
try testing.expectEqual(@as(f64, 1.0), try testEval("1 ror 64"));
}
test "standard mode: the programmer width setting does not reach it" {
// `~` used to read env.programmer_config.bit_width while the shifts beside it
// ignored it, so `--bits 8` changed one and not the other. Standard mode is
// fixed at 64-bit signed.
var env = Environment.init(testing.allocator, .standard);
test "standard mode: its integer type is fixed at 64-bit signed" {
// `~` used to read `env.programmer_config.bit_width` while the shifts beside it
// ignored it, so `--bits 8` changed one operator and not the others. The
// environment no longer carries a width to disagree about: there is nothing to
// set here, which is the point.
var env = Environment.init(testing.allocator);
defer env.deinit();
env.programmer_config.bit_width = .bits8;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
@ -937,6 +937,9 @@ test "standard mode: the programmer width setting does not reach it" {
var shifted = try evalString(&env, alloc, "1 << 10");
defer shifted.deinit();
try testing.expectEqual(@as(f64, 1024.0), shifted.toFloat(alloc));
try testing.expectEqual(@as(u8, 64), standard_int_type.bits());
try testing.expectEqual(Integer.Signedness.signed, standard_int_type.signedness);
}
test "eval rotate left in standard mode" {
@ -1007,7 +1010,7 @@ test "evalStringInfo: detects hex literal" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .standard);
var env = Environment.init(alloc);
defer env.deinit();
const info = try evalStringInfo(&env, alloc, "0o777 - 0x0f");
try testing.expectEqual(@as(f64, 496.0), info.value.toFloat(alloc));
@ -1018,7 +1021,7 @@ test "evalStringInfo: pure decimal has no nondecimal literal" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .standard);
var env = Environment.init(alloc);
defer env.deinit();
const info = try evalStringInfo(&env, alloc, "2 + 2");
try testing.expectEqual(@as(f64, 4.0), info.value.toFloat(alloc));
@ -1029,7 +1032,7 @@ test "evalStringInfo: binary literal detected in nested expr" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .standard);
var env = Environment.init(alloc);
defer env.deinit();
const info = try evalStringInfo(&env, alloc, "sqrt(0b100) + 1");
try testing.expect(info.has_nondecimal_literal);
@ -1202,7 +1205,7 @@ test "exact: overflow from an absurd exponent is reported as overflow" {
/// Evaluate and keep the exact result. The arena owns everything.
fn testEvalNumber(arena: *std.heap.ArenaAllocator, source: []const u8) !Number {
const a = arena.allocator();
var env = Environment.init(a, .standard);
var env = Environment.init(a);
defer env.deinit();
return evalString(&env, a, source);
}
@ -1288,7 +1291,7 @@ test "Number API: variables keep the exactness of their expression" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const a = arena.allocator();
var env = Environment.init(a, .standard);
var env = Environment.init(a);
defer env.deinit();
const assigned = try evalString(&env, a, "X = 0.1");
@ -1305,7 +1308,7 @@ test "Number API: Ans keeps exactness between evaluations" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const a = arena.allocator();
var env = Environment.init(a, .standard);
var env = Environment.init(a);
defer env.deinit();
_ = try evalString(&env, a, "1/3");
@ -1318,7 +1321,7 @@ test "Number API: Ans keeps exactness between evaluations" {
test "Number API: reassigning a variable releases the old value" {
// Exercises the replace path in setVar, which must deinit the previous
// Number rather than leaking it.
var env = Environment.init(testing.allocator, .standard);
var env = Environment.init(testing.allocator);
defer env.deinit();
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
@ -1335,7 +1338,7 @@ test "Number API: reassigning a variable releases the old value" {
test "Number API: a variable name outliving its source text stays valid" {
// setVar duplicates the name because it points into the expression source,
// which the caller may free (the TUI frees history on Ctrl-L).
var env = Environment.init(testing.allocator, .standard);
var env = Environment.init(testing.allocator);
defer env.deinit();
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
@ -1463,7 +1466,7 @@ test "financial: results are inexact, so they do not claim exactness" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc, .standard);
var env = Environment.init(alloc);
defer env.deinit();
const result = try evalString(&env, alloc, "cagr(1000, 2000, 10)");
@ -1501,7 +1504,7 @@ test "financial: wrong argument counts are unknown functions, not silent default
// whole parsed tree on every call, which an arena silently absorbs.
test "no leak: a successful evaluation releases the parsed tree" {
var env = Environment.init(testing.allocator, .standard);
var env = Environment.init(testing.allocator);
defer env.deinit();
const sources = [_][]const u8{
@ -1522,7 +1525,7 @@ test "no leak: a successful evaluation releases the parsed tree" {
}
test "no leak: a failed evaluation releases the parsed tree" {
var env = Environment.init(testing.allocator, .standard);
var env = Environment.init(testing.allocator);
defer env.deinit();
const sources = [_][]const u8{
@ -1548,7 +1551,7 @@ test "no leak: a failed evaluation releases the parsed tree" {
test "no leak: repeated evaluation does not accumulate" {
// A long interactive session is the case that made this visible: the TUI
// evaluates on every Enter and never frees anything itself.
var env = Environment.init(testing.allocator, .standard);
var env = Environment.init(testing.allocator);
defer env.deinit();
var i: usize = 0;
@ -1600,7 +1603,7 @@ test "a malformed group is an error, not a silently merged number" {
test "a grouped literal past 2^53 is still exact" {
// The separators have to reach the exact re-parse, not just the f64 channel.
var env = Environment.init(testing.allocator, .standard);
var env = Environment.init(testing.allocator);
defer env.deinit();
var value = try evalString(&env, testing.allocator, "9,007,199,254,740,993");
defer value.deinit();

View file

@ -27,8 +27,7 @@
const std = @import("std");
const math = std.math;
const types = @import("types.zig");
const CalcError = types.CalcError;
const CalcError = @import("errors.zig").CalcError;
/// Iteration cap for the rate solver.
pub const max_iterations: usize = 1000;

View file

@ -17,9 +17,9 @@
//! magnitude is below the fractional budget
const std = @import("std");
const types = @import("types.zig");
const BitWidth = types.BitWidth;
const Endianness = types.Endianness;
const Integer = @import("Integer.zig");
const BitWidth = Integer.BitWidth;
const Endianness = std.builtin.Endian;
const Number = @import("number.zig").Number;
/// A formatted value with both display and clipboard representations.
@ -321,6 +321,30 @@ pub fn writeGroupedDecimal(dest: []u8, text: []const u8) usize {
return w + parts.tail.len;
}
/// The width to print the hex, octal and binary rows at, for a standard-mode value
/// that has no configured width (FR-1.9).
///
/// The narrowest of the standard widths that holds `value`, so a small number does
/// not come out padded to 64 bits: 1 prints as `01`, and 256 steps up to `01 00`.
/// It rounds up to a width a reader recognises rather than to a bit count, which is
/// what makes the hex row read as whole bytes.
///
/// Unsigned only: it counts significant bits of the pattern, so a negative value's
/// two's complement form would always report the full width. Callers reach this
/// only for non-negative integers, which is also what FR-1.9 promises. Zero has no
/// significant bits and prints at the narrowest width.
///
/// Programmer mode does not use this; there the width is the user's setting.
pub fn displayWidthFor(value: u128) BitWidth {
return switch (128 - @clz(value)) {
0...8 => .bits8,
9...16 => .bits16,
17...32 => .bits32,
33...64 => .bits64,
else => .bits128,
};
}
/// Format an integer for programmer mode hex display.
/// Display: "FF FF FF FF" (space per byte), byte order per `endian`.
/// Raw: "0xFFFFFFFF" (no separators, canonical MSB-first value regardless of
@ -624,6 +648,31 @@ test "formatFloat: zero" {
try testing.expectEqualStrings("0", result.raw);
}
test "displayWidthFor: the narrowest standard width that holds the value" {
const BW = Integer.BitWidth;
try testing.expectEqual(BW.bits8, displayWidthFor(0));
try testing.expectEqual(BW.bits8, displayWidthFor(255));
try testing.expectEqual(BW.bits16, displayWidthFor(256));
try testing.expectEqual(BW.bits16, displayWidthFor(65535));
try testing.expectEqual(BW.bits32, displayWidthFor(65536));
try testing.expectEqual(BW.bits64, displayWidthFor(0x1_0000_0000));
try testing.expectEqual(BW.bits128, displayWidthFor(0x1_0000_0000_0000_0000));
try testing.expectEqual(BW.bits128, displayWidthFor(std.math.maxInt(u128)));
}
test "displayWidthFor: the chosen width holds the value and sizes the rows" {
// What the width is for: the hex row of a small number is one byte, not eight.
var buf: [256]u8 = undefined;
for ([_]u128{ 0, 1, 255, 256, 0xFFFF, 0x1_0000, std.math.maxInt(u64), std.math.maxInt(u128) }) |value| {
const bw = displayWidthFor(value);
try testing.expectEqual(value, value & bw.mask());
const hex = formatHex(&buf, value, bw, .big);
// Two hex digits per byte, plus one space between bytes.
const bytes = bw.bits() / 8;
try testing.expectEqual(@as(usize, bytes * 2 + bytes - 1), hex.display.len);
}
}
test "formatHex: 8-bit" {
var buf: [256]u8 = undefined;
const result = formatHex(&buf, 0xFF, .bits8, .big);
@ -1476,10 +1525,10 @@ test "splitDecimalText: one place that takes decimal text apart" {
try testing.expectEqual(@as(usize, 4), negative.int_digits);
try testing.expectEqualStrings(".56", negative.tail);
const integer = splitDecimalText("-70");
try testing.expectEqual(@as(usize, 1), integer.sign_len);
try testing.expectEqual(@as(usize, 2), integer.int_digits);
try testing.expectEqualStrings("", integer.tail);
const whole = splitDecimalText("-70");
try testing.expectEqual(@as(usize, 1), whole.sign_len);
try testing.expectEqual(@as(usize, 2), whole.int_digits);
try testing.expectEqualStrings("", whole.tail);
const explicit_plus = splitDecimalText("+5");
try testing.expectEqual(@as(usize, 1), explicit_plus.sign_len);

View file

@ -21,7 +21,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const rational = @import("rational.zig");
const Rational = rational.Rational;
const CalcError = @import("types.zig").CalcError;
const CalcError = @import("errors.zig").CalcError;
/// Map a numeric-model error onto the engine's error set.
///
@ -624,7 +624,7 @@ test "sqrt: the domain error reaches the engine error set as a domain error" {
}
test "toCalcError maps every numeric error, with no default" {
const CalcErr = @import("types.zig").CalcError;
const CalcErr = @import("errors.zig").CalcError;
// One mapping for the evaluator and the unit converter, which used to have a
// copy each. Walking the whole set keeps the two tiers of error vocabulary
// lined up: a member added to Error has to be given a CalcError here.

View file

@ -20,8 +20,7 @@ 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 CalcError = types.CalcError;
const CalcError = @import("errors.zig").CalcError;
/// Precedence levels (higher = tighter binding).
///

View file

@ -1,38 +1,47 @@
//! Programmer mode evaluator for Tally.
//!
//! All operations use exact integer arithmetic (u128 storage), with results
//! masked to the configured bit width. No floating-point involved.
//! Produces Integer values with signed/unsigned interpretation.
//! All operations use exact integer arithmetic (u128 storage), with results masked
//! to the configured width. No floating-point involved. The bitwise operators, the
//! shifts and the rotations live in `bitwise.zig`, which standard mode shares; what
//! is here is the wrapping arithmetic and the walk over the tree.
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 Integer = @import("Integer.zig");
const IntType = Integer.IntType;
const Endianness = std.builtin.Endian;
const CalcError = @import("errors.zig").CalcError;
const parser_mod = @import("parser.zig");
const Parser = parser_mod.Parser;
const bitwise = @import("bitwise.zig");
/// What programmer mode needs to know: the integer type to compute in, and one
/// display preference that does not affect arithmetic.
pub const Config = struct {
int_type: IntType = .{},
/// Byte order for the HEX and ASCII rows only. Defaults to big-endian so the
/// HEX row reads as the number itself (matching DEC/OCT/BIN); the little-endian
/// view (x86 memory layout) is available via the toggle.
display_endian: Endianness = .big,
};
/// 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);
pub fn evalProgrammer(config: Config, expr: *const Expr) CalcError!Integer {
return .{
.raw = raw & config.bit_width.mask(),
.bit_width = config.bit_width,
.signedness = config.signedness,
.raw = try evalExpr(config, expr),
.int_type = config.int_type,
};
}
/// Recursively evaluate an expression to a raw u128.
fn evalExpr(config: ProgrammerConfig, expr: *const Expr) CalcError!u128 {
fn evalExpr(config: Config, expr: *const Expr) CalcError!u128 {
switch (expr.*) {
.number => |n| {
if (n.int_value) |int_val| {
return int_val & config.bit_width.mask();
return int_val & config.int_type.mask();
}
// Float literal in programmer mode: truncate to integer.
// (Number literals are always non-negative; unary minus is a
@ -45,19 +54,19 @@ fn evalExpr(config: ProgrammerConfig, expr: *const Expr) CalcError!u128 {
if (!std.math.isFinite(value) or value < 0) return CalcError.DomainError;
if (value >= 340282366920938463463374607431768211456.0) return CalcError.Overflow;
const val: u128 = @intFromFloat(value);
return val & config.bit_width.mask();
return val & config.int_type.mask();
},
.string_literal => |text| {
// Pack ASCII bytes into integer.
// Big-endian packing: first char -> most significant used byte.
const max_bytes = @as(usize, config.bit_width.bits()) / 8;
const max_bytes = @as(usize, config.int_type.bits()) / 8;
if (text.len > max_bytes) return CalcError.Overflow;
var result: u128 = 0;
for (text) |byte| {
if (byte > 0x7F) return CalcError.InvalidNumber;
result = (result << 8) | byte;
}
return result & config.bit_width.mask();
return result & config.int_type.mask();
},
.variable => {
return CalcError.UnknownVariable;
@ -67,7 +76,7 @@ fn evalExpr(config: ProgrammerConfig, expr: *const Expr) CalcError!u128 {
},
.unary => |u| {
const operand = try evalExpr(config, u.operand);
const domain = bitwise.Domain.fromConfig(config);
const domain = config.int_type;
return switch (u.op) {
.negate => bitwise.negate(domain, operand),
.bitwise_not => bitwise.not(domain, operand),
@ -91,8 +100,8 @@ fn evalExpr(config: ProgrammerConfig, expr: *const Expr) CalcError!u128 {
/// `bitwise.zig`, which standard mode uses too, so the two modes cannot drift
/// apart again. What remains is the arithmetic, which genuinely differs between the
/// modes: it wraps at the width here and is exact rational arithmetic there.
fn evalBinaryOp(config: ProgrammerConfig, op: BinaryOp, left: u128, right: u128) CalcError!u128 {
const mask = config.bit_width.mask();
fn evalBinaryOp(config: Config, op: BinaryOp, left: u128, right: u128) CalcError!u128 {
const mask = config.int_type.mask();
const result: u128 = switch (op) {
.add => (left +% right) & mask,
@ -121,7 +130,7 @@ fn evalBinaryOp(config: ProgrammerConfig, op: BinaryOp, left: u128, right: u128)
// resolves the operator at comptime, so an operator added to `BinaryOp`
// that `bitwise.fromBinaryOp` does not know is a compile error here.
inline else => |fixed_op| try bitwise.apply(
bitwise.Domain.fromConfig(config),
config.int_type,
comptime bitwise.fromBinaryOp(fixed_op).?,
left,
right,
@ -132,7 +141,7 @@ fn evalBinaryOp(config: ProgrammerConfig, op: BinaryOp, left: u128, right: u128)
}
/// High-level: parse and evaluate a string in programmer mode.
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: ProgrammerConfig) CalcError!Integer {
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) CalcError!Integer {
var p = Parser.init(allocator, source);
const expr = try p.parse();
// Same ownership rule as evalStringInfo: the tree is ours to release, and the
@ -149,10 +158,12 @@ fn testProg(source: []const u8) !Integer {
return testProgWith(source, .{});
}
fn testProgWith(source: []const u8, config: ProgrammerConfig) !Integer {
/// Tests care about the integer type, never about the display byte order, so they
/// pass the type directly.
fn testProgWith(source: []const u8, int_type: IntType) !Integer {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
return evalProgrammerString(arena.allocator(), source, config);
return evalProgrammerString(arena.allocator(), source, .{ .int_type = int_type });
}
test "prog: simple number" {
@ -177,7 +188,7 @@ test "prog: addition" {
test "prog: subtraction wrapping" {
// 5 - 10 in 8-bit unsigned wraps
const result = try testProgWith("5 - 10", .{ .bit_width = .bits8 });
const result = try testProgWith("5 - 10", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 251), result.unsignedValue()); // 256 - 5
try testing.expectEqual(@as(i128, -5), result.signedValue());
}
@ -188,7 +199,7 @@ test "prog: multiplication" {
}
test "prog: multiplication overflow 8-bit" {
const result = try testProgWith("200 * 2", .{ .bit_width = .bits8 });
const result = try testProgWith("200 * 2", .{ .width = .bits8 });
// 400 & 0xFF = 144
try testing.expectEqual(@as(u128, 144), result.unsignedValue());
}
@ -239,22 +250,22 @@ test "prog: and/or/not keywords" {
try testing.expectEqual(@as(u128, 0x0F), a.unsignedValue());
const o = try testProg("0xF0 or 0x0F");
try testing.expectEqual(@as(u128, 0xFF), o.unsignedValue());
const n = try testProgWith("not 0x0F", .{ .bit_width = .bits8 });
const n = try testProgWith("not 0x0F", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0xF0), n.unsignedValue());
}
test "prog: bitwise NOT 8-bit" {
const result = try testProgWith("~0x0F", .{ .bit_width = .bits8 });
const result = try testProgWith("~0x0F", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0xF0), result.unsignedValue());
}
test "prog: bitwise NOT 16-bit" {
const result = try testProgWith("~0x00FF", .{ .bit_width = .bits16 });
const result = try testProgWith("~0x00FF", .{ .width = .bits16 });
try testing.expectEqual(@as(u128, 0xFF00), result.unsignedValue());
}
test "prog: bitwise NOT 32-bit" {
const result = try testProgWith("~0", .{ .bit_width = .bits32 });
const result = try testProgWith("~0", .{ .width = .bits32 });
try testing.expectEqual(@as(u128, 0xFFFF_FFFF), result.unsignedValue());
}
@ -265,52 +276,52 @@ test "prog: shift left" {
test "prog: shift left past the width shifts everything out" {
// The distance used to be clamped to width - 1, so this gave 128.
const result = try testProgWith("1 << 8", .{ .bit_width = .bits8 });
const result = try testProgWith("1 << 8", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0), result.unsignedValue());
// One less than the width still keeps the bit.
const edge = try testProgWith("1 << 7", .{ .bit_width = .bits8 });
const edge = try testProgWith("1 << 7", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 128), edge.unsignedValue());
}
test "prog: logical shift right" {
const result = try testProgWith("0x80 >>> 4", .{ .bit_width = .bits8 });
const result = try testProgWith("0x80 >>> 4", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 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 });
const result = try testProgWith("0x80 >> 1", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0xC0), result.unsignedValue());
try testing.expectEqual(@as(i128, -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 });
const result = try testProgWith("0x40 >> 1", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 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 });
const result = try testProgWith("0x81 rol 1", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 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 });
const result = try testProgWith("0x81 ror 1", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0xC0), result.unsignedValue());
}
test "prog: negation two's complement" {
const result = try testProgWith("-1", .{ .bit_width = .bits8 });
const result = try testProgWith("-1", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0xFF), result.unsignedValue());
try testing.expectEqual(@as(i128, -1), result.signedValue());
}
test "prog: negation 16-bit" {
const result = try testProgWith("-42", .{ .bit_width = .bits16 });
const result = try testProgWith("-42", .{ .width = .bits16 });
try testing.expectEqual(@as(i128, -42), result.signedValue());
}
@ -334,23 +345,23 @@ test "prog: chained shifts" {
test "prog: mask applied to input" {
// 0x1FF in 8-bit mode should be masked to 0xFF
const result = try testProgWith("0x1FF", .{ .bit_width = .bits8 });
const result = try testProgWith("0x1FF", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0xFF), result.unsignedValue());
}
test "prog: 32-bit operations" {
const result = try testProgWith("0xFFFF_FFFF + 1", .{ .bit_width = .bits32 });
const result = try testProgWith("0xFFFF_FFFF + 1", .{ .width = .bits32 });
try testing.expectEqual(@as(u128, 0), result.unsignedValue());
}
test "prog: 64-bit max" {
const result = try testProgWith("~0", .{ .bit_width = .bits64 });
const result = try testProgWith("~0", .{ .width = .bits64 });
try testing.expectEqual(@as(u128, 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 });
const result = try testProgWith("-5", .{ .width = .bits8 });
try testing.expectEqual(@as(i128, -5), result.signedValue());
}
@ -399,7 +410,7 @@ test "prog: ASCII literal in expression" {
test "prog: ASCII literal overflow 8-bit" {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer _ = arena.deinit();
const result = evalProgrammerString(arena.allocator(), "'AB'", .{ .bit_width = .bits8 });
const result = evalProgrammerString(arena.allocator(), "'AB'", .{ .int_type = .{ .width = .bits8 } });
try testing.expectError(CalcError.Overflow, result);
}
@ -413,30 +424,30 @@ test "prog: arithmetic shift right past the width leaves the sign fill" {
// 0xFF in 8-bit is -1; shifting a negative value all the way out leaves every
// bit set, which is still -1. The distance used to be clamped to width - 1,
// which reached the same answer here for the wrong reason.
const result = try testProgWith("0xFF >> 20", .{ .bit_width = .bits8 });
const result = try testProgWith("0xFF >> 20", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0xFF), result.unsignedValue());
try testing.expectEqual(@as(i128, -1), result.signedValue());
}
test "prog: logical shift right past the width shifts everything out" {
// Used to clamp the distance to 7 and give 1.
const result = try testProgWith("0xFF >>> 20", .{ .bit_width = .bits8 });
const result = try testProgWith("0xFF >>> 20", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0), result.unsignedValue());
// One less than the width still keeps the bottom bit.
const edge = try testProgWith("0xFF >>> 7", .{ .bit_width = .bits8 });
const edge = try testProgWith("0xFF >>> 7", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 1), edge.unsignedValue());
}
test "prog: arithmetic shift right past the width, positive value" {
// 0x40 in 8-bit is positive, so the fill is zeros and everything shifts out.
const result = try testProgWith("0x40 >> 20", .{ .bit_width = .bits8 });
const result = try testProgWith("0x40 >> 20", .{ .width = .bits8 });
try testing.expectEqual(@as(u128, 0), result.unsignedValue());
}
test "no leak: evalProgrammerString releases the parsed tree" {
// testing.allocator rather than an arena, so a retained AST fails the test.
const config: ProgrammerConfig = .{};
const config: Config = .{};
const good = [_][]const u8{ "0xFF and 0x0F", "1 << 8", "not 0", "0b1010 xor 0b0101", "5 rol 2" };
for (good) |source| {
_ = try evalProgrammerString(std.testing.allocator, source, config);
@ -450,7 +461,7 @@ test "no leak: evalProgrammerString releases the parsed tree" {
}
test "programmer mode: a float literal out of range errors instead of aborting" {
const config: ProgrammerConfig = .{};
const config: Config = .{};
// `tally -p '1e40'` used to abort the process here: @intFromFloat on a value
// past u128 is illegal behaviour, and only 3.14 was ever tested.
try std.testing.expectError(
@ -469,7 +480,7 @@ test "programmer mode: a float literal out of range errors instead of aborting"
}
test "programmer mode: an infinite or NaN literal is a domain error" {
const config: ProgrammerConfig = .{};
const config: Config = .{};
// 10^400 overflows the exact tier's float projection to infinity.
try std.testing.expectError(
CalcError.DomainError,

View file

@ -6,8 +6,16 @@
//! literals, and space/comma/underscore digit separators.
const std = @import("std");
const types = @import("types.zig");
const Base = types.Base;
/// The base a numeric literal was written in. A lexical property: the tokenizer
/// determines it from the prefix, and the AST carries it so the evaluator can tell
/// `0x10` from `16` for the multi-base display (FR-1.9).
pub const Base = enum {
decimal,
hex,
octal,
binary,
};
pub const TokenKind = enum {
// Literals

View file

@ -1,274 +0,0 @@
//! 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(u8) {
bits8 = 8,
bits16 = 16,
bits32 = 32,
bits64 = 64,
bits128 = 128,
/// Returns the mask for this bit width (all bits set within width).
pub fn mask(self: BitWidth) u128 {
return switch (self) {
.bits8 => 0xFF,
.bits16 => 0xFFFF,
.bits32 => 0xFFFF_FFFF,
.bits64 => 0xFFFF_FFFF_FFFF_FFFF,
.bits128 => 0xFFFF_FFFF_FFFF_FFFF_FFFF_FFFF_FFFF_FFFF,
};
}
/// Returns the number of bits as a plain integer.
pub fn bits(self: BitWidth) u8 {
return @intFromEnum(self);
}
/// Returns the smallest standard width that can hold the given value.
pub fn smallestFor(value: u128) BitWidth {
if (value <= 0xFF) return .bits8;
if (value <= 0xFFFF) return .bits16;
if (value <= 0xFFFF_FFFF) return .bits32;
if (value <= 0xFFFF_FFFF_FFFF_FFFF) return .bits64;
return .bits128;
}
};
/// 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 u128; interpretation depends on bit_width and signedness.
pub const Integer = struct {
raw: u128,
bit_width: BitWidth,
signedness: Signedness,
/// Apply the bit width mask, truncating to the configured width.
pub fn masked(self: Integer) u128 {
return self.raw & self.bit_width.mask();
}
/// Interpret as a signed value (sign-extend from bit_width).
pub fn signedValue(self: Integer) i128 {
const m = self.masked();
const width = self.bit_width.bits();
const sign_bit: u128 = @as(u128, 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) u128 {
return self.masked();
}
};
/// Programmer mode configuration.
pub const ProgrammerConfig = struct {
bit_width: BitWidth = .bits64,
signedness: Signedness = .signed,
/// Byte order for the HEX and ASCII rows only. Defaults to big-endian so
/// the HEX row reads as the number itself (matching DEC/OCT/BIN); the
/// little-endian view (x86 memory layout) is available via the toggle.
display_endian: Endianness = .big,
};
/// 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,
};
/// The human-readable phrase for an error, with no prefix and no newline.
///
/// The single source of these strings. The CLI and the TUI each had their own
/// switch over the same error set, differing only in punctuation and in what they
/// had forgotten: the TUI was missing `InsufficientParameters`, `ConvergenceFailure`
/// and `InvalidExpression` and rendered all three as "evaluation error". Callers add
/// their own decoration ("error: " and a newline for the CLI, "error: " for the
/// TUI), and a view with better context can still override individual cases, as the
/// financial form does.
pub fn errorPhrase(err: CalcError) []const u8 {
return switch (err) {
// Parser
CalcError.UnexpectedToken => "unexpected token",
CalcError.UnmatchedParen => "unmatched parenthesis",
CalcError.InvalidNumber => "invalid number",
CalcError.UnknownFunction => "unknown function",
CalcError.UnknownVariable => "unknown variable",
CalcError.UnexpectedEnd => "unexpected end of expression",
CalcError.InvalidExpression => "invalid expression",
// Evaluation
CalcError.DivisionByZero => "division by zero",
CalcError.Overflow => "overflow",
CalcError.InvalidOperandType => "invalid operand type",
CalcError.DomainError => "domain error",
// Struct layout
CalcError.InvalidType => "invalid type",
CalcError.InvalidFieldName => "invalid field name",
CalcError.DuplicateFieldName => "duplicate field name",
CalcError.StructTooLarge => "struct too large",
// Financial
CalcError.InsufficientParameters => "these values do not determine an answer",
CalcError.ConvergenceFailure => "no solution found",
// Units
CalcError.UnknownUnit => "unknown unit",
CalcError.IncompatibleUnits => "incompatible units (different categories)",
// System
CalcError.OutOfMemory => "out of memory",
};
}
test "BitWidth.mask" {
try std.testing.expectEqual(@as(u128, 0xFF), BitWidth.bits8.mask());
try std.testing.expectEqual(@as(u128, 0xFFFF), BitWidth.bits16.mask());
try std.testing.expectEqual(@as(u128, 0xFFFF_FFFF), BitWidth.bits32.mask());
try std.testing.expectEqual(@as(u128, 0xFFFF_FFFF_FFFF_FFFF), BitWidth.bits64.mask());
try std.testing.expectEqual(@as(u128, 0xFFFF_FFFF_FFFF_FFFF_FFFF_FFFF_FFFF_FFFF), BitWidth.bits128.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(i128, -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(i128, 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(i128, -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(i128, -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(u128, 0xFF), val.unsignedValue());
}
test "BitWidth.smallestFor" {
try std.testing.expectEqual(BitWidth.bits8, BitWidth.smallestFor(0));
try std.testing.expectEqual(BitWidth.bits8, BitWidth.smallestFor(255));
try std.testing.expectEqual(BitWidth.bits16, BitWidth.smallestFor(256));
try std.testing.expectEqual(BitWidth.bits16, BitWidth.smallestFor(65535));
try std.testing.expectEqual(BitWidth.bits32, BitWidth.smallestFor(65536));
try std.testing.expectEqual(BitWidth.bits64, BitWidth.smallestFor(0x1_0000_0000));
try std.testing.expectEqual(BitWidth.bits128, BitWidth.smallestFor(0x1_0000_0000_0000_0000));
}
// -- One error phrase table --
//
// The CLI and the TUI each had a full switch over this error set, and the TUI's had
// already fallen behind: InsufficientParameters, ConvergenceFailure and
// InvalidExpression all came out as "evaluation error". The phrases now live here
// once and each frontend adds its own decoration at comptime.
test "errorPhrase: every error in the set has its own phrase" {
// Exhaustive by construction: the switch in errorPhrase has no else branch, so
// adding an error to CalcError without a phrase is a compile error rather than a
// silent fallback. This walks the set to prove the phrases are distinct and
// non-empty.
const fields = @typeInfo(CalcError).error_set.?;
var seen: [fields.len][]const u8 = undefined;
inline for (fields, 0..) |field, i| {
const phrase = errorPhrase(@field(CalcError, field.name));
try std.testing.expect(phrase.len > 0);
// No prefix and no newline: decoration belongs to the caller.
try std.testing.expect(!std.mem.startsWith(u8, phrase, "error"));
try std.testing.expect(std.mem.indexOfScalar(u8, phrase, '\n') == null);
seen[i] = phrase;
}
for (seen, 0..) |phrase, i| {
for (seen[i + 1 ..]) |other| {
if (std.mem.eql(u8, phrase, other)) {
std.debug.print("two errors share the phrase \"{s}\"\n", .{phrase});
return error.TestUnexpectedResult;
}
}
}
}
test "errorPhrase: usable at comptime, which is how frontends decorate it" {
const decorated = comptime "error: " ++ errorPhrase(CalcError.DivisionByZero);
try std.testing.expectEqualStrings("error: division by zero", decorated);
}
test "errorPhrase: the cases the TUI table used to lose" {
try std.testing.expectEqualStrings("invalid expression", errorPhrase(CalcError.InvalidExpression));
try std.testing.expectEqualStrings("no solution found", errorPhrase(CalcError.ConvergenceFailure));
try std.testing.expectEqualStrings(
"these values do not determine an answer",
errorPhrase(CalcError.InsufficientParameters),
);
}

View file

@ -19,8 +19,7 @@
//! No allocation, no I/O. Adding a unit means adding a table entry.
const std = @import("std");
const types = @import("types.zig");
const CalcError = types.CalcError;
const CalcError = @import("errors.zig").CalcError;
const rational_mod = @import("rational.zig");
const Rational = rational_mod.Rational;
const number_mod = @import("number.zig");

View file

@ -8,16 +8,23 @@ pub const CliResult = struct {
is_error: bool,
};
/// Which evaluator a CLI invocation wants.
///
/// The CLI's own concept, not the engine's: the engine has `evalString` and
/// `evalProgrammerString` and no notion of a mode. There are exactly two here
/// because `-p` is the only mode flag; the TUI's four tabs are its own enum.
pub const Mode = enum { standard, programmer };
/// 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,
mode: Mode,
/// Width, signedness and byte order for programmer mode. The CLI accepts
/// the same settings the TUI has, so a session in one can be reproduced in
/// the other.
config: engine.types.ProgrammerConfig = .{},
config: engine.programmer.Config = .{},
},
conversion: struct {
/// Kept as text so it can be parsed exactly rather than through f64.
@ -37,8 +44,8 @@ pub const ParsedArgs = union(enum) {
};
pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedArgs {
var mode: engine.Mode = .standard;
var config: engine.types.ProgrammerConfig = .{};
var mode: Mode = .standard;
var config: engine.programmer.Config = .{};
var expr_parts = std.ArrayList([]const u8).empty;
defer expr_parts.deinit(allocator);
@ -67,10 +74,10 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA
} else if (std.mem.eql(u8, arg, "--version")) {
return .{ .output = .{ .text = "tally 0.1.0\n", .is_error = false } };
} else if (std.mem.eql(u8, arg, "--signed")) {
config.signedness = .signed;
config.int_type.signedness = .signed;
mode = .programmer;
} else if (std.mem.eql(u8, arg, "--unsigned")) {
config.signedness = .unsigned;
config.int_type.signedness = .unsigned;
mode = .programmer;
} else {
// Flags that take a value, accepted as either `--bits 8` or `--bits=8`.
@ -78,7 +85,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const []const u8) ParsedA
.absent => {},
.missing => return .{ .output = .{ .text = bits_usage, .is_error = true } },
.value => |text| {
config.bit_width = parseBitWidth(text) orelse
config.int_type.width = parseBitWidth(text) orelse
return .{ .output = .{ .text = bits_usage, .is_error = true } };
// The width only means something in programmer mode: standard
// mode is fixed at 64-bit signed. Asking for a width is asking
@ -147,10 +154,10 @@ fn flagValue(arg: []const u8, name: []const u8, args: []const []const u8, index:
return .absent;
}
fn parseBitWidth(text: []const u8) ?engine.types.BitWidth {
fn parseBitWidth(text: []const u8) ?engine.Integer.BitWidth {
// Driven by the enum, so a width added to `BitWidth` is accepted here without
// a second list to update.
inline for (@typeInfo(engine.types.BitWidth).@"enum".fields) |field| {
inline for (@typeInfo(engine.Integer.BitWidth).@"enum".fields) |field| {
if (std.mem.eql(u8, text, comptime std.fmt.comptimePrint("{d}", .{field.value}))) {
return @enumFromInt(field.value);
}
@ -161,7 +168,7 @@ fn parseBitWidth(text: []const u8) ?engine.types.BitWidth {
/// The accepted widths, as they appear in messages: "8, 16, 32, 64, 128".
const bit_width_list = blk: {
var list: []const u8 = "";
for (@typeInfo(engine.types.BitWidth).@"enum".fields, 0..) |field, i| {
for (@typeInfo(engine.Integer.BitWidth).@"enum".fields, 0..) |field, i| {
list = list ++ (if (i == 0) "" else ", ") ++ std.fmt.comptimePrint("{d}", .{field.value});
}
break :blk list;
@ -176,7 +183,7 @@ comptime {
}
}
fn parseEndian(text: []const u8) ?engine.types.Endianness {
fn parseEndian(text: []const u8) ?std.builtin.Endian {
if (std.ascii.eqlIgnoreCase(text, "big") or std.ascii.eqlIgnoreCase(text, "be")) return .big;
if (std.ascii.eqlIgnoreCase(text, "little") or std.ascii.eqlIgnoreCase(text, "le")) return .little;
return null;
@ -404,7 +411,7 @@ fn formatConversionUnits(
}
/// 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 {
pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: Mode, buf: []u8) CliResult {
return evaluateWith(allocator, expression, mode, .{}, buf);
}
@ -413,8 +420,8 @@ pub fn evaluate(allocator: std.mem.Allocator, expression: []const u8, mode: engi
pub fn evaluateWith(
allocator: std.mem.Allocator,
expression: []const u8,
mode: engine.Mode,
config: engine.types.ProgrammerConfig,
mode: Mode,
config: engine.programmer.Config,
buf: []u8,
) CliResult {
if (mode == .programmer) {
@ -424,7 +431,7 @@ pub fn evaluateWith(
return formatProgrammerResult(buf, result, config);
}
var env = engine.Environment.init(allocator, .standard);
var env = engine.Environment.init(allocator);
defer env.deinit();
// A standalone "to" keyword means this is a unit conversion, e.g.
@ -475,7 +482,7 @@ fn isDisplayableInt(value: f64) bool {
/// The decimal display is already computed; append hex/oct/bin.
fn formatStandardMultiBase(buf: []u8, dec_display: []const u8, value: f64) CliResult {
const int_val: u128 = @intFromFloat(value);
const bw = engine.types.BitWidth.smallestFor(int_val);
const bw = engine.formatter.displayWidthFor(int_val);
var hex_buf: [256]u8 = undefined;
var oct_buf: [256]u8 = undefined;
@ -501,7 +508,7 @@ fn formatStandardMultiBase(buf: []u8, dec_display: []const u8, value: f64) CliRe
return .{ .output = output, .is_error = false };
}
fn formatProgrammerResult(buf: []u8, result: engine.types.Integer, config: engine.types.ProgrammerConfig) CliResult {
fn formatProgrammerResult(buf: []u8, result: engine.Integer, config: engine.programmer.Config) CliResult {
const value = result.unsignedValue();
const signed = result.signedValue();
@ -511,11 +518,11 @@ fn formatProgrammerResult(buf: []u8, result: engine.types.Integer, config: engin
var oct_buf: [256]u8 = undefined;
var bin_buf: [512]u8 = undefined;
const hex = engine.formatter.formatHex(&hex_buf, value, config.bit_width, config.display_endian);
const hex = engine.formatter.formatHex(&hex_buf, value, config.int_type.width, config.display_endian);
const dec = engine.formatter.formatDecimalUnsigned(&dec_buf, value);
const sdec = engine.formatter.formatDecimalSigned(&sdec_buf, signed);
const oct = engine.formatter.formatOctal(&oct_buf, value, config.bit_width);
const bin = engine.formatter.formatBinary(&bin_buf, value, config.bit_width);
const oct = engine.formatter.formatOctal(&oct_buf, value, config.int_type.width);
const bin = engine.formatter.formatBinary(&bin_buf, value, config.int_type.width);
const output = std.fmt.bufPrint(buf,
\\ dec(signed): {s}
@ -533,7 +540,7 @@ fn formatProgrammerResult(buf: []u8, result: engine.types.Integer, config: engin
/// Turn an engine error into a CLI line.
///
/// The phrases live once, in `engine.types.errorPhrase`. This adds the prefix and
/// The phrases live once, in `engine.errors.errorPhrase`. This adds the prefix and
/// the newline at comptime, so the strings still have static lifetime and there is
/// no second copy of the wording to drift. The CLI and the TUI previously each kept
/// their own switch over the whole error set; the TUI's was already missing three
@ -549,7 +556,7 @@ fn errorMessage(err: engine.CalcError) []const u8 {
/// silently reads "evaluation error".
fn decoratedError(err: engine.CalcError) []const u8 {
return switch (err) {
inline else => |e| comptime "error: " ++ engine.types.errorPhrase(e) ++ "\n",
inline else => |e| comptime "error: " ++ engine.errors.errorPhrase(e) ++ "\n",
};
}
@ -888,7 +895,7 @@ test "parseArgs: simple expression" {
switch (parsed) {
.expression => |e| {
try testing.expectEqualStrings("2+2", e.text);
try testing.expectEqual(engine.Mode.standard, e.mode);
try testing.expectEqual(Mode.standard, e.mode);
testing.allocator.free(e.text);
},
else => unreachable,
@ -910,7 +917,7 @@ 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.expectEqual(Mode.programmer, e.mode);
try testing.expectEqualStrings("0xFF", e.text);
testing.allocator.free(e.text);
},
@ -922,7 +929,7 @@ 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.expectEqual(Mode.programmer, e.mode);
try testing.expectEqualStrings("0xF0 | 0x0F", e.text);
testing.allocator.free(e.text);
},
@ -937,16 +944,16 @@ test "parseArgs: --bits sets the width, in either spelling, and implies -p" {
}) |args| {
const e = parseArgs(testing.allocator, args).expression;
defer testing.allocator.free(e.text);
try testing.expectEqual(engine.types.BitWidth.bits8, e.config.bit_width);
try testing.expectEqual(engine.Integer.BitWidth.bits8, e.config.int_type.width);
// Asking for a width is asking for programmer mode: standard mode is fixed
// at 64-bit signed, so the flag would mean nothing there.
try testing.expectEqual(engine.Mode.programmer, e.mode);
try testing.expectEqual(Mode.programmer, e.mode);
try testing.expectEqualStrings("0xFF", e.text);
}
}
test "parseArgs: every documented bit width is accepted" {
for ([_]struct { text: []const u8, expected: engine.types.BitWidth }{
for ([_]struct { text: []const u8, expected: engine.Integer.BitWidth }{
.{ .text = "8", .expected = .bits8 },
.{ .text = "16", .expected = .bits16 },
.{ .text = "32", .expected = .bits32 },
@ -964,13 +971,13 @@ test "parseArgs: every documented bit width is accepted" {
test "parseArgs: --signed and --unsigned set the signedness and imply -p" {
const signed = parseArgs(testing.allocator, &.{ "--signed", "0xFF" }).expression;
defer testing.allocator.free(signed.text);
try testing.expectEqual(engine.types.Signedness.signed, signed.config.signedness);
try testing.expectEqual(engine.Mode.programmer, signed.mode);
try testing.expectEqual(engine.Integer.Signedness.signed, signed.config.int_type.signedness);
try testing.expectEqual(Mode.programmer, signed.mode);
const unsigned = parseArgs(testing.allocator, &.{ "--unsigned", "0xFF" }).expression;
defer testing.allocator.free(unsigned.text);
try testing.expectEqual(engine.types.Signedness.unsigned, unsigned.config.signedness);
try testing.expectEqual(engine.Mode.programmer, unsigned.mode);
try testing.expectEqual(engine.Integer.Signedness.unsigned, unsigned.config.int_type.signedness);
try testing.expectEqual(Mode.programmer, unsigned.mode);
}
test "parseArgs: --endian sets the byte order and accepts both spellings" {
@ -981,10 +988,10 @@ test "parseArgs: --endian sets the byte order and accepts both spellings" {
}) |args| {
const e = parseArgs(testing.allocator, args).expression;
defer testing.allocator.free(e.text);
try testing.expectEqual(engine.types.Endianness.little, e.config.display_endian);
try testing.expectEqual(std.builtin.Endian.little, e.config.display_endian);
}
try testing.expectEqual(engine.types.Endianness.big, parseEndian("big").?);
try testing.expectEqual(engine.types.Endianness.big, parseEndian("BE").?);
try testing.expectEqual(std.builtin.Endian.big, parseEndian("big").?);
try testing.expectEqual(std.builtin.Endian.big, parseEndian("BE").?);
try testing.expect(parseEndian("middle") == null);
try testing.expect(parseEndian("") == null);
}
@ -992,9 +999,9 @@ test "parseArgs: --endian sets the byte order and accepts both spellings" {
test "parseArgs: the flags combine, and order does not matter" {
const e = parseArgs(testing.allocator, &.{ "--unsigned", "--bits", "16", "--endian", "little", "0xFF", "+", "1" }).expression;
defer testing.allocator.free(e.text);
try testing.expectEqual(engine.types.BitWidth.bits16, e.config.bit_width);
try testing.expectEqual(engine.types.Signedness.unsigned, e.config.signedness);
try testing.expectEqual(engine.types.Endianness.little, e.config.display_endian);
try testing.expectEqual(engine.Integer.BitWidth.bits16, e.config.int_type.width);
try testing.expectEqual(engine.Integer.Signedness.unsigned, e.config.int_type.signedness);
try testing.expectEqual(std.builtin.Endian.little, e.config.display_endian);
try testing.expectEqualStrings("0xFF + 1", e.text);
}
@ -1043,31 +1050,29 @@ test "the programmer config reaches the result" {
const alloc = arena.allocator();
// 8-bit: 0xFF + 1 wraps to 0.
const narrow = evaluateWith(alloc, "0xFF + 1", .programmer, .{ .bit_width = .bits8 }, &buf);
const narrow = evaluateWith(alloc, "0xFF + 1", .programmer, .{ .int_type = .{ .width = .bits8 } }, &buf);
try testing.expect(!narrow.is_error);
try testing.expect(std.mem.indexOf(u8, narrow.output, "dec(unsigned): 0\n") != null);
// Signedness decides whether >> extends the sign.
const signed = evaluateWith(alloc, "0xFF >> 1", .programmer, .{
.bit_width = .bits8,
.signedness = .signed,
.int_type = .{ .width = .bits8, .signedness = .signed },
}, &buf);
try testing.expect(std.mem.indexOf(u8, signed.output, "dec(signed): -1") != null);
const unsigned = evaluateWith(alloc, "0xFF >> 1", .programmer, .{
.bit_width = .bits8,
.signedness = .unsigned,
.int_type = .{ .width = .bits8, .signedness = .unsigned },
}, &buf);
try testing.expect(std.mem.indexOf(u8, unsigned.output, "dec(unsigned): 127") != null);
// Byte order reverses the hex row and nothing else.
const little = evaluateWith(alloc, "0xDEAD", .programmer, .{
.bit_width = .bits16,
.int_type = .{ .width = .bits16 },
.display_endian = .little,
}, &buf);
try testing.expect(std.mem.indexOf(u8, little.output, "hex: AD DE") != null);
const big = evaluateWith(alloc, "0xDEAD", .programmer, .{
.bit_width = .bits16,
.int_type = .{ .width = .bits16 },
.display_endian = .big,
}, &buf);
try testing.expect(std.mem.indexOf(u8, big.output, "hex: DE AD") != null);

View file

@ -132,7 +132,7 @@ pub const App = struct {
mode: Mode,
// Programmer mode state
prog_value: u128,
prog_config: engine.types.ProgrammerConfig,
prog_config: engine.programmer.Config,
bit_cursor: u7,
prog_field: ProgField,
value_zone_active: bool, // true = cursor in value display, false = in input
@ -219,7 +219,7 @@ pub const App = struct {
return .{
.allocator = allocator,
.io = io,
.env = engine.evaluator.Environment.init(allocator, .standard),
.env = engine.evaluator.Environment.init(allocator),
.input = text_field,
.history = .empty,
.show_help = false,
@ -373,7 +373,7 @@ pub const App = struct {
self.value_zone_active = true;
self.prog_field = target.field;
if (target.bit) |bit| {
if (bit < self.prog_config.bit_width.bits()) self.bit_cursor = bit;
if (bit < self.prog_config.int_type.width.bits()) self.bit_cursor = bit;
} else {
self.alignCursorToField();
}
@ -381,10 +381,10 @@ pub const App = struct {
.toggle_bit => |bit| {
self.value_zone_active = true;
self.prog_field = if (self.prog_field == .bin) .bin else .bits;
if (bit < self.prog_config.bit_width.bits()) {
if (bit < self.prog_config.int_type.width.bits()) {
self.bit_cursor = bit;
self.prog_value ^= @as(u128, 1) << bit;
self.prog_value &= self.prog_config.bit_width.mask();
self.prog_value &= self.prog_config.int_type.width.mask();
}
},
.focus_input => self.value_zone_active = false,
@ -429,10 +429,9 @@ pub const App = struct {
/// Switch modes, carrying the last answer into programmer mode.
fn setMode(self: *App, new_mode: Mode) void {
self.mode = new_mode;
self.env.mode = switch (new_mode) {
.programmer => .programmer,
else => .standard,
};
// The environment has no mode to set: which evaluator runs is decided at
// the call, not by state. This used to write `self.env.mode`, a field the
// engine never read.
if (new_mode == .programmer) self.loadAnsIntoProgrammer();
self.value_zone_active = false;
}
@ -450,15 +449,15 @@ pub const App = struct {
const signed: i128 = @intFromFloat(ans);
self.prog_value = @bitCast(signed);
}
self.prog_value &= self.prog_config.bit_width.mask();
self.prog_value &= self.prog_config.int_type.width.mask();
} else {
self.float_format = .f64;
self.float_view_active = true;
self.syncFloatWidth();
self.prog_value = @as(u64, @bitCast(ans));
self.prog_field = .bits;
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
if (self.bit_cursor >= self.prog_config.int_type.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.int_type.width.bits() - 1);
}
}
}
@ -652,8 +651,8 @@ pub const App = struct {
self.syncFloatWidth();
// Keep the bit grid focused so arrows/space edit bits directly.
self.prog_field = .bits;
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
if (self.bit_cursor >= self.prog_config.int_type.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.int_type.width.bits() - 1);
}
}
ctx.redraw = true;
@ -790,7 +789,7 @@ pub const App = struct {
// Up/Down: move between fields
if (key.matches(vaxis.Key.up, .{})) {
if (self.prog_field == .bits) {
const width = self.prog_config.bit_width.bits();
const width = self.prog_config.int_type.width.bits();
const bits_per_row: u8 = if (width > 32) 32 else width;
if (@as(u8, self.bit_cursor) + bits_per_row < width) {
self.bit_cursor += @intCast(bits_per_row);
@ -803,7 +802,7 @@ pub const App = struct {
}
if (key.matches(vaxis.Key.down, .{})) {
if (self.prog_field == .bits) {
const width = self.prog_config.bit_width.bits();
const width = self.prog_config.int_type.width.bits();
const bits_per_row: u8 = if (width > 32) 32 else width;
if (self.bit_cursor >= bits_per_row) {
self.bit_cursor -= @intCast(bits_per_row);
@ -819,7 +818,7 @@ pub const App = struct {
if (key.matches(vaxis.Key.left, .{})) {
const step = self.fieldBitStep();
if (step > 0) {
const width = self.prog_config.bit_width.bits();
const width = self.prog_config.int_type.width.bits();
if (@as(u16, self.bit_cursor) + step < width) {
self.bit_cursor += @intCast(step);
}
@ -840,7 +839,7 @@ pub const App = struct {
if (key.matches(' ', .{})) {
if (self.prog_field == .bits) {
self.prog_value ^= @as(u128, 1) << self.bit_cursor;
self.prog_value &= self.prog_config.bit_width.mask();
self.prog_value &= self.prog_config.int_type.width.mask();
}
return;
}
@ -868,7 +867,7 @@ pub const App = struct {
const shift: u7 = self.bit_cursor & 0x7C; // round down to nibble boundary
const mask = ~(@as(u128, 0xF) << shift);
self.prog_value = (self.prog_value & mask) | (@as(u128, n) << shift);
self.prog_value &= self.prog_config.bit_width.mask();
self.prog_value &= self.prog_config.int_type.width.mask();
// Move cursor right (toward LSB)
if (shift >= 4) self.bit_cursor -= 4;
}
@ -880,7 +879,7 @@ pub const App = struct {
const shift: u7 = (self.bit_cursor / 3) * 3; // round down to octal boundary
const mask = ~(@as(u128, 0x7) << shift);
self.prog_value = (self.prog_value & mask) | (@as(u128, digit) << shift);
self.prog_value &= self.prog_config.bit_width.mask();
self.prog_value &= self.prog_config.int_type.width.mask();
if (shift >= 3) self.bit_cursor -= 3;
}
},
@ -890,7 +889,7 @@ pub const App = struct {
if (self.bit_cursor > 0) self.bit_cursor -= 1;
} else if (char == '1') {
self.prog_value |= @as(u128, 1) << self.bit_cursor;
self.prog_value &= self.prog_config.bit_width.mask();
self.prog_value &= self.prog_config.int_type.width.mask();
if (self.bit_cursor > 0) self.bit_cursor -= 1;
}
},
@ -898,7 +897,7 @@ pub const App = struct {
if (char >= '0' and char <= '9') {
// Operate on the in-width portion so hidden upper bits do
// not corrupt the arithmetic; the edit commits to width.
const m = self.prog_config.bit_width.mask();
const m = self.prog_config.int_type.width.mask();
self.prog_value = (((self.prog_value & m) *% 10) +% (char - '0')) & m;
}
},
@ -913,15 +912,15 @@ pub const App = struct {
/// upper bits. The display masks to width and shows a warning while the
/// value does not fit. Explicit value edits still commit to width.
fn cycleBitWidth(self: *App) void {
self.prog_config.bit_width = switch (self.prog_config.bit_width) {
self.prog_config.int_type.width = switch (self.prog_config.int_type.width) {
.bits8 => .bits16,
.bits16 => .bits32,
.bits32 => .bits64,
.bits64 => .bits128,
.bits128 => .bits8,
};
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
if (self.bit_cursor >= self.prog_config.int_type.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.int_type.width.bits() - 1);
}
}
@ -933,7 +932,7 @@ pub const App = struct {
}
fn toggleSignedness(self: *App) void {
self.prog_config.signedness = switch (self.prog_config.signedness) {
self.prog_config.int_type.signedness = switch (self.prog_config.int_type.signedness) {
.signed => .unsigned,
.unsigned => .signed,
};
@ -949,12 +948,12 @@ pub const App = struct {
/// Snap the bit width to match the active float format (f32 -> 32, f64 -> 64).
fn syncFloatWidth(self: *App) void {
self.prog_config.bit_width = switch (self.float_format) {
self.prog_config.int_type.width = switch (self.float_format) {
.f32 => .bits32,
.f64 => .bits64,
};
if (self.bit_cursor >= self.prog_config.bit_width.bits()) {
self.bit_cursor = @intCast(self.prog_config.bit_width.bits() - 1);
if (self.bit_cursor >= self.prog_config.int_type.width.bits()) {
self.bit_cursor = @intCast(self.prog_config.int_type.width.bits() - 1);
}
}
@ -1046,7 +1045,7 @@ pub const App = struct {
as_float < 340282366920938463463374607431768211456.0)
{
const int_val: u128 = @intFromFloat(as_float);
const bw = engine.types.BitWidth.smallestFor(int_val);
const bw = engine.formatter.displayWidthFor(int_val);
var hex_buf: [256]u8 = undefined;
var oct_buf: [256]u8 = undefined;
var bin_buf: [512]u8 = undefined;
@ -1408,13 +1407,13 @@ pub fn drawHistory(items: []const App.HistoryEntry, surface: *vxfw.Surface, star
/// Turn an engine error into a status-line string.
///
/// The phrases live once, in `engine.types.errorPhrase`; the prefix is added at
/// The phrases live once, in `engine.errors.errorPhrase`; the prefix is added at
/// comptime. This switch used to be a second full copy of the CLI's, and had fallen
/// behind: `InsufficientParameters`, `ConvergenceFailure` and `InvalidExpression`
/// all came out as "evaluation error".
fn errorStr(err: engine.CalcError) []const u8 {
return switch (err) {
inline else => |e| comptime "error: " ++ engine.types.errorPhrase(e),
inline else => |e| comptime "error: " ++ engine.errors.errorPhrase(e),
};
}
@ -1770,9 +1769,9 @@ test "other modes still get their own keys after financial mode was added" {
// Programmer mode's bit-width cycle still works.
app.setMode(.programmer);
const width_before = app.prog_config.bit_width;
const width_before = app.prog_config.int_type.width;
try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } });
try testing.expect(app.prog_config.bit_width != width_before);
try testing.expect(app.prog_config.int_type.width != width_before);
}
test "shift-tab walks back through the modes" {
@ -2024,7 +2023,7 @@ test "render: programmer mode warns when the value exceeds the display width" {
defer app.deinit();
app.setMode(.programmer);
app.prog_value = 0xDEADBEEF;
app.prog_config.bit_width = .bits8;
app.prog_config.int_type.width = .bits8;
const rows = try renderApp(arena, &app, 100, 30);
try testing.expect(test_render.contains(rows, "value exceeds 8 bits"));
@ -2041,11 +2040,11 @@ test "render: programmer mode is well formed at every width and setting" {
app.prog_value = 0xFEDCBA9876543210;
for ([_]engine.BitWidth{ .bits8, .bits16, .bits32, .bits64, .bits128 }) |width| {
app.prog_config.bit_width = width;
for ([_]engine.types.Endianness{ .little, .big }) |endian| {
app.prog_config.int_type.width = width;
for ([_]std.builtin.Endian{ .little, .big }) |endian| {
app.prog_config.display_endian = endian;
for ([_]engine.types.Signedness{ .signed, .unsigned }) |signedness| {
app.prog_config.signedness = signedness;
for ([_]engine.Integer.Signedness{ .signed, .unsigned }) |signedness| {
app.prog_config.int_type.signedness = signedness;
const rows = try renderApp(arena, &app, 100, 40);
try testing.expect(test_render.furniture(rows).intact());
var buf: [24]u8 = undefined;
@ -2066,7 +2065,7 @@ test "render: the float view decodes a known bit pattern" {
app.setMode(.programmer);
app.float_view_active = true;
app.float_format = .f32;
app.prog_config.bit_width = .bits32;
app.prog_config.int_type.width = .bits32;
app.prog_value = @as(u32, @bitCast(@as(f32, 1.0)));
const rows = try renderApp(arena, &app, 100, 30);
@ -2107,7 +2106,7 @@ test "render: the float view decodes every classification by name" {
};
for (cases) |case| {
app.float_format = case.format;
app.prog_config.bit_width = if (case.format == .f32) .bits32 else .bits64;
app.prog_config.int_type.width = if (case.format == .f32) .bits32 else .bits64;
app.prog_value = case.bits;
const rows = try renderApp(arena, &app, 100, 34);
try testing.expect(test_render.furniture(rows).intact());
@ -2348,7 +2347,7 @@ test "render: 128-bit programmer mode keeps its input line on a short terminal"
var app = testApp();
defer app.deinit();
app.setMode(.programmer);
app.prog_config.bit_width = .bits128;
app.prog_config.int_type.width = .bits128;
// Four grid rows plus six base rows do not fit in 16 rows. The view used to
// draw them anyway, putting the BIN row on the prompt.
@ -2556,12 +2555,12 @@ test "programmer mode: every clickable control acts" {
try app.applyAction(&ctx, .{ .prog_field = .{ .field = .oct, .bit = null } });
try testing.expectEqual(App.ProgField.oct, app.prog_field);
// An out-of-width bit is ignored rather than moving the cursor off the value.
app.prog_config.bit_width = .bits8;
app.prog_config.int_type.width = .bits8;
try app.applyAction(&ctx, .{ .prog_field = .{ .field = .bits, .bit = 100 } });
try testing.expect(app.bit_cursor < 8);
// Toggling bits.
app.prog_config.bit_width = .bits32;
app.prog_config.int_type.width = .bits32;
app.prog_value = 0;
try app.applyAction(&ctx, .{ .toggle_bit = 3 });
try testing.expectEqual(@as(u128, 8), app.prog_value);
@ -2571,15 +2570,15 @@ test "programmer mode: every clickable control acts" {
try testing.expectEqual(@as(u128, 0), app.prog_value);
// Settings.
const width_before = app.prog_config.bit_width;
const width_before = app.prog_config.int_type.width;
try app.applyAction(&ctx, .cycle_width);
try testing.expect(app.prog_config.bit_width != width_before);
try testing.expect(app.prog_config.int_type.width != width_before);
const endian_before = app.prog_config.display_endian;
try app.applyAction(&ctx, .toggle_endian);
try testing.expect(app.prog_config.display_endian != endian_before);
const signed_before = app.prog_config.signedness;
const signed_before = app.prog_config.int_type.signedness;
try app.applyAction(&ctx, .toggle_signedness);
try testing.expect(app.prog_config.signedness != signed_before);
try testing.expect(app.prog_config.int_type.signedness != signed_before);
// Float overlay and its format toggle.
try app.applyAction(&ctx, .toggle_float);
@ -2606,15 +2605,15 @@ test "programmer mode: bit width cycles through every size and keeps the cursor
app.setMode(.programmer);
app.value_zone_active = true;
app.bit_cursor = 100;
app.prog_config.bit_width = .bits8;
app.prog_config.int_type.width = .bits8;
var seen: usize = 0;
while (seen < 6) : (seen += 1) {
try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } });
try testing.expect(app.bit_cursor < app.prog_config.bit_width.bits());
try testing.expect(app.bit_cursor < app.prog_config.int_type.width.bits());
}
// Six steps through five widths lands one past the start: 8, 16, 32, 64, 128, 8, 16.
try testing.expectEqual(engine.BitWidth.bits16, app.prog_config.bit_width);
try testing.expectEqual(engine.BitWidth.bits16, app.prog_config.int_type.width);
}
test "programmer mode: typing edits the focused field in its own base" {
@ -2624,7 +2623,7 @@ test "programmer mode: typing edits the focused field in its own base" {
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
app.value_zone_active = true;
app.prog_config.bit_width = .bits32;
app.prog_config.int_type.width = .bits32;
// Hex nibble entry.
app.prog_field = .hex;
@ -2669,7 +2668,7 @@ test "programmer mode: arrows and space navigate the bit grid" {
defer ctx.cmds.deinit(testing.allocator);
app.setMode(.programmer);
app.value_zone_active = true;
app.prog_config.bit_width = .bits64;
app.prog_config.int_type.width = .bits64;
app.prog_field = .bits;
app.bit_cursor = 0;
@ -2737,7 +2736,7 @@ test "float view: typing a decimal stores the nearest bit pattern" {
app.setMode(.programmer);
app.float_view_active = true;
app.float_format = .f64;
app.prog_config.bit_width = .bits64;
app.prog_config.int_type.width = .bits64;
try app.input.insertSliceAtCursor("3.14");
try press(&app, &ctx, .{ .codepoint = vaxis.Key.enter });
@ -2752,7 +2751,7 @@ test "float view: typing a decimal stores the nearest bit pattern" {
// In the float overlay Ctrl-W swaps format instead of cycling width.
try press(&app, &ctx, .{ .codepoint = 'w', .mods = .{ .ctrl = true } });
try testing.expectEqual(engine.FloatFormat.f32, app.float_format);
try testing.expectEqual(engine.BitWidth.bits32, app.prog_config.bit_width);
try testing.expectEqual(engine.BitWidth.bits32, app.prog_config.int_type.width);
// Ctrl-F leaves the overlay.
try press(&app, &ctx, .{ .codepoint = 'f', .mods = .{ .ctrl = true } });
@ -3025,7 +3024,7 @@ test "render: programmer mode draws the cursor in whichever field is focused" {
for (fields) |field| {
app.prog_field = field;
for ([_]engine.BitWidth{ .bits8, .bits64, .bits128 }) |width| {
app.prog_config.bit_width = width;
app.prog_config.int_type.width = width;
app.bit_cursor = @intCast(@min(5, width.bits() - 1));
const rows = try renderApp(arena, &app, 110, 40);
try testing.expect(test_render.furniture(rows).intact());

View file

@ -870,7 +870,7 @@ fn money(buf: []u8, value: f64) []const u8 {
/// Error text for this view.
///
/// Only the cases where a form knows more than the engine does are overridden; the
/// rest defer to `engine.types.errorPhrase`, so this is no longer a third copy of
/// rest defer to `engine.errors.errorPhrase`, so this is no longer a third copy of
/// the whole table. A generic "domain error" is useless in a form, where the cause
/// is always one of a few bad entries, but "division by zero" needs no improving.
pub fn errorText(err: engine.CalcError) []const u8 {
@ -878,7 +878,7 @@ pub fn errorText(err: engine.CalcError) []const u8 {
engine.CalcError.DomainError => "check the entries: values must be positive and a payment must cover the interest",
engine.CalcError.ConvergenceFailure => "no rate solves these cash flows",
engine.CalcError.InsufficientParameters => "these values do not determine an answer",
else => engine.types.errorPhrase(err),
else => engine.errors.errorPhrase(err),
};
}

View file

@ -37,7 +37,7 @@ pub fn drawFloatView(app: *tui.App, surface: *vxfw.Surface, width: u16, height:
_ = width;
const format = app.float_format;
const total = format.totalBits();
const bw = app.prog_config.bit_width;
const bw = app.prog_config.int_type.width;
const bits: u64 = @truncate(app.prog_value & bw.mask());
const info = fi.decompose(format, bits);

View file

@ -9,7 +9,7 @@ const tui = @import("../tui.zig");
const C = draw.C;
pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, height: u16) void {
const bw = app.prog_config.bit_width;
const bw = app.prog_config.int_type.width;
const val = app.prog_value & bw.mask();
const focused = app.prog_field;
@ -17,7 +17,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
var config_buf: [80]u8 = undefined;
const config_str = std.fmt.bufPrint(&config_buf, "Bits: {d} Signed: {s} Endian: {s}", .{
bw.bits(),
if (app.prog_config.signedness == .signed) "yes" else "no",
if (app.prog_config.int_type.signedness == .signed) "yes" else "no",
if (app.prog_config.display_endian == .little) "LE" else "BE",
}) catch "Bits: ??";
draw.writeStr(surface, 2, 2, config_str, .{ .fg = C.muted });
@ -65,7 +65,7 @@ pub fn drawProgrammerMode(app: *tui.App, surface: *vxfw.Surface, width: u16, hei
drawBitGrid(app, surface, grid_start, val, bw);
const int = engine.types.Integer{ .raw = val, .bit_width = bw, .signedness = .signed };
const int = engine.Integer.init(val, .{ .width = bw, .signedness = .signed });
// DEC(s)
var sdec_buf: [256]u8 = undefined;
@ -282,7 +282,7 @@ fn drawFieldWithCursor(surface: *vxfw.Surface, row: u16, col: u16, text: []const
}
}
fn drawBitGrid(app: *tui.App, surface: *vxfw.Surface, start_row: u16, val: u128, bw: engine.types.BitWidth) void {
fn drawBitGrid(app: *tui.App, surface: *vxfw.Surface, start_row: u16, val: u128, bw: engine.Integer.BitWidth) void {
const width_bits = bw.bits();
const bits_per_row: u8 = if (width_bits > 32) 32 else width_bits;
const num_rows: u16 = (@as(u16, width_bits) + bits_per_row - 1) / bits_per_row;