bug fixes and programmer/standard mode mod behavior separation
This commit is contained in:
parent
e31a74e829
commit
4d4f2fc50f
11 changed files with 679 additions and 152 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
# Build, test, sign and publish Tally.
|
# Build, test, sign and publish Tally.
|
||||||
#
|
#
|
||||||
# Every push, any branch: the engine and both terminal frontends are built and tested,
|
# 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
|
# the JNI layer is proved against a real JVM, the Android libraries are built and
|
||||||
# app is built and unit tested. Artifacts are attached to the run.
|
# 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:
|
# Pushes to master also sign and publish, signing with action-hsm-sign:
|
||||||
# - the APK, signed (schemes v2 and v3) by the HSM
|
# - the APK, signed (schemes v2 and v3) by the HSM
|
||||||
|
|
@ -130,15 +130,12 @@ jobs:
|
||||||
echo "${GRADLE_SHA256} $RUNNER_TEMP/gradle.zip" | sha256sum -c -
|
echo "${GRADLE_SHA256} $RUNNER_TEMP/gradle.zip" | sha256sum -c -
|
||||||
unzip -q "$RUNNER_TEMP/gradle.zip" -d "$RUNNER_TEMP"
|
unzip -q "$RUNNER_TEMP/gradle.zip" -d "$RUNNER_TEMP"
|
||||||
echo "$RUNNER_TEMP/gradle-${GRADLE_VERSION}/bin" >> "$GITHUB_PATH"
|
echo "$RUNNER_TEMP/gradle-${GRADLE_VERSION}/bin" >> "$GITHUB_PATH"
|
||||||
- name: Build the native libraries
|
- name: Build and audit the native libraries
|
||||||
# Gradle never invokes Zig, so this comes first or the APK packages nothing.
|
# 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
|
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
|
- name: Unit test and build the app
|
||||||
working-directory: android
|
working-directory: android
|
||||||
# The release APK is unsigned here; signing happens on the HSM runner. Lint's
|
# The release APK is unsigned here; signing happens on the HSM runner. Lint's
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
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
|
the build rather than a one-time check: `zig build android` runs `build/audit_android.zig`
|
||||||
undefined dynamic symbols, no TLS segment, and `LOAD` aligned to 0x4000. Removing the
|
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.
|
panic machinery also took the libraries from ~290KB to ~170-186KB.
|
||||||
|
|
||||||
### 6.4 How Kotlin reaches the C ABI
|
### 6.4 How Kotlin reaches the C ABI
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.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.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.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.
|
- **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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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.
|
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
|
### 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
|
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
|
the zero-filling variant of `>>`, so `<<<` would have to be the zero-filling
|
||||||
variant of `<<`, which is `<<` itself.
|
variant of `<<`, which is `<<` itself.
|
||||||
|
|
||||||
Standard mode still projects operands through f64 before operating and converts
|
The f64 projection this item went on to describe (`-8 >>> 1` printing
|
||||||
the result back, so a result above 2^53 is rounded: `-8 >>> 1` prints
|
9.223372036854776e18) was fixed by Task 5.20: it prints 9223372036854775804.
|
||||||
9.223372036854776e18 rather than 9223372036854775804. Making the fixed-width path
|
2. ~~Signed division and modulo in programmer mode use unsigned semantics:
|
||||||
read and return exact integers is a separate change, worth doing with item 3
|
`-10 / 2` gives 9223372036854775803.~~ Fixed by Task 5.31, with C's rules.
|
||||||
below, and it would alter how large results display.
|
3. ~~Multi-base detail lines are computed through an f64 round trip, so
|
||||||
2. Signed division and modulo in programmer mode use unsigned semantics:
|
`0xDEADBEEFDEADBEEF` shows `hex: DE AD BE EF DE AD C0 00`.~~ Fixed by Task 5.31,
|
||||||
`-10 / 2` gives 9223372036854775803.
|
in all three frontends at once.
|
||||||
3. Multi-base detail lines are computed through an f64 round trip, so
|
4. ~~Cross-tier `max`/`min` compare through f64 and return the wrong operand when
|
||||||
`0xDEADBEEFDEADBEEF` shows `hex: DE AD BE EF DE AD C0 00`. The exact value is
|
the values differ below f64 resolution.~~ Fixed by Task 5.31.
|
||||||
right there; the display path should use it.
|
5. ~~`Rational.toFloat` double-rounds (no sticky bit), so results can be 1 ulp off.~~
|
||||||
4. Cross-tier `max`/`min` compare through f64 and return the wrong operand when
|
Fixed by Task 5.31, and it was worse in the subnormal range: a value could round
|
||||||
the values differ below f64 resolution.
|
to 0 rather than to the smallest subnormal.
|
||||||
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
|
||||||
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
|
||||||
Enter never fires there and printable keys mutate `prog_value`.
|
5.31.
|
||||||
7. ~~Amortization rejects valid loans because the derived payment rounds down to
|
7. ~~Amortization rejects valid loans because the derived payment rounds down to
|
||||||
cents before the "covers the interest" check; the principal column is not
|
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
|
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
|
18. **Entry 15 review findings for `src/tui.zig`**, recorded rather than acted on. In
|
||||||
rough order of weight:
|
rough order of weight:
|
||||||
|
|
||||||
- **A double free on OOM in `submitConvert`.** `value` is guarded by an
|
- ~~**A double free on OOM in `submitConvert`.**~~ Fixed by Task 5.31, along with
|
||||||
`errdefer value.deinit()`, and the conversion-failure branch also calls
|
a second free of the same kind the note missed and a leak of the input text.
|
||||||
`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.
|
|
||||||
- **`ProgField.expression` is a dead focus stop.** Up/Down in the value zone cycles
|
- **`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
|
`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
|
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
|
`-9223372036854775808.0` and `18446744073709551616.0`; and `handleKey` is a
|
||||||
200-line if-chain mixing global keys, mode-specific keys and zone dispatch, which
|
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.
|
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
|
19. ~~**Nothing enforces the freestanding-library rule that design 6.3 depends on.**~~
|
||||||
Android libraries must have zero `DT_NEEDED`, zero undefined dynamic symbols and no
|
Fixed by Task 5.32: `zig build android` audits every ABI's library with `std.elf`
|
||||||
TLS segment, or they fail in `dlopen` before any code runs. That property was restored
|
before installing any, 16 KiB alignment included, and `zig build test` proves the
|
||||||
by hand and is checked by hand, and three things make it likely to regress silently:
|
audit fails on a library that breaks each rule.
|
||||||
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.
|
|
||||||
20. **Bracketed paste submits a line at a time.** vxfw turns bracketed paste on, and the
|
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
|
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
|
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
|
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,
|
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
|
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
|
- **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:
|
`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
|
mise-action hands the job's token to mise as `GITHUB_TOKEN`, and on Forgejo that is not
|
||||||
|
|
|
||||||
|
|
@ -563,51 +563,83 @@ pub fn factorial(allocator: Allocator, n: u64) Error!Rational {
|
||||||
|
|
||||||
// -- Conversion --
|
// -- 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
|
/// This used to divide out a 72-bit quotient, drop the remainder, and let the f64
|
||||||
/// correctly, rather than dividing two separately-rounded floats (which
|
/// conversion round the quotient and then `ldexp` round it again for a subnormal.
|
||||||
/// would lose precision twice and overflow for large values).
|
/// 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 {
|
pub fn toFloat(self: Rational, allocator: Allocator) f64 {
|
||||||
if (self.isZero()) return 0;
|
if (self.isZero()) return 0;
|
||||||
|
const magnitude = self.magnitudeToFloat(allocator) catch return std.math.nan(f64);
|
||||||
const negative = self.isNegative();
|
return if (self.isNegative()) -magnitude else magnitude;
|
||||||
|
|
||||||
var num = self.num.clone() catch return std.math.nan(f64);
|
|
||||||
defer {
|
|
||||||
var n = num;
|
|
||||||
n.deinit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 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();
|
num.abs();
|
||||||
|
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 = try Managed.init(allocator);
|
||||||
|
defer r.deinit();
|
||||||
|
try q.divFloor(&r, &num, &den);
|
||||||
|
const quotient = try q.toInt(u128);
|
||||||
|
const sticky = !r.eqlZero();
|
||||||
|
|
||||||
const num_bits: i64 = @intCast(num.bitCountAbs());
|
// The low bits of `quotient` that an f64 cannot keep: all but 53 for a normal
|
||||||
const den_bits: i64 = @intCast(self.den.bitCountAbs());
|
// 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);
|
||||||
|
|
||||||
// Aim for ~72 significant bits in the quotient, comfortably more than
|
const kept = quotient >> drop;
|
||||||
// f64's 53, so a single final rounding is correct.
|
const rest = quotient & ((@as(u128, 1) << drop) - 1);
|
||||||
const target: i64 = 72;
|
const half = @as(u128, 1) << (drop - 1);
|
||||||
const shift: i64 = target - (num_bits - den_bits);
|
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.
|
||||||
var scaled = Managed.init(allocator) catch return std.math.nan(f64);
|
return std.math.ldexp(@as(f64, @floatFromInt(kept + @intFromBool(up))), @intCast(@as(i64, drop) - scale));
|
||||||
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);
|
/// The exact value of a finite float. Every finite f64 is `m * 2^e` for integers `m`
|
||||||
defer q.deinit();
|
/// and `e`, so nothing is lost: `0.1` is 3602879701896397/36028797018963968.
|
||||||
var r = Managed.init(allocator) catch return std.math.nan(f64);
|
/// Infinity and NaN have no rational value, and are not accepted.
|
||||||
defer r.deinit();
|
pub fn fromFloat(allocator: Allocator, value: f64) Error!Rational {
|
||||||
q.divFloor(&r, &scaled, &self.den) catch return std.math.nan(f64);
|
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;
|
||||||
|
|
||||||
const quotient, _ = q.toFloat(f64, .nearest_even);
|
var num = try Managed.initSet(allocator, mantissa);
|
||||||
const exponent: i32 = @intCast(-shift);
|
errdefer num.deinit();
|
||||||
const result = std.math.ldexp(quotient, exponent);
|
var den = try Managed.initSet(allocator, 1);
|
||||||
return if (negative) -result else result;
|
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.
|
/// 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" {
|
test "denBitCount grows with the denominator" {
|
||||||
var small = try Rational.initRatio(alloc, 1, 2);
|
var small = try Rational.initRatio(alloc, 1, 2);
|
||||||
defer small.deinit();
|
defer small.deinit();
|
||||||
|
|
|
||||||
|
|
@ -243,11 +243,8 @@ pub const Session = struct {
|
||||||
// The multi-base rows standard mode adds when the expression was written in
|
// 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).
|
// another base and the answer is a whole number that fits a pattern (FR-1.9).
|
||||||
var bases: ?Bases = null;
|
var bases: ?Bases = null;
|
||||||
const as_float = info.value.toFloat(scratch);
|
if (info.basePattern()) |int| {
|
||||||
if (info.has_nondecimal_literal and isDisplayableInt(as_float)) {
|
bases = basesOf(scratch, int) catch |err| return self.writeError(err);
|
||||||
bases = basesOf(scratch, @intFromFloat(as_float)) catch |err| {
|
|
||||||
return self.writeError(err);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.writeJson(.{
|
return self.writeJson(.{
|
||||||
|
|
@ -548,12 +545,7 @@ pub const Session = struct {
|
||||||
units: []const UnitJson,
|
units: []const UnitJson,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn basesOf(scratch: std.mem.Allocator, raw: u128) !Bases {
|
fn basesOf(scratch: std.mem.Allocator, int: engine.Integer) !Bases {
|
||||||
const int: engine.Integer = .{
|
|
||||||
.raw = raw,
|
|
||||||
.width = engine.BitWidth.displayFor(raw),
|
|
||||||
.signedness = .unsigned,
|
|
||||||
};
|
|
||||||
return .{
|
return .{
|
||||||
.hex = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.hex, .{})}),
|
.hex = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.hex, .{})}),
|
||||||
.oct = try std.fmt.allocPrint(scratch, "{f}", .{int.fmt(.octal, .{})}),
|
.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 --
|
// -- Exports --
|
||||||
|
|
||||||
/// Create a session. Returns null when there is no memory for one.
|
/// Create a session. Returns null when there is no memory for one.
|
||||||
|
|
|
||||||
|
|
@ -644,6 +644,25 @@ pub const EvalInfo = struct {
|
||||||
value: Number,
|
value: Number,
|
||||||
/// True if the expression contained any non-decimal literal (hex/oct/bin).
|
/// True if the expression contained any non-decimal literal (hex/oct/bin).
|
||||||
has_nondecimal_literal: bool,
|
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.
|
/// 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);
|
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" {
|
test "eval string literal in standard mode packs ASCII" {
|
||||||
// 'A' -> 0x41 -> 65
|
// 'A' -> 0x41 -> 65
|
||||||
const result = try testEval("'A'");
|
const result = try testEval("'A'");
|
||||||
|
|
|
||||||
|
|
@ -322,7 +322,26 @@ pub const Number = union(enum) {
|
||||||
const y = b.toFloat(allocator);
|
const y = b.toFloat(allocator);
|
||||||
if (x < y) return .lt;
|
if (x < y) return .lt;
|
||||||
if (x > y) return .gt;
|
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 {
|
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));
|
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 --
|
// -- Allocation-failure safety --
|
||||||
//
|
//
|
||||||
// Mirrors the sweep in Rational.zig: fail the Nth allocation for every N, and
|
// Mirrors the sweep in Rational.zig: fail the Nth allocation for every N, and
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// `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
|
/// 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.
|
/// 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 {
|
fn evalBinaryOp(op: BinaryOp, left_in: Integer, right_in: Integer) Error!Integer {
|
||||||
const mask = left_in.width.mask();
|
const mask = left_in.width.mask();
|
||||||
const left = left_in.unsignedValue();
|
const left = left_in.unsignedValue();
|
||||||
const right = right_in.unsignedValue();
|
const right = right_in.unsignedValue();
|
||||||
|
const signed = left_in.signedness == .signed;
|
||||||
|
|
||||||
const raw: u128 = switch (op) {
|
const raw: u128 = switch (op) {
|
||||||
.add => left +% right,
|
.add => left +% right,
|
||||||
|
|
@ -137,13 +142,16 @@ fn evalBinaryOp(op: BinaryOp, left_in: Integer, right_in: Integer) Error!Integer
|
||||||
.mul => left *% right,
|
.mul => left *% right,
|
||||||
.div => blk: {
|
.div => blk: {
|
||||||
if (right == 0) return Error.DivisionByZero;
|
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: {
|
.mod => blk: {
|
||||||
if (right == 0) return Error.DivisionByZero;
|
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: {
|
.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
|
// Integer exponentiation
|
||||||
var base = left;
|
var base = left;
|
||||||
var exp = right;
|
var exp = right;
|
||||||
|
|
@ -167,6 +175,23 @@ fn evalBinaryOp(op: BinaryOp, left_in: Integer, right_in: Integer) Error!Integer
|
||||||
return left_in.withRaw(raw);
|
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.
|
/// High-level: parse and evaluate a string in programmer mode.
|
||||||
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) Error!Integer {
|
pub fn evalProgrammerString(allocator: Allocator, source: []const u8, config: Config) Error!Integer {
|
||||||
var p = Parser.init(allocator, source);
|
var p = Parser.init(allocator, source);
|
||||||
|
|
@ -246,6 +271,64 @@ test "prog: modulo" {
|
||||||
try testing.expectEqual(@as(u128, 1), result.unsignedValue());
|
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" {
|
test "prog: power" {
|
||||||
const result = try testProg("2 ** 10");
|
const result = try testProg("2 ** 10");
|
||||||
try testing.expectEqual(@as(u128, 1024), result.unsignedValue());
|
try testing.expectEqual(@as(u128, 1024), result.unsignedValue());
|
||||||
|
|
|
||||||
|
|
@ -111,35 +111,19 @@ pub fn evaluateWith(
|
||||||
const shown = info.value.render(allocator, display_format) catch |err| return .fail(err);
|
const shown = info.value.render(allocator, display_format) catch |err| return .fail(err);
|
||||||
defer shown.deinit(allocator);
|
defer shown.deinit(allocator);
|
||||||
|
|
||||||
// Enrich with multi-base view when the expression used non-decimal
|
// The hex, octal and binary rows, when the expression used a non-decimal literal
|
||||||
// literals and the result is a non-negative integer.
|
// and the answer is a whole number that fits a pattern (FR-1.9).
|
||||||
if (info.has_nondecimal_literal and isDisplayableInt(info.value.toFloat(allocator))) {
|
if (info.basePattern()) |int| return standardMultiBase(buf, shown.text, int);
|
||||||
return standardMultiBase(buf, shown.text, info.value.toFloat(allocator));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Into the caller's buffer, so the result does not depend on the allocator
|
// Into the caller's buffer, so the result does not depend on the allocator
|
||||||
// outliving this call.
|
// outliving this call.
|
||||||
return .ok(std.fmt.bufPrint(buf, "{s}\n", .{shown.text}) catch return .too_long);
|
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.
|
/// A standard-mode result with its multi-base rows. The rows print themselves, and
|
||||||
fn isDisplayableInt(value: f64) bool {
|
/// `dec_display` is the caller's allocated text rather than a slice of `buf`, so
|
||||||
return value >= 0 and value == @trunc(value) and value < 340282366920938463463374607431768211456.0;
|
/// writing into `buf` cannot clobber it.
|
||||||
}
|
fn standardMultiBase(buf: []u8, dec_display: []const u8, int: engine.Integer) Result {
|
||||||
|
|
||||||
/// 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.
|
|
||||||
return .ok(std.fmt.bufPrint(buf, "{s}\n hex: {f}\n oct: {f}\n bin: {f}\n", .{
|
return .ok(std.fmt.bufPrint(buf, "{s}\n hex: {f}\n oct: {f}\n bin: {f}\n", .{
|
||||||
dec_display,
|
dec_display,
|
||||||
int.fmt(.hex, .{}),
|
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(!octal.is_error);
|
||||||
try testing.expect(std.mem.startsWith(u8, octal.output, "1,022\n"));
|
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);
|
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" {
|
test "evaluate: a non-decimal literal with no whole answer stays decimal" {
|
||||||
|
|
|
||||||
130
src/tui.zig
130
src/tui.zig
|
|
@ -517,7 +517,8 @@ pub const App = struct {
|
||||||
|
|
||||||
/// Handle arrow navigation inside the convert view's selection zone.
|
/// Handle arrow navigation inside the convert view's selection zone.
|
||||||
/// Returns true if the key was consumed.
|
/// 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;
|
const table_len = engine.units.unitsIn(self.conv_category).len;
|
||||||
|
|
||||||
// Left/Right move between the category, from, and to columns.
|
// Left/Right move between the category, from, and to columns.
|
||||||
|
|
@ -528,7 +529,7 @@ pub const App = struct {
|
||||||
.to => .from,
|
.to => .from,
|
||||||
};
|
};
|
||||||
ctx.redraw = true;
|
ctx.redraw = true;
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
if (key.matches(vaxis.Key.right, .{})) {
|
if (key.matches(vaxis.Key.right, .{})) {
|
||||||
self.conv_zone = switch (self.conv_zone) {
|
self.conv_zone = switch (self.conv_zone) {
|
||||||
|
|
@ -537,7 +538,7 @@ pub const App = struct {
|
||||||
.to => .category,
|
.to => .category,
|
||||||
};
|
};
|
||||||
ctx.redraw = true;
|
ctx.redraw = true;
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Up/Down move the selection within the focused column, wrapping.
|
// Up/Down move the selection within the focused column, wrapping.
|
||||||
|
|
@ -547,7 +548,7 @@ pub const App = struct {
|
||||||
-1
|
-1
|
||||||
else
|
else
|
||||||
0;
|
0;
|
||||||
if (delta == 0) return false;
|
if (delta == 0) return;
|
||||||
|
|
||||||
switch (self.conv_zone) {
|
switch (self.conv_zone) {
|
||||||
.category => {
|
.category => {
|
||||||
|
|
@ -560,7 +561,6 @@ pub const App = struct {
|
||||||
.to => self.conv_to_idx = wrapIndex(self.conv_to_idx, delta, table_len),
|
.to => self.conv_to_idx = wrapIndex(self.conv_to_idx, delta, table_len),
|
||||||
}
|
}
|
||||||
ctx.redraw = true;
|
ctx.redraw = true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
|
fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vxfw.EventContext, event: vxfw.Event) anyerror!void {
|
||||||
|
|
@ -662,8 +662,13 @@ pub const App = struct {
|
||||||
ctx.redraw = true;
|
ctx.redraw = true;
|
||||||
return;
|
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 (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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// VALUE ZONE key handling
|
// VALUE ZONE key handling: programmer mode's. Every other mode with a value
|
||||||
if (self.value_zone_active) {
|
// 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);
|
self.handleValueZoneKey(key);
|
||||||
ctx.redraw = true;
|
ctx.redraw = true;
|
||||||
return;
|
return;
|
||||||
|
|
@ -1081,17 +1088,7 @@ pub const App = struct {
|
||||||
const result_copy = try self.allocator.dupe(u8, shown.text);
|
const result_copy = try self.allocator.dupe(u8, shown.text);
|
||||||
|
|
||||||
var details: ?[3][]const u8 = null;
|
var details: ?[3][]const u8 = null;
|
||||||
const as_float = info.value.toFloat(self.allocator);
|
if (info.basePattern()) |int| {
|
||||||
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,
|
|
||||||
};
|
|
||||||
details = .{
|
details = .{
|
||||||
try std.fmt.allocPrint(self.allocator, "hex: {f}", .{int.fmt(.hex, .{})}),
|
try std.fmt.allocPrint(self.allocator, "hex: {f}", .{int.fmt(.hex, .{})}),
|
||||||
try std.fmt.allocPrint(self.allocator, "oct: {f}", .{int.fmt(.octal, .{})}),
|
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
|
/// 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
|
/// 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.
|
/// 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 {
|
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
|
var value = engine.Number.parse(self.allocator, expr_text) catch
|
||||||
engine.evalString(&self.env, self.allocator, expr_text) catch |err| {
|
engine.evalString(&self.env, self.allocator, expr_text) catch |err| {
|
||||||
const msg = try self.allocator.dupe(u8, errorStr(err));
|
try self.appendError(expr_text, err);
|
||||||
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
|
text_kept = true;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
errdefer value.deinit();
|
var value_kept = false;
|
||||||
|
defer if (!value_kept) value.deinit();
|
||||||
|
|
||||||
const pair = self.convUnits();
|
const pair = self.convUnits();
|
||||||
var converted = engine.units.convertExactUnits(self.allocator, value, pair.from, pair.to) catch |err| {
|
var converted = engine.units.convertExactUnits(self.allocator, value, pair.from, pair.to) catch |err| {
|
||||||
value.deinit();
|
try self.appendError(expr_text, err);
|
||||||
const msg = try self.allocator.dupe(u8, errorStr(err));
|
text_kept = true;
|
||||||
try self.history.append(self.allocator, .{ .expr = expr_text, .result = msg, .is_error = true });
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer converted.deinit();
|
defer converted.deinit();
|
||||||
|
|
@ -1147,6 +1157,7 @@ pub const App = struct {
|
||||||
// Adopt the new value only once the conversion has succeeded.
|
// Adopt the new value only once the conversion has succeeded.
|
||||||
self.conv_value.deinit();
|
self.conv_value.deinit();
|
||||||
self.conv_value = value;
|
self.conv_value = value;
|
||||||
|
value_kept = true;
|
||||||
|
|
||||||
const shown_in = try self.conv_value.render(self.allocator, display_format);
|
const shown_in = try self.conv_value.render(self.allocator, display_format);
|
||||||
defer shown_in.deinit(self.allocator);
|
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}", .{
|
const result = try std.fmt.allocPrint(self.allocator, "{s} {s} = {s} {s}", .{
|
||||||
shown_in.text, pair.from.name, shown_out.text, pair.to.name,
|
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 });
|
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").
|
/// 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);
|
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" {
|
test "convert mode: swap works from either zone and clicks select units" {
|
||||||
var app = testApp();
|
var app = testApp();
|
||||||
defer app.deinit();
|
defer app.deinit();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue