nandi/rustnimpublic Fork 0
12c0a01b02392b18af24b583ba4ff65ad040e86a
Commits
Clone
git clone https://git.rickub.com/nandi/rustnim.git
git clone ssh://git@rickub.com/nandi/rustnim.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Lower the `log` facade, and add explicit enum discriminants 12c0a01 · on 12c0a01b02392b18af24b583ba4ff65ad040e86a · nandithebull · 3h ago
README.md · 147 lines · 6.5 KBmarkdown
Blame HistoryOpen raw

rustnim

A Rust → Nim transpiler. It parses Rust with syn and emits Nim source that
behaves the same way — same stdout, same exit status — when both are built and
run.

The rule the whole thing is organised around: never approximate a semantic
you cannot represent.
If a construct doesn't map, rustnim says so and exits
non-zero. It never writes a file it can't stand behind.

That rule comes from a real failure. We tried an existing transpiler that
advertises Rust support on the base16ct crate; it emitted an empty file and
exited 0. The write-up is in findings/.

Build and run

cargo build --release
./target/release/rustnim input.rs -o out.nim
nim c out.nim

Several inputs are concatenated into one Nim module, in the order given — Nim
has no per-file mod, so items are flattened and names must not collide:

rustnim src/lib.rs src/error.rs --cfg feature=alloc -o crate.nim

What works

Functions and impl methods, structs, enums (C-like and data-carrying),
Option/Result with ?, generics, saturating_*/checked_*,
let/let mut, the integer and float operators at
exact widths, as casts, if/while/loop/for, match including binding
patterns, Vec/slices/arrays, type aliases, function-typed parameters
(impl Fn(A) -> B), multi-file input, #[cfg], and println!/format! with
{}, {:?}, {:x}, {:b}, positional and inline-named arguments, and
zero/space padding.

Also: the formatting trait impls (Display, Debug, LowerHex, UpperHex,
Binary, Octal) and From; and slice iterators — iter, iter_mut,
enumerate, zip, chunks_exact, chunks_exact_mut, windows — resolved
into a single index loop where each binding is an lvalue into the original
container, so *d = v through iter_mut() reaches the caller's slice.
Borrowed slices are views, not copies, including as return types.

Closures and unsafe blocks, &str as a borrowed view rather than an owned
copy, formatting impls whose fmt body writes repeatedly, and the
assert!/assert_eq!/debug_assert* family. Modules: pass the crate root first and each further file after it,
and items are scoped by module, so lower::decode and mixed::decode stay
distinct.

Rejected with a reason, rather than guessed at: i128/u128, generics,
move closures, trait impls other than the ones above, iterator adaptors with
no index-loop equivalent (map, filter, take_while), float→int casts, and
any standard-library method that isn't mapped.

base16ct

It goes through. tests/cases/026-base16ct-crate/ transpiles every source
file of base16ct 1.0.0 — error.rs, lower.rs, upper.rs, mixed.rs,
display.rs — byte-for-byte as published on crates.io, plus lib.rs's
decoded_len, encoded_len and decode_inner verbatim, with the alloc
half enabled. Decoding and encoding are byte-identical to rustc's, including
encode_str (a closure over an unsafe block returning a borrowed &str
view of the bytes just written) and HexDisplay, whose UpperHex impl writes
once per byte into the formatter.

That is the crate the transpiler in findings/ emitted an empty
file for, while exiting 0.

How strong is "byte-identical"? PROOF.md answers that precisely:
exhaustive over every two-byte decode input, every two-byte encode input and
every single byte through encode_str/HexDisplay, plus a compositional
argument for longer inputs and 20,000 pseudorandom cases attacking it.
cargo test --test proof runs it — 151,463 cases, 9.1 MB of output, compared
byte for byte.

bitflags! and log

bitflags is the most-depended-on translatable crate in libcosmic's tree (79
of 741), and it is 26 macro_rules! definitions. Expanding the macro does not
help — the expansion still calls into the crate's own macro-defined runtime.
So the macro is lowered directly, and checked against the real crate: the test
links bitflags for the oracle only, and rustnim has to reproduce its
behaviour without it, byte for byte.

log (61 dependents) is the same shape — 20 macro_rules! — and gets the
same treatment. rustnim models log's emitting side: a transpiled library's
info! calls work and, with no logger installed, do nothing, exactly as in
Rust. Installing a logger is done from Nim with rsLogSetLogger.

Does it generalise?

base16ct is the crate this was built toward, so a second one was tried.
adler2 2.0.1 — a stateful checksum with operator-overload trait impls and a
hand-unrolled loop, structurally nothing like base16ct — works, and is
byte-identical across every single byte, every length to 600, and 144
incremental-write splits. It needed ten new features, and it caught a
regression that 33 passing cases had not.

cosmic-theme's spacing scale, corner radii and density model also transpile
byte-identically — the part of it that is not built on palette.

A 400-crate survey says something worth knowing: clearing the single most
common blocker has twice moved the number of fully-working crates by zero,
because each unblocked crate just hits its next one. Blockers are deep, not
wide, and a frequency ranking of first blockers is not a roadmap.
DESIGN.md has the tables.

Tests

cargo test

The tests are differential, with rustc as the oracle. Each case in
tests/cases/ is compiled by rustc and run, transpiled and compiled by Nim and
run, and the two stdouts and exit statuses must match. Building anything less
than that — checking the Nim output "looks right", say — is how you end up
shipping the bug in findings/. rustnim exiting 0 with a missing or empty
output file is a hard failure, and so is an empty corpus.

rustc is invoked without -O so debug-profile overflow checks are on, which is
the matching pair for nim c's defaults.

Nim is found at .nim-toolchain/bin/nim in the repo root or any parent, or via
RUSTNIM_NIM. Run a single case with:

RUSTNIM_CASE=005 cargo test --test differential -- --nocapture

Layout

file role
src/ty.rs Rust type → Nim type, exact widths, explicit rejections
src/lower.rs items, statements, expressions → Nim
src/fmt.rs println!/format! format strings
src/prelude.nim Option/Result, panic, Display/Debug runtime
tests/differential.rs the runner above

DESIGN.md has the reasoning: how each construct is mapped, what
was settled by running both compilers rather than by reading docs, and what's
still open.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# rustnim

A Rust → Nim transpiler. It parses Rust with `syn` and emits Nim source that
behaves the same way — same stdout, same exit status — when both are built and
run.

The rule the whole thing is organised around: **never approximate a semantic
you cannot represent.** If a construct doesn't map, `rustnim` says so and exits
non-zero. It never writes a file it can't stand behind.

That rule comes from a real failure. We tried an existing transpiler that
advertises Rust support on the `base16ct` crate; it emitted an empty file and
exited 0. The write-up is in [`findings/`](findings/).

## Build and run

```bash
cargo build --release
./target/release/rustnim input.rs -o out.nim
nim c out.nim
```

Several inputs are concatenated into one Nim module, in the order given — Nim
has no per-file `mod`, so items are flattened and names must not collide:

```bash
rustnim src/lib.rs src/error.rs --cfg feature=alloc -o crate.nim
```

## What works

Functions and `impl` methods, structs, enums (C-like and data-carrying),
`Option`/`Result` with `?`, generics, `saturating_*`/`checked_*`,
`let`/`let mut`, the integer and float operators at
exact widths, `as` casts, `if`/`while`/`loop`/`for`, `match` including binding
patterns, `Vec`/slices/arrays, type aliases, function-typed parameters
(`impl Fn(A) -> B`), multi-file input, `#[cfg]`, and `println!`/`format!` with
`{}`, `{:?}`, `{:x}`, `{:b}`, positional and inline-named arguments, and
zero/space padding.

Also: the formatting trait impls (`Display`, `Debug`, `LowerHex`, `UpperHex`,
`Binary`, `Octal`) and `From`; and slice iterators — `iter`, `iter_mut`,
`enumerate`, `zip`, `chunks_exact`, `chunks_exact_mut`, `windows` — resolved
into a single index loop where each binding is an lvalue into the original
container, so `*d = v` through `iter_mut()` reaches the caller's slice.
Borrowed slices are views, not copies, including as return types.

Closures and `unsafe` blocks, `&str` as a borrowed view rather than an owned
copy, formatting impls whose `fmt` body writes repeatedly, and the
`assert!`/`assert_eq!`/`debug_assert*` family. Modules: pass the crate root first and each further file after it,
and items are scoped by module, so `lower::decode` and `mixed::decode` stay
distinct.

Rejected with a reason, rather than guessed at: `i128`/`u128`, generics,
`move` closures, trait impls other than the ones above, iterator adaptors with
no index-loop equivalent (`map`, `filter`, `take_while`), float→int casts, and
any standard-library method that isn't mapped.

## base16ct

**It goes through.** `tests/cases/026-base16ct-crate/` transpiles every source
file of base16ct 1.0.0 — `error.rs`, `lower.rs`, `upper.rs`, `mixed.rs`,
`display.rs` — byte-for-byte as published on crates.io, plus `lib.rs`'s
`decoded_len`, `encoded_len` and `decode_inner` verbatim, with the `alloc`
half enabled. Decoding and encoding are byte-identical to rustc's, including
`encode_str` (a closure over an `unsafe` block returning a borrowed `&str`
view of the bytes just written) and `HexDisplay`, whose `UpperHex` impl writes
once per byte into the formatter.

That is the crate the transpiler in [`findings/`](findings/) emitted an empty
file for, while exiting 0.

How strong is "byte-identical"? [`PROOF.md`](PROOF.md) answers that precisely:
exhaustive over every two-byte decode input, every two-byte encode input and
every single byte through `encode_str`/`HexDisplay`, plus a compositional
argument for longer inputs and 20,000 pseudorandom cases attacking it.
`cargo test --test proof` runs it — 151,463 cases, 9.1 MB of output, compared
byte for byte.

## `bitflags!` and `log`

`bitflags` is the most-depended-on translatable crate in libcosmic's tree (79
of 741), and it is 26 `macro_rules!` definitions. Expanding the macro does not
help — the expansion still calls into the crate's own macro-defined runtime.
So the macro is lowered directly, and checked against the real crate: the test
links bitflags for the *oracle only*, and rustnim has to reproduce its
behaviour without it, byte for byte.

`log` (61 dependents) is the same shape — 20 `macro_rules!` — and gets the
same treatment. rustnim models log's *emitting* side: a transpiled library's
`info!` calls work and, with no logger installed, do nothing, exactly as in
Rust. Installing a logger is done from Nim with `rsLogSetLogger`.

## Does it generalise?

`base16ct` is the crate this was built toward, so a second one was tried.
`adler2` 2.0.1 — a stateful checksum with operator-overload trait impls and a
hand-unrolled loop, structurally nothing like `base16ct` — works, and is
byte-identical across every single byte, every length to 600, and 144
incremental-write splits. It needed ten new features, and it caught a
regression that 33 passing cases had not.

`cosmic-theme`'s spacing scale, corner radii and density model also transpile
byte-identically — the part of it that is not built on `palette`.

A 400-crate survey says something worth knowing: clearing the single most
common blocker has twice moved the number of fully-working crates by *zero*,
because each unblocked crate just hits its next one. Blockers are deep, not
wide, and a frequency ranking of first blockers is not a roadmap.
[`DESIGN.md`](DESIGN.md) has the tables.

## Tests

```bash
cargo test
```

The tests are differential, with rustc as the oracle. Each case in
`tests/cases/` is compiled by rustc and run, transpiled and compiled by Nim and
run, and the two stdouts and exit statuses must match. Building anything less
than that — checking the Nim output "looks right", say — is how you end up
shipping the bug in `findings/`. `rustnim` exiting 0 with a missing or empty
output file is a hard failure, and so is an empty corpus.

rustc is invoked without `-O` so debug-profile overflow checks are on, which is
the matching pair for `nim c`'s defaults.

Nim is found at `.nim-toolchain/bin/nim` in the repo root or any parent, or via
`RUSTNIM_NIM`. Run a single case with:

```bash
RUSTNIM_CASE=005 cargo test --test differential -- --nocapture
```

## Layout

| file | role |
|---|---|
| `src/ty.rs` | Rust type → Nim type, exact widths, explicit rejections |
| `src/lower.rs` | items, statements, expressions → Nim |
| `src/fmt.rs` | `println!`/`format!` format strings |
| `src/prelude.nim` | `Option`/`Result`, panic, `Display`/`Debug` runtime |
| `tests/differential.rs` | the runner above |

[`DESIGN.md`](DESIGN.md) has the reasoning: how each construct is mapped, what
was settled by running both compilers rather than by reading docs, and what's
still open.