bug fixes and programmer/standard mode mod behavior separation

This commit is contained in:
Emil Lerch 2026-10-13 08:56:28 -07:00
parent e31a74e829
commit 4d4f2fc50f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 679 additions and 152 deletions

View file

@ -1,8 +1,8 @@
# Build, test, sign and publish Tally.
#
# Every push, any branch: the engine and both terminal frontends are built and tested,
# the JNI layer is proved against a real JVM, the Android libraries are audited, and the
# app is built and unit tested. Artifacts are attached to the run.
# the JNI layer is proved against a real JVM, the Android libraries are built and
# audited, and the app is built and unit tested. Artifacts are attached to the run.
#
# Pushes to master also sign and publish, signing with action-hsm-sign:
# - the APK, signed (schemes v2 and v3) by the HSM
@ -130,15 +130,12 @@ jobs:
echo "${GRADLE_SHA256} $RUNNER_TEMP/gradle.zip" | sha256sum -c -
unzip -q "$RUNNER_TEMP/gradle.zip" -d "$RUNNER_TEMP"
echo "$RUNNER_TEMP/gradle-${GRADLE_VERSION}/bin" >> "$GITHUB_PATH"
- name: Build the native libraries
# Gradle never invokes Zig, so this comes first or the APK packages nothing.
- name: Build and audit the native libraries
# Gradle never invokes Zig, so this comes first or the APK packages nothing. The
# build audits each library before installing it (build/audit_android.zig): no
# undefined symbols, no TLS, no DT_NEEDED, 16 KiB LOAD alignment. Each of those
# loads on a desktop and fails in dlopen on a phone.
run: zig build android --summary all
- name: Audit the native libraries
# No undefined symbols, no TLS, no DT_NEEDED: each one loads on a desktop and
# fails in dlopen on a phone.
run: |
command -v readelf >/dev/null || { sudo apt-get update && sudo apt-get install -y binutils; }
android/audit-libs.sh zig-out/android
- name: Unit test and build the app
working-directory: android
# The release APK is unsigned here; signing happens on the HSM runner. Lint's

View file

@ -1473,8 +1473,11 @@ The general lesson is that a freestanding shared library's dependencies are deci
code that never runs. Two of the three came from a debugging aid on the panic path, and
with `BIND_NOW` set - it is, on all three ABIs - even an unreferenced PLT entry is
resolved at load, so "nothing calls it" is not a defence. The audit is therefore part of
the build's evidence rather than a one-time check: for each ABI, zero `DT_NEEDED`, zero
undefined dynamic symbols, no TLS segment, and `LOAD` aligned to 0x4000. Removing the
the build rather than a one-time check: `zig build android` runs `build/audit_android.zig`
over each ABI's library before installing any of them, and requires zero `DT_NEEDED`, zero
undefined dynamic symbols, no TLS segment, `LOAD` aligned to 0x4000, the right ELF machine,
and the JNI entry points exported. `zig build test` holds the auditor itself to failing on
a fixture library that breaks every one of those rules (Task 5.32). Removing the
panic machinery also took the libraries from ~290KB to ~170-186KB.
### 6.4 How Kotlin reaches the C ABI

View file

@ -26,9 +26,9 @@ A calculator application with three frontends (CLI, TUI, Android) sharing a comm
- **FR-2.1**: Accept input in decimal, hexadecimal (`0x`), octal (`0o`), and binary (`0b`) formats.
- **FR-2.2**: Simultaneously display results in all four bases (dec, hex, oct, bin).
- **FR-2.3**: Support configurable bit widths: 8, 16, 32, 64, 128-bit. Standard mode is fixed at 64-bit signed; a width other than 64 is what programmer mode is for.
- **FR-2.4**: Support bitwise operators: AND (`&` or `and`), OR (`|` or `or`), XOR (`xor` keyword), NOT (`~` or `not`), left shift (`<<`), right shift (logical `>>>`), arithmetic right shift (`>>`), rotate left (`rol`), rotate right (`ror`). Note: `^` is always exponentiation (never XOR) - see FR-2.12. `>>` fills the vacated high bits with copies of the sign bit, so `-8 >> 1` is -4; `>>>` fills them with zeros. In an unsigned width there is no sign to extend, so `>>` fills with zeros there too. Bits shifted past the width are discarded rather than wrapped (`0b1000 << 1` is 16); `rol` and `ror` are the operators that wrap.
- **FR-2.4**: Support bitwise operators: AND (`&` or `and`), OR (`|` or `or`), XOR (`xor` keyword), NOT (`~` or `not`), left shift (`<<`), right shift (logical `>>>`), arithmetic right shift (`>>`), rotate left (`rol`), rotate right (`ror`). Note: `^` is always exponentiation (never XOR) - see FR-2.12. `>>` fills the vacated high bits with copies of the sign bit, so `-8 >> 1` is -4; `>>>` fills them with zeros. In an unsigned width there is no sign to extend, so `>>` fills with zeros there too. Bits shifted past the width are discarded rather than wrapped (`0b1000 << 1` is 16); `rol` and `ror` are the operators that wrap. Arithmetic wraps at the width. Signed `/` and `%` follow C: the quotient truncates toward zero and the remainder takes the dividend's sign, so `-7 / 2` is -3 and `-7 % 3` is -1, which is what C, Zig's `@divTrunc` and `@rem`, Rust, Java, Go and x86's `idiv` compute, and what programmer mode exists to check. This is the one operator whose meaning differs between the modes, deliberately: standard mode's `%` follows the sign of the divisor (`-7 % 3` is 2 there), because it is arithmetic on numbers rather than on machine integers. `minInt / -1` wraps to `minInt`, and its remainder is 0. Unsigned `/` and `%` divide the patterns. A negative exponent to `**` or `^` is a domain error, as a negative shift distance is (FR-2.13).
- **FR-2.13**: A shift distance at or beyond the bit width shifts every original bit out: `1 << 64` is 0 in 64-bit, and 8-bit `0xFF >>> 20` is 0. For `>>` the result is the sign fill, so a negative value shifted that far is -1 and a non-negative one is 0. Rotation instead reduces the distance modulo the width, because a rotation is cyclic: `1 rol 65` is `1 rol 1`. A negative shift or rotate distance is a domain error rather than a very large one.
- **FR-2.12**: The `^` operator means exponentiation in all modes (never XOR). This avoids mode-dependent operator overloading. XOR is available only via the `xor` keyword. Power is also available via `**`. This keeps every operator's meaning identical across standard and programmer modes.
- **FR-2.12**: The `^` operator means exponentiation in all modes (never XOR). This avoids mode-dependent operator overloading. XOR is available only via the `xor` keyword. Power is also available via `**`. This keeps every operator's meaning identical across standard and programmer modes, with the one exception FR-2.4 records: signed `%`, which follows C in programmer mode.
- **FR-2.5**: Display both signed (two's complement) and unsigned interpretations of the current value.
- **FR-2.6**: Visualize the bit pattern as a grid (integer.exposed style) - bits individually addressable/toggleable in TUI and Android.
- **FR-2.7**: SUPERSEDED by FR-7.8's removal. Single-letter keys for switching the primary base cannot coexist with editable value fields, where those letters are hex and binary digits. Base selection is by arrow key and mouse, and all four bases are on screen at once anyway (FR-2.2), which is most of what the shortcut was for.

View file

@ -950,6 +950,96 @@ plans a `--raw` flag, but today it is exercised only by tests.
100% line coverage, engine 99.44%. CLI output byte-identical across all five rows,
both byte orders, ASCII packing and the multi-base standard-mode view.
### Task 5.32: The Android libraries are audited by the build that makes them
Open item 19. The rule design 6.3 depends on - no `DT_NEEDED`, no undefined dynamic
symbols, no TLS segment - was checked by `android/audit-libs.sh`, a `readelf` pipeline
CI ran as a step of its own after the build, with binutils installed first. A developer
running `zig build android` and Gradle got no check at all, and the 16 KiB `LOAD`
alignment that Play requires was not checked anywhere.
- `build/audit_android.zig`, a host tool that reads each library with `std.elf`: the
program headers (no `PT_TLS`, every `PT_LOAD` aligned to at least 0x4000), the dynamic
section (no `DT_NEEDED`), and the dynamic symbol table (nothing undefined, and
`JNI_OnLoad` and the `Java_*` entry points defined). It also checks the ELF machine
against the ABI directory, so a library cannot land in the wrong one. It names every
problem it finds rather than stopping at the first, and prints nothing on success.
- `zig build android` runs it over all three libraries before installing any of them,
so a failing library is never in `zig-out/android` for Gradle to package. Removing
`link_z_max_page_size` makes the step fail with the segment named and leaves the
installed library untouched.
- `zig build test` holds the auditor to failing: `build/audit_android_fixture.zig` is a
library that references an undefined symbol, has a thread-local (which also pulls in
`__tls_get_addr`), is linked against `build/audit_android_dependency.zig` for a
`DT_NEEDED`, keeps the default 4 KiB alignment and exports no JNI. The step requires
exit status 1 and each of seven messages, so an auditor that stopped detecting any one
rule would fail the suite.
- The script, the `android-audit` mise task and CI's separate audit step (and its
binutils install) are gone; CI's `zig build android` is the audit now.
- Verify: all three real libraries pass, the fixture fails with every expected message,
1060 tests and the build steps pass, fmt and zlint clean.
### Task 5.31: Six wrong answers off the open list
Open items 2 to 6 and the first of item 18's findings, each reproduced against the
binary before it was fixed, each fixed with a test that fails on the old code. Every
one of those tests was checked that way: the old code was put back and the test run.
**Signed division in programmer mode (item 2).** `/` and `%` divided the unsigned
patterns, so `tally -p '-10 / 2'` was 9,223,372,036,854,775,803 and `-7 % 3` was 0. They
now follow C (FR-2.4): truncation toward zero and the dividend's sign, so -5, -3 for
`-7 / 2`, and -1 for `-7 % 3`. That is a deliberate difference from standard mode's `%`,
which follows the divisor: programmer mode checks what code computes, and every
language a user of it writes in truncates. `minInt / -1` wraps to `minInt`, computed as
the negation it is, since at 128 bits the quotient does not fit an `i128`. A negative
exponent was the same flaw, read as 2^64 - 1, so `2 ** -1` answered 0; it is a domain
error now, as a negative shift distance is. Unsigned division is unchanged.
**Multi-base rows through f64 (item 3).** `tally 0xDEADBEEFDEADBEEF` printed the right
decimal over `hex: DE AD BE EF DE AD C0 00`. The CLI, the TUI and the C ABI each
converted the answer to f64 and back, three copies of one test, so the bug was three
bugs. `EvalInfo.basePattern()` is the one answer now, read from the exact value, and the
three copies are gone. An inexact answer that is a whole float keeps its rows, since a
float has exactly one integer value.
**Comparison across tiers (item 4).** `Number.order` compared an exact value with a float
by rounding the exact one, so `min(2^53 + 1, 2^53 * sin(pi/2))` answered the larger.
Rounding cannot reorder two values, only tie them, so a tie with one side exact is now
settled exactly: every finite float is a rational, and `Rational.fromFloat` gives it.
Infinities sit past every exact value; NaN stays the tie it was.
**`toFloat` rounded twice (item 5).** It divided out a 72-bit quotient, dropped the
remainder, rounded to 53 bits, and for a subnormal let `ldexp` round again. A remainder
below the 72nd bit is what decides a tie, so `1 + 2^-53 + 2^-100` came out as 1 rather
than the next float up, and `2^-1075 * (1 + 2^-60)` as 0 rather than 5e-324: worse than
the "1 ulp" the item recorded. It now forms a 64-bit quotient with a sticky bit and
rounds once, by hand, at the precision the result's exponent allows. The tests include
ties on both sides of every boundary, the overflow to infinity at the top, and agreement
with `std.fmt.parseFloat` (which rounds correctly) on 22 hard decimal inputs. A sampled
round trip through `fromFloat` covers 2000 random finite patterns.
**Convert mode's selection zone (item 6).** A key it had no use for fell through to
programmer mode's value-zone handler, so a digit typed while choosing units edited the
programmer register. The zone keeps every key now, and the programmer handler is guarded
by mode, so the rule does not depend on the order of the checks above it.
**`submitConvert` freed twice (item 18).** The failure branch freed the value and then
ran two `try`s under an `errdefer` that freed it again. The review missed a second one:
the `errdefer` stayed armed after `conv_value` took the value, so a failed render freed
the value convert mode was still showing, and the next conversion freed it again. The
input text leaked on every failed allocation too. Ownership is now two flags and two
`defer`s, and an allocation-failure sweep through a working and a failing conversion
aborts on the old code.
Also: item 1's note about standard mode's `-8 >>> 1` was stale (Task 5.20 fixed it) and
is struck.
- Verify: 1060 tests pass (13 new) in Debug and every release mode, fmt and zlint clean.
The changed lines are covered except one: `submitConvert`'s branch for a conversion
that fails other than for memory, which a parsed value does not appear able to reach.
`programmer.zig` is at 100%.
### Task 5.30: One output path in the CLI, and redirected output that was overwriting itself
A review of `format.zig` and `main.zig` after the struct work, for duplication and for
@ -1820,21 +1910,21 @@ review reached that file.
the zero-filling variant of `>>`, so `<<<` would have to be the zero-filling
variant of `<<`, which is `<<` itself.
Standard mode still projects operands through f64 before operating and converts
the result back, so a result above 2^53 is rounded: `-8 >>> 1` prints
9.223372036854776e18 rather than 9223372036854775804. Making the fixed-width path
read and return exact integers is a separate change, worth doing with item 3
below, and it would alter how large results display.
2. Signed division and modulo in programmer mode use unsigned semantics:
`-10 / 2` gives 9223372036854775803.
3. Multi-base detail lines are computed through an f64 round trip, so
`0xDEADBEEFDEADBEEF` shows `hex: DE AD BE EF DE AD C0 00`. The exact value is
right there; the display path should use it.
4. Cross-tier `max`/`min` compare through f64 and return the wrong operand when
the values differ below f64 resolution.
5. `Rational.toFloat` double-rounds (no sticky bit), so results can be 1 ulp off.
6. Convert mode's selection zone falls through to the programmer key handler, so
Enter never fires there and printable keys mutate `prog_value`.
The f64 projection this item went on to describe (`-8 >>> 1` printing
9.223372036854776e18) was fixed by Task 5.20: it prints 9223372036854775804.
2. ~~Signed division and modulo in programmer mode use unsigned semantics:
`-10 / 2` gives 9223372036854775803.~~ Fixed by Task 5.31, with C's rules.
3. ~~Multi-base detail lines are computed through an f64 round trip, so
`0xDEADBEEFDEADBEEF` shows `hex: DE AD BE EF DE AD C0 00`.~~ Fixed by Task 5.31,
in all three frontends at once.
4. ~~Cross-tier `max`/`min` compare through f64 and return the wrong operand when
the values differ below f64 resolution.~~ Fixed by Task 5.31.
5. ~~`Rational.toFloat` double-rounds (no sticky bit), so results can be 1 ulp off.~~
Fixed by Task 5.31, and it was worse in the subnormal range: a value could round
to 0 rather than to the smallest subnormal.
6. ~~Convert mode's selection zone falls through to the programmer key handler, so
Enter never fires there and printable keys mutate `prog_value`.~~ Fixed by Task
5.31.
7. ~~Amortization rejects valid loans because the derived payment rounds down to
cents before the "covers the interest" check; the principal column is not
rounded at all.~~ Fixed by Task 5.23, though not as the item describes. The
@ -1917,12 +2007,8 @@ review reached that file.
18. **Entry 15 review findings for `src/tui.zig`**, recorded rather than acted on. In
rough order of weight:
- **A double free on OOM in `submitConvert`.** `value` is guarded by an
`errdefer value.deinit()`, and the conversion-failure branch also calls
`value.deinit()` explicitly before two `try`s. If either the `dupe` or the
`history.append` fails, the errdefer fires on an already-freed value. The fix is
the ownership-transfer pattern: `var adopted = false; defer if (!adopted)
value.deinit();`, with the flag set where `conv_value` takes it.
- ~~**A double free on OOM in `submitConvert`.**~~ Fixed by Task 5.31, along with
a second free of the same kind the note missed and a leak of the input text.
- **`ProgField.expression` is a dead focus stop.** Up/Down in the value zone cycles
`bin -> expression -> bits`, but no row is drawn for it, no key does anything in
it (`handleValueInput` has an empty prong, `fieldBitStep` returns 0), and nothing
@ -1970,18 +2056,10 @@ review reached that file.
`-9223372036854775808.0` and `18446744073709551616.0`; and `handleKey` is a
200-line if-chain mixing global keys, mode-specific keys and zone dispatch, which
reads fine today but is where a new binding will end up in the wrong order.
19. **Nothing enforces the freestanding-library rule that design 6.3 depends on.** The
Android libraries must have zero `DT_NEEDED`, zero undefined dynamic symbols and no
TLS segment, or they fail in `dlopen` before any code runs. That property was restored
by hand and is checked by hand, and three things make it likely to regress silently:
any `std` call can reach for a libc symbol on a path that never executes, `BIND_NOW`
is set so even an unreferenced PLT entry is resolved at load, and the one instance
found so far reproduced on **arm64-v8a only** - so an x86_64 emulator, which is what
CI would use, passes while phones fail. It wants a build step that parses the emitted
`.so` with `std.elf` and fails on a non-empty undefined-symbol set, per ABI, rather
than a `readelf` invocation in a shell. That step is also the natural home for the
16 KiB `LOAD` alignment check, which is currently only a Play submission rule nobody
verifies locally.
19. ~~**Nothing enforces the freestanding-library rule that design 6.3 depends on.**~~
Fixed by Task 5.32: `zig build android` audits every ABI's library with `std.elf`
before installing any, 16 KiB alignment included, and `zig build test` proves the
audit fails on a library that breaks each rule.
20. **Bracketed paste submits a line at a time.** vxfw turns bracketed paste on, and the
TUI drops `paste_start` and `paste_end` (`typeErasedEventHandler`'s `else`), so a
pasted newline arrives as Enter. Pasting a two-line expression in standard mode
@ -2420,7 +2498,8 @@ under the short SHA and `latest`). ntfy notifications on every job, as elsewhere
emulator. Debian's `apksigner` package depends on the desktop JRE (X11, GTK, Mesa: 115
packages); its jar and wrapper are unpacked over the headless JRE instead (42 packages,
649 MB to 361 MB), and apt's dependency check still passes. `android/audit-libs.sh` (and `mise run android-audit`) is the symbol audit
that had been done by hand; it fails on a libc-linked library, checked.
that had been done by hand; it fails on a libc-linked library, checked. (Both since
replaced by an audit inside `zig build android`, Task 5.32.)
- **Deviations from the plan above:** toolchains come from setup-zig (the version from
`build.zig.zon`) and pinned, SHA-256-checked downloads of zlint and Gradle, not mise:
mise-action hands the job's token to mise as `GITHUB_TOKEN`, and on Forgejo that is not

View file

@ -563,51 +563,83 @@ pub fn factorial(allocator: Allocator, n: u64) Error!Rational {
// -- Conversion --
/// Convert to the nearest f64.
/// The nearest f64, correctly rounded: half to even, once.
///
/// Scales the numerator so the quotient carries enough bits to round
/// correctly, rather than dividing two separately-rounded floats (which
/// would lose precision twice and overflow for large values).
/// This used to divide out a 72-bit quotient, drop the remainder, and let the f64
/// conversion round the quotient and then `ldexp` round it again for a subnormal.
/// Each step was sound alone and wrong together (open item 5): a remainder below the
/// 72nd bit is exactly what decides a tie, so `1 + 2^-53 + 2^-100` came out as 1
/// rather than the next float up, and `2^-1075 * (1 + 2^-60)` as 0 rather than the
/// smallest subnormal.
///
/// NaN if the conversion cannot allocate. Callers that care use the exact value.
pub fn toFloat(self: Rational, allocator: Allocator) f64 {
if (self.isZero()) return 0;
const magnitude = self.magnitudeToFloat(allocator) catch return std.math.nan(f64);
return if (self.isNegative()) -magnitude else magnitude;
}
const negative = self.isNegative();
fn magnitudeToFloat(self: Rational, allocator: Allocator) (Allocator.Error || Managed.ConvertError)!f64 {
const magnitude_bits = @as(i64, @intCast(self.num.bitCountAbs())) - @as(i64, @intCast(self.den.bitCountAbs()));
// |self| lies in [2^(magnitude_bits - 1), 2^(magnitude_bits + 1)), so these two
// are past the largest float and below half the smallest, whatever the digits.
if (magnitude_bits >= 1025) return std.math.inf(f64);
if (magnitude_bits <= -1076) return 0;
var num = self.num.clone() catch return std.math.nan(f64);
defer {
var n = num;
n.deinit();
}
// quotient = floor(|self| * 2^scale): 64 or 65 bits, plus whether anything was
// left over, which is the sticky bit a correct rounding needs.
const scale: i64 = 64 - magnitude_bits;
var num = try self.num.clone();
defer num.deinit();
num.abs();
const num_bits: i64 = @intCast(num.bitCountAbs());
const den_bits: i64 = @intCast(self.den.bitCountAbs());
// Aim for ~72 significant bits in the quotient, comfortably more than
// f64's 53, so a single final rounding is correct.
const target: i64 = 72;
const shift: i64 = target - (num_bits - den_bits);
var scaled = Managed.init(allocator) catch return std.math.nan(f64);
defer scaled.deinit();
if (shift > 0) {
if (shift > 1 << 20) return if (negative) -0.0 else 0.0;
scaled.shiftLeft(&num, @intCast(shift)) catch return std.math.nan(f64);
} else {
scaled.shiftRight(&num, @intCast(-shift)) catch return std.math.nan(f64);
}
var q = Managed.init(allocator) catch return std.math.nan(f64);
var den = try self.den.clone();
defer den.deinit();
if (scale >= 0) try num.shiftLeft(&num, @intCast(scale)) else try den.shiftLeft(&den, @intCast(-scale));
var q = try Managed.init(allocator);
defer q.deinit();
var r = Managed.init(allocator) catch return std.math.nan(f64);
var r = try Managed.init(allocator);
defer r.deinit();
q.divFloor(&r, &scaled, &self.den) catch return std.math.nan(f64);
try q.divFloor(&r, &num, &den);
const quotient = try q.toInt(u128);
const sticky = !r.eqlZero();
const quotient, _ = q.toFloat(f64, .nearest_even);
const exponent: i32 = @intCast(-shift);
const result = std.math.ldexp(quotient, exponent);
return if (negative) -result else result;
// The low bits of `quotient` that an f64 cannot keep: all but 53 for a normal
// result, everything below 2^-1074 for a subnormal one. Always at least 11, and at
// most 66, by the bounds above.
const length: i64 = 128 - @clz(quotient);
const exponent = length - 1 - scale;
const drop: u7 = @intCast(if (exponent >= -1022) length - 53 else scale - 1074);
const kept = quotient >> drop;
const rest = quotient & ((@as(u128, 1) << drop) - 1);
const half = @as(u128, 1) << (drop - 1);
const up = rest > half or (rest == half and (sticky or kept & 1 != 0));
// `kept` has at most 53 bits, so this is exact, overflow to infinity aside.
return std.math.ldexp(@as(f64, @floatFromInt(kept + @intFromBool(up))), @intCast(@as(i64, drop) - scale));
}
/// The exact value of a finite float. Every finite f64 is `m * 2^e` for integers `m`
/// and `e`, so nothing is lost: `0.1` is 3602879701896397/36028797018963968.
/// Infinity and NaN have no rational value, and are not accepted.
pub fn fromFloat(allocator: Allocator, value: f64) Error!Rational {
std.debug.assert(std.math.isFinite(value));
const bits: u64 = @bitCast(value);
const biased: i32 = @intCast((bits >> 52) & 0x7FF);
const fraction = bits & ((@as(u64, 1) << 52) - 1);
// A normal float has an implicit leading one; a subnormal has the exponent of the
// smallest normal and no leading one. 1075 is the bias plus the 52 fraction bits.
const mantissa = if (biased == 0) fraction else fraction | (@as(u64, 1) << 52);
// `@max` with a constant narrows its result type to what it can return (an
// unsigned one here), so the cast is what lets the subtraction go negative.
const exponent: i32 = @as(i32, @max(biased, 1)) - 1075;
var num = try Managed.initSet(allocator, mantissa);
errdefer num.deinit();
var den = try Managed.initSet(allocator, 1);
errdefer den.deinit();
if (exponent >= 0) try num.shiftLeft(&num, @intCast(exponent)) else try den.shiftLeft(&den, @intCast(-exponent));
if (bits >> 63 != 0) num.negate();
return finish(allocator, &num, &den);
}
/// Render as an exact fraction, e.g. "781250/12573" or "7" for integers.
@ -1574,6 +1606,158 @@ test "toFloat: round trip through parseDecimal for a range of values" {
}
}
/// The sum of `2^e` for each exponent: a way to write a value with chosen bits far
/// apart, which is what a rounding test needs.
fn sumOfPowers(exponents: []const i64) !Rational {
var sum = try Rational.initZero(alloc);
errdefer sum.deinit();
var two = try Rational.initInt(alloc, 2);
defer two.deinit();
for (exponents) |e| {
var term = try Rational.powInt(alloc, two, e);
defer term.deinit();
const next = try Rational.add(alloc, sum, term);
sum.deinit();
sum = next;
}
return sum;
}
test "toFloat: one rounding, half to even, with the bits below deciding a tie" {
// Open item 5. The cases sit on or near a halfway point between two floats, where
// a remainder dropped before rounding gives the wrong side.
const Case = struct { exponents: []const i64, expected: f64 };
for ([_]Case{
// Just above halfway between 1 and the next float: up. Was 1.
.{ .exponents = &.{ 0, -53, -100 }, .expected = 1.0 + 0x1p-52 },
// Exactly halfway: to even, which is 1.
.{ .exponents = &.{ 0, -53 }, .expected = 1.0 },
// Exactly halfway above an odd float: up, to even.
.{ .exponents = &.{ 0, -52, -53 }, .expected = 1.0 + 0x1p-51 },
// Just below halfway: down.
.{ .exponents = &.{ 0, -54, -55, -56, -57, -58, -59, -60 }, .expected = 1.0 },
// Subnormal: just above half the smallest one rounds up to it. Was 0.
.{ .exponents = &.{ -1075, -1135 }, .expected = 0x1p-1074 },
// Exactly half the smallest subnormal: to even, which is 0.
.{ .exponents = &.{-1075}, .expected = 0.0 },
// Halfway between the smallest subnormal and the next: to even.
.{ .exponents = &.{ -1074, -1075 }, .expected = 0x1p-1073 },
.{ .exponents = &.{-1074}, .expected = 0x1p-1074 },
.{ .exponents = &.{-1023}, .expected = 0x1p-1023 },
.{ .exponents = &.{-1022}, .expected = 0x1p-1022 },
// The largest subnormal plus half a step rounds into the normal range.
.{ .exponents = &.{ -1023, -1074, -1075, -1100 }, .expected = 0x1p-1023 + 0x1p-1073 },
}) |case| {
var r = try sumOfPowers(case.exponents);
defer r.deinit();
try testing.expectEqual(@as(u64, @bitCast(case.expected)), @as(u64, @bitCast(r.toFloat(alloc))));
var negative = try Rational.negate(alloc, r);
defer negative.deinit();
try testing.expectEqual(@as(u64, @bitCast(-case.expected)), @as(u64, @bitCast(negative.toFloat(alloc))));
}
}
test "toFloat: the top of the range rounds to the largest float or to infinity" {
var two = try Rational.initInt(alloc, 2);
defer two.deinit();
var scale = try Rational.powInt(alloc, two, 970);
defer scale.deinit();
// (2^54 - 1) * 2^970 is exactly halfway between the largest float and 2^1024, and
// the largest float is odd, so it rounds up: to infinity, as IEEE 754 says.
var top = try Rational.initInt(alloc, (@as(u64, 1) << 54) - 1);
defer top.deinit();
var halfway = try Rational.mul(alloc, top, scale);
defer halfway.deinit();
try testing.expectEqual(std.math.inf(f64), halfway.toFloat(alloc));
// A hair below it is the largest float.
var hair = try Rational.powInt(alloc, two, 900);
defer hair.deinit();
var below = try Rational.sub(alloc, halfway, hair);
defer below.deinit();
try testing.expectEqual(std.math.floatMax(f64), below.toFloat(alloc));
// Far past either end.
var huge = try Rational.parseDecimal(alloc, "1e400");
defer huge.deinit();
try testing.expectEqual(std.math.inf(f64), huge.toFloat(alloc));
var tiny = try Rational.parseDecimal(alloc, "1e-400");
defer tiny.deinit();
try testing.expectEqual(@as(f64, 0), tiny.toFloat(alloc));
}
test "toFloat: agrees with a correctly rounded parse on hard decimal inputs" {
// `std.fmt.parseFloat` rounds correctly, so it is an independent oracle for any
// decimal the exact parser also reads. These are the classic hard cases: ties,
// near-ties, both edges of the subnormal range and the top of the normal one.
for ([_][]const u8{
"9007199254740993", "9007199254740995",
"9007199254740997", "0.1",
"0.3", "1e23",
"8.98846567431158e307", "1.7976931348623157e308",
"1.7976931348623158e308", "1.7976931348623159e308",
"2.2250738585072011e-308", "2.2250738585072014e-308",
"2.2250738585072012e-308", "4.9406564584124654e-324",
"2.4703282292062328e-324", "2.4703282292062327e-324",
"7.4109846876186982e-324", "123456789012345678901234567890e-10",
"0.30000000000000004440892098500626", "1.00000000000000011102230246251565404236316680908203125",
"1.00000000000000011102230246251565404236316680908203124", "1.00000000000000011102230246251565404236316680908203126",
}) |text| {
var r = try Rational.parseDecimal(alloc, text);
defer r.deinit();
const expected = try std.fmt.parseFloat(f64, text);
try testing.expectEqual(@as(u64, @bitCast(expected)), @as(u64, @bitCast(r.toFloat(alloc))));
}
}
test "fromFloat is exact, and toFloat inverts it for every finite float" {
var tenth = try Rational.fromFloat(alloc, 0.1);
defer tenth.deinit();
const text = try tenth.toFractionString(alloc);
defer alloc.free(text);
try testing.expectEqualStrings("3602879701896397/36028797018963968", text);
var negative_zero = try Rational.fromFloat(alloc, -0.0);
defer negative_zero.deinit();
try testing.expect(negative_zero.isZero());
var smallest = try Rational.fromFloat(alloc, 0x1p-1074);
defer smallest.deinit();
try testing.expectEqual(@as(usize, 1075), smallest.denBitCount());
var big = try Rational.fromFloat(alloc, -0x1p100);
defer big.deinit();
try testing.expect(big.isInteger() and big.isNegative());
// Every finite pattern, sampled: the conversion back must be the identity, which
// holds only if both directions are exact and correctly rounded.
var prng: std.Random.DefaultPrng = .init(0x7a11);
const random = prng.random();
var checked: usize = 0;
while (checked < 2000) {
const bits = random.int(u64);
const value: f64 = @bitCast(bits);
if (!std.math.isFinite(value)) continue;
var r = try Rational.fromFloat(alloc, value);
defer r.deinit();
// A rational has no negative zero, so -0.0 comes back as 0.0.
const expected: u64 = if (value == 0) 0 else bits;
try testing.expectEqual(expected, @as(u64, @bitCast(r.toFloat(alloc))));
checked += 1;
}
}
test "fromFloat releases everything on any allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, struct {
fn run(a: Allocator) !void {
var r = try fromFloat(a, -0x1.23456789abcdep-1050);
r.deinit();
}
}.run, .{});
}
test "denBitCount grows with the denominator" {
var small = try Rational.initRatio(alloc, 1, 2);
defer small.deinit();

View file

@ -243,11 +243,8 @@ pub const Session = struct {
// The multi-base rows standard mode adds when the expression was written in
// another base and the answer is a whole number that fits a pattern (FR-1.9).
var bases: ?Bases = null;
const as_float = info.value.toFloat(scratch);
if (info.has_nondecimal_literal and isDisplayableInt(as_float)) {
bases = basesOf(scratch, @intFromFloat(as_float)) catch |err| {
return self.writeError(err);
};
if (info.basePattern()) |int| {
bases = basesOf(scratch, int) catch |err| return self.writeError(err);
}
return self.writeJson(.{
@ -548,12 +545,7 @@ pub const Session = struct {
units: []const UnitJson,
};
fn basesOf(scratch: std.mem.Allocator, raw: u128) !Bases {
const int: engine.Integer = .{
.raw = raw,
.width = engine.BitWidth.displayFor(raw),
.signedness = .unsigned,
};
fn basesOf(scratch: std.mem.Allocator, int: engine.Integer) !Bases {
return .{
.hex = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.hex, .{})}),
.oct = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.octal, .{})}),
@ -606,12 +598,6 @@ pub const Session = struct {
}
};
/// Whether an f64 names a whole number a `u128` pattern can hold, which is what the
/// multi-base rows need. The CLI has the same test for the same reason.
fn isDisplayableInt(value: f64) bool {
return value >= 0 and value == @trunc(value) and value < 340282366920938463463374607431768211456.0;
}
// -- Exports --
/// Create a session. Returns null when there is no memory for one.

View file

@ -644,6 +644,25 @@ pub const EvalInfo = struct {
value: Number,
/// True if the expression contained any non-decimal literal (hex/oct/bin).
has_nondecimal_literal: bool,
/// The answer as a bit pattern for the hex, octal and binary rows standard mode
/// adds when the expression used a non-decimal literal (FR-1.9), at the narrowest
/// width that holds it. Null when the rows do not apply: no such literal, or an
/// answer that is negative, fractional, or wider than 128 bits.
///
/// An exact answer gives its own bits. The CLI, the TUI and the C ABI each used
/// to convert the answer to f64 first, three copies of one test, so everything
/// past 2^53 lost its low bits: `0xDEADBEEFDEADBEEF` printed the right decimal
/// over `hex: DE AD BE EF DE AD C0 00`. An inexact answer has no bits but the
/// float's, and a whole float has exactly one integer value, so it keeps that.
pub fn basePattern(self: EvalInfo) ?Integer {
if (!self.has_nondecimal_literal) return null;
const raw: u128 = switch (self.value) {
.exact => self.value.asExactInt(u128) orelse return null,
.inexact => |f| if (f >= 0 and f == @trunc(f) and f < 0x1p128) @intFromFloat(f) else return null,
};
return .{ .raw = raw, .width = .displayFor(raw), .signedness = .unsigned };
}
};
/// High-level evaluate: parse a string and evaluate it.
@ -1242,6 +1261,44 @@ test "evalStringInfo: binary literal detected in nested expr" {
try testing.expect(info.has_nondecimal_literal);
}
test "basePattern: the exact answer's own bits, past 2^53 included" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var env = Environment.init(alloc);
defer env.deinit();
const Case = struct { source: []const u8, raw: ?u128, width: Integer.BitWidth = .bits8 };
for ([_]Case{
// Open item 3: an f64 holds 53 bits, so these came back with the low ones
// rounded away.
.{ .source = "0xDEADBEEFDEADBEEF", .raw = 0xDEADBEEFDEADBEEF, .width = .bits64 },
.{ .source = "0x20000000000001", .raw = 0x20000000000001, .width = .bits64 },
.{ .source = "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", .raw = std.math.maxInt(u128), .width = .bits128 },
.{ .source = "0x10 * 0x10", .raw = 256, .width = .bits16 },
.{ .source = "0xFF", .raw = 255 },
// An inexact answer that is a whole float has one integer value, and keeps it.
.{ .source = "0x10 * sin(pi / 2)", .raw = 16 },
// No rows for a fraction, a negative, or something wider than 128 bits.
.{ .source = "0xFF / 2", .raw = null },
.{ .source = "0x0 - 1", .raw = null },
.{ .source = "0x100 ^ 16", .raw = null },
.{ .source = "0x10 * pi", .raw = null },
// Or when nothing was written in another base.
.{ .source = "255", .raw = null },
}) |case| {
const info = try evalStringInfo(&env, alloc, case.source);
const pattern = info.basePattern();
if (case.raw) |raw| {
try testing.expectEqual(raw, pattern.?.raw);
try testing.expectEqual(case.width, pattern.?.width);
try testing.expectEqual(Integer.Signedness.unsigned, pattern.?.signedness);
} else {
try testing.expect(pattern == null);
}
}
}
test "eval string literal in standard mode packs ASCII" {
// 'A' -> 0x41 -> 65
const result = try testEval("'A'");

View file

@ -322,7 +322,26 @@ pub const Number = union(enum) {
const y = b.toFloat(allocator);
if (x < y) return .lt;
if (x > y) return .gt;
return .eq;
// Equal as floats. Rounding never puts two values in the wrong order, but it
// can make different ones equal: `2^53 + 1` and `2^53` round to the same
// f64, which made `min(2^53 + 1, 2^53 * sin(pi/2))` answer the larger one
// (open item 4). Two inexact values are their floats, so their tie is real;
// with one side exact, the float is compared exactly instead.
if (a == .inexact and b == .inexact) return .eq;
const float = if (a == .inexact) a.inexact else b.inexact;
// NaN is unordered, and stays the tie it always was.
if (std.math.isNan(float)) return .eq;
// An exact value is finite, so an infinity is past it whatever it rounded to.
if (std.math.isInf(float)) {
const float_side: std.math.Order = if (float > 0) .gt else .lt;
return if (a == .inexact) float_side else float_side.invert();
}
var exact_float = try Rational.fromFloat(allocator, float);
defer exact_float.deinit();
return if (a == .exact)
Rational.order(allocator, a.exact, exact_float)
else
Rational.order(allocator, exact_float, b.exact);
}
pub fn eql(allocator: Allocator, a: Number, b: Number) Error!bool {
@ -1066,6 +1085,50 @@ test "max and min with an inexact operand return that operand as-is" {
try testing.expectEqual(@as(f64, 2.0), hi.toFloat(alloc));
}
test "order across tiers is exact where the floats tie" {
// Open item 4: `2^53 + 1` rounds to the same f64 as `2^53`, so the two compared
// equal and `min` returned its first argument, the larger one.
var two = try Number.fromInt(alloc, 2);
defer two.deinit();
var fifty_three = try Number.fromInt(alloc, 53);
defer fifty_three.deinit();
var p53 = try Number.pow(alloc, two, fifty_three);
defer p53.deinit();
var one = try Number.fromInt(alloc, 1);
defer one.deinit();
var above = try Number.add(alloc, p53, one);
defer above.deinit();
var float = Number.fromFloat(0x1p53);
defer float.deinit();
try testing.expectEqual(std.math.Order.gt, try Number.order(alloc, above, float));
try testing.expectEqual(std.math.Order.lt, try Number.order(alloc, float, above));
try testing.expectEqual(std.math.Order.eq, try Number.order(alloc, p53, float));
var low = try Number.min(alloc, above, float);
defer low.deinit();
try testing.expect(low == .inexact);
var high = try Number.max(alloc, float, above);
defer high.deinit();
try testing.expect(high == .exact);
try testing.expectEqual(@as(?u64, (1 << 53) + 1), high.asExactInt(u64));
// A tenth is not 0.1: the float is a little above it.
var tenth = try Number.parse(alloc, "0.1");
defer tenth.deinit();
try testing.expectEqual(std.math.Order.lt, try Number.order(alloc, tenth, Number.fromFloat(0.1)));
// An exact value too large for a float still sits below infinity, and above
// negative infinity, though it rounds to the first.
var huge = try Number.parse(alloc, "1e400");
defer huge.deinit();
try testing.expectEqual(std.math.Order.lt, try Number.order(alloc, huge, Number.fromFloat(std.math.inf(f64))));
try testing.expectEqual(std.math.Order.gt, try Number.order(alloc, Number.fromFloat(std.math.inf(f64)), huge));
try testing.expectEqual(std.math.Order.gt, try Number.order(alloc, huge, Number.fromFloat(-std.math.inf(f64))));
// NaN orders as it always did.
try testing.expectEqual(std.math.Order.eq, try Number.order(alloc, huge, Number.fromFloat(std.math.nan(f64))));
}
// -- Allocation-failure safety --
//
// Mirrors the sweep in Rational.zig: fail the Nth allocation for every N, and

View file

@ -126,10 +126,15 @@ fn literalToInteger(allocator: Allocator, config: Config, n: ast.Expr.Number) Er
/// `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.
///
/// Addition, subtraction and multiplication are the same on a two's complement
/// pattern whether it is read as signed or not, which is why the pattern is all they
/// look at. Division, remainder and the exponent are not, and they ask.
fn evalBinaryOp(op: BinaryOp, left_in: Integer, right_in: Integer) Error!Integer {
const mask = left_in.width.mask();
const left = left_in.unsignedValue();
const right = right_in.unsignedValue();
const signed = left_in.signedness == .signed;
const raw: u128 = switch (op) {
.add => left +% right,
@ -137,13 +142,16 @@ fn evalBinaryOp(op: BinaryOp, left_in: Integer, right_in: Integer) Error!Integer
.mul => left *% right,
.div => blk: {
if (right == 0) return Error.DivisionByZero;
break :blk left / right;
break :blk if (signed) signedDivision(left_in, right_in).quotient else left / right;
},
.mod => blk: {
if (right == 0) return Error.DivisionByZero;
break :blk left % right;
break :blk if (signed) signedDivision(left_in, right_in).remainder else left % right;
},
.pow => blk: {
// A negative exponent is a domain error, as a negative shift distance is
// (FR-2.13). Read as unsigned it was 2^64 - 1, and `2 ** -1` answered 0.
if (right_in.isNegative()) return Error.DomainError;
// Integer exponentiation
var base = left;
var exp = right;
@ -167,6 +175,23 @@ fn evalBinaryOp(op: BinaryOp, left_in: Integer, right_in: Integer) Error!Integer
return left_in.withRaw(raw);
}
/// Signed division as C defines it (FR-2.4): the quotient truncates toward zero and
/// the remainder takes the sign of the dividend, so `-7 / 2` is -3 and `-7 % 3` is
/// -1. That is what C, Zig's `@divTrunc` and `@rem`, Rust, Java, Go and x86's `idiv`
/// compute, and programmer mode exists to check what code computes. It differs from
/// standard mode's `%`, which follows the sign of the divisor; see FR-2.4.
///
/// `minInt / -1` has no two's complement answer and wraps to `minInt`, as every other
/// overflow here wraps. Dividing by -1 is negation, so it is computed as one: at
/// 128 bits the quotient would not fit an `i128` at all. The divisor is not zero;
/// the caller has checked.
fn signedDivision(left: Integer, right: Integer) struct { quotient: u128, remainder: u128 } {
const a = left.signedValue();
const b = right.signedValue();
if (b == -1) return .{ .quotient = 0 -% @as(u128, @bitCast(a)), .remainder = 0 };
return .{ .quotient = @bitCast(@divTrunc(a, b)), .remainder = @bitCast(@rem(a, b)) };
}
/// High-level: parse and evaluate a string in programmer mode.
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) Error!Integer {
var p = Parser.init(allocator, source);
@ -246,6 +271,64 @@ test "prog: modulo" {
try testing.expectEqual(@as(u128, 1), result.unsignedValue());
}
test "prog: signed division truncates toward zero, as C does" {
// Open item 2: these divided the unsigned patterns, so `-10 / 2` was
// 9223372036854775803.
const Case = struct { source: []const u8, value: i128 };
for ([_]Case{
.{ .source = "-10 / 2", .value = -5 },
.{ .source = "-7 / 2", .value = -3 },
.{ .source = "7 / -2", .value = -3 },
.{ .source = "-7 / -2", .value = 3 },
.{ .source = "7 / 2", .value = 3 },
// The remainder takes the dividend's sign, so a == (a / b) * b + a % b.
.{ .source = "-7 % 3", .value = -1 },
.{ .source = "7 % -3", .value = 1 },
.{ .source = "-7 % -3", .value = -1 },
.{ .source = "7 % 3", .value = 1 },
.{ .source = "-6 % 3", .value = 0 },
}) |case| {
for ([_]BitWidth{ .bits8, .bits16, .bits32, .bits64, .bits128 }) |width| {
const result = try testProgWith(case.source, .{ .width = width });
try testing.expectEqual(case.value, result.signedValue());
}
}
}
test "prog: minInt / -1 wraps to minInt at every width, and its remainder is 0" {
for ([_]BitWidth{ .bits8, .bits16, .bits32, .bits64, .bits128 }) |width| {
const config: Config = .{ .width = width };
// The pattern with only the sign bit set, written so it parses at any width.
var buf: [64]u8 = undefined;
const min = try std.fmt.bufPrint(&buf, "(1 << {d})", .{width.bits() - 1});
var expr_buf: [80]u8 = undefined;
const quotient = try testProgWith(try std.fmt.bufPrint(&expr_buf, "{s} / -1", .{min}), config);
try testing.expectEqual(@as(u128, 1) << @intCast(width.bits() - 1), quotient.unsignedValue());
const remainder = try testProgWith(try std.fmt.bufPrint(&expr_buf, "{s} % -1", .{min}), config);
try testing.expectEqual(@as(u128, 0), remainder.unsignedValue());
}
}
test "prog: unsigned division still divides the patterns" {
// 8-bit unsigned 0xF6 is 246, not -10.
const config: Config = .{ .width = .bits8, .signedness = .unsigned };
try testing.expectEqual(@as(u128, 123), (try testProgWith("0xF6 / 2", config)).unsignedValue());
try testing.expectEqual(@as(u128, 0), (try testProgWith("0xF6 % 2", config)).unsignedValue());
// And the same pattern signed is -10.
const signed = try testProgWith("0xF6 / 2", .{ .width = .bits8 });
try testing.expectEqual(@as(i128, -5), signed.signedValue());
}
test "prog: a negative exponent is a domain error, a negative base is not" {
try testing.expectError(Error.DomainError, testProg("2 ** -1"));
try testing.expectEqual(@as(i128, -8), (try testProg("(-2) ** 3")).signedValue());
try testing.expectEqual(@as(i128, 16), (try testProg("(-2) ** 4")).signedValue());
// Unsigned, the same pattern is a large exponent rather than a negative one.
const config: Config = .{ .width = .bits8, .signedness = .unsigned };
try testing.expectEqual(@as(u128, 0), (try testProgWith("2 ** 0xFF", config)).unsignedValue());
}
test "prog: power" {
const result = try testProg("2 ** 10");
try testing.expectEqual(@as(u128, 1024), result.unsignedValue());

View file

@ -111,35 +111,19 @@ pub fn evaluateWith(
const shown = info.value.render(allocator, display_format) catch |err| return .fail(err);
defer shown.deinit(allocator);
// Enrich with multi-base view when the expression used non-decimal
// literals and the result is a non-negative integer.
if (info.has_nondecimal_literal and isDisplayableInt(info.value.toFloat(allocator))) {
return standardMultiBase(buf, shown.text, info.value.toFloat(allocator));
}
// The hex, octal and binary rows, when the expression used a non-decimal literal
// and the answer is a whole number that fits a pattern (FR-1.9).
if (info.basePattern()) |int| return standardMultiBase(buf, shown.text, int);
// Into the caller's buffer, so the result does not depend on the allocator
// outliving this call.
return .ok(std.fmt.bufPrint(buf, "{s}\n", .{shown.text}) catch return .too_long);
}
/// True if the f64 is a non-negative integer within u128 range.
fn isDisplayableInt(value: f64) bool {
return value >= 0 and value == @trunc(value) and value < 340282366920938463463374607431768211456.0;
}
/// Format a standard-mode result with an inline multi-base breakdown.
/// The decimal display is already computed; append hex/oct/bin.
fn standardMultiBase(buf: []u8, dec_display: []const u8, value: f64) Result {
const int_val: u128 = @intFromFloat(value);
const int: engine.Integer = .{
.raw = int_val,
.width = engine.BitWidth.displayFor(int_val),
.signedness = .unsigned,
};
// The rows print themselves, so nothing here needs a buffer per base.
// `dec_display` is the caller's allocated text rather than a slice of `buf`,
// so writing into `buf` cannot clobber it and it needs no copy first.
/// A standard-mode result with its multi-base rows. The rows print themselves, and
/// `dec_display` is the caller's allocated text rather than a slice of `buf`, so
/// writing into `buf` cannot clobber it.
fn standardMultiBase(buf: []u8, dec_display: []const u8, int: engine.Integer) Result {
return .ok(std.fmt.bufPrint(buf, "{s}\n hex: {f}\n oct: {f}\n bin: {f}\n", .{
dec_display,
int.fmt(.hex, .{}),
@ -809,6 +793,17 @@ test "evaluate: an expression written in another base answers in every base" {
try testing.expect(!octal.is_error);
try testing.expect(std.mem.startsWith(u8, octal.output, "1,022\n"));
try testing.expect(std.mem.indexOf(u8, octal.output, "hex: 03 FE") != null);
// Past 2^53 the rows agree with the decimal line above them. They came from an
// f64 and read `DE AD BE EF DE AD C0 00` under the right decimal (open item 3).
const wide = evaluateWith(arena.allocator(), "0xDEADBEEFDEADBEEF", .standard, .{}, &buf);
try testing.expectEqualStrings(
\\16,045,690,984,833,335,023
\\ hex: DE AD BE EF DE AD BE EF
\\ oct: 1 572 555 756 773 653 337 357
\\ bin: 1101 1110 1010 1101 1011 1110 1110 1111 1101 1110 1010 1101 1011 1110 1110 1111
\\
, wide.output);
}
test "evaluate: a non-decimal literal with no whole answer stays decimal" {

View file

@ -517,7 +517,8 @@ pub const App = struct {
/// Handle arrow navigation inside the convert view's selection zone.
/// Returns true if the key was consumed.
fn handleConvertSelectionKey(self: *App, ctx: *vxfw.EventContext, key: vaxis.Key) !bool {
/// Keys in convert mode's selection zone. Anything but the arrows does nothing.
fn handleConvertSelectionKey(self: *App, ctx: *vxfw.EventContext, key: vaxis.Key) void {
const table_len = engine.units.unitsIn(self.conv_category).len;
// Left/Right move between the category, from, and to columns.
@ -528,7 +529,7 @@ pub const App = struct {
.to => .from,
};
ctx.redraw = true;
return true;
return;
}
if (key.matches(vaxis.Key.right, .{})) {
self.conv_zone = switch (self.conv_zone) {
@ -537,7 +538,7 @@ pub const App = struct {
.to => .category,
};
ctx.redraw = true;
return true;
return;
}
// Up/Down move the selection within the focused column, wrapping.
@ -547,7 +548,7 @@ pub const App = struct {
-1
else
0;
if (delta == 0) return false;
if (delta == 0) return;
switch (self.conv_zone) {
.category => {
@ -560,7 +561,6 @@ pub const App = struct {
.to => self.conv_to_idx = wrapIndex(self.conv_to_idx, delta, table_len),
}
ctx.redraw = true;
return true;
}
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
@ -662,8 +662,13 @@ pub const App = struct {
ctx.redraw = true;
return;
}
// The selection zone keeps every key. One it has no use for used to fall
// through to programmer mode's value-zone handler below, so a digit typed
// here edited the programmer register and Space flipped one of its bits
// (open item 6).
if (self.value_zone_active) {
if (try self.handleConvertSelectionKey(ctx, key)) return;
self.handleConvertSelectionKey(ctx, key);
return;
}
}
@ -701,8 +706,10 @@ pub const App = struct {
return;
}
// VALUE ZONE key handling
if (self.value_zone_active) {
// VALUE ZONE key handling: programmer mode's. Every other mode with a value
// zone has returned above, and the guard makes that a rule rather than an
// ordering.
if (self.mode == .programmer and self.value_zone_active) {
self.handleValueZoneKey(key);
ctx.redraw = true;
return;
@ -1081,17 +1088,7 @@ pub const App = struct {
const result_copy = try self.allocator.dupe(u8, shown.text);
var details: ?[3][]const u8 = null;
const as_float = info.value.toFloat(self.allocator);
if (info.has_nondecimal_literal and as_float >= 0 and
as_float == @trunc(as_float) and
as_float < 340282366920938463463374607431768211456.0)
{
const int_val: u128 = @intFromFloat(as_float);
const int: engine.Integer = .{
.raw = int_val,
.width = engine.BitWidth.displayFor(int_val),
.signedness = .unsigned,
};
if (info.basePattern()) |int| {
details = .{
try std.fmt.allocPrint(self.allocator, "hex: {f}", .{int.fmt(.hex, .{})}),
try std.fmt.allocPrint(self.allocator, "oct: {f}", .{int.fmt(.octal, .{})}),
@ -1126,20 +1123,33 @@ pub const App = struct {
/// In convert mode, the input line sets the value to convert. A bare number
/// is parsed exactly; anything else is evaluated as a standard expression so
/// things like "2*3.5" or "sqrt(2)" work as the input value.
/// Convert the typed value and record it in history.
///
/// Two things are owned here until something takes them: the history takes
/// `expr_text` when an entry is appended, and `conv_value` takes the value once
/// the conversion succeeds. A flag says whether each was taken, and every other
/// path frees what it still holds, once. The failure branch used to free the
/// value and then run two `try`s under an `errdefer` that freed it again, and the
/// `errdefer` stayed armed after `conv_value` took the value, so a failed render
/// freed the value convert mode was still showing (open item 18). `expr_text`
/// leaked on every failed allocation.
fn submitConvert(self: *App, expr_text: []const u8) !void {
var text_kept = false;
defer if (!text_kept) self.allocator.free(expr_text);
var value = engine.Number.parse(self.allocator, expr_text) catch
engine.evalString(&self.env, self.allocator, expr_text) catch |err| {
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
try self.appendError(expr_text, err);
text_kept = true;
return;
};
errdefer value.deinit();
var value_kept = false;
defer if (!value_kept) value.deinit();
const pair = self.convUnits();
var converted = engine.units.convertExactUnits(self.allocator, value, pair.from, pair.to) catch |err| {
value.deinit();
const msg = try self.allocator.dupe(u8, errorStr(err));
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
try self.appendError(expr_text, err);
text_kept = true;
return;
};
defer converted.deinit();
@ -1147,6 +1157,7 @@ pub const App = struct {
// Adopt the new value only once the conversion has succeeded.
self.conv_value.deinit();
self.conv_value = value;
value_kept = true;
const shown_in = try self.conv_value.render(self.allocator, display_format);
defer shown_in.deinit(self.allocator);
@ -1156,7 +1167,17 @@ pub const App = struct {
const result = try std.fmt.allocPrint(self.allocator, "{s} {s} = {s} {s}", .{
shown_in.text, pair.from.name, shown_out.text, pair.to.name,
});
errdefer self.allocator.free(result);
try self.history.append(self.allocator, .{ .expr = expr_text, .result = result, .is_error = false });
text_kept = true;
}
/// Record an error in history. On success the entry owns `expr_text`; on failure
/// nothing was taken and the caller still owns it.
fn appendError(self: *App, expr_text: []const u8, err: engine.Error) !void {
const msg = try self.allocator.dupe(u8, errorStr(err));
errdefer self.allocator.free(msg);
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
}
/// Evaluate and record a standard-mode unit conversion ("100 km to mi").
@ -3059,6 +3080,65 @@ test "convert mode: selection keys move between columns and wrap within them" {
try testing.expectEqual(category_before, app.conv_category);
}
test "convert mode: a submit that runs out of memory frees what it holds, once" {
// Open item 18. Every allocation fails in turn, through a conversion that works
// and through one that reports an error. `testing.allocator` underneath reports
// a leak, and panics on a double free, which is what the old failure branch did.
for ([_][]const u8{ "2 * 3", "nope" }) |source| {
var completed = false;
var fail_index: usize = 0;
while (!completed) : (fail_index += 1) {
var failing = testing.FailingAllocator.init(testing.allocator, .{ .fail_index = fail_index });
var app = App.init(failing.allocator(), undefined);
defer app.deinit();
app.setMode(.convert);
const text = failing.allocator().dupe(u8, source) catch continue;
app.submitConvert(text) catch |err| {
try testing.expectEqual(error.OutOfMemory, err);
continue;
};
completed = true;
try testing.expectEqual(@as(usize, 1), app.history.items.len);
try testing.expectEqualStrings(source, app.history.items[0].expr);
}
try testing.expect(fail_index > 2);
}
}
test "convert mode: keys the selection zone has no use for change nothing" {
// Open item 6: they fell through to programmer mode's value-zone handler, so a
// digit typed here edited the programmer register and Space flipped one of its
// bits, in a mode that does not show either.
var app = testApp();
defer app.deinit();
var ctx = testCtx();
defer ctx.cmds.deinit(testing.allocator);
app.prog_value = 0x1234;
app.prog_field = .hex;
app.setMode(.convert);
app.value_zone_active = true;
const zone = app.conv_zone;
const from = app.conv_from_idx;
const to = app.conv_to_idx;
for ([_]vaxis.Key{
.{ .codepoint = '7', .text = "7" },
.{ .codepoint = 'f', .text = "f" },
.{ .codepoint = ' ', .text = " " },
.{ .codepoint = vaxis.Key.enter },
.{ .codepoint = vaxis.Key.backspace },
}) |key| try press(&app, &ctx, key);
try testing.expectEqual(@as(u128, 0x1234), app.prog_value);
try testing.expectEqual(zone, app.conv_zone);
try testing.expectEqual(from, app.conv_from_idx);
try testing.expectEqual(to, app.conv_to_idx);
try testing.expect(app.value_zone_active);
try testing.expectEqual(@as(usize, 0), app.history.items.len);
// And the input line did not receive them either: the zone has the keys.
try testing.expectEqual(@as(usize, 0), app.input.buf.realLength());
}
test "convert mode: swap works from either zone and clicks select units" {
var app = testApp();
defer app.deinit();