Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions fjs/ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions fjs/media/rust/module.f.mjs
Original file line number Diff line number Diff line change
@@ -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 <input>.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('')
50 changes: 50 additions & 0 deletions fjs/media/rust/proof.f.mjs
Original file line number Diff line number Diff line change
@@ -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'),
},
}
91 changes: 91 additions & 0 deletions fjs/nanvm/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading