| Scaffold rustnim: Rust->Nim transpiler, type mapping 1a218c2 nandi 7h ago | 1 | # rustnim — a Rust → Nim transpiler |
| 2 | |
| 3 | ## Status |
| 4 | |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 7h ago | 5 | **Transpiling, and measured.** The differential runner is in place and the |
| 6 | whole corpus is green: 21 cases, 17 behavioural and 4 rejections, every one |
| 7 | of which compiles under both rustc and Nim and produces identical stdout and |
| 8 | exit status. Run it with `cargo test`. |
| 9 | |
| 10 | Passing today: functions, `impl` methods, structs, `let`/`let mut`, the full |
| 11 | integer and float operator set at exact widths, `as` casts, `if`/`while`/ |
| 12 | `loop`/`for`, `match`, `Vec`/slices/arrays, and `println!`/`format!` with |
| 13 | `{}`, `{:?}`, `{:x}`, `{:b}`, positional and inline-named arguments, and |
| 14 | zero/space padding. |
| Scaffold rustnim: Rust->Nim transpiler, type mapping 1a218c2 nandi 7h ago | 15 | |
| 16 | ## Why this exists |
| 17 | |
| 18 | We tried [tarekwasfy01/Code-Transpiler](https://github.com/tarekwasfy01/Code-Transpiler), |
| 19 | which advertises `rust` as a source language, on the `base16ct` crate. It emits |
| 20 | empty files and exits 0. The full investigation is in [`findings/`](findings/) |
| 21 | and is published at |
| 22 | https://rickub.com/nandi/code-transpiler-rust-frontend-findings |
| 23 | |
| 24 | The decisive finding, and the reason this is a new project rather than a patch: |
| 25 | its Universal AST cannot represent Rust. `defaultSemanticTypeContract()` in |
| 26 | `internal/backend/semantic_program.go:85` is hardcoded to |
| 27 | |
| 28 | ``` |
| 29 | numeric: binary64, integer_width: unknown, truth: r_compatible, |
| 30 | ownership: unknown, index_base: 1 |
| 31 | ``` |
| 32 | |
| 33 | and `semantic_document.go:1014` *validates* that every contract equals exactly |
| 34 | that, while `typed_operation.go:46` rejects any value model that is not |
| 35 | `tagged_dynamic_binary64`. There is no integer width and no ownership in the |
| 36 | model at all. Code like `base16ct`'s constant-time decoder — |
| 37 | |
| 38 | ```rust |
| 39 | ret += (((0x2fi16 - byte) & (byte - 0x3a)) >> 8) & (byte - 47); |
| 40 | ``` |
| 41 | |
| 42 | — depends on exact 16-bit signed wrapping and arithmetic shift. Lowering that |
| 43 | into a 1-indexed dynamic float64 model produces silently wrong answers. So the |
| 44 | first rule of this project is the one that codebase broke: |
| 45 | |
| 46 | > **Never approximate a semantic you cannot represent. Fail loudly instead.** |
| 47 | |
| 48 | `src/ty.rs` already does this: `i128`/`u128` are rejected with a reason rather |
| 49 | than widened or truncated. |
| 50 | |
| 51 | ## Architecture |
| 52 | |
| 53 | ``` |
| 54 | Rust source ──syn──> syn AST ──lower──> Nim source ──nim c──> binary |
| 55 | ``` |
| 56 | |
| 57 | **The frontend is `syn`, deliberately.** Hand-rolling a Rust grammar is how the |
| 58 | other project went wrong; a correct parser is not the interesting part of this |
| 59 | problem. The interesting part is the lowering, which is where all the work goes. |
| 60 | |
| 61 | Planned modules: |
| 62 | |
| 63 | | file | role | state | |
| 64 | |---|---|---| |
| 65 | | `src/ty.rs` | Rust type → Nim type, exact widths, explicit rejections | written | |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 7h ago | 66 | | `src/lower.rs` | items, statements, expressions → Nim | written | |
| 67 | | `src/fmt.rs` | `println!`/`format!` format-string handling | written | |
| 68 | | `src/prelude.nim` | `Option`/`Result`/panic/`Display`/`Debug` runtime | written | |
| 69 | | `src/main.rs` | CLI: `rustnim <in.rs> -o <out.nim>` | written | |
| 70 | | `tests/differential.rs` | the runner described below | written | |
| 71 | |
| 72 | ### Type propagation is load-bearing |
| 73 | |
| 74 | Rust infers an unsuffixed integer literal's type from context and falls back |
| 75 | to `i32`; Nim falls back to 64-bit `int`. So `lower.rs` threads an *expected |
| 76 | type* down through every expression — into `let` annotations, call arguments, |
| 77 | `match` patterns, compound assignments and both operands of a binary — and |
| 78 | annotates every binding it emits. Without that, `let x: u8 = 200; x + 100` |
| 79 | means two different things in the two languages. With it, a width the lowering |
| 80 | gets wrong becomes a Nim compile error (a loud failure, reported by the |
| 81 | runner) rather than a wrong answer. |
| Scaffold rustnim: Rust->Nim transpiler, type mapping 1a218c2 nandi 7h ago | 82 | |
| 83 | ## Mapping decisions made so far |
| 84 | |
| 85 | - **Integers**: exact width. `i32`→`int32`, `usize`→`uint`, etc. `i128`/`u128` |
| 86 | rejected. |
| 87 | - **Indexing**: both 0-based. Direct. |
| 88 | - **`&T`** → plain value. **`&mut T`** → `var T` parameter. |
| 89 | - **`&[T]`** → `openArray[T]` in parameter position, `seq[T]` when owned. |
| 90 | `Nim::owned()` performs that conversion. |
| 91 | - **Ownership/borrowck**: ignored. Nim is GC'd; for safe Rust this is sound. |
| 92 | - **`Option`/`Result`** → object variants in the prelude. |
| 93 | - **`match`** → Nim `case` where the arms are simple, `if`/`elif` when arms have |
| 94 | guards or bindings. |
| 95 | - **Rust's expression-orientation** maps well: Nim `if`/`case` are expressions |
| 96 | too, and a proc's trailing expression is its return value. |
| 97 | |
| Settle signed-shr and unsigned-wrap semantics against both compilers 87c9cc8 nandi 7h ago | 98 | ### Settled empirically (Nim 2.2.4 vs rustc 1.98.1, both run) |
| 99 | |
| 100 | 1. **Nim's `shr` on a signed integer is arithmetic**, matching Rust. |
| 101 | `int16(-256) shr 8` = `-1` in Nim; `(-256i16) >> 8` = `-1` in Rust. |
| 102 | `base16ct`'s decoder depends on this, so it maps directly with no helper. |
| 103 | 2. **Nim's fixed-width unsigned arithmetic wraps silently**, matching Rust's |
| 104 | `wrapping_*`. `uint8(200) + 100` = `44` in Nim; `200u8.wrapping_add(100)` |
| 105 | = `44` in Rust. So `wrapping_add` on an unsigned type is just `+`. |
| 106 | |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 7h ago | 107 | 3. **We model rustc's debug profile.** Rust debug builds panic on signed |
| 108 | integer overflow; Nim's default build raises `OverflowDefect` on it. Those |
| 109 | are the matching pair, so the runner invokes `rustc` without `-O` and `nim |
| 110 | c` with its defaults, and `tests/cases/016` pins the behaviour. A Rust |
| 111 | panic exits 101 where a Nim Defect exits 1, so every generated module ends |
| 112 | with a handler that maps one to the other — otherwise the runner's |
| 113 | exit-status comparison would be vacuous. `wrapping_*` is therefore an |
| 114 | explicit operation on both sides: unsigned maps to the bare operator (item |
| 115 | 2), signed is routed through the unsigned view of the same width. |
| 116 | 4. **`char` round-trips.** Rust `char` → Nim `Rune`, confirmed for ASCII and |
| 117 | non-ASCII scalars in both `{}` and `{:?}`, and across `as u32` |
| 118 | (`tests/cases/014`). |
| 119 | |
| Settle signed-shr and unsigned-wrap semantics against both compilers 87c9cc8 nandi 7h ago | 120 | ### Still open |
| 121 | |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 7h ago | 122 | 5. `checked_*` and `saturating_*` are not mapped yet; they are currently |
| 123 | rejected as unsupported methods rather than approximated. |
| 124 | 6. Generics, traits, enums, closures, iterator adaptors and `?` are all |
| 125 | rejected with a reason. `base16ct` needs enums and `Result`-carrying |
| 126 | functions, so those are next. |
| 127 | 7. Float formatting matches Rust for ordinary values and for `inf`/`NaN`, but |
| 128 | the exponent-form thresholds have only been checked at `1e21`. |
| Scaffold rustnim: Rust->Nim transpiler, type mapping 1a218c2 nandi 7h ago | 129 | |
| 130 | ## Testing: differential, not golden |
| 131 | |
| 132 | The bar is **behavioural equivalence with rustc**, not that the output looks |
| 133 | plausible. For each case in `tests/cases/`: |
| 134 | |
| 135 | ``` |
| 136 | rustc case.rs && ./case > expected |
| 137 | rustnim case.rs -o case.nim && nim c -r case.nim > actual |
| 138 | diff expected actual |
| 139 | ``` |
| 140 | |
| 141 | A case only counts as passing when both binaries build *and* produce identical |
| Add the differential test runner, and a lowering to measure with it 8ac32af nandithebull 7h ago | 142 | stdout *and* exit with the same status. |
| 143 | |
| 144 | `tests/differential.rs` implements this, and checks each stage separately so a |
| 145 | failure says where it went wrong: `rustnim`, `rustc`, `nim`, or `diff`. Three |
| 146 | guards exist specifically because of how the other transpiler failed: |
| 147 | |
| 148 | - `rustnim` exiting 0 while writing **no output file** is a failure. |
| 149 | - `rustnim` exiting 0 while writing an **empty output file** is a failure. |
| 150 | - An **empty corpus** is a failure, so the runner cannot pass by finding |
| 151 | nothing to do. |
| 152 | |
| 153 | All three have been verified by deliberately breaking the transpiler and |
| 154 | confirming the runner goes red. |
| 155 | |
| 156 | Cases carry directives in leading `//@` comments: |
| 157 | |
| 158 | | directive | meaning | |
| 159 | |---|---| |
| 160 | | `//@ reject: <substring>` | `rustnim` must *fail*, with this in its message | |
| 161 | | `//@ skip: <reason>` | not run; reported as skipped | |
| 162 | | `//@ args: <argv>` | passed to both binaries | |
| 163 | | `//@ stdin: <line>` | fed to both binaries | |
| 164 | |
| 165 | `reject` cases are how the "fail loudly" rule is tested rather than merely |
| 166 | stated: `900`–`904` pin the rejections of `i128`, an unmapped standard-library |
| 167 | method, a float→int cast, an unimplemented format spec, and a closure. |
| 168 | |
| 169 | Run one case with `RUSTNIM_CASE=005 cargo test --test differential -- |
| 170 | --nocapture`. Nim is found at `.nim-toolchain/bin/nim` in the repository root |
| 171 | or any parent, or via `RUSTNIM_NIM`. |
| Scaffold rustnim: Rust->Nim transpiler, type mapping 1a218c2 nandi 7h ago | 172 | |
| 173 | ## Toolchain |
| 174 | |
| 175 | - `rustc` / `cargo` 1.98.1 — system. |
| 176 | - Nim 2.2.4 — vendored at `.nim-toolchain/` (gitignored; downloaded from |
| 177 | nim-lang.org, not installed system-wide). Binary: `.nim-toolchain/bin/nim`. |
| 178 | |
| 179 | ## Milestone 1 |
| 180 | |
| 181 | Transpile `base16ct` 1.0.0 — the crate the other transpiler failed on — and |
| 182 | have its decoder produce byte-identical output to the Rust original. It is a |
| 183 | good target: 613 lines, `no_std`, no dependencies, and its constant-time |
| 184 | integer arithmetic is exactly the kind of thing a sloppy transpiler gets wrong. |