diff --git a/.gitattributes b/.gitattributes
index fcadb2cf97..7ade3d6750 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,4 @@
* text eol=lf
+
+# Generated by `npm run ci-update`; see nanvm-lib/tests/README.md.
+nanvm-lib/tests/test/generated.rs linguist-generated=true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2a755bb648..1a9f327f0d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,10 @@ history.
## Unreleased
+- `nanvm-lib` operator behaviour is described once, as data, in
+ `fjs/nanvm/module.f.mjs`: the JavaScript proof and the generated
+ Rust tests both read it, so a case is written once instead of twice
+ [#1489](https://github.com/functionalscript/functionalscript/pull/1489)
- **BREAKING CHANGES:** `fjs/bnf/proof` migrates from authored
TypeScript (`.f.ts`) to JSDoc-typed JavaScript (`.f.mjs`) — no local
types to split. Importers must use the `.f.mjs` specifier
diff --git a/fjs/ci/README.md b/fjs/ci/README.md
index 20efacbc6c..0d76c91e39 100644
--- a/fjs/ci/README.md
+++ b/fjs/ci/README.md
@@ -95,9 +95,14 @@ and `ci-update`. A typical FunctionalScript project can define them like this:
}
```
-`ci-update` must regenerate every generated file the project keeps in Git —
-today `.github/workflows/ci.yml`, with more (e.g. generated Rust sources)
-planned. The Node 26 job runs it right after `npm ci` and fails via
+`ci-update` must regenerate every generated file the project keeps in Git, not
+only the workflow. `fjs ci` covers `.github/workflows/ci.yml` and the generated
+Nix flakes; a project with other generators chains them into the same script, as
+this repository does for `nanvm-lib/tests/test/generated.rs` (see
+[`fjs/nanvm/README.md`](../nanvm/README.md)). Everything chained there is
+covered by the drift check below for free.
+
+The Node 26 job runs it right after `npm ci` and fails via
`git add -A && git diff --cached --exit-code` when the committed tree no longer
matches the generator's output, so forgetting to regenerate after changing a
generator breaks the build instead of silently using stale files. Staging with
diff --git a/fjs/media/rust/module.f.mjs b/fjs/media/rust/module.f.mjs
new file mode 100644
index 0000000000..29ccf81910
--- /dev/null
+++ b/fjs/media/rust/module.f.mjs
@@ -0,0 +1,93 @@
+/**
+ * Rust source literals.
+ *
+ * Printing a value as Rust source is not specific to any one generator: the
+ * MVP roadmap's `fjs compile .rs` backend and `nanvm-lib`'s generated
+ * operator tests both need the same escaping and the same spelling of the
+ * numeric edge cases. This module owns that layer — the syntax of a literal —
+ * and nothing above it. Expressions, items, and whatever API a generator
+ * targets stay with the generator.
+ *
+ * The sibling of `fjs/media/nix`, which does the same for Nix expressions.
+ *
+ * @module
+ *
+ * @example
+ *
+ * ```js
+ * import { f64Literal, i64Literal, stringLiteral } from './module.f.mjs'
+ *
+ * stringLiteral('a"b') // '"a\\"b"'
+ * f64Literal(-0) // '-0f64'
+ * i64Literal(-456n) // '-456'
+ * ```
+ */
+
+/**
+ * A double-quoted Rust string literal.
+ *
+ * Any other control character is rejected rather than escaped: no caller needs
+ * one, and a silently mangled literal is worse than a failed generation.
+ *
+ * @type {(v: string) => string}
+ */
+export const stringLiteral = v => `"${[...v].map(c => {
+ switch (c) {
+ case '\\': { return '\\\\' }
+ case '"': { return '\\"' }
+ case '\n': { return '\\n' }
+ case '\r': { return '\\r' }
+ case '\t': { return '\\t' }
+ default: {
+ if (c < ' ' || c === '\u007f') {
+ throw ['control character in a Rust string literal', v]
+ }
+ return c
+ }
+ }
+}).join('')}"`
+
+/**
+ * An `f64` literal.
+ *
+ * `toString` already prints the shortest round-tripping decimal and Rust
+ * parses decimal float literals the same way JavaScript does, so the digits
+ * carry over unchanged. Only the three non-finite values and `-0` — which
+ * `toString` prints as `0` — need spelling out. The `f64` suffix keeps whole
+ * numbers from lexing as integers.
+ *
+ * @type {(v: number) => string}
+ */
+export const f64Literal = v => {
+ if (Number.isNaN(v)) { return 'f64::NAN' }
+ if (v === Infinity) { return 'f64::INFINITY' }
+ if (v === -Infinity) { return 'f64::NEG_INFINITY' }
+ return `${Object.is(v, -0) ? '-0' : v.toString()}f64`
+}
+
+const i64Min = -(2n ** 63n)
+const i64Max = 2n ** 63n - 1n
+
+/**
+ * An `i64` literal. Throws for a value the type cannot hold, rather than
+ * silently truncating it.
+ *
+ * @type {(v: bigint) => string}
+ */
+export const i64Literal = v => {
+ if (v < i64Min || v > i64Max) { throw ['bigint out of i64 range', v] }
+ return v.toString()
+}
+
+/**
+ * A `snake_case` Rust identifier from a `camelCase` name.
+ *
+ * Only the casing is converted: a name that is not already a valid identifier
+ * stays invalid, so callers pass names they control.
+ *
+ * @type {(v: string) => string}
+ */
+export const snakeCase = v => [...v].map(c => {
+ const lower = c.toLowerCase()
+ return c === lower ? c : `_${lower}`
+}).join('')
diff --git a/fjs/media/rust/proof.f.mjs b/fjs/media/rust/proof.f.mjs
new file mode 100644
index 0000000000..32b6cfe167
--- /dev/null
+++ b/fjs/media/rust/proof.f.mjs
@@ -0,0 +1,50 @@
+/**
+ * Proofs for Rust source literals.
+ *
+ * @module
+ */
+
+import { assertEq } from '../../asserts/module.f.mjs'
+import { f64Literal, i64Literal, snakeCase, stringLiteral } from './module.f.mjs'
+
+export const proof = {
+ stringLiteral: () => {
+ assertEq(stringLiteral(''), '""')
+ assertEq(stringLiteral('abc'), '"abc"')
+ assertEq(stringLiteral('a\\b'), '"a\\\\b"')
+ assertEq(stringLiteral('a"b'), '"a\\"b"')
+ assertEq(stringLiteral('a\nb'), '"a\\nb"')
+ assertEq(stringLiteral('a\rb'), '"a\\rb"')
+ assertEq(stringLiteral('a\tb'), '"a\\tb"')
+ // Non-ASCII needs no escape: Rust source is UTF-8.
+ assertEq(stringLiteral('é'), '"é"')
+ },
+ f64Literal: () => {
+ assertEq(f64Literal(NaN), 'f64::NAN')
+ assertEq(f64Literal(Infinity), 'f64::INFINITY')
+ assertEq(f64Literal(-Infinity), 'f64::NEG_INFINITY')
+ assertEq(f64Literal(-0), '-0f64')
+ assertEq(f64Literal(0), '0f64')
+ assertEq(f64Literal(2.3), '2.3f64')
+ assertEq(f64Literal(-239), '-239f64')
+ // Rust's exponent accepts a `+`, which is how `toString` prints it.
+ assertEq(f64Literal(1e21), '1e+21f64')
+ },
+ i64Literal: () => {
+ assertEq(i64Literal(0n), '0')
+ assertEq(i64Literal(-456n), '-456')
+ assertEq(i64Literal(-(2n ** 63n)), '-9223372036854775808')
+ assertEq(i64Literal(2n ** 63n - 1n), '9223372036854775807')
+ },
+ snakeCase: () => {
+ assertEq(snakeCase('emptyArray'), 'empty_array')
+ assertEq(snakeCase('stringCoercion'), 'string_coercion')
+ assertEq(snakeCase('eq'), 'eq')
+ },
+ throw: {
+ i64TooLarge: () => i64Literal(2n ** 63n),
+ i64TooSmall: () => i64Literal(-(2n ** 63n) - 1n),
+ controlCharacter: () => stringLiteral('a\u0000b'),
+ deleteCharacter: () => stringLiteral('a\u007fb'),
+ },
+}
diff --git a/fjs/nanvm/README.md b/fjs/nanvm/README.md
new file mode 100644
index 0000000000..a5b4575105
--- /dev/null
+++ b/fjs/nanvm/README.md
@@ -0,0 +1,91 @@
+# The FunctionalScript side of NaNVM
+
+`nanvm-lib/` is the Rust crate. This directory is the FunctionalScript that
+targets it — today the shared operator test data and the printer that turns it
+into Rust tests; later the `.rs` output branch of `fjs compile` (see
+[`nanvm-lib/todo/mvp-roadmap.md`](../../nanvm-lib/todo/mvp-roadmap.md)).
+
+Operator behaviour is described **once**, as data, and checked twice: against a
+standard JavaScript engine and against `nanvm-lib`. Adding an operator or a case
+means editing one file.
+
+```text
+module.f.mjs ──> proof.f.mjs ──────────────────────────────────> a JS engine
+ (data) └─> rust/module.f.mjs ──> nanvm-lib/tests/test/generated.rs ──> nanvm-lib
+ (printer) (generated)
+```
+
+## Files
+
+| File | Role |
+|---|---|
+| [`types.ts`](types.ts) | The shape of the data: `Value`, `Case`, `Group`, `Eq`, `Data`. |
+| [`module.f.mjs`](module.f.mjs) | **The single source of truth** — every operator case, as data. |
+| [`proof.f.mjs`](proof.f.mjs) | Runs each case through the native JavaScript operators. |
+| [`rust/module.f.mjs`](rust/module.f.mjs) | Prints the data as Rust, against the `nanvm-lib` API. |
+| [`update/module.f.mjs`](update/module.f.mjs) | Writes the printer's output. Run by `npm run ci-update`. |
+
+Rust *literal* syntax — string escaping, `f64`/`i64` spelling, `snake_case`
+identifiers — is not specific to this generator and lives in
+[`fjs/media/rust`](../media/rust/module.f.mjs).
+
+## Writing a case
+
+Operands and expectations are ordinary JavaScript values, following
+[`fjs/types/rtti`](../types/rtti/README.md)'s convention that a constant is its
+own description:
+
+```js
+{ name: 'arrayNumber', args: [[2.3]], expected: 2.3 },
+{ name: 'emptyObjectByOne', args: [{}, 1], expected: NaN },
+{ name: 'bigint', args: [0n], expected: throws },
+```
+
+Three things a literal cannot express are written as thunks — a function in the
+data is always a *description*, never a value that happens to be a function:
+
+| Thunk | Means |
+|---|---|
+| `functionValue` | a function value (no operator here inspects which one) |
+| `ref(name)` | one of the `eq` `shared` values, so the *same* object reaches both sides |
+| `throws` | the case must throw; valid only as `expected` |
+
+`expected` is compared with `Object.is`, so `NaN` matches `NaN` and `0` does not
+match `-0`. The Rust side compares the same way.
+
+## The loop
+
+1. Add the case to `data` in [`module.f.mjs`](module.f.mjs).
+2. `npm test` — the JavaScript proof now covers it, which is what makes the
+ expectation authoritative: it is JavaScript's answer, not a guess.
+3. `npm run ci-update` to regenerate, then `cargo test`.
+4. If `nanvm-lib` does not implement it yet, give the case a `rust` reason. The
+ generated file keeps it as a commented-out `TODO`, and the JavaScript proof
+ keeps running it.
+
+Never edit `nanvm-lib/tests/test/generated.rs`: CI regenerates it on every pull
+request and fails if the committed copy differs (see
+[`fjs/ci/README.md`](../ci/README.md)).
+
+## What is not shared
+
+Two kinds of test stay hand-written, because there is nothing on the other side
+to compare them with.
+
+**JavaScript only** — [`proof.f.mjs`](proof.f.mjs)'s `jsOnly` section:
+`ToPrimitive` consulting an object's `toString` method, and a function's string
+form (engine-specific source text). `nanvm-lib` has no object methods yet.
+
+**Rust only** — `nanvm-lib/tests/test/main.rs`: `try_into` out of `Any`, `Debug`
+formatting, multi-limb bigint arithmetic, serialization round-trips, and the
+exact text of `nanvm-lib`'s own error messages. These are properties of the VM,
+not of JavaScript.
+
+## Known divergence
+
+`String(123n)` is `"123"` in JavaScript and `"0x7Bn"` in `nanvm-lib` — see
+[bigint-decimal-string-coercion](../../nanvm-lib/todo/bigint-decimal-string-coercion.md).
+The two affected cases carry a `rust` reason, so the gap is recorded in the data
+itself rather than in a coverage table. That is the point of the arrangement: a
+divergence is a property of a case, and a table of them goes stale the moment
+someone fixes one.
diff --git a/fjs/nanvm/module.f.mjs b/fjs/nanvm/module.f.mjs
new file mode 100644
index 0000000000..f04658ee92
--- /dev/null
+++ b/fjs/nanvm/module.f.mjs
@@ -0,0 +1,240 @@
+/**
+ * The single source of truth for `nanvm-lib` operator behaviour.
+ *
+ * This module is pure data: it names every operator case once, with the
+ * arguments and the expected result written as ordinary JavaScript values.
+ * Two consumers read it, so a new case is written once and checked twice:
+ *
+ * - [`proof.f.mjs`](./proof.f.mjs) runs each case through the native
+ * JavaScript operators, proving that the expectations describe JavaScript.
+ * - [`rust/module.f.mjs`](./rust/module.f.mjs) prints each case as Rust,
+ * producing `nanvm-lib/tests/test/generated.rs`, which runs the same case
+ * against `nanvm-lib`.
+ *
+ * Cases `nanvm-lib` does not implement yet carry a `rust` reason and are
+ * emitted as commented-out `TODO`s instead of being silently dropped — the
+ * gaps between the two implementations are part of the data.
+ *
+ * @module
+ *
+ * @import { Case, Data, Special, Value } from './types.ts'
+ *
+ * @example
+ *
+ * ```js
+ * import { data } from './module.f.mjs'
+ *
+ * data.groups.length // 4
+ * ```
+ */
+
+/**
+ * A function value.
+ *
+ * Every operator here coerces a function through `ToPrimitive`, which never
+ * inspects it, so which function it is does not matter.
+ *
+ * @type {Special}
+ */
+export const functionValue = () => ['function']
+
+/**
+ * The case must throw. Valid only as a case's `expected`.
+ *
+ * @type {Special}
+ */
+export const throws = () => ['throw']
+
+/**
+ * One of the `eq` `shared` values, so the same object reaches both sides of a
+ * comparison.
+ *
+ * @type {(name: string) => Special}
+ */
+export const ref = name => () => ['ref', name]
+
+/**
+ * `+n` and `-n` share their whole argument space: both coerce with `ToNumber`
+ * and differ only in the sign of the result. Listing the arguments once keeps
+ * the two groups from drifting apart.
+ *
+ * @type {(negate: boolean) => readonly Case[]}
+ */
+const numberCoercionCases = negate => {
+ /** @type {(v: number) => number} */
+ const result = v => negate ? -v : v
+ return [
+ { name: 'null', args: [null], expected: result(0) },
+ { name: 'undefined', args: [undefined], expected: NaN },
+ { name: 'booleanFalse', args: [false], expected: result(0) },
+ { name: 'booleanTrue', args: [true], expected: result(1) },
+ { name: 'numberZero', args: [0], expected: result(0) },
+ { name: 'numberPositive', args: [2.3], expected: result(2.3) },
+ { name: 'numberNegative', args: [-2.3], expected: result(-2.3) },
+ { name: 'numberLarge', args: [-239], expected: result(-239) },
+ { name: 'numberInfinity', args: [Infinity], expected: result(Infinity) },
+ { name: 'numberNegativeInfinity', args: [-Infinity], expected: result(-Infinity) },
+ { name: 'numberNan', args: [NaN], expected: NaN },
+ { name: 'stringEmpty', args: [''], expected: result(0) },
+ { name: 'stringZero', args: ['0'], expected: result(0) },
+ { name: 'stringNumber', args: ['2.3'], expected: result(2.3) },
+ { name: 'stringExponent', args: ['2.3e2'], expected: result(230) },
+ { name: 'stringNotANumber', args: ['a'], expected: NaN },
+ { name: 'arrayEmpty', args: [[]], expected: result(0) },
+ { name: 'arrayNumber', args: [[2.3]], expected: result(2.3) },
+ { name: 'arrayNegativeNumber', args: [[-0.3]], expected: result(-0.3) },
+ { name: 'arrayString', args: [['-2.3']], expected: result(-2.3) },
+ { name: 'arrayPositiveString', args: [['0.3']], expected: result(0.3) },
+ { name: 'arrayNull', args: [[null]], expected: result(0) },
+ { name: 'arrayPair', args: [[null, null]], expected: NaN },
+ { name: 'objectEmpty', args: [{}], expected: NaN },
+ { name: 'function', args: [functionValue], expected: NaN },
+ ]
+}
+
+/**
+ * `*` between a number and a bigint throws, so the pairs below never mix the
+ * two except in the case that proves it. Every pair is checked in both orders
+ * — see `commutative`.
+ *
+ * @type {readonly Case[]}
+ */
+const mulCases = [
+ { name: 'nullByNull', args: [null, null], expected: 0 },
+ { name: 'nullByZero', args: [null, 0], expected: 0 },
+ { name: 'undefinedByZero', args: [undefined, 0], expected: NaN },
+ { name: 'trueByZero', args: [true, 0], expected: 0 },
+ { name: 'trueByOne', args: [true, 1], expected: 1 },
+ { name: 'trueByTen', args: [true, 10], expected: 10 },
+ { name: 'falseByZero', args: [false, 0], expected: 0 },
+ { name: 'falseByOne', args: [false, 1], expected: 0 },
+ { name: 'falseByTen', args: [false, 10], expected: 0 },
+ { name: 'zeroByZero', args: [0, 0], expected: 0 },
+ { name: 'zeroByOne', args: [0, 1], expected: 0 },
+ { name: 'oneByOne', args: [1, 1], expected: 1 },
+ { name: 'oneByMinusOne', args: [1, -1], expected: -1 },
+ { name: 'oneByTen', args: [1, 10], expected: 10 },
+ { name: 'minusOneByTen', args: [-1, 10], expected: -10 },
+ { name: 'tenByTen', args: [10, 10], expected: 100 },
+ { name: 'minusTenByTen', args: [-10, 10], expected: -100 },
+ { name: 'bigZeroByZero', args: [0n, 0n], expected: 0n },
+ { name: 'bigZeroByOne', args: [0n, 1n], expected: 0n },
+ { name: 'bigOneByOne', args: [1n, 1n], expected: 1n },
+ { name: 'bigOneByMinusOne', args: [1n, -1n], expected: -1n },
+ { name: 'bigOneByTen', args: [1n, 10n], expected: 10n },
+ { name: 'bigMinusOneByTen', args: [-1n, 10n], expected: -10n },
+ { name: 'bigTenByTen', args: [10n, 10n], expected: 100n },
+ { name: 'bigMinusTenByTen', args: [-10n, 10n], expected: -100n },
+ { name: 'emptyStringByOne', args: ['', 1], expected: 0 },
+ { name: 'stringTenByOne', args: ['10', 1], expected: 10 },
+ { name: 'stringLetterByOne', args: ['a', 1], expected: NaN },
+ { name: 'stringBigintByOne', args: ['1n', 1], expected: NaN },
+ { name: 'emptyArrayByOne', args: [[], 1], expected: 0 },
+ { name: 'arrayTenByOne', args: [[10], 1], expected: 10 },
+ { name: 'arrayStringTenByOne', args: [['10'], 1], expected: 10 },
+ { name: 'arrayPairByOne', args: [[0, 0], 1], expected: NaN },
+ { name: 'emptyObjectByOne', args: [{}, 1], expected: NaN },
+ { name: 'numberByBigint', args: [1, 1n], expected: throws },
+]
+
+const hexadecimalBigint =
+ 'nanvm-lib prints bigints in hexadecimal; see nanvm-lib/todo/bigint-decimal-string-coercion.md'
+
+/**
+ * `String(x)`.
+ *
+ * A function's string form is its source text, which no two engines have to
+ * agree on, so it is not shared data — [`proof.f.mjs`](./proof.f.mjs) checks
+ * the JavaScript side separately.
+ *
+ * @type {readonly Case[]}
+ */
+const stringCoercionCases = [
+ { name: 'number', args: [123], expected: '123' },
+ { name: 'negativeNumber', args: [-456], expected: '-456' },
+ { name: 'zero', args: [0], expected: '0' },
+ { name: 'negativeZero', args: [-0], expected: '0' },
+ { name: 'infinity', args: [Infinity], expected: 'Infinity' },
+ { name: 'negativeInfinity', args: [-Infinity], expected: '-Infinity' },
+ { name: 'nan', args: [NaN], expected: 'NaN' },
+ { name: 'booleanTrue', args: [true], expected: 'true' },
+ { name: 'booleanFalse', args: [false], expected: 'false' },
+ { name: 'null', args: [null], expected: 'null' },
+ { name: 'undefined', args: [undefined], expected: 'undefined' },
+ { name: 'string', args: ['already'], expected: 'already' },
+ { name: 'bigint', args: [123n], expected: '123', rust: hexadecimalBigint },
+ { name: 'negativeBigint', args: [-456n], expected: '-456', rust: hexadecimalBigint },
+ { name: 'emptyArray', args: [[]], expected: '' },
+ { name: 'singletonArray', args: [[1]], expected: '1' },
+ { name: 'array', args: [[1, 2, 3]], expected: '1,2,3' },
+ { name: 'nestedArray', args: [[1, [2, 3], 4]], expected: '1,2,3,4' },
+ { name: 'arrayWithNullish', args: [[null, undefined, 1]], expected: ',,1' },
+ { name: 'emptyObject', args: [{}], expected: '[object Object]' },
+ { name: 'object', args: [{ a: 1 }], expected: '[object Object]' },
+]
+
+/** @type {Data} */
+export const data = {
+ eq: {
+ shared: {
+ emptyArray: [],
+ stringArray: ['0'],
+ object: { '0': '0' },
+ },
+ cases: [
+ { name: 'nullByNull', a: null, b: null, eq: true },
+ { name: 'undefinedByUndefined', a: undefined, b: undefined, eq: true },
+ { name: 'nullByUndefined', a: null, b: undefined, eq: false },
+ { name: 'trueByTrue', a: true, b: true, eq: true },
+ { name: 'falseByFalse', a: false, b: false, eq: true },
+ { name: 'trueByFalse', a: true, b: false, eq: false },
+ { name: 'falseByUndefined', a: false, b: undefined, eq: false },
+ { name: 'falseByNull', a: false, b: null, eq: false },
+ { name: 'numberBySameNumber', a: 2.3, b: 2.3, eq: true },
+ { name: 'numberByOtherNumber', a: 2.3, b: -5.4, eq: false },
+ { name: 'nanByNan', a: NaN, b: NaN, eq: false },
+ { name: 'zeroByNegativeZero', a: 0, b: -0, eq: true },
+ { name: 'infinityByInfinity', a: Infinity, b: Infinity, eq: true },
+ {
+ name: 'negativeInfinityByNegativeInfinity',
+ a: -Infinity,
+ b: -Infinity,
+ eq: true,
+ },
+ { name: 'infinityByNegativeInfinity', a: Infinity, b: -Infinity, eq: false },
+ { name: 'undefinedByNan', a: undefined, b: NaN, eq: false },
+ { name: 'undefinedByZero', a: undefined, b: 0, eq: false },
+ { name: 'stringBySameString', a: 'hello', b: 'hello', eq: true },
+ { name: 'stringByOtherString', a: 'hello', b: 'world', eq: false },
+ { name: 'zeroByStringZero', a: 0, b: '0', eq: false },
+ { name: 'bigintBySameBigint', a: 12n, b: 12n, eq: true },
+ { name: 'bigintByNegatedBigint', a: 12n, b: -12n, eq: false },
+ { name: 'bigintByOtherBigint', a: 12n, b: 13n, eq: false },
+ { name: 'twelveByStringTwelve', a: 12n, b: '12', eq: false },
+ { name: 'arrayByItself', a: ref('emptyArray'), b: ref('emptyArray'), eq: true },
+ { name: 'arrayByEqualArray', a: [], b: [], eq: false },
+ { name: 'stringArrayByItself', a: ref('stringArray'), b: ref('stringArray'), eq: true },
+ { name: 'objectByItself', a: ref('object'), b: ref('object'), eq: true },
+ { name: 'objectByEqualObject', a: ref('object'), b: { '0': '0' }, eq: false },
+ ],
+ },
+ groups: [
+ {
+ op: 'unaryPlus',
+ cases: [
+ ...numberCoercionCases(false),
+ { name: 'bigint', args: [0n], expected: throws },
+ ],
+ },
+ {
+ op: 'unaryMinus',
+ cases: [
+ ...numberCoercionCases(true),
+ { name: 'bigintPositive', args: [1n], expected: -1n },
+ { name: 'bigintNegative', args: [-1n], expected: 1n },
+ ],
+ },
+ { op: 'mul', commutative: true, cases: mulCases },
+ { op: 'stringCoercion', cases: stringCoercionCases },
+ ],
+}
diff --git a/fjs/nanvm/proof.f.mjs b/fjs/nanvm/proof.f.mjs
new file mode 100644
index 0000000000..df50f3be07
--- /dev/null
+++ b/fjs/nanvm/proof.f.mjs
@@ -0,0 +1,173 @@
+/**
+ * The JavaScript reference for `nanvm-lib`'s operators.
+ *
+ * Every case in [`module.f.mjs`](./module.f.mjs) is run through the native
+ * JavaScript operators here, so the shared data is proven to describe
+ * JavaScript before `nanvm-lib/tests/test/generated.rs` holds `nanvm-lib` to
+ * it. This module contains no test cases of its own beyond the `jsOnly`
+ * section at the end — adding a case means editing the data.
+ *
+ * @module
+ *
+ * @import { Case, EqCase, Op, Struct, Value } from './types.ts'
+ */
+
+import { assert, assertEq } from '../asserts/module.f.mjs'
+import { data } from './module.f.mjs'
+
+const { entries, fromEntries, hasOwn, is } = Object
+
+/**
+ * Builds the JavaScript value a `Value` describes.
+ *
+ * `resolve` supplies the *already built* object a `ref` names — rebuilding it
+ * per reference would hand each side of a comparison its own object, which is
+ * exactly what a `ref` exists to avoid.
+ *
+ * @type {(resolve: (name: string) => unknown) => (v: Value) => unknown}
+ */
+const build = resolve => v => {
+ if (v === null) { return null }
+ if (typeof v === 'function') {
+ const info = v()
+ switch (info[0]) {
+ case 'function': { return () => 5 }
+ case 'ref': { return resolve(info[1]) }
+ case 'throw': { throw ['`throws` is not a value', info] }
+ }
+ }
+ if (Array.isArray(v)) { return v.map(build(resolve)) }
+ if (typeof v === 'object') {
+ return fromEntries(entries(v).map(([k, p]) => [k, build(resolve)(p)]))
+ }
+ return v
+}
+
+/** Cases outside the `eq` group share nothing, so a `ref` is a mistake there. */
+const value = build(name => { throw ['unknown shared value', name] })
+
+/**
+ * Applies an operator to already-built arguments.
+ *
+ * The `any` casts are the point of the exercise: these operators are being
+ * applied to operand types TypeScript rejects (`{} * 1`, `-[]`), which is
+ * exactly the coercion behaviour under test.
+ *
+ * @type {(op: Op) => (args: readonly unknown[]) => unknown}
+ */
+const apply = op => args => {
+ const [a, b] = /** @type {readonly any[]} */(args)
+ switch (op) {
+ case 'unaryPlus': { return +a }
+ case 'unaryMinus': { return -a }
+ case 'mul': { return a * b }
+ case 'stringCoercion': { return String(a) }
+ }
+}
+
+/** `true` when a case's `expected` is `throws` rather than a value. */
+const isThrows = (/** @type {Value} */ expected) =>
+ typeof expected === 'function' && expected()[0] === 'throw'
+
+/**
+ * Every argument order a case is checked in: one, or both for a commutative
+ * operator. The Rust printer applies the same rule.
+ *
+ * @type {(commutative: boolean) => (c: Case) => readonly (readonly[string, readonly Value[]])[]}
+ */
+const orders = commutative => c => commutative
+ ? [[c.name, c.args], [`${c.name}Swapped`, c.args.toReversed()]]
+ : [[c.name, c.args]]
+
+/**
+ * The leaf tests of one group, keyed by case name.
+ *
+ * Throwing cases go under a nested `throw` key — the framework's structural
+ * way of declaring that a test is expected to throw. A throwing leaf stops at
+ * its first exception, which is why each argument order is its own leaf.
+ *
+ * @type {(op: Op) => (commutative: boolean) => (cases: readonly Case[]) => object}
+ */
+const group = op => commutative => cases => {
+ /** @type {(c: Case) => readonly (readonly[string, () => void])[]} */
+ const leaves = c => {
+ const { expected } = c
+ /** @type {(args: readonly Value[]) => () => void} */
+ const fn = isThrows(expected)
+ ? args => () => { apply(op)(args.map(value)) }
+ : args => () => {
+ // `Object.is` rather than `===`, so `NaN` matches `NaN` and
+ // `0` does not match `-0`; the Rust side compares the same way.
+ const result = apply(op)(args.map(value))
+ const e = value(expected)
+ assert(is(result, e), [result, 'is not', e])
+ }
+ return orders(commutative)(c).map(([name, args]) => [name, fn(args)])
+ }
+ const ok = cases.filter(c => !isThrows(c.expected)).flatMap(leaves)
+ const bad = cases.filter(c => isThrows(c.expected)).flatMap(leaves)
+ return bad.length === 0
+ ? fromEntries(ok)
+ : { ...fromEntries(ok), throw: fromEntries(bad) }
+}
+
+const eqProof = (() => {
+ const { shared, cases } = data.eq
+ // Built once, so two `ref`s to the same name really are the same object.
+ const built = fromEntries(entries(shared).map(([k, v]) => [k, value(v)]))
+ const operand = build(name => {
+ assert(hasOwn(built, name), ['unknown shared value', name])
+ return built[name]
+ })
+ /** @type {(c: EqCase) => readonly[string, () => void]} */
+ const leaf = c => [c.name, () => {
+ const a = /** @type {any} */(operand(c.a))
+ const b = /** @type {any} */(operand(c.b))
+ assertEq(a === b, c.eq, [a, c.eq ? '===' : '!==', b])
+ assertEq(b === a, c.eq)
+ }]
+ return fromEntries(cases.map(leaf))
+})()
+
+/**
+ * Behaviour that is real JavaScript but has no `nanvm-lib` counterpart to
+ * share data with, so it stays here instead of in `module.f.mjs`.
+ */
+const jsOnly = {
+ /**
+ * `Object.is` distinguishes `0` from `-0` where `===` does not; the whole
+ * corpus relies on that, so it is checked directly.
+ */
+ negativeZero: () => {
+ assert(is(-0, -0))
+ assert(!is(0, -0))
+ assert(is(NaN, NaN))
+ },
+ /** A function's string form is engine-specific — only its type is fixed. */
+ functionToString: () => {
+ assertEq(typeof String(() => 5), 'string')
+ },
+ /**
+ * `ToPrimitive` consults a `toString` method. `nanvm-lib` has no object
+ * methods yet, so these cases cannot be shared; see
+ * `nanvm-lib/todo/mvp-roadmap.md`.
+ */
+ toStringMethod: () => {
+ assertEq(String({ toString: () => 'custom string' }), 'custom string')
+ },
+ throw: {
+ toStringThrows: () => String({ toString: () => { throw 'Custom error' } }),
+ toStringNotAFunction: () => String(/** @type {any} */({ toString: 'hello' })),
+ toStringNotPrimitive: () => String({ toString: () => [] }),
+ /** `throws` describes a case's outcome; it is not a value to build. */
+ throwsIsNotAValue: () => value(() => ['throw']),
+ unknownRef: () => value(() => ['ref', 'nope']),
+ },
+}
+
+export const proof = {
+ eq: eqProof,
+ ...fromEntries(data.groups.map(
+ g => [g.op, group(g.op)(g.commutative === true)(g.cases)])),
+ jsOnly,
+}
diff --git a/fjs/nanvm/rust/module.f.mjs b/fjs/nanvm/rust/module.f.mjs
new file mode 100644
index 0000000000..29a5f15568
--- /dev/null
+++ b/fjs/nanvm/rust/module.f.mjs
@@ -0,0 +1,166 @@
+/**
+ * Prints the shared operator test data as Rust.
+ *
+ * The output is `nanvm-lib/tests/test/generated.rs`: one statement per case,
+ * calling the hand-written helpers in `nanvm-lib/tests/test/harness.rs`. Only
+ * the helpers are written by hand — a new operator case is added to
+ * [`../module.f.mjs`](../module.f.mjs) and appears on both the JavaScript and
+ * the Rust side at once.
+ *
+ * Literal syntax comes from [`fjs/media/rust`](../../media/rust/module.f.mjs);
+ * what is specific to this module is the `nanvm-lib` API the statements target.
+ *
+ * Every emitted function carries `#[rustfmt::skip]`: the line layout here is
+ * one statement per case, and `cargo fmt -- --check` runs in CI, so the
+ * printer would otherwise have to reproduce rustfmt's wrapping exactly.
+ *
+ * @module
+ *
+ * @import { Case, Data, Eq, Group, Op, Value } from '../types.ts'
+ *
+ * @example
+ *
+ * ```js
+ * import { generate } from './module.f.mjs'
+ * import { data } from '../module.f.mjs'
+ *
+ * generate(data) // the contents of `nanvm-lib/tests/test/generated.rs`
+ * ```
+ */
+
+import {
+ f64Literal,
+ i64Literal,
+ snakeCase,
+ stringLiteral,
+} from '../../media/rust/module.f.mjs'
+
+const { entries } = Object
+
+const indent = ' '
+
+/**
+ * Where this printer's output goes, relative to the repository root.
+ *
+ * `tests/test/` rather than `tests/`: cargo turns every `tests/*.rs` into its
+ * own test target, so a generated file directly in `tests/` would be built as
+ * a second, harness-less crate. Inside `tests/test/` it is an ordinary
+ * submodule of the `tests/test/main.rs` target.
+ */
+export const directory = 'nanvm-lib/tests/test'
+
+/** @type {string} */
+export const path = `${directory}/generated.rs`
+
+/**
+ * A Rust expression of type `Any`.
+ *
+ * Every use site fixes `A`, so no expression needs a turbofish: the harness
+ * helpers take `Any` arguments and the shared `let` bindings are annotated.
+ *
+ * @type {(v: Value) => string}
+ */
+export const valueExpr = v => {
+ if (v === null) { return 'Nullish::Null.to_any()' }
+ if (typeof v === 'function') {
+ const info = v()
+ switch (info[0]) {
+ case 'function': { return 'function_any()' }
+ case 'ref': { return `${snakeCase(info[1])}.clone()` }
+ case 'throw': { throw ['`throws` is not a value', info] }
+ }
+ }
+ switch (typeof v) {
+ case 'undefined': { return 'Nullish::Undefined.to_any()' }
+ case 'boolean': { return `${v}.to_any()` }
+ case 'number': { return `(${f64Literal(v)}).to_any()` }
+ case 'string': { return `string_any(${stringLiteral(v)})` }
+ case 'bigint': { return `bigint_any(${i64Literal(v)})` }
+ }
+ if (Array.isArray(v)) {
+ const items = /** @type {readonly Value[]} */(v)
+ return items.length === 0
+ ? 'Array::default().to_any()'
+ : `[${items.map(valueExpr).join(', ')}].to_array().to_any()`
+ }
+ const properties = entries(v)
+ return properties.length === 0
+ ? 'Object::default().to_any()'
+ : `[${properties.map(
+ ([k, p]) => `(string_key(${stringLiteral(k)}), ${valueExpr(p)})`).join(', ')}].to_object().to_any()`
+}
+
+/** @type {(op: Op) => (args: readonly string[]) => string} */
+export const call = op => args => {
+ switch (op) {
+ case 'unaryPlus': { return `Any::unary_plus(${args[0]})` }
+ case 'unaryMinus': { return `-(${args[0]})` }
+ case 'mul': { return `${args[0]} * ${args[1]}` }
+ case 'stringCoercion': { return `${args[0]}.to_string().map(|v| v.to_any())` }
+ }
+}
+
+/**
+ * Comments out a statement `nanvm-lib` cannot pass yet, keeping the case
+ * visible in the generated file as the work still to do.
+ *
+ * @type {(reason: string|undefined) => (statement: string) => readonly string[]}
+ */
+const emit = reason => statement => reason === undefined
+ ? [`${indent}${statement}`]
+ : [`${indent}// TODO: ${reason}`, `${indent}// ${statement}`]
+
+/**
+ * Every argument order a case is checked in — both for a commutative
+ * operator, matching what the JavaScript proof does.
+ *
+ * @type {(commutative: boolean) => (c: Case) => readonly (readonly[string, readonly Value[]])[]}
+ */
+const orders = commutative => c => commutative
+ ? [[c.name, c.args], [`${c.name}Swapped`, c.args.toReversed()]]
+ : [[c.name, c.args]]
+
+/** @type {(expected: Value) => (name: string) => (result: string) => string} */
+const assertion = expected => name => result =>
+ typeof expected === 'function' && expected()[0] === 'throw'
+ ? `check_throws::(${stringLiteral(name)}, ${result});`
+ : `check::(${stringLiteral(name)}, ${result}, ${valueExpr(expected)});`
+
+/** @type {(g: Group) => readonly string[]} */
+const groupFn = g => [
+ '#[rustfmt::skip]',
+ `fn ${snakeCase(g.op)}() {`,
+ ...g.cases.flatMap(c => orders(g.commutative === true)(c).flatMap(
+ ([name, args]) => emit(c.rust)(
+ assertion(c.expected)(name)(call(g.op)(args.map(valueExpr)))))),
+ '}',
+ '',
+]
+
+/** @type {(eq: Eq) => readonly string[]} */
+const eqFn = eq => [
+ '#[rustfmt::skip]',
+ 'fn eq() {',
+ ...entries(eq.shared).map(
+ ([k, v]) => `${indent}let ${snakeCase(k)}: Any = ${valueExpr(v)};`),
+ ...eq.cases.flatMap(c => emit(c.rust)(
+ `check_eq::(${stringLiteral(c.name)}, ${valueExpr(c.a)}, ${valueExpr(c.b)}, ${c.eq});`)),
+ '}',
+ '',
+]
+
+/** @type {(data: Data) => string} */
+export const generate = data => [
+ '// @generated by `npm run ci-update` from `fjs/nanvm/module.f.mjs`.',
+ '// Do not edit: change the shared operator test data and regenerate.',
+ '',
+ 'use super::harness::*;',
+ '',
+ ...eqFn(data.eq),
+ ...data.groups.flatMap(groupFn),
+ 'pub fn all() {',
+ `${indent}eq::();`,
+ ...data.groups.map(g => `${indent}${snakeCase(g.op)}::();`),
+ '}',
+ '',
+].join('\n')
diff --git a/fjs/nanvm/rust/proof.f.mjs b/fjs/nanvm/rust/proof.f.mjs
new file mode 100644
index 0000000000..a56d9588d2
--- /dev/null
+++ b/fjs/nanvm/rust/proof.f.mjs
@@ -0,0 +1,119 @@
+/**
+ * Proofs for the Rust printer.
+ *
+ * The interesting assertion is `generate`'s on a tiny synthetic corpus: it
+ * pins the exact text of every construct the printer can emit, so a change in
+ * layout is a visible diff here and not only in the generated file.
+ *
+ * @module
+ *
+ * @import { Data } from '../types.ts'
+ */
+
+import { assert, assertEq } from '../../asserts/module.f.mjs'
+import { data, functionValue, ref, throws } from '../module.f.mjs'
+import { call, directory, generate, path, valueExpr } from './module.f.mjs'
+import { snakeCase } from '../../media/rust/module.f.mjs'
+
+/**
+ * One case of every shape the printer can emit: a shared value and a
+ * reference to it, a skipped case, a throwing case, and a commutative binary
+ * operator.
+ *
+ * @type {Data}
+ */
+const sample = {
+ eq: {
+ shared: { emptyArray: [] },
+ cases: [
+ { name: 'itself', a: ref('emptyArray'), b: ref('emptyArray'), eq: true },
+ { name: 'skipped', a: null, b: null, eq: true, rust: 'not yet' },
+ ],
+ },
+ groups: [
+ { op: 'unaryPlus', cases: [{ name: 'bigint', args: [0n], expected: throws }] },
+ {
+ op: 'mul',
+ commutative: true,
+ cases: [{ name: 'oneByTwo', args: [1, 2], expected: 2 }],
+ },
+ ],
+}
+
+const expected = `// @generated by \`npm run ci-update\` from \`fjs/nanvm/module.f.mjs\`.
+// Do not edit: change the shared operator test data and regenerate.
+
+use super::harness::*;
+
+#[rustfmt::skip]
+fn eq() {
+ let empty_array: Any = Array::default().to_any();
+ check_eq::("itself", empty_array.clone(), empty_array.clone(), true);
+ // TODO: not yet
+ // check_eq::("skipped", Nullish::Null.to_any(), Nullish::Null.to_any(), true);
+}
+
+#[rustfmt::skip]
+fn unary_plus() {
+ check_throws::("bigint", Any::unary_plus(bigint_any(0)));
+}
+
+#[rustfmt::skip]
+fn mul() {
+ check::("oneByTwo", (1f64).to_any() * (2f64).to_any(), (2f64).to_any());
+ check::("oneByTwoSwapped", (2f64).to_any() * (1f64).to_any(), (2f64).to_any());
+}
+
+pub fn all() {
+ eq::();
+ unary_plus::();
+ mul::();
+}
+`
+
+export const proof = {
+ path: () => {
+ assertEq(directory, 'nanvm-lib/tests/test')
+ assertEq(path, 'nanvm-lib/tests/test/generated.rs')
+ },
+ valueExpr: () => {
+ assertEq(valueExpr(null), 'Nullish::Null.to_any()')
+ assertEq(valueExpr(undefined), 'Nullish::Undefined.to_any()')
+ assertEq(valueExpr(true), 'true.to_any()')
+ assertEq(valueExpr(false), 'false.to_any()')
+ assertEq(valueExpr(-0.3), '(-0.3f64).to_any()')
+ assertEq(valueExpr('a'), 'string_any("a")')
+ assertEq(valueExpr(-1n), 'bigint_any(-1)')
+ assertEq(valueExpr([]), 'Array::default().to_any()')
+ assertEq(valueExpr([null]), '[Nullish::Null.to_any()].to_array().to_any()')
+ assertEq(valueExpr({}), 'Object::default().to_any()')
+ assertEq(
+ valueExpr({ k: null }),
+ '[(string_key("k"), Nullish::Null.to_any())].to_object().to_any()')
+ assertEq(valueExpr(functionValue), 'function_any()')
+ assertEq(valueExpr(ref('stringArray')), 'string_array.clone()')
+ },
+ call: () => {
+ assertEq(call('unaryPlus')(['x']), 'Any::unary_plus(x)')
+ assertEq(call('unaryMinus')(['x']), '-(x)')
+ assertEq(call('mul')(['x', 'y']), 'x * y')
+ assertEq(call('stringCoercion')(['x']), 'x.to_string().map(|v| v.to_any())')
+ },
+ generate: () => {
+ assertEq(generate(sample), expected)
+ },
+ generateData: () => {
+ // The real corpus, which is what `ci-update` writes. Only its shape is
+ // asserted here: its contents are checked by `cargo test`.
+ const result = generate(data)
+ assert(result.endsWith('}\n'), result)
+ assert(result.includes('pub fn all() {'), result)
+ for (const g of data.groups) {
+ assert(result.includes(`fn ${snakeCase(g.op)}() {`), g.op)
+ assert(result.includes(`${snakeCase(g.op)}::();`), g.op)
+ }
+ },
+ throw: {
+ throwsIsNotAValue: () => valueExpr(throws),
+ },
+}
diff --git a/fjs/nanvm/types.ts b/fjs/nanvm/types.ts
new file mode 100644
index 0000000000..28fb98d1b3
--- /dev/null
+++ b/fjs/nanvm/types.ts
@@ -0,0 +1,125 @@
+/**
+ * Type-level API for the shared operator test data.
+ *
+ * The data described here is the single source of truth for operator
+ * behaviour: [`proof.f.mjs`](./proof.f.mjs) runs it against a standard
+ * JavaScript engine, and [`rust/module.f.mjs`](./rust/module.f.mjs) prints it
+ * as the Rust tests in [`test/generated.rs`](./test/generated.rs).
+ *
+ * @module
+ */
+
+/**
+ * A value under test, written as itself.
+ *
+ * The shape follows `fjs/types/rtti`, where a constant is its own schema and a
+ * thunk describes anything that needs a tag: `2.3`, `'a'`, `12n`, `[1, 2]`,
+ * and `{ a: 1 }` mean exactly what they look like, and the only tagged forms
+ * are the ones a literal cannot express — see {@link Special}.
+ *
+ * Writing operands as plain JavaScript is what makes the corpus readable
+ * (`args: [[2.3]], expected: 2.3` rather than a tree of constructor calls) and
+ * costs nothing: both consumers already have to walk the value, and `typeof`
+ * plus `Array.isArray` recovers everything a tag would have carried.
+ */
+export type Value = Const | Special
+
+/** A value that is its own description. */
+export type Const =
+ | null
+ | undefined
+ | boolean
+ | number
+ | string
+ | bigint
+ | readonly Value[]
+ | Struct
+
+/** An object value. Property order is the order the Rust printer emits. */
+export type Struct = { readonly [k in string]?: Value }
+
+/**
+ * Something no literal can express, described by a thunk.
+ *
+ * A function in the data is therefore always a *description*, never a value
+ * that happens to be a function — `functionValue` is how the data says "a
+ * function".
+ */
+export type Special = () => Info
+
+/**
+ * What a {@link Special} describes.
+ *
+ * - `function` — a function value. Every operator here coerces one through
+ * `ToPrimitive`, which never inspects it, so there is nothing to carry.
+ * - `ref` — one of the {@link Eq} `shared` values, so the *same* object
+ * reaches both sides of a comparison.
+ * - `throw` — not a value at all: the case must throw. Valid only as a
+ * {@link Case}'s `expected`.
+ */
+export type Info =
+ | readonly ['function']
+ | readonly ['ref', string]
+ | readonly ['throw']
+
+/** The operators covered by the shared data. */
+export type Op = 'unaryPlus' | 'unaryMinus' | 'mul' | 'stringCoercion'
+
+/**
+ * One operator test case.
+ *
+ * `expected` is compared with `Object.is`, so `NaN` matches `NaN` and `0` does
+ * not match `-0`; `throws` there means the operation must throw, and the
+ * exception value — being engine-specific — is not part of the data.
+ *
+ * `rust` marks a case `nanvm-lib` does not implement yet: the value is the
+ * reason, the generated Rust keeps the case as a commented-out `TODO`, and
+ * the JavaScript proof still runs it. Removing the property is what turns the
+ * case on for Rust — the gap list is data, not prose in a README.
+ */
+export type Case = {
+ readonly name: string
+ readonly args: readonly Value[]
+ readonly expected: Value
+ readonly rust?: string
+}
+
+/**
+ * The cases of one operator.
+ *
+ * `commutative` additionally checks every case with its arguments swapped,
+ * which is what the hand-written tests did for `*`.
+ */
+export type Group = {
+ readonly op: Op
+ readonly commutative?: boolean
+ readonly cases: readonly Case[]
+}
+
+/** One strict-equality (`===`) case; `eq` is the expected result. */
+export type EqCase = {
+ readonly name: string
+ readonly name2?: string
+ readonly a: Value
+ readonly b: Value
+ readonly eq: boolean
+ readonly rust?: string
+}
+
+/**
+ * Strict-equality cases plus the values they share.
+ *
+ * Equality of arrays and objects is reference equality in both JavaScript and
+ * `nanvm-lib`, so a case can only express "the same object" by naming a value
+ * in `shared` and reaching it with a `ref`.
+ */
+export type Eq = {
+ readonly shared: Struct
+ readonly cases: readonly EqCase[]
+}
+
+/** The whole shared test corpus. */
+export type Data = {
+ readonly eq: Eq
+ readonly groups: readonly Group[]
+}
diff --git a/fjs/nanvm/update/module.f.mjs b/fjs/nanvm/update/module.f.mjs
new file mode 100644
index 0000000000..291cd308e1
--- /dev/null
+++ b/fjs/nanvm/update/module.f.mjs
@@ -0,0 +1,33 @@
+/**
+ * Writes the generated Rust operator tests.
+ *
+ * The printer in [`../rust/module.f.mjs`](../rust/module.f.mjs) is pure; this
+ * module is the thin effectful shell around it, invoked from `ci-update` so
+ * the CI drift check regenerates the file on every pull request and fails
+ * when the committed copy is stale.
+ *
+ * @module
+ *
+ * @import { Effect } from '../../effects/types.ts'
+ * @import { Mkdir, NodeProgram, WriteFile } from '../../effects/node/types.ts'
+ */
+
+import { mapStep, step } from '../../effects/module.f.mjs'
+import { mkdir, writeUtf8File } from '../../effects/node/module.f.mjs'
+import { unwrap } from '../../types/result/module.f.mjs'
+import { data } from '../module.f.mjs'
+import { directory, generate, path } from '../rust/module.f.mjs'
+
+/**
+ * Regenerates `nanvm-lib/tests/test/generated.rs` from the shared test data.
+ *
+ * @type {() => Effect}
+ */
+export const generateRustTests = () => {
+ const directoryReady = mapStep(mkdir(directory, { recursive: true }), unwrap)
+ const written = step(directoryReady, () => writeUtf8File(path, generate(data)))
+ return mapStep(written, unwrap)
+}
+
+/** @type {NodeProgram} */
+export const main = () => mapStep(generateRustTests(), () => 0)
diff --git a/fjs/nanvm/update/proof.f.mjs b/fjs/nanvm/update/proof.f.mjs
new file mode 100644
index 0000000000..f5e0236efb
--- /dev/null
+++ b/fjs/nanvm/update/proof.f.mjs
@@ -0,0 +1,32 @@
+/**
+ * Proofs for the generated-Rust writer.
+ *
+ * @module
+ */
+
+import { assert, assertEq } from '../../asserts/module.f.mjs'
+import { step } from '../../effects/module.f.mjs'
+import { readUtf8File } from '../../effects/node/module.f.mjs'
+import {
+ defaultNodeProgramOptions,
+ emptyState,
+ virtual,
+} from '../../effects/node/virtual/module.f.mjs'
+import { data } from '../module.f.mjs'
+import { generate, path } from '../rust/module.f.mjs'
+import { generateRustTests, main } from './module.f.mjs'
+
+export const proof = {
+ generateRustTests: () => {
+ // The target directory does not exist in `emptyState`, so this also
+ // covers the `mkdir` the writer does before the file write.
+ const written = step(generateRustTests(), () => readUtf8File(path))
+ const [, [tag, result]] = virtual(emptyState)(written)
+ assert(tag === 'ok', result)
+ assertEq(result, generate(data))
+ },
+ main: () => {
+ const [, result] = virtual(emptyState)(main(defaultNodeProgramOptions))
+ assertEq(result, 0)
+ },
+}
diff --git a/nanvm-lib/tests/README.md b/nanvm-lib/tests/README.md
index 5afbd2669c..a9e9b13d0c 100644
--- a/nanvm-lib/tests/README.md
+++ b/nanvm-lib/tests/README.md
@@ -1,121 +1,28 @@
# Tests
-[`proof.f.ts`](proof.f.ts) is the JavaScript reference — it runs the same operations against a standard JS engine.
-[`test.rs`](test.rs) is the Rust counterpart — it runs the same operations against `nanvm-lib`.
-
-The goal is a near 1-to-1 match so that any divergence signals either a missing Rust test or an unimplemented feature.
-
-## Coverage map
-
-`[x]` = present, `[ ]` = missing.
-
-### `eq` / strict equality (`===` / `!==`)
-
-| Test case | `proof.f.ts` | `test.rs` | Notes |
-|------------------------|:---:|:---:|-------|
-| `nullish` | [x] | [x] | `nullish_eq` |
-| `boolean.boolean` | [x] | [x] | `bool_eq` |
-| `boolean.nullish` | [x] | [x] | `bool_eq` |
-| `number.number` | [x] | [x] | `number_eq` — includes NaN, ±0, ±Inf |
-| `number.nullish` | [x] | [x] | `number_eq` |
-| `string.string` | [x] | [x] | `string_eq` |
-| `string.number` | [x] | [x] | `old_eq` |
-| `bigint.bigint` | [x] | [x] | `bigint_eq` |
-| `array.array` | [x] | [x] | `array_eq` |
-| `object.object` | [x] | [x] | `object_eq` |
-
-### `unary_plus` (`+n`)
-
-| Test case | `proof.f.ts` | `test.rs` | Notes |
-|------------------------|:---:|:---:|-------|
-| `null` | [x] | [x] | |
-| `undefined` | [x] | [x] | |
-| `boolean.false` | [x] | [x] | |
-| `boolean.true` | [x] | [x] | |
-| `number.zero` | [x] | [x] | |
-| `number.positive` | [x] | [x] | |
-| `number.negative` | [x] | [x] | |
-| `string.empty` | [x] | [x] | |
-| `string.zero` | [x] | [x] | |
-| `string.positive` | [x] | [x] | TS tests `"2.3"`, Rust tests `"2.3e2"` — same concept |
-| `string.nan` | [x] | [x] | |
-| `bigint.throw` | [x] | [x] | |
-| `array.empty` | [x] | [x] | |
-| `array.single_number` | [x] | [x] | |
-| `array.single_string` | [x] | [x] | TS tests `["-2.3"]→-2.3`, Rust tests `["0.3"]→0.3` — Rust missing negative case |
-| `array.multiple` | [x] | [x] | |
-| `object.empty` | [x] | [x] | |
-| `function` | [x] | [x] | |
-
-### `unary_minus` (`-n`)
-
-| Test case | `proof.f.ts` | `test.rs` | Notes |
-|------------------------|:---:|:---:|-------|
-| `null` | [x] | [x] | |
-| `undefined` | [x] | [x] | |
-| `boolean.false` | [x] | [x] | |
-| `boolean.true` | [x] | [x] | |
-| `number.zero` | [x] | [x] | |
-| `number.positive` | [x] | [x] | |
-| `number.negative` | [x] | [x] | |
-| `string.empty` | [x] | [x] | |
-| `string.zero` | [x] | [x] | |
-| `string.positive` | [x] | [x] | |
-| `string.nan` | [x] | [x] | |
-| `bigint.positive` | [x] | [x] | |
-| `bigint.negative` | [x] | [x] | |
-| `array.empty` | [x] | [x] | |
-| `array.single_number` | [x] | [x] | |
-| `array.single_string` | [x] | [x] | |
-| `array.multiple` | [x] | [x] | |
-| `object.empty` | [x] | [ ] | `{}` → NaN missing in Rust |
-| `function` | [x] | [x] | |
-
-### `stringCoercion` (`String(x)`)
-
-| Test case | `proof.f.ts` | `test.rs` | Notes |
-|--------------------------------|:---:|:---:|-------|
-| `number` | [x] | [x] | `number_coerce_to_string` |
-| `bool` | [x] | [ ] | `true`/`false` → `"true"`/`"false"` missing in Rust |
-| `null` | [x] | [ ] | `null` → `"null"` missing in Rust |
-| `undefined` | [x] | [ ] | `undefined` → `"undefined"` missing in Rust |
-| `bigint` | [x] | [ ] | `123n` → `"123"` missing in Rust |
-| `array` | [x] | [x] | `array_coerce_to_string` — Rust additionally tests nested arrays |
-| `func` | [x] | [ ] | `typeof String(fn) === "string"` missing in Rust |
-| `object.norm` | [x] | [ ] | `{}` → `"[object Object]"` missing in Rust |
-| `object.toString` | [x] | [ ] | custom `toString()` method missing in Rust |
-| `object.toStringThrow` | [x] | [ ] | throwing `toString()` missing in Rust |
-| `object.toStringNotFunc` | [x] | [ ] | non-function `toString` missing in Rust |
-| `object.toStringNonPrimitive` | [x] | [ ] | `toString()` returning non-primitive missing in Rust |
-
-### `mul` (`*`)
-
-| Test case | `proof.f.ts` | `test.rs` | Notes |
-|------------------------|:---:|:---:|-------|
-| `mul` (all cases) | [x] | [x] | Full table covering null, bool, number, bigint, string, array, object |
-
-### BigInt-specific — only in `test.rs`
-
-| Test case | `proof.f.ts` | `test.rs` | Notes |
-|------------------------|:---:|:---:|-------|
-| `bigint_add` | [ ] | [x] | Addition at `Any` level |
-| `bigint_mul` | [ ] | [x] | Multiplication with large multi-limb values |
-| `bigint_negative_zero` | [ ] | [x] | Rust normalization: `-0n === 0n` |
-
-### Infrastructure — only in `test.rs`
-
-| Test case | `proof.f.ts` | `test.rs` | Notes |
-|------------------------|:---:|:---:|-------|
-| `serialization` | [ ] | [x] | Round-trip serialize/deserialize for all types |
-| `format_fn` | [ ] | [x] | Rust `Debug` formatting of `Function` |
-
-## Summary
-
-| Gap | Action needed |
-|-----|---------------|
-| `stringCoercion` for bool/null/undefined/bigint/func/object | Add Rust tests in `test.rs` |
-| `unary_minus.object.empty` | Add Rust test case |
-| `bigint_add` / `bigint_mul` | Add TS tests in `proof.f.ts` |
-| `serialization` | JS serialization is out of scope (VM-internal), keep Rust-only |
-| `format_fn` / `bigint_negative_zero` | Rust-specific, keep Rust-only |
-| `old_eq` in `test.rs` | Redundant — overlaps with `nullish_eq`/`bool_eq`/`number_eq`/etc.; consider removing |
+Operator behaviour is **not** written here. It is described once, as data, in
+[`fjs/nanvm`](../../fjs/nanvm/README.md), and arrives in this crate as
+[`test/generated.rs`](test/generated.rs) — so a case is written once and checked
+twice, against a JavaScript engine and against `nanvm-lib`.
+
+| File | Role |
+|---|---|
+| [`test/main.rs`](test/main.rs) | Hand-written tests with no JavaScript counterpart. |
+| [`test/harness.rs`](test/harness.rs) | Value constructors and assertions the generated file calls. |
+| [`test/generated.rs`](test/generated.rs) | **Generated. Do not edit.** One statement per case. |
+
+`test/main.rs` rather than `test.rs`: cargo makes every `tests/*.rs` its own
+test target, so the generated file and the harness have to live in a
+subdirectory to stay ordinary submodules, and a subdirectory's entry point is
+`main.rs`.
+
+## Changing what is tested
+
+An operator case belongs in [`fjs/nanvm/module.f.mjs`](../../fjs/nanvm/module.f.mjs);
+`npm run ci-update` regenerates `test/generated.rs` from it, and CI fails if the
+committed copy is stale.
+
+What stays here is everything with no JavaScript counterpart: `try_into` out of
+`Any`, `Debug` formatting, multi-limb bigint arithmetic, serialization
+round-trips, and the exact text of `nanvm-lib`'s own error messages. These are
+properties of the VM, not of JavaScript, so there is nothing to share.
diff --git a/nanvm-lib/tests/proof.f.ts b/nanvm-lib/tests/proof.f.ts
deleted file mode 100644
index 5ae7fc9a58..0000000000
--- a/nanvm-lib/tests/proof.f.ts
+++ /dev/null
@@ -1,289 +0,0 @@
-import { assert } from '../../fjs/asserts/module.f.mjs'
-
-const { is } = Object;
-
-const ois = (a: unknown) => (b: unknown): void => {
- assert(is(a, b), [a, 'is', b])
-}
-
-const { isNaN } = Number;
-
-const nanRes = (op: (n: unknown) => unknown) => (n: unknown): void => {
- const result = op(n);
- assert(isNaN(result), result)
-}
-
-const stringCoercion = String
-const multiply = (a: any) => (b: any): unknown => a * b
-const multiplyEq = (a: any) => (b: any) => (expected: unknown): void => {
- ois(multiply(a)(b))(expected)
- ois(multiply(b)(a))(expected)
-}
-
-export const proof = {
- eq: () => {
- const e = (a: unknown) => (b: unknown): void => {
- if (a === b) { } else { throw [a, '===', b] }
- }
-
- const n = (a: unknown) => (b: unknown): void => {
- if (a !== b) { } else { throw [a, '!==', b] }
- }
- return {
- nullish: () => {
- e(null)(null)
- e(undefined)(undefined)
- n(null)(undefined)
- },
- boolean: {
- boolean: () => {
- e(true)(true)
- e(false)(false)
- n(true)(false)
- },
- nullish: () => {
- n(false)(undefined)
- n(false)(null)
- }
- },
- number: {
- number: () => {
- e(2.3)(2.3)
- n(2.3)(-5.4)
- n(NaN)(NaN)
- e(0)(-0)
- if (!is(-0, -0)) { throw -0 }
- if (is(0, -0)) { throw -0 }
- e(Infinity)(Infinity)
- e(-Infinity)(-Infinity)
- n(Infinity)(-Infinity)
- },
- nullish: () => {
- n(undefined)(NaN)
- n(undefined)(0)
- }
- },
- string: {
- string: () => {
- e("hello")("hello")
- n("hello")("world")
- },
- number: () => {
- n(0)("0")
- }
- },
- bigint: {
- bigint: () => {
- e(12n)(12n)
- n(12n)(-12n)
- n(12n)(13n)
- }
- },
- array: {
- array: () => {
- const a: any = []
- e(a)(a)
- n([])([])
- const a0 = ['0']
- e(a0)(a0)
- }
- },
- object: {
- object: () => {
- const o = { '0': '0' }
- e(o)(o)
- n(o)({ '0': '0' })
- }
- }
- }
- },
- unary_plus: () => {
- const op = (n: any) => +n
- const nan = nanRes(op)
- return {
- null: () => ois(op(null))(0),
- undefined: () => nan(undefined),
- boolean: {
- false: () => ois(op(false))(0),
- true: () => ois(op(true))(1)
- },
- number: {
- zero: () => ois(op(0))(0),
- positive: () => ois(op(2.3))(2.3),
- negative: () => ois(op(-2.3))(-2.3)
- },
- string: {
- empty: () => ois(op(""))(0),
- zero: () => ois(op("0"))(0),
- positive: () => ois(op("2.3"))(2.3),
- nan: () => nan("a")
- },
- bigint: {
- throw: () => op(0n),
- },
- array: {
- empty: () => ois(op([]))(0),
- single_number: () => ois(op([2.3]))(2.3),
- single_string: () => ois(op(["-2.3"]))(-2.3),
- multiple: () => nan([null, null])
- },
- object: {
- empty: () => nan({})
- // TODO: test objects with valueOf, toString functions - when Rust logic is implemented
- },
- function: () => nan(op(() => {}))
- }
- },
- unary_minus: () => {
- const op = (n: any) => -n
- const nan = nanRes(op)
- return {
- null: () => ois(op(null))(-0),
- undefined: () => nan(undefined),
- boolean: {
- false: () => ois(op(false))(-0),
- true: () => ois(op(true))(-1)
- },
- number: {
- zero: () => ois(op(0))(-0),
- positive: () => ois(op(2.3))(-2.3),
- negative: () => ois(op(-2.3))(2.3)
- },
- string: {
- empty: () => ois(op(""))(-0),
- zero: () => ois(op("0"))(-0),
- positive: () => ois(op("2.3"))(-2.3),
- nan: () => nan("a")
- },
- bigint: {
- positive: () => ois(op(1n))(-1n),
- negative: () => ois(op(-1n))(1n),
- },
- array: {
- empty: () => ois(op([]))(-0),
- single_number: () => ois(op([2.3]))(-2.3),
- single_string: () => ois(op(["-2.3"]))(2.3),
- multiple: () => nan([null, null])
- },
- object: {
- empty: () => nan({})
- // TODO: test objects with valueOf, toString functions - when Rust logic is implemented
- },
- function: () => nan(op(() => {}))
- }
- },
- mul: () => {
- return {
- nullish: () => {
- multiplyEq(null)(null)(0)
- multiplyEq(null)(0)(0)
- multiplyEq(undefined)(0)(NaN)
- },
- boolean: () => {
- multiplyEq(true)(0)(0)
- multiplyEq(true)(1)(1)
- multiplyEq(true)(10)(10)
- multiplyEq(false)(0)(0)
- multiplyEq(false)(1)(0)
- multiplyEq(false)(10)(0)
- },
- number: () => {
- multiplyEq(0)(0)(0)
- multiplyEq(0)(1)(0)
- multiplyEq(1)(1)(1)
- multiplyEq(1)(-1)(-1)
- multiplyEq(1)(10)(10)
- multiplyEq(-1)(10)(-10)
- multiplyEq(10)(10)(100)
- multiplyEq(-10)(10)(-100)
- },
- bigint: () => {
- multiplyEq(0n)(0n)(0n)
- multiplyEq(0n)(1n)(0n)
- multiplyEq(1n)(1n)(1n)
- multiplyEq(1n)(-1n)(-1n)
- multiplyEq(1n)(10n)(10n)
- multiplyEq(-1n)(10n)(-10n)
- multiplyEq(10n)(10n)(100n)
- multiplyEq(-10n)(10n)(-100n)
- },
- string: () => {
- multiplyEq('')(1)(0)
- multiplyEq('10')(1)(10)
- multiplyEq('a')(1)(NaN)
- multiplyEq('1n')(1)(NaN)
- },
- array: () => {
- multiplyEq([])(1)(0)
- multiplyEq([10])(1)(10)
- multiplyEq(['10'])(1)(10)
- multiplyEq([0, 0])(1)(NaN)
- },
- object: () => multiplyEq({})(1)(NaN)
- }
- },
- stringCoercion: {
- number: () => {
- if (stringCoercion(123) !== '123') { throw [123, 'toString', '123'] }
- if (stringCoercion(-456) !== '-456') { throw [-456, 'toString', '-456'] }
- if (stringCoercion(0) !== '0') { throw [0, 'toString', '0'] }
- if (stringCoercion(-0) !== '0') { throw [0, 'toString', '0'] }
- if (stringCoercion(1/(-0)) !== '-Infinity') { throw [0, 'toString', '-Infinity'] }
- if (stringCoercion(Infinity) !== 'Infinity') { throw [Infinity, 'toString', 'Infinity'] }
- if (stringCoercion(-Infinity) !== '-Infinity') { throw [-Infinity, 'toString', '-Infinity'] }
- if (stringCoercion(1/-Infinity) !== '0') { throw [-Infinity, 'toString', '0'] }
- if (stringCoercion(NaN) !== 'NaN') { throw [NaN, 'toString', 'NaN'] }
- },
- bool: () => {
- if (stringCoercion(true) !== 'true') { throw [true, 'toString', 'true'] }
- if (stringCoercion(false) !== 'false') { throw [false, 'toString', 'false'] }
- },
- null: () => {
- if (stringCoercion(null) !== 'null') { throw [null, 'toString', 'null'] }
- },
- undefined: () => {
- if (stringCoercion(undefined) !== 'undefined') { throw [undefined, 'toString', 'undefined'] }
- },
- bigint: () => {
- if (stringCoercion(123n) !== '123') { throw [123n, 'toString', '123'] }
- if (stringCoercion(-456n) !== '-456') { throw [-456n, 'toString', '-456'] }
- },
- array: () => {
- const arr = [1, 2, 3]
- if (stringCoercion(arr) !== '1,2,3') { throw [arr, 'toString', '1,2,3'] }
- },
- func: () => {
- const func = () => 5
- if (typeof stringCoercion(func) !== 'string') { throw [func, 'toString'] }
- // if (stringCoercion(func) !== '() => 5') { throw [func, 'toString', 'function result'] }
- },
- object: {
- norm: () => {
- const obj = { a: 1, b: 2 }
- if (stringCoercion(obj) !== '[object Object]') { throw [obj, 'toString', '[object Object]'] }
- },
- toString: () => {
- const x = { toString: () => 'custom string' }
- if (stringCoercion(x) !== 'custom string') { throw [x, 'toString', 'custom string'] }
- },
- toStringThrow: {
- throw: () => {
- const x = { toString: () => { throw new Error('Custom error') } }
- stringCoercion(x)
- }
- },
- toStringNotFunc: {
- throw: () => {
- const x = { toString: 'hello' }
- stringCoercion(x)
- }
- },
- toStringNonPrimitive: {
- throw: () => {
- const x = { toString: () => [] }
- stringCoercion(x)
- }
- }
- }
- }
-}
diff --git a/nanvm-lib/tests/test.rs b/nanvm-lib/tests/test.rs
deleted file mode 100644
index bf999d0b45..0000000000
--- a/nanvm-lib/tests/test.rs
+++ /dev/null
@@ -1,769 +0,0 @@
-use nanvm_lib::{
- common::{default::default, iter::Iter, serializable::Serializable},
- naive,
- sign::Sign,
- vm::{
- Any, Array, BigInt, Function, IContainer, IVm, Nullish, Object, Property, String, ToAny,
- ToArray, ToObject, Unpacked,
- },
-};
-
-fn nullish_eq() {
- let n0: Any = Nullish::Null.to_any();
- let n1 = Nullish::Null.to_any();
- assert_eq!(n0, n1);
- let u0 = Nullish::Undefined.to_any();
- let u1 = Nullish::Undefined.to_any();
- assert_eq!(u0, u1);
- assert_ne!(n0, u0);
-
- let x: Nullish = n0.try_into().unwrap();
- assert_eq!(x, Nullish::Null);
-}
-
-fn bool_eq() {
- let t0: Any = true.to_any();
- let t1 = true.to_any();
- assert_eq!(t0, t1);
- let f0 = false.to_any();
- let f1 = false.to_any();
- assert_eq!(f0, f1);
- assert_ne!(t0, f0);
-
- let x: bool = t0.try_into().unwrap();
- assert!(x);
-}
-
-fn number_eq() {
- // 0.5
- let a0: Any = 0.5.to_any();
- let a1 = 0.5.to_any();
- assert_eq!(a0, a1);
-
- // 3.0
- let b0 = 3.0.to_any();
- let b1 = 3.0.to_any();
- assert_eq!(b0, b1);
- assert_ne!(a0, b0);
-
- // 0.0 and -0.0
- let pz = 0.0.to_any();
- let nz = (-0.0).to_any();
- assert_eq!(pz, nz);
- assert_ne!(a0, pz);
-
- let nzf: f64 = nz.try_into().unwrap();
- let nzs = format!("{nzf}");
- assert_eq!(nzs, "-0");
- let x = 1.0 / nzf;
- assert_ne!(x, f64::INFINITY);
- assert_eq!(x, -f64::INFINITY);
-
- // Infinity
- let i0 = f64::INFINITY.to_any();
- let i1 = f64::INFINITY.to_any();
- assert_eq!(i0, i1);
- assert_ne!(a0, i0);
-
- // -Infinity
- let ni0 = f64::NEG_INFINITY.to_any();
- let ni1 = f64::NEG_INFINITY.to_any();
- assert_eq!(ni0, ni1);
- assert_ne!(i0, ni0);
- let ni = x.to_any();
- assert_eq!(ni, ni0);
-
- // NaN
- let nan0 = f64::NAN.to_any();
- let nan1 = f64::NAN.to_any();
- assert_ne!(nan0, nan1);
- assert_ne!(i0, nan0);
- let nan0n: f64 = nan0.try_into().unwrap();
- assert!(nan0n.is_nan());
- let nan1n: f64 = nan1.try_into().unwrap();
- assert!(nan1n.is_nan());
-}
-
-fn string_eq() {
- let s0: Any = "Hello".into();
- let s1 = "Hello".into();
- assert_eq!(s0, s1);
- let s2 = "World".into();
- assert_ne!(s0, s2);
-
- let s: String = s0.try_into().unwrap();
- assert_eq!(s, String::from("Hello"));
-
- let x = format!("{s:?}");
- assert_eq!(x, "\"Hello\"");
-}
-
-fn object_eq() {
- let e0: Any = Object::default().to_any();
- let e1 = Object::default().to_any();
- assert_eq!(e0, e0);
- assert_ne!(e0, e1);
-
- let o0: Object = e0.try_into().unwrap();
- let o1: Object = e1.try_into().unwrap();
- assert_eq!(o0, o0);
- assert_ne!(o0, o1);
-
- let x = format!("{o0:?}");
- assert_eq!(x, "{}");
-}
-
-fn array_eq() {
- let e0: Any = Array::default().to_any();
- let e1 = Array::default().to_any();
- assert_eq!(e0, e0);
- assert_ne!(e0, e1);
-
- let a0: Array = e0.try_into().unwrap();
- let a1: Array = e1.try_into().unwrap();
- assert_eq!(a0, a0);
- assert_ne!(a0, a1);
-
- let x = format!("{a0:?}");
- assert_eq!(x, "[]");
-}
-
-fn bigint_eq() {
- let b0: Any = BigInt::default().to_any();
- let b1 = BigInt::default().to_any();
- assert_eq!(b0, b1);
- let z: BigInt<_> = b0.try_into().unwrap();
- assert_eq!(z, default());
- let x = format!("{z:?}");
- assert_eq!(x, "0n");
-
- {
- let bm: BigInt = i64::MIN.into();
- let x = format!("{bm:?}");
- // 0123456789ABCDEF
- assert_eq!(x, "-0x8000000000000000n");
- let i: i64 = i64::MIN;
- let m = i.overflowing_neg().0 as u64;
- assert_eq!(m, 0x8000000000000000);
- }
-
- {
- let bm: BigInt = (i64::MIN + 1).into();
- let x = format!("{bm:?}");
- // 0123456789ABCDEF
- assert_eq!(x, "-0x7FFFFFFFFFFFFFFFn");
- let i: i64 = i64::MIN + 1;
- let m = i.overflowing_neg().0 as u64;
- assert_eq!(m, 0x7FFFFFFFFFFFFFFF);
- }
-
- {
- let bm: BigInt = i64::MAX.into();
- let x = format!("{bm:?}");
- // 0123456789ABCDEF
- assert_eq!(x, "0x7FFFFFFFFFFFFFFFn");
- }
-
- {
- let bm: BigInt = u64::MAX.into();
- let x = format!("{bm:?}");
- // 0123456789ABCDEF
- assert_eq!(x, "0xFFFFFFFFFFFFFFFFn");
- }
-
- {
- let bm: BigInt = 0u64.into();
- let x = format!("{bm:?}");
- assert_eq!(x, "0n");
- }
-
- {
- let bm: BigInt = 0i64.into();
- let x = format!("{bm:?}");
- assert_eq!(x, "0n");
- }
-}
-
-fn eq_container(a: T, b: T, e: fn(a: &T::Item, &T::Item) -> bool) -> bool {
- a.into_iter().eq_by_(b.into_iter(), e)
-}
-
-fn eq_value(a: &Any, b: &Any) -> bool {
- match (a.clone().into(), b.clone().into()) {
- (Unpacked::Nullish(a), Unpacked::Nullish(b)) => a == b,
- (Unpacked::Boolean(a), Unpacked::Boolean(b)) => a == b,
- (Unpacked::Number(a), Unpacked::Number(b)) => a.to_bits() == b.to_bits(),
- (Unpacked::String(a), Unpacked::String(b)) => a == b,
- (Unpacked::BigInt(a), Unpacked::BigInt(b)) => a == b,
- (Unpacked::Array(a), Unpacked::Array(b)) => eq_container(a, b, eq_value),
- (Unpacked::Object(a), Unpacked::Object(b)) => {
- eq_container(a, b, |x: &Property, y: &Property| {
- x.0 == y.0 && eq_value(&x.1, &y.1)
- })
- }
- _ => false,
- }
-}
-
-// We keep old_eq here despite the fact that it's mostly redundant (most likely). At a better moment
-// we will revisit this test and remove redundant cases here.
-fn old_eq() {
- // nullish
- let null0: Any = Nullish::Null.to_any();
- let null1 = Nullish::Null.to_any();
- let undefined0 = Nullish::Undefined.to_any();
- let undefined1 = Nullish::Undefined.to_any();
- {
- assert_eq!(null0, null1);
- assert_eq!(undefined0, undefined1);
- assert_ne!(null1, undefined0);
- }
- // boolean
- let true0: Any = true.to_any();
- let true1 = true.to_any();
- let false0 = false.to_any();
- let false1 = false.to_any();
- {
- // boolean
- {
- assert_eq!(true0, true1);
- assert_eq!(false0, false1);
- assert_ne!(true0, false0);
- }
- // nullish
- {
- assert_ne!(false0, undefined0);
- assert_ne!(false0, null0);
- }
- }
- // number
- let number00: Any = 2.3.to_any();
- let number01 = 2.3.to_any();
- let number1 = (-5.4).to_any();
- let number_nan = f64::NAN.to_any();
- let number_p0 = 0.0.to_any();
- let number_n0 = (-0.0).to_any();
- let number_p_inf0: Any = f64::INFINITY.to_any();
- let number_p_inf1 = f64::INFINITY.to_any();
- let number_n_inf0 = (-f64::INFINITY).to_any();
- let number_n_inf1 = (-f64::INFINITY).to_any();
- {
- // number
- {
- assert_eq!(number00, number01);
- assert_ne!(number00, number1);
- assert_ne!(number_nan, number_nan);
- assert_eq!(number_p0, number_n0);
- // Object.is()
- assert_eq!((-0f64).to_bits(), (-0f64).to_bits());
- assert_ne!(0f64.to_bits(), (-0f64).to_bits());
- assert_eq!(number_p_inf0, number_p_inf1);
- assert_eq!(number_n_inf0, number_n_inf1);
- assert_ne!(number_p_inf0, number_n_inf0);
- }
- // nullish
- {
- assert_ne!(number_nan, undefined0);
- assert_ne!(number00, undefined0);
- }
- }
- // string
- let string_hello0: Any = "Hello!".into();
- let string_hello1 = "Hello!".into();
- let string_world0 = "world!".into();
- let string0: Any = "0".into();
- let s0: String = "0".into();
- {
- {
- assert_eq!(string_hello0, string_hello1);
- assert_ne!(string_hello0, string_world0);
- }
- {
- assert_ne!(number_p0, string0.clone());
- }
- }
- // bigint
- let bigint12_0: Any = Into::>::into(12u64).to_any();
- let bigint12_1 = Into::>::into(12u64).to_any();
- let bigint12m = Into::>::into(-12i64).to_any();
- let bigint13 = Into::>::into(13u64).to_any();
- {
- assert_eq!(bigint12_0, bigint12_1);
- assert_ne!(bigint12_0, bigint12m);
- assert_ne!(bigint12_0, bigint13);
- }
- // array
- let array0: Any = Array::default().to_any();
- let array1 = Array::default().to_any();
- let array2: Any = [string0.clone()].to_array().to_any();
- {
- assert_eq!(array0, array0);
- assert_ne!(array0, array1);
- assert_eq!(array2, array2);
- }
- // object
- let object0: Any = [(s0.clone(), string0.clone())].to_object().to_any();
- let object1 = [(s0, string0)].to_object().to_any();
- {
- assert_eq!(object0, object0);
- assert_ne!(object0, object1);
- }
-}
-
-fn serialization() {
- use std::io::Cursor;
-
- let values: &[Any] = &[
- Nullish::Null.to_any(),
- Nullish::Undefined.to_any(),
- true.to_any(),
- false.to_any(),
- 2.3.to_any(),
- "Hello".into(),
- Into::>::into(12u64).to_any(),
- Array::default().to_any(),
- [7.0.to_any()].to_array().to_any(),
- [("a".into(), 1.0.to_any()), ("b".into(), "c".into())]
- .to_object()
- .to_any(),
- ];
-
- for value in values.into_iter() {
- let mut buf = Vec::new();
- value.clone().serialize(&mut buf).unwrap();
- let mut cursor = Cursor::new(buf);
- let result = Any::deserialize(&mut cursor).unwrap();
- assert!(eq_value(&value, &result));
- }
-}
-
-fn number_coerce_to_string() {
- let n: Any = 123.0.to_any();
- assert_eq!(n.to_string(), Ok("123".into()));
-
- let n: Any = (-456.0).to_any();
- assert_eq!(n.to_string(), Ok("-456".into()));
-
- let n: Any = (0.0).to_any();
- assert_eq!(n.to_string(), Ok("0".into()));
-
- let n: Any = (-0.0).to_any();
- assert_eq!(n.to_string(), Ok("0".into()));
-
- let n: Any = (1.0 / -0.0).to_any();
- assert_eq!(n.to_string(), Ok("-Infinity".into()));
-
- let n: Any = f64::INFINITY.to_any();
- assert_eq!(n.to_string(), Ok("Infinity".into()));
-
- let n: Any = f64::NEG_INFINITY.to_any();
- assert_eq!(n.to_string(), Ok("-Infinity".into()));
-
- let n: Any = f64::NAN.to_any();
- assert_eq!(n.to_string(), Ok("NaN".into()));
-}
-
-fn array_coerce_to_string() {
- let a: Any = [].to_array().to_any();
- assert_eq!(a.to_string(), Ok("".into()));
-
- let a: Any = [1.0.to_any()].to_array().to_any();
- assert_eq!(a.to_string(), Ok("1".into()));
-
- let a: Any = [1.0.to_any(), 2.0.to_any(), 3.0.to_any()]
- .to_array()
- .to_any();
- assert_eq!(a.to_string(), Ok("1,2,3".into()));
-
- let a: Any = [
- 1.0.to_any(),
- [2.0.to_any(), 3.0.to_any()].to_array().to_any(),
- 4.0.to_any(),
- ]
- .to_array()
- .to_any();
- assert_eq!(a.to_string(), Ok("1,2,3,4".into()));
-}
-
-fn format_fn() {
- let f = Function::(A::InternalFunction::new_ok(
- ("myfunc".into(), 2),
- [0xDE, 0xAD, 0xBE, 0xEF],
- ));
- let x = format!("{f:?}");
- assert_eq!(x, "function myfunc(a0,a1) {DEADBEEF}");
-}
-
-fn assert_is_nan(a: Any, test_case: &str) {
- let nan = Any::unary_plus(a).unwrap();
- let f = nan.to_number().expect(test_case);
- assert!(f.is_nan(), "{test_case}");
-}
-
-fn test_op(result: Any, expected: Any, test_case: &str) {
- match expected.clone().into() {
- Unpacked::Number(f) => {
- if f.is_nan() {
- assert_is_nan(result, test_case);
- } else {
- let res: f64 = result.try_into().unwrap();
- assert_eq!(f.to_bits(), res.to_bits(), "{test_case}");
- }
- }
- Unpacked::BigInt(_) => {
- assert_eq!(result, expected);
- }
- _ => panic!("expected is neither Number nor BigInt in '{}'", test_case),
- }
-}
-
-fn unary_plus() {
- {
- let n: Any = Nullish::Null.to_any();
- assert_eq!(Any::unary_plus(n), Ok(0.0.to_any()));
- }
- {
- let n: Any = Nullish::Undefined.to_any();
- let result = Any::unary_plus(n).unwrap();
- // Check that the result is NaN
- let num: f64 = result.try_into().unwrap();
- assert!(num.is_nan());
- }
- {
- let n: Any = false.to_any();
- assert_eq!(Any::unary_plus(n), Ok(0.0.to_any()));
- }
- {
- let n: Any = true.to_any();
- assert_eq!(Any::unary_plus(n), Ok(1.0.to_any()));
- }
- {
- let n: Any = 0.0.to_any();
- assert_eq!(Any::unary_plus(n.clone()), Ok(n));
- }
- {
- let n: Any = (-239.0).to_any();
- assert_eq!(Any::unary_plus(n.clone()), Ok(n));
- }
- {
- let n: Any = f64::INFINITY.to_any();
- assert_eq!(Any::unary_plus(n.clone()), Ok(n));
- }
- {
- let n: Any = f64::NEG_INFINITY.to_any();
- assert_eq!(Any::unary_plus(n.clone()), Ok(n));
- }
- {
- let n: Any = f64::NAN.to_any();
- let result = Any::unary_plus(n).unwrap();
- // Check that the result is NaN
- let num: f64 = result.try_into().unwrap();
- assert!(num.is_nan());
- }
-
- let n0 = 0.0.to_any::();
- let nan = f64::NAN.to_any::();
- let null = Nullish::Null.to_any::();
- let test_cases: &[(Any, Any, &str)] = &[
- (null.clone(), n0.clone(), "null"),
- (Nullish::Undefined.to_any(), nan.clone(), "undefined"),
- (true.to_any(), 1.0.to_any(), "boolean true"),
- (false.to_any(), n0.clone(), "boolean false"),
- (n0.clone(), 0.0.to_any(), "number 0"),
- (2.3.to_any(), 2.3.to_any(), "number 2.3"),
- ((-2.3).to_any(), (-2.3).to_any(), "number -2.3"),
- ("".into(), n0.clone(), "string \"\""),
- ("0".into(), n0.clone(), "string \"0\""),
- ("2.3e2".into(), 2.3e2.to_any(), "string \"2.3e2\""),
- ("a".into(), nan.clone(), "string \"a\""),
- ([].to_array().to_any(), n0.clone(), "array []"),
- (
- [(-0.3).to_any()].to_array().to_any(),
- (-0.3).to_any(),
- "array [-0.3]",
- ),
- (
- ["0.3".into()].to_array().to_any(),
- 0.3.to_any(),
- "array [\"0.3\"]",
- ),
- (
- [null.clone()].to_array().to_any(),
- n0.clone(),
- "array [null]",
- ),
- (
- [null.clone(), null.clone()].to_array().to_any(),
- nan.clone(),
- "array [null,null]",
- ),
- ([].to_object().to_any(), nan.clone(), "object {{}}"),
- // TODO: decide on testing objects with valueOf, toString functions.
- (
- Function::(A::InternalFunction::new_ok(("".into(), 0), [0])).to_any(),
- nan.clone(),
- "function",
- ),
- ];
- for (a, expected, test_case) in test_cases.iter() {
- test_op::(
- Any::unary_plus(a.clone()).unwrap(),
- expected.clone(),
- test_case,
- );
- }
-
- // bigint
- let b0: Any = BigInt::default().to_any();
- assert_eq!(
- Any::unary_plus(b0),
- Err("TypeError: Cannot convert a BigInt value to a number".into())
- );
-}
-
-fn unary_minus() {
- let nan = f64::NAN.to_any::();
- let null = Nullish::Null.to_any::();
- let bi1: BigInt = 1u64.into();
- let bi_m1: BigInt = (-1i64).into();
- let test_cases: &[(Any, Any, &str)] = &[
- (null.clone(), (-0.0).to_any(), "null"),
- (Nullish::Undefined.to_any(), nan.clone(), "undefined"),
- (true.to_any(), (-1.0).to_any(), "boolean true"),
- (false.to_any(), (-0.0).to_any(), "boolean false"),
- (0.0.to_any::().clone(), (-0.0).to_any(), "number 0"),
- ((-2.3).to_any(), 2.3.to_any(), "number -2.3"),
- (2.3.to_any(), (-2.3).to_any(), "number 2.3"),
- ("".into(), (-0.0).to_any(), "string \"\""),
- ("0".into(), (-0.0).to_any(), "string \"0\""),
- ("2.3e2".into(), (-2.3e2).to_any(), "string \"2.3e2\""),
- ("a".into(), nan.clone(), "string \"a\""),
- ([].to_array().to_any(), (-0.0).to_any(), "array []"),
- (bi1.to_any(), bi_m1.to_any(), "bigint 1n"),
- (
- [(-0.3).to_any()].to_array().to_any(),
- 0.3.to_any(),
- "array [-0.3]",
- ),
- (
- [null.clone()].to_array().to_any(),
- (-0.0).to_any(),
- "array [null]",
- ),
- (
- [null.clone(), null.clone()].to_array().to_any(),
- nan.clone(),
- "array [null,null]",
- ),
- (
- ["0.3".into()].to_array().to_any(),
- (-0.3).to_any(),
- "array [\"0.3\"]",
- ),
- // TODO: decide on testing objects with valueOf, toString functions.
- (
- Function::(A::InternalFunction::new_ok(("".into(), 0), [0])).to_any(),
- nan.clone(),
- "function",
- ),
- ];
- for (a, expected, test_case) in test_cases.iter() {
- test_op::((-a.clone()).unwrap(), expected.clone(), test_case);
- }
-}
-
-fn mul() {
- let n0: Any = 0.0.to_any();
- let n1: Any = 1.0.to_any();
- let n_minus1: Any = (-1.0).to_any();
- let n10: Any = 10.0.to_any();
- let n_minus10: Any = (-10.0).to_any();
- let nan: Any = f64::NAN.to_any();
- let null: Any = Nullish::Null.to_any();
- let true_: Any = true.to_any();
- let false_: Any = false.to_any();
- let bi0: Any = BigInt::default().to_any();
- let bi1: Any = Into::>::into(1u64).to_any();
- let bi_minus1: Any = Into::>::into(-1i64).to_any();
- let bi10: Any = Into::>::into(10u64).to_any();
- let bi_minus10: Any = Into::>::into(-10i64).to_any();
- let test_cases: &[(Any, Any, Any, &str)] = &[
- (null.clone(), null.clone(), n0.clone(), "null by null"),
- (null.clone(), n0.clone(), n0.clone(), "null by 0"),
- (
- Nullish::Undefined.to_any(),
- n0.clone(),
- nan.clone(),
- "undefined by 0",
- ),
- (true_.clone(), n0.clone(), n0.clone(), "boolean true by 0"),
- (true_.clone(), n1.clone(), n1.clone(), "boolean true by 1"),
- (
- true_.clone(),
- n10.clone(),
- n10.clone(),
- "boolean true by 10",
- ),
- (false_.clone(), n0.clone(), n0.clone(), "boolean false by 0"),
- (false_.clone(), n1.clone(), n0.clone(), "boolean false by 1"),
- (
- false_.clone(),
- n10.clone(),
- n0.clone(),
- "boolean false by 10",
- ),
- (n0.clone(), n0.clone(), n0.clone(), "0 by 0"),
- (n0.clone(), n1.clone(), n0.clone(), "0 by 1"),
- (n1.clone(), n1.clone(), n1.clone(), "1 by 1"),
- (n1.clone(), n_minus1.clone(), n_minus1.clone(), "1 by -1"),
- (n1.clone(), n10.clone(), n10.clone(), "1 by 10"),
- (n_minus1.clone(), n10.clone(), n_minus10.clone(), "-1 by 10"),
- (n10.clone(), n10.clone(), 100.0.to_any(), "10 by 10"),
- (
- n_minus10.clone(),
- n10.clone(),
- (-100.0).to_any(),
- "-10 by 10",
- ),
- (bi0.clone(), bi0.clone(), bi0.clone(), "0n by 0n"),
- (bi0.clone(), bi1.clone(), bi0.clone(), "0n by 1n"),
- (bi1.clone(), bi1.clone(), bi1.clone(), "1n by 1n"),
- (
- bi1.clone(),
- bi_minus1.clone(),
- bi_minus1.clone(),
- "1n by -1n",
- ),
- (bi1.clone(), bi10.clone(), bi10.clone(), "1n by 10n"),
- (
- bi_minus1.clone(),
- bi10.clone(),
- Into::>::into(-10i64).to_any(),
- "-1n by 10n",
- ),
- (
- bi10.clone(),
- bi10.clone(),
- Into::>::into(100i64).to_any(),
- "10n by 10n",
- ),
- (
- bi_minus10.clone(),
- bi10.clone(),
- Into::>::into(-100i64).to_any(),
- "-10n by 10n",
- ),
- ("".into(), n1.clone(), n0.clone(), "\"\" by 1"),
- ("10".into(), n1.clone(), n10.clone(), "\"10\" by 1"),
- ("a".into(), n1.clone(), nan.clone(), "\"a\" by 1"),
- ("1n".into(), n1.clone(), nan.clone(), "\"1n\" by 1"),
- ([].to_array().to_any(), n1.clone(), n0.clone(), "[] by 1"),
- (
- [n10.clone()].to_array().to_any(),
- n1.clone(),
- n10.clone(),
- "[10] by 1",
- ),
- (
- ["10".into()].to_array().to_any(),
- n1.clone(),
- n10.clone(),
- "[\"10\"] by 1",
- ),
- (
- [n0.clone(), n0.clone()].to_array().to_any(),
- n1.clone(),
- nan.clone(),
- "[\"0,0\"] by 1",
- ),
- (
- [].to_object().to_any(),
- n1.clone(),
- nan.clone(),
- "{{}} by 1",
- ),
- // TODO: decide on testing objects with valueOf, toString functions.
- ];
- for (a, b, expected, test_case) in test_cases.iter() {
- test_op::(
- (a.clone() * b.clone()).unwrap(),
- expected.clone(),
- test_case,
- );
- test_op::(
- (b.clone() * a.clone()).unwrap(),
- expected.clone(),
- test_case,
- );
- }
-}
-
-fn bigint_add() {
- let n0: Any = BigInt::default().to_any();
- assert_eq!((n0.clone() + n0.clone()), n0);
- let n2: Any = BigInt::from(2u64).to_any();
- let n4: Any = BigInt::from(4u64).to_any();
- assert_eq!((n0.clone() + n2.clone()), n2);
- assert_eq!((n2.clone() + n4.clone()), BigInt::from(6u64).to_any());
-}
-
-fn bigint_mul() {
- let n0: Any = BigInt::default().to_any();
- let n1: Any = BigInt::from(1u64).to_any();
- assert_eq!((n1.clone() * n0.clone()).unwrap(), n0);
- assert_eq!((n0.clone() * n1.clone()).unwrap(), n0);
-
- let n_minus1: Any = BigInt::from(-1i64).to_any();
- assert_eq!((n_minus1.clone() * n0.clone()).unwrap(), n0);
- assert_eq!((n0.clone() * n_minus1.clone()).unwrap(), n0);
- assert_eq!((n_minus1.clone() * n_minus1.clone()).unwrap(), n1);
-
- let a: Any = BigInt::normalize_new(Sign::Positive, [1, 2, 3, 4]).to_any();
- let b: Any = BigInt::normalize_new(Sign::Positive, [5, 6, 7]).to_any();
- let expected: Any = BigInt::normalize_new(Sign::Positive, [5, 16, 34, 52, 45, 28]).to_any();
- assert_eq!((a.clone() * b.clone()).unwrap(), expected);
- assert_eq!((b.clone() * a.clone()).unwrap(), expected);
-
- let a: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX]).to_any();
- let expected: Any = BigInt::normalize_new(Sign::Positive, [1, u64::MAX - 1]).to_any();
- assert_eq!((a.clone() * a.clone()).unwrap(), expected);
-
- let b: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX, u64::MAX, u64::MAX]).to_any();
- let expected: Any =
- BigInt::normalize_new(Sign::Positive, [1, u64::MAX, u64::MAX, u64::MAX - 1]).to_any();
- assert_eq!((a.clone() * b.clone()).unwrap(), expected);
- assert_eq!((b.clone() * a.clone()).unwrap(), expected);
-}
-
-fn bigint_negative_zero() {
- let mn0: BigInt = BigInt::normalize_new(Sign::Negative, []);
- let n0: BigInt = BigInt::default();
- assert_eq!(mn0, n0);
-}
-
-fn gen_test() {
- nullish_eq::();
- bool_eq::();
- number_eq::();
- string_eq::();
- object_eq::();
- array_eq::();
- bigint_eq::();
- old_eq::();
- serialization::();
- number_coerce_to_string::();
- array_coerce_to_string::();
- unary_plus::();
- unary_minus::();
- mul::();
- bigint_add::();
- bigint_mul::();
- bigint_negative_zero::();
- //
- format_fn::();
-}
-
-#[test]
-fn test() {
- gen_test::();
-}
diff --git a/nanvm-lib/tests/test/generated.rs b/nanvm-lib/tests/test/generated.rs
new file mode 100644
index 0000000000..1f714d52ba
--- /dev/null
+++ b/nanvm-lib/tests/test/generated.rs
@@ -0,0 +1,210 @@
+// @generated by `npm run ci-update` from `fjs/nanvm/module.f.mjs`.
+// Do not edit: change the shared operator test data and regenerate.
+
+use super::harness::*;
+
+#[rustfmt::skip]
+fn eq() {
+ let empty_array: Any = Array::default().to_any();
+ let string_array: Any = [string_any("0")].to_array().to_any();
+ let object: Any = [(string_key("0"), string_any("0"))].to_object().to_any();
+ check_eq::("nullByNull", Nullish::Null.to_any(), Nullish::Null.to_any(), true);
+ check_eq::("undefinedByUndefined", Nullish::Undefined.to_any(), Nullish::Undefined.to_any(), true);
+ check_eq::("nullByUndefined", Nullish::Null.to_any(), Nullish::Undefined.to_any(), false);
+ check_eq::("trueByTrue", true.to_any(), true.to_any(), true);
+ check_eq::("falseByFalse", false.to_any(), false.to_any(), true);
+ check_eq::("trueByFalse", true.to_any(), false.to_any(), false);
+ check_eq::("falseByUndefined", false.to_any(), Nullish::Undefined.to_any(), false);
+ check_eq::("falseByNull", false.to_any(), Nullish::Null.to_any(), false);
+ check_eq::("numberBySameNumber", (2.3f64).to_any(), (2.3f64).to_any(), true);
+ check_eq::("numberByOtherNumber", (2.3f64).to_any(), (-5.4f64).to_any(), false);
+ check_eq::("nanByNan", (f64::NAN).to_any(), (f64::NAN).to_any(), false);
+ check_eq::("zeroByNegativeZero", (0f64).to_any(), (-0f64).to_any(), true);
+ check_eq::("infinityByInfinity", (f64::INFINITY).to_any(), (f64::INFINITY).to_any(), true);
+ check_eq::("negativeInfinityByNegativeInfinity", (f64::NEG_INFINITY).to_any(), (f64::NEG_INFINITY).to_any(), true);
+ check_eq::("infinityByNegativeInfinity", (f64::INFINITY).to_any(), (f64::NEG_INFINITY).to_any(), false);
+ check_eq::("undefinedByNan", Nullish::Undefined.to_any(), (f64::NAN).to_any(), false);
+ check_eq::("undefinedByZero", Nullish::Undefined.to_any(), (0f64).to_any(), false);
+ check_eq::("stringBySameString", string_any("hello"), string_any("hello"), true);
+ check_eq::("stringByOtherString", string_any("hello"), string_any("world"), false);
+ check_eq::("zeroByStringZero", (0f64).to_any(), string_any("0"), false);
+ check_eq::("bigintBySameBigint", bigint_any(12), bigint_any(12), true);
+ check_eq::("bigintByNegatedBigint", bigint_any(12), bigint_any(-12), false);
+ check_eq::("bigintByOtherBigint", bigint_any(12), bigint_any(13), false);
+ check_eq::("twelveByStringTwelve", bigint_any(12), string_any("12"), false);
+ check_eq::("arrayByItself", empty_array.clone(), empty_array.clone(), true);
+ check_eq::("arrayByEqualArray", Array::default().to_any(), Array::default().to_any(), false);
+ check_eq::("stringArrayByItself", string_array.clone(), string_array.clone(), true);
+ check_eq::("objectByItself", object.clone(), object.clone(), true);
+ check_eq::("objectByEqualObject", object.clone(), [(string_key("0"), string_any("0"))].to_object().to_any(), false);
+}
+
+#[rustfmt::skip]
+fn unary_plus() {
+ check::("null", Any::unary_plus(Nullish::Null.to_any()), (0f64).to_any());
+ check::("undefined", Any::unary_plus(Nullish::Undefined.to_any()), (f64::NAN).to_any());
+ check::("booleanFalse", Any::unary_plus(false.to_any()), (0f64).to_any());
+ check::("booleanTrue", Any::unary_plus(true.to_any()), (1f64).to_any());
+ check::("numberZero", Any::unary_plus((0f64).to_any()), (0f64).to_any());
+ check::("numberPositive", Any::unary_plus((2.3f64).to_any()), (2.3f64).to_any());
+ check::("numberNegative", Any::unary_plus((-2.3f64).to_any()), (-2.3f64).to_any());
+ check::("numberLarge", Any::unary_plus((-239f64).to_any()), (-239f64).to_any());
+ check::("numberInfinity", Any::unary_plus((f64::INFINITY).to_any()), (f64::INFINITY).to_any());
+ check::("numberNegativeInfinity", Any::unary_plus((f64::NEG_INFINITY).to_any()), (f64::NEG_INFINITY).to_any());
+ check::("numberNan", Any::unary_plus((f64::NAN).to_any()), (f64::NAN).to_any());
+ check::("stringEmpty", Any::unary_plus(string_any("")), (0f64).to_any());
+ check::("stringZero", Any::unary_plus(string_any("0")), (0f64).to_any());
+ check::("stringNumber", Any::unary_plus(string_any("2.3")), (2.3f64).to_any());
+ check::("stringExponent", Any::unary_plus(string_any("2.3e2")), (230f64).to_any());
+ check::("stringNotANumber", Any::unary_plus(string_any("a")), (f64::NAN).to_any());
+ check::("arrayEmpty", Any::unary_plus(Array::default().to_any()), (0f64).to_any());
+ check::("arrayNumber", Any::unary_plus([(2.3f64).to_any()].to_array().to_any()), (2.3f64).to_any());
+ check::("arrayNegativeNumber", Any::unary_plus([(-0.3f64).to_any()].to_array().to_any()), (-0.3f64).to_any());
+ check::("arrayString", Any::unary_plus([string_any("-2.3")].to_array().to_any()), (-2.3f64).to_any());
+ check::("arrayPositiveString", Any::unary_plus([string_any("0.3")].to_array().to_any()), (0.3f64).to_any());
+ check::("arrayNull", Any::unary_plus([Nullish::Null.to_any()].to_array().to_any()), (0f64).to_any());
+ check::("arrayPair", Any::unary_plus([Nullish::Null.to_any(), Nullish::Null.to_any()].to_array().to_any()), (f64::NAN).to_any());
+ check::("objectEmpty", Any::unary_plus(Object::default().to_any()), (f64::NAN).to_any());
+ check::("function", Any::unary_plus(function_any()), (f64::NAN).to_any());
+ check_throws::("bigint", Any::unary_plus(bigint_any(0)));
+}
+
+#[rustfmt::skip]
+fn unary_minus() {
+ check::("null", -(Nullish::Null.to_any()), (-0f64).to_any());
+ check::("undefined", -(Nullish::Undefined.to_any()), (f64::NAN).to_any());
+ check::("booleanFalse", -(false.to_any()), (-0f64).to_any());
+ check::("booleanTrue", -(true.to_any()), (-1f64).to_any());
+ check::("numberZero", -((0f64).to_any()), (-0f64).to_any());
+ check::("numberPositive", -((2.3f64).to_any()), (-2.3f64).to_any());
+ check::("numberNegative", -((-2.3f64).to_any()), (2.3f64).to_any());
+ check::("numberLarge", -((-239f64).to_any()), (239f64).to_any());
+ check::("numberInfinity", -((f64::INFINITY).to_any()), (f64::NEG_INFINITY).to_any());
+ check::("numberNegativeInfinity", -((f64::NEG_INFINITY).to_any()), (f64::INFINITY).to_any());
+ check::("numberNan", -((f64::NAN).to_any()), (f64::NAN).to_any());
+ check::("stringEmpty", -(string_any("")), (-0f64).to_any());
+ check::("stringZero", -(string_any("0")), (-0f64).to_any());
+ check::("stringNumber", -(string_any("2.3")), (-2.3f64).to_any());
+ check::("stringExponent", -(string_any("2.3e2")), (-230f64).to_any());
+ check::("stringNotANumber", -(string_any("a")), (f64::NAN).to_any());
+ check::("arrayEmpty", -(Array::default().to_any()), (-0f64).to_any());
+ check::("arrayNumber", -([(2.3f64).to_any()].to_array().to_any()), (-2.3f64).to_any());
+ check::("arrayNegativeNumber", -([(-0.3f64).to_any()].to_array().to_any()), (0.3f64).to_any());
+ check::("arrayString", -([string_any("-2.3")].to_array().to_any()), (2.3f64).to_any());
+ check::("arrayPositiveString", -([string_any("0.3")].to_array().to_any()), (-0.3f64).to_any());
+ check::("arrayNull", -([Nullish::Null.to_any()].to_array().to_any()), (-0f64).to_any());
+ check::("arrayPair", -([Nullish::Null.to_any(), Nullish::Null.to_any()].to_array().to_any()), (f64::NAN).to_any());
+ check::("objectEmpty", -(Object::default().to_any()), (f64::NAN).to_any());
+ check::("function", -(function_any()), (f64::NAN).to_any());
+ check::("bigintPositive", -(bigint_any(1)), bigint_any(-1));
+ check::("bigintNegative", -(bigint_any(-1)), bigint_any(1));
+}
+
+#[rustfmt::skip]
+fn mul() {
+ check::("nullByNull", Nullish::Null.to_any() * Nullish::Null.to_any(), (0f64).to_any());
+ check::("nullByNullSwapped", Nullish::Null.to_any() * Nullish::Null.to_any(), (0f64).to_any());
+ check::("nullByZero", Nullish::Null.to_any() * (0f64).to_any(), (0f64).to_any());
+ check::("nullByZeroSwapped", (0f64).to_any() * Nullish::Null.to_any(), (0f64).to_any());
+ check::("undefinedByZero", Nullish::Undefined.to_any() * (0f64).to_any(), (f64::NAN).to_any());
+ check::("undefinedByZeroSwapped", (0f64).to_any() * Nullish::Undefined.to_any(), (f64::NAN).to_any());
+ check::("trueByZero", true.to_any() * (0f64).to_any(), (0f64).to_any());
+ check::("trueByZeroSwapped", (0f64).to_any() * true.to_any(), (0f64).to_any());
+ check::("trueByOne", true.to_any() * (1f64).to_any(), (1f64).to_any());
+ check::("trueByOneSwapped", (1f64).to_any() * true.to_any(), (1f64).to_any());
+ check::("trueByTen", true.to_any() * (10f64).to_any(), (10f64).to_any());
+ check::("trueByTenSwapped", (10f64).to_any() * true.to_any(), (10f64).to_any());
+ check::("falseByZero", false.to_any() * (0f64).to_any(), (0f64).to_any());
+ check::("falseByZeroSwapped", (0f64).to_any() * false.to_any(), (0f64).to_any());
+ check::("falseByOne", false.to_any() * (1f64).to_any(), (0f64).to_any());
+ check::("falseByOneSwapped", (1f64).to_any() * false.to_any(), (0f64).to_any());
+ check::("falseByTen", false.to_any() * (10f64).to_any(), (0f64).to_any());
+ check::("falseByTenSwapped", (10f64).to_any() * false.to_any(), (0f64).to_any());
+ check::("zeroByZero", (0f64).to_any() * (0f64).to_any(), (0f64).to_any());
+ check::("zeroByZeroSwapped", (0f64).to_any() * (0f64).to_any(), (0f64).to_any());
+ check::("zeroByOne", (0f64).to_any() * (1f64).to_any(), (0f64).to_any());
+ check::("zeroByOneSwapped", (1f64).to_any() * (0f64).to_any(), (0f64).to_any());
+ check::("oneByOne", (1f64).to_any() * (1f64).to_any(), (1f64).to_any());
+ check::("oneByOneSwapped", (1f64).to_any() * (1f64).to_any(), (1f64).to_any());
+ check::("oneByMinusOne", (1f64).to_any() * (-1f64).to_any(), (-1f64).to_any());
+ check::("oneByMinusOneSwapped", (-1f64).to_any() * (1f64).to_any(), (-1f64).to_any());
+ check::("oneByTen", (1f64).to_any() * (10f64).to_any(), (10f64).to_any());
+ check::("oneByTenSwapped", (10f64).to_any() * (1f64).to_any(), (10f64).to_any());
+ check::("minusOneByTen", (-1f64).to_any() * (10f64).to_any(), (-10f64).to_any());
+ check::("minusOneByTenSwapped", (10f64).to_any() * (-1f64).to_any(), (-10f64).to_any());
+ check::("tenByTen", (10f64).to_any() * (10f64).to_any(), (100f64).to_any());
+ check::("tenByTenSwapped", (10f64).to_any() * (10f64).to_any(), (100f64).to_any());
+ check::("minusTenByTen", (-10f64).to_any() * (10f64).to_any(), (-100f64).to_any());
+ check::("minusTenByTenSwapped", (10f64).to_any() * (-10f64).to_any(), (-100f64).to_any());
+ check::("bigZeroByZero", bigint_any(0) * bigint_any(0), bigint_any(0));
+ check::("bigZeroByZeroSwapped", bigint_any(0) * bigint_any(0), bigint_any(0));
+ check::("bigZeroByOne", bigint_any(0) * bigint_any(1), bigint_any(0));
+ check::("bigZeroByOneSwapped", bigint_any(1) * bigint_any(0), bigint_any(0));
+ check::("bigOneByOne", bigint_any(1) * bigint_any(1), bigint_any(1));
+ check::("bigOneByOneSwapped", bigint_any(1) * bigint_any(1), bigint_any(1));
+ check::("bigOneByMinusOne", bigint_any(1) * bigint_any(-1), bigint_any(-1));
+ check::("bigOneByMinusOneSwapped", bigint_any(-1) * bigint_any(1), bigint_any(-1));
+ check::("bigOneByTen", bigint_any(1) * bigint_any(10), bigint_any(10));
+ check::("bigOneByTenSwapped", bigint_any(10) * bigint_any(1), bigint_any(10));
+ check::("bigMinusOneByTen", bigint_any(-1) * bigint_any(10), bigint_any(-10));
+ check::("bigMinusOneByTenSwapped", bigint_any(10) * bigint_any(-1), bigint_any(-10));
+ check::("bigTenByTen", bigint_any(10) * bigint_any(10), bigint_any(100));
+ check::("bigTenByTenSwapped", bigint_any(10) * bigint_any(10), bigint_any(100));
+ check::("bigMinusTenByTen", bigint_any(-10) * bigint_any(10), bigint_any(-100));
+ check::("bigMinusTenByTenSwapped", bigint_any(10) * bigint_any(-10), bigint_any(-100));
+ check::("emptyStringByOne", string_any("") * (1f64).to_any(), (0f64).to_any());
+ check::("emptyStringByOneSwapped", (1f64).to_any() * string_any(""), (0f64).to_any());
+ check::("stringTenByOne", string_any("10") * (1f64).to_any(), (10f64).to_any());
+ check::("stringTenByOneSwapped", (1f64).to_any() * string_any("10"), (10f64).to_any());
+ check::("stringLetterByOne", string_any("a") * (1f64).to_any(), (f64::NAN).to_any());
+ check::("stringLetterByOneSwapped", (1f64).to_any() * string_any("a"), (f64::NAN).to_any());
+ check::("stringBigintByOne", string_any("1n") * (1f64).to_any(), (f64::NAN).to_any());
+ check::("stringBigintByOneSwapped", (1f64).to_any() * string_any("1n"), (f64::NAN).to_any());
+ check::("emptyArrayByOne", Array::default().to_any() * (1f64).to_any(), (0f64).to_any());
+ check::("emptyArrayByOneSwapped", (1f64).to_any() * Array::default().to_any(), (0f64).to_any());
+ check::("arrayTenByOne", [(10f64).to_any()].to_array().to_any() * (1f64).to_any(), (10f64).to_any());
+ check::("arrayTenByOneSwapped", (1f64).to_any() * [(10f64).to_any()].to_array().to_any(), (10f64).to_any());
+ check::("arrayStringTenByOne", [string_any("10")].to_array().to_any() * (1f64).to_any(), (10f64).to_any());
+ check::("arrayStringTenByOneSwapped", (1f64).to_any() * [string_any("10")].to_array().to_any(), (10f64).to_any());
+ check::("arrayPairByOne", [(0f64).to_any(), (0f64).to_any()].to_array().to_any() * (1f64).to_any(), (f64::NAN).to_any());
+ check::("arrayPairByOneSwapped", (1f64).to_any() * [(0f64).to_any(), (0f64).to_any()].to_array().to_any(), (f64::NAN).to_any());
+ check::("emptyObjectByOne", Object::default().to_any() * (1f64).to_any(), (f64::NAN).to_any());
+ check::("emptyObjectByOneSwapped", (1f64).to_any() * Object::default().to_any(), (f64::NAN).to_any());
+ check_throws::("numberByBigint", (1f64).to_any() * bigint_any(1));
+ check_throws::("numberByBigintSwapped", bigint_any(1) * (1f64).to_any());
+}
+
+#[rustfmt::skip]
+fn string_coercion() {
+ check::("number", (123f64).to_any().to_string().map(|v| v.to_any()), string_any("123"));
+ check::("negativeNumber", (-456f64).to_any().to_string().map(|v| v.to_any()), string_any("-456"));
+ check::("zero", (0f64).to_any().to_string().map(|v| v.to_any()), string_any("0"));
+ check::("negativeZero", (-0f64).to_any().to_string().map(|v| v.to_any()), string_any("0"));
+ check::("infinity", (f64::INFINITY).to_any().to_string().map(|v| v.to_any()), string_any("Infinity"));
+ check::("negativeInfinity", (f64::NEG_INFINITY).to_any().to_string().map(|v| v.to_any()), string_any("-Infinity"));
+ check::("nan", (f64::NAN).to_any().to_string().map(|v| v.to_any()), string_any("NaN"));
+ check::("booleanTrue", true.to_any().to_string().map(|v| v.to_any()), string_any("true"));
+ check::("booleanFalse", false.to_any().to_string().map(|v| v.to_any()), string_any("false"));
+ check::("null", Nullish::Null.to_any().to_string().map(|v| v.to_any()), string_any("null"));
+ check::("undefined", Nullish::Undefined.to_any().to_string().map(|v| v.to_any()), string_any("undefined"));
+ check::("string", string_any("already").to_string().map(|v| v.to_any()), string_any("already"));
+ // TODO: nanvm-lib prints bigints in hexadecimal; see nanvm-lib/todo/bigint-decimal-string-coercion.md
+ // check::("bigint", bigint_any(123).to_string().map(|v| v.to_any()), string_any("123"));
+ // TODO: nanvm-lib prints bigints in hexadecimal; see nanvm-lib/todo/bigint-decimal-string-coercion.md
+ // check::("negativeBigint", bigint_any(-456).to_string().map(|v| v.to_any()), string_any("-456"));
+ check::("emptyArray", Array::default().to_any().to_string().map(|v| v.to_any()), string_any(""));
+ check::("singletonArray", [(1f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any("1"));
+ check::("array", [(1f64).to_any(), (2f64).to_any(), (3f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any("1,2,3"));
+ check::("nestedArray", [(1f64).to_any(), [(2f64).to_any(), (3f64).to_any()].to_array().to_any(), (4f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any("1,2,3,4"));
+ check::("arrayWithNullish", [Nullish::Null.to_any(), Nullish::Undefined.to_any(), (1f64).to_any()].to_array().to_any().to_string().map(|v| v.to_any()), string_any(",,1"));
+ check::("emptyObject", Object::default().to_any().to_string().map(|v| v.to_any()), string_any("[object Object]"));
+ check::("object", [(string_key("a"), (1f64).to_any())].to_object().to_any().to_string().map(|v| v.to_any()), string_any("[object Object]"));
+}
+
+pub fn all() {
+ eq::();
+ unary_plus::();
+ unary_minus::();
+ mul::();
+ string_coercion::();
+}
diff --git a/nanvm-lib/tests/test/harness.rs b/nanvm-lib/tests/test/harness.rs
new file mode 100644
index 0000000000..6dfd82db9c
--- /dev/null
+++ b/nanvm-lib/tests/test/harness.rs
@@ -0,0 +1,75 @@
+//! Hand-written support for the generated operator tests.
+//!
+//! `generated.rs` contains one statement per case and nothing else; every
+//! value constructor and every assertion it uses lives here, so the printer in
+//! `fjs/nanvm/rust/module.f.mjs` only has to name them. Re-exports at the top are
+//! what the generated file's `use super::harness::*;` pulls in.
+
+pub use nanvm_lib::vm::{Any, Array, IVm, Nullish, Object, ToAny, ToArray, ToObject};
+
+use nanvm_lib::vm::{BigInt, Function, IContainer, String, Unpacked};
+
+/// An `Any` holding the string `v`.
+pub fn string_any(v: &str) -> Any {
+ v.into()
+}
+
+/// An object property key.
+pub fn string_key(v: &str) -> String {
+ v.into()
+}
+
+/// An `Any` holding the bigint `v`.
+pub fn bigint_any(v: i64) -> Any {
+ Into::>::into(v).to_any()
+}
+
+/// An `Any` holding a function.
+///
+/// Which function does not matter: every operator covered by the shared data
+/// coerces a function through `ToPrimitive`, which never inspects its body.
+pub fn function_any() -> Any {
+ Function::(A::InternalFunction::new_ok(("".into(), 0), [0])).to_any()
+}
+
+/// `Object.is`, the comparison the shared data's expectations are written in:
+/// `NaN` matches `NaN`, and `0` does not match `-0`.
+///
+/// `==` on `Any` is JavaScript's `===`, which gets both of those backwards, so
+/// numbers are compared by their bits instead.
+fn same(a: &Any, b: &Any) -> bool {
+ match (a.clone().into(), b.clone().into()) {
+ (Unpacked::Number(x), Unpacked::Number(y)) => {
+ if x.is_nan() || y.is_nan() {
+ x.is_nan() && y.is_nan()
+ } else {
+ x.to_bits() == y.to_bits()
+ }
+ }
+ _ => a == b,
+ }
+}
+
+/// Checks that an operator returned `expected`.
+pub fn check(case: &str, result: Result, Any>, expected: Any) {
+ match result {
+ Ok(v) => assert!(same(&v, &expected), "{case}: {v:?} is not {expected:?}"),
+ Err(e) => panic!("{case}: unexpected throw: {e:?}"),
+ }
+}
+
+/// Checks that an operator threw.
+///
+/// The thrown value is engine-specific, so the shared data does not describe
+/// it and nothing here asserts on it.
+pub fn check_throws(case: &str, result: Result, Any>) {
+ if let Ok(v) = result {
+ panic!("{case}: expected a throw, got {v:?}");
+ }
+}
+
+/// Checks strict equality (`===`) both ways round.
+pub fn check_eq(case: &str, a: Any, b: Any, expected: bool) {
+ assert_eq!(a == b, expected, "{case}");
+ assert_eq!(b == a, expected, "{case} reversed");
+}
diff --git a/nanvm-lib/tests/test/main.rs b/nanvm-lib/tests/test/main.rs
new file mode 100644
index 0000000000..8ccd0b208a
--- /dev/null
+++ b/nanvm-lib/tests/test/main.rs
@@ -0,0 +1,253 @@
+//! Tests with no JavaScript counterpart.
+//!
+//! Operator behaviour is *not* tested here. It is described once, as data, in
+//! `fjs/nanvm/module.f.mjs`, and reaches this crate as `generated.rs`
+//! (see `nanvm-lib/tests/README.md`). What stays hand-written is everything
+//! that has nothing to compare against in a JS engine: conversions out of
+//! `Any`, `Debug` formatting, bigint limb arithmetic, and serialization.
+
+mod generated;
+mod harness;
+
+use nanvm_lib::{
+ common::{default::default, iter::Iter, serializable::Serializable},
+ naive,
+ sign::Sign,
+ vm::{
+ Any, Array, BigInt, Function, IContainer, IVm, Nullish, Object, Property, String, ToAny,
+ ToArray, ToObject, Unpacked,
+ },
+};
+
+/// `try_into` out of `Any`, for each type that supports it.
+fn conversions() {
+ let n: Any = Nullish::Null.to_any();
+ let n: Nullish = n.try_into().unwrap();
+ assert_eq!(n, Nullish::Null);
+
+ let t: Any = true.to_any();
+ let t: bool = t.try_into().unwrap();
+ assert!(t);
+
+ let s: Any = "Hello".into();
+ let s: String = s.try_into().unwrap();
+ assert_eq!(s, String::from("Hello"));
+
+ let nan: Any = f64::NAN.to_any();
+ let nan: f64 = nan.try_into().unwrap();
+ assert!(nan.is_nan());
+
+ let nz: Any = (-0.0).to_any();
+ let nz: f64 = nz.try_into().unwrap();
+ assert_eq!(format!("{nz}"), "-0");
+ assert_eq!(1.0 / nz, -f64::INFINITY);
+
+ // The generated tests compare numbers with `Object.is` semantics, which
+ // `harness::same` implements with `to_bits`; this is the property that
+ // makes that work.
+ assert_eq!((-0f64).to_bits(), (-0f64).to_bits());
+ assert_ne!(0f64.to_bits(), (-0f64).to_bits());
+}
+
+fn debug_format() {
+ let s: Any = "Hello".into();
+ let s: String = s.try_into().unwrap();
+ assert_eq!(format!("{s:?}"), "\"Hello\"");
+
+ let o: Any = Object::default().to_any();
+ let o: Object = o.try_into().unwrap();
+ assert_eq!(format!("{o:?}"), "{}");
+
+ let a: Any = Array::default().to_any();
+ let a: Array = a.try_into().unwrap();
+ assert_eq!(format!("{a:?}"), "[]");
+
+ let b: Any = BigInt::default().to_any();
+ let b: BigInt = b.try_into().unwrap();
+ assert_eq!(b, default());
+ assert_eq!(format!("{b:?}"), "0n");
+}
+
+/// `Debug` for bigints at the edges of the one-limb range.
+fn bigint_debug_format() {
+ {
+ let bm: BigInt = i64::MIN.into();
+ let x = format!("{bm:?}");
+ // 0123456789ABCDEF
+ assert_eq!(x, "-0x8000000000000000n");
+ let i: i64 = i64::MIN;
+ let m = i.overflowing_neg().0 as u64;
+ assert_eq!(m, 0x8000000000000000);
+ }
+
+ {
+ let bm: BigInt = (i64::MIN + 1).into();
+ let x = format!("{bm:?}");
+ // 0123456789ABCDEF
+ assert_eq!(x, "-0x7FFFFFFFFFFFFFFFn");
+ let i: i64 = i64::MIN + 1;
+ let m = i.overflowing_neg().0 as u64;
+ assert_eq!(m, 0x7FFFFFFFFFFFFFFF);
+ }
+
+ {
+ let bm: BigInt = i64::MAX.into();
+ let x = format!("{bm:?}");
+ // 0123456789ABCDEF
+ assert_eq!(x, "0x7FFFFFFFFFFFFFFFn");
+ }
+
+ {
+ let bm: BigInt = u64::MAX.into();
+ let x = format!("{bm:?}");
+ // 0123456789ABCDEF
+ assert_eq!(x, "0xFFFFFFFFFFFFFFFFn");
+ }
+
+ {
+ let bm: BigInt = 0u64.into();
+ let x = format!("{bm:?}");
+ assert_eq!(x, "0n");
+ }
+
+ {
+ let bm: BigInt = 0i64.into();
+ let x = format!("{bm:?}");
+ assert_eq!(x, "0n");
+ }
+}
+
+fn eq_container(a: T, b: T, e: fn(a: &T::Item, &T::Item) -> bool) -> bool {
+ a.into_iter().eq_by_(b.into_iter(), e)
+}
+
+/// Structural equality, which `==` on `Any` deliberately is not: containers
+/// compare by reference there, so a round-tripped value never equals its
+/// original.
+fn eq_value(a: &Any, b: &Any) -> bool {
+ match (a.clone().into(), b.clone().into()) {
+ (Unpacked::Nullish(a), Unpacked::Nullish(b)) => a == b,
+ (Unpacked::Boolean(a), Unpacked::Boolean(b)) => a == b,
+ (Unpacked::Number(a), Unpacked::Number(b)) => a.to_bits() == b.to_bits(),
+ (Unpacked::String(a), Unpacked::String(b)) => a == b,
+ (Unpacked::BigInt(a), Unpacked::BigInt(b)) => a == b,
+ (Unpacked::Array(a), Unpacked::Array(b)) => eq_container(a, b, eq_value),
+ (Unpacked::Object(a), Unpacked::Object(b)) => {
+ eq_container(a, b, |x: &Property, y: &Property| {
+ x.0 == y.0 && eq_value(&x.1, &y.1)
+ })
+ }
+ _ => false,
+ }
+}
+
+fn serialization() {
+ use std::io::Cursor;
+
+ let values: &[Any] = &[
+ Nullish::Null.to_any(),
+ Nullish::Undefined.to_any(),
+ true.to_any(),
+ false.to_any(),
+ 2.3.to_any(),
+ "Hello".into(),
+ Into::>::into(12u64).to_any(),
+ Array::default().to_any(),
+ [7.0.to_any()].to_array().to_any(),
+ [("a".into(), 1.0.to_any()), ("b".into(), "c".into())]
+ .to_object()
+ .to_any(),
+ ];
+
+ for value in values.iter() {
+ let mut buf = Vec::new();
+ value.clone().serialize(&mut buf).unwrap();
+ let mut cursor = Cursor::new(buf);
+ let result = Any::deserialize(&mut cursor).unwrap();
+ assert!(eq_value(value, &result));
+ }
+}
+
+fn format_fn() {
+ let f = Function::(A::InternalFunction::new_ok(
+ ("myfunc".into(), 2),
+ [0xDE, 0xAD, 0xBE, 0xEF],
+ ));
+ let x = format!("{f:?}");
+ assert_eq!(x, "function myfunc(a0,a1) {DEADBEEF}");
+}
+
+/// The generated `unary_plus` case only asserts *that* `+0n` throws; the
+/// message is `nanvm-lib`'s own, so it is pinned here.
+fn unary_plus_bigint_message() {
+ let b: Any = BigInt::default().to_any();
+ assert_eq!(
+ Any::unary_plus(b),
+ Err("TypeError: Cannot convert a BigInt value to a number".into())
+ );
+}
+
+fn bigint_add() {
+ let n0: Any = BigInt::default().to_any();
+ assert_eq!((n0.clone() + n0.clone()), n0);
+ let n2: Any = BigInt::from(2u64).to_any();
+ let n4: Any = BigInt::from(4u64).to_any();
+ assert_eq!((n0.clone() + n2.clone()), n2);
+ assert_eq!((n2.clone() + n4.clone()), BigInt::from(6u64).to_any());
+}
+
+/// Multi-limb multiplication, which the shared data cannot reach: its bigints
+/// all fit in an `i64`.
+fn bigint_mul() {
+ let n0: Any = BigInt::default().to_any();
+ let n1: Any = BigInt::from(1u64).to_any();
+ assert_eq!((n1.clone() * n0.clone()).unwrap(), n0);
+ assert_eq!((n0.clone() * n1.clone()).unwrap(), n0);
+
+ let n_minus1: Any = BigInt::from(-1i64).to_any();
+ assert_eq!((n_minus1.clone() * n0.clone()).unwrap(), n0);
+ assert_eq!((n0.clone() * n_minus1.clone()).unwrap(), n0);
+ assert_eq!((n_minus1.clone() * n_minus1.clone()).unwrap(), n1);
+
+ let a: Any = BigInt::normalize_new(Sign::Positive, [1, 2, 3, 4]).to_any();
+ let b: Any = BigInt::normalize_new(Sign::Positive, [5, 6, 7]).to_any();
+ let expected: Any = BigInt::normalize_new(Sign::Positive, [5, 16, 34, 52, 45, 28]).to_any();
+ assert_eq!((a.clone() * b.clone()).unwrap(), expected);
+ assert_eq!((b.clone() * a.clone()).unwrap(), expected);
+
+ let a: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX]).to_any();
+ let expected: Any = BigInt::normalize_new(Sign::Positive, [1, u64::MAX - 1]).to_any();
+ assert_eq!((a.clone() * a.clone()).unwrap(), expected);
+
+ let b: Any = BigInt::normalize_new(Sign::Negative, [u64::MAX, u64::MAX, u64::MAX]).to_any();
+ let expected: Any =
+ BigInt::normalize_new(Sign::Positive, [1, u64::MAX, u64::MAX, u64::MAX - 1]).to_any();
+ assert_eq!((a.clone() * b.clone()).unwrap(), expected);
+ assert_eq!((b.clone() * a.clone()).unwrap(), expected);
+}
+
+/// A `nanvm-lib` normalization invariant: there is no negative zero bigint.
+fn bigint_negative_zero() {
+ let mn0: BigInt = BigInt::normalize_new(Sign::Negative, []);
+ let n0: BigInt = BigInt::default();
+ assert_eq!(mn0, n0);
+}
+
+fn gen_test() {
+ generated::all::();
+ //
+ conversions::();
+ debug_format::();
+ bigint_debug_format::();
+ serialization::();
+ unary_plus_bigint_message::();
+ bigint_add::();
+ bigint_mul::();
+ bigint_negative_zero::();
+ format_fn::();
+}
+
+#[test]
+fn test() {
+ gen_test::();
+}
diff --git a/nanvm-lib/todo/bigint-decimal-string-coercion.md b/nanvm-lib/todo/bigint-decimal-string-coercion.md
new file mode 100644
index 0000000000..cd79016dc8
--- /dev/null
+++ b/nanvm-lib/todo/bigint-decimal-string-coercion.md
@@ -0,0 +1,54 @@
+## Coerce bigints to decimal strings
+
+**Priority:** P3
+**Status:** open
+
+### Problem
+
+`StringCoercion::bigint` (`src/vm/string_coercion.rs`) formats through `Debug`:
+
+```rust
+fn bigint(self, v: BigInt) -> Self::Result {
+ // TODO: we should use different algorithm for large numbers.
+ to_result(&format!("{v:?}"))
+}
+```
+
+`Debug` for `BigInt` prints hexadecimal with an `n` suffix, so `String(123n)`
+returns `"0x7Bn"` where JavaScript returns `"123"`, and `String(-456n)` returns
+`"-0x1C8n"` instead of `"-456"`. `ToString` on a bigint is
+[decimal by specification](https://tc39.es/ecma262/#sec-numeric-types-bigint-tostring)
+unless an explicit radix is passed to `BigInt.prototype.toString`.
+
+Two cases in the shared operator test data
+([`fjs/nanvm/module.f.mjs`](../../fjs/nanvm/module.f.mjs), `stringCoercion`)
+carry a `rust` reason pointing here and are therefore commented out in
+`tests/test/generated.rs`. Deleting those two `rust` reasons and regenerating
+is the acceptance test for this issue.
+
+### Proposal
+
+Convert the limb vector to decimal digits: repeatedly divide the magnitude by
+the largest power of ten that fits in a limb (`10^19` for `u64`), emitting 19
+digits per step and zero-padding all but the most significant group, then
+prefix `-` for a negative sign. That is O(n²) in the number of limbs, which is
+the same complexity the existing `Debug` path has and is fine at the sizes the
+VM sees today; a divide-and-conquer split is a later optimization, not a
+blocker.
+
+`Debug` keeps its hexadecimal form — it is a developer-facing dump of the limb
+representation, and the bigint formatting tests in
+[`tests/test/main.rs`](../tests/test/main.rs) pin it deliberately.
+
+### Tasks
+
+- [ ] Add decimal conversion for `BigInt`.
+- [ ] Use it from `StringCoercion::bigint`.
+- [ ] Remove the two `rust` reasons from `stringCoercion` in the shared test
+ data and regenerate `tests/test/generated.rs`.
+
+### Related
+
+- [`nanvm-lib/tests/README.md`](../tests/README.md) — how the shared operator
+ test data records divergences like this one.
+- [mvp-roadmap](./mvp-roadmap.md) — the operators task this belongs to.
diff --git a/nanvm-lib/todo/bigint-operator-test-scaffolding.md b/nanvm-lib/todo/bigint-operator-test-scaffolding.md
index e306fce821..5d439bb7d2 100644
--- a/nanvm-lib/todo/bigint-operator-test-scaffolding.md
+++ b/nanvm-lib/todo/bigint-operator-test-scaffolding.md
@@ -54,6 +54,7 @@ Either way, delete the per-file comment clones.
### Related
-- [single-source-of-truth-for-operator-tests.md](./single-source-of-truth-for-operator-tests.md)
- — cross-language (JS proof vs Rust test) duplication; this issue is the
- intra-Rust helper duplication, a different site and mechanism.
+- [`nanvm-lib/tests/README.md`](../tests/README.md) — the shared operator test
+ data, which removed the cross-language (JS proof vs Rust test) duplication;
+ this issue is the intra-Rust helper duplication, a different site and
+ mechanism.
diff --git a/nanvm-lib/todo/mvp-roadmap.md b/nanvm-lib/todo/mvp-roadmap.md
index 8c956aa9ad..b937ea8068 100644
--- a/nanvm-lib/todo/mvp-roadmap.md
+++ b/nanvm-lib/todo/mvp-roadmap.md
@@ -286,15 +286,17 @@ as a generic `Any` facility, post-MVP.
fixture may use `.f.mjs`; it does not define the repository extension
contract. See
[fjs-nanvm-integration](../../todo/fjs-nanvm-integration.md).
-- [ ] **Test generation for operators** — one test-data module drives both
- the FJS proof (JS engine reference) and the generated Rust tests.
- Implement **before** the operators task below, so every new operator is
- tested once, not twice. Doubly important now: the shared operator layer
- is what keeps the interpreter and the generated code in agreement. See
- [single-source-of-truth-for-operator-tests](./single-source-of-truth-for-operator-tests.md).
+- [x] **Test generation for operators** — one test-data module drives both
+ the FJS proof (JS engine reference) and the generated Rust tests, so
+ every new operator is tested once, not twice. Doubly important now: the
+ shared operator layer is what keeps the interpreter and the generated
+ code in agreement. See
+ [`nanvm-lib/tests/README.md`](../tests/README.md).
- [ ] **Complete all basic FunctionalScript operators** (Rust), including the
short-circuit operators `&&`, `||`, `??` (lazy evaluation, like `?:`).
- Preceded by the test-generation task above.
+ Each operator arrives as cases in
+ [`fjs/nanvm/module.f.mjs`](../../fjs/nanvm/module.f.mjs), which is what
+ tests it on both sides.
Current status: [operator tables in `nanvm-lib/README.md`](../README.md).
Spec: [operators](../../todo/lang/2340-operators.md).
- [ ] **Parser**, using [`fjs/bnf/`](../../fjs/bnf/README.md) (FJS).
@@ -388,6 +390,6 @@ compiler-compatibility migration rather than a separate rewrite.
walking-skeleton integration: the `.rs` output target and the harness.
- [console-program](./console-program.md) — the self-hosted `nanvm` crate
(post-MVP).
-- [single-source-of-truth-for-operator-tests](./single-source-of-truth-for-operator-tests.md)
- — test generation preceding the operators task.
+- [`nanvm-lib/tests/README.md`](../tests/README.md) — the shared operator test
+ data driving both the FJS proof and the generated Rust tests.
- [fs-vm-load-save](./fs-vm-load-save.md) — load/execute/save semantics.
diff --git a/nanvm-lib/todo/single-source-of-truth-for-operator-tests.md b/nanvm-lib/todo/single-source-of-truth-for-operator-tests.md
deleted file mode 100644
index c926ef53fc..0000000000
--- a/nanvm-lib/todo/single-source-of-truth-for-operator-tests.md
+++ /dev/null
@@ -1,31 +0,0 @@
-## Single source of truth for operator tests
-
-**Priority:** P1
-**Status:** open
-
-Part of the [MVP roadmap](./mvp-roadmap.md): implement before completing the
-basic operators, so every new operator is tested once (in the shared test
-data) instead of twice (FJS proof + Rust tests written by hand).
-
-### Problem
-
-Operator tests are written twice:
-
-- [`tests/proof.f.ts`](tests/proof.f.ts) — runs against a standard JS engine to prove JS semantics.
-- [`tests/test.rs`](tests/test.rs) — runs the same operations against `nanvm-lib`.
-
-The two files diverge over time (mismatches documented in [`tests/README.md`](tests/README.md)). Every new operator requires updating both files manually.
-
-### Proposal
-
-Introduce `tests/module.f.ts` as the single source of truth — pure data describing inputs and expected outputs for each operator.
-
-1. **JS proof** — a thin `proof.f.ts` imports the test data and runs each case through native JS operators.
-2. **Rust tests** — a small Node/Deno script (`gen-tests.ts`) reads `module.f.ts` and writes `test.rs`, wired into `npm run update`.
-
-### Tasks
-
-- [ ] Design the test case data schema in `tests/module.f.ts`.
-- [ ] Migrate `eq`, `unary_plus`, `unary_minus`, `stringCoercion`, `mul` test cases.
-- [ ] Write `proof.f.ts` as a thin consumer of `module.f.ts`.
-- [ ] Write `gen-tests.ts` to emit `test.rs` from `module.f.ts`.
diff --git a/package.json b/package.json
index cfd6266cdb..6677d25268 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
"test": "tsc && node ./fjs/module.ts t",
"cov": "node --test --experimental-test-coverage --test-coverage-include=**/module.f.ts --test-coverage-include=**/module.f.mjs",
"start": "node ./fjs/module.ts",
- "ci-update": "node ./fjs/module.ts ci",
+ "ci-update": "node ./fjs/module.ts ci && node ./fjs/module.ts r ./fjs/nanvm/update/module.f.mjs",
"dev-update": "node ./fjs/module.ts r ./fjs/dev/update/module.f.mjs",
"update": "npm run ci-update && npm install && deno install && bun install",
"index-html": "node ./fjs/module.ts r ./fjs/website/module.f.mjs",
diff --git a/todo/camel-case-proof-keys.md b/todo/camel-case-proof-keys.md
index 2bcc9b5376..d2ed902ddd 100644
--- a/todo/camel-case-proof-keys.md
+++ b/todo/camel-case-proof-keys.md
@@ -1,4 +1,4 @@
-## camel-case-proof-keys. 48 `proof` test keys are snake_case
+## camel-case-proof-keys. 42 `proof` test keys are snake_case
**Priority:** P4
**Status:** open
@@ -7,13 +7,12 @@
A `proof` object's keys are the test names the runner prints
(`proof.historyStep.overDo()`), and the repository writes identifiers in
-camelCase everywhere else. 48 keys across 7 files are snake_case instead:
+camelCase everywhere else. 42 keys across 6 files are snake_case instead:
| file | count |
| --- | --- |
| `fjs/sul/id/proof.f.mjs` | 18 |
| `fjs/sul/level/hash/proof.f.mjs` | 11 |
-| `nanvm-lib/tests/proof.f.ts` | 6 |
| `fjs/sul/proof.f.mjs` | 5 |
| `fjs/types/bit_vec/proof.f.ts` | 4 |
| `fjs/types/prime_field/proof.f.mjs` | 3 |
@@ -43,7 +42,7 @@ everywhere at once.
### Tasks
-- [ ] Rename the keys in the seven files above.
+- [ ] Rename the keys in the six files above.
- [ ] Add the convention to `AGENTS.md`, noting the keyword/export exception.
- [ ] `npx tsc` clean; `fjs t` passes (test names change, counts do not).