# rustnim — a Rust → Nim transpiler ## Status **Transpiling, and measured.** The differential runner is in place and the whole corpus is green: 21 cases, 17 behavioural and 4 rejections, every one of which compiles under both rustc and Nim and produces identical stdout and exit status. Run it with `cargo test`. Passing today: functions, `impl` methods, structs, `let`/`let mut`, the full integer and float operator set at exact widths, `as` casts, `if`/`while`/ `loop`/`for`, `match`, `Vec`/slices/arrays, and `println!`/`format!` with `{}`, `{:?}`, `{:x}`, `{:b}`, positional and inline-named arguments, and zero/space padding. ## Why this exists We tried [tarekwasfy01/Code-Transpiler](https://github.com/tarekwasfy01/Code-Transpiler), which advertises `rust` as a source language, on the `base16ct` crate. It emits empty files and exits 0. The full investigation is in [`findings/`](findings/) and is published at https://rickub.com/nandi/code-transpiler-rust-frontend-findings The decisive finding, and the reason this is a new project rather than a patch: its Universal AST cannot represent Rust. `defaultSemanticTypeContract()` in `internal/backend/semantic_program.go:85` is hardcoded to ``` numeric: binary64, integer_width: unknown, truth: r_compatible, ownership: unknown, index_base: 1 ``` and `semantic_document.go:1014` *validates* that every contract equals exactly that, while `typed_operation.go:46` rejects any value model that is not `tagged_dynamic_binary64`. There is no integer width and no ownership in the model at all. Code like `base16ct`'s constant-time decoder — ```rust ret += (((0x2fi16 - byte) & (byte - 0x3a)) >> 8) & (byte - 47); ``` — depends on exact 16-bit signed wrapping and arithmetic shift. Lowering that into a 1-indexed dynamic float64 model produces silently wrong answers. So the first rule of this project is the one that codebase broke: > **Never approximate a semantic you cannot represent. Fail loudly instead.** `src/ty.rs` already does this: `i128`/`u128` are rejected with a reason rather than widened or truncated. ## Architecture ``` Rust source ──syn──> syn AST ──lower──> Nim source ──nim c──> binary ``` **The frontend is `syn`, deliberately.** Hand-rolling a Rust grammar is how the other project went wrong; a correct parser is not the interesting part of this problem. The interesting part is the lowering, which is where all the work goes. Planned modules: | file | role | state | |---|---|---| | `src/ty.rs` | Rust type → Nim type, exact widths, explicit rejections | written | | `src/lower.rs` | items, statements, expressions → Nim | written | | `src/fmt.rs` | `println!`/`format!` format-string handling | written | | `src/prelude.nim` | `Option`/`Result`/panic/`Display`/`Debug` runtime | written | | `src/main.rs` | CLI: `rustnim -o ` | written | | `tests/differential.rs` | the runner described below | written | ### Type propagation is load-bearing Rust infers an unsuffixed integer literal's type from context and falls back to `i32`; Nim falls back to 64-bit `int`. So `lower.rs` threads an *expected type* down through every expression — into `let` annotations, call arguments, `match` patterns, compound assignments and both operands of a binary — and annotates every binding it emits. Without that, `let x: u8 = 200; x + 100` means two different things in the two languages. With it, a width the lowering gets wrong becomes a Nim compile error (a loud failure, reported by the runner) rather than a wrong answer. ## Mapping decisions made so far - **Integers**: exact width. `i32`→`int32`, `usize`→`uint`, etc. `i128`/`u128` rejected. - **Indexing**: both 0-based. Direct. - **`&T`** → plain value. **`&mut T`** → `var T` parameter. - **`&[T]`** → `openArray[T]` in parameter position, `seq[T]` when owned. `Nim::owned()` performs that conversion. - **Ownership/borrowck**: ignored. Nim is GC'd; for safe Rust this is sound. - **`Option`/`Result`** → object variants in the prelude. - **`match`** → Nim `case` where the arms are simple, `if`/`elif` when arms have guards or bindings. - **Rust's expression-orientation** maps well: Nim `if`/`case` are expressions too, and a proc's trailing expression is its return value. ### Settled empirically (Nim 2.2.4 vs rustc 1.98.1, both run) 1. **Nim's `shr` on a signed integer is arithmetic**, matching Rust. `int16(-256) shr 8` = `-1` in Nim; `(-256i16) >> 8` = `-1` in Rust. `base16ct`'s decoder depends on this, so it maps directly with no helper. 2. **Nim's fixed-width unsigned arithmetic wraps silently**, matching Rust's `wrapping_*`. `uint8(200) + 100` = `44` in Nim; `200u8.wrapping_add(100)` = `44` in Rust. So `wrapping_add` on an unsigned type is just `+`. 3. **We model rustc's debug profile.** Rust debug builds panic on signed integer overflow; Nim's default build raises `OverflowDefect` on it. Those are the matching pair, so the runner invokes `rustc` without `-O` and `nim c` with its defaults, and `tests/cases/016` pins the behaviour. A Rust panic exits 101 where a Nim Defect exits 1, so every generated module ends with a handler that maps one to the other — otherwise the runner's exit-status comparison would be vacuous. `wrapping_*` is therefore an explicit operation on both sides: unsigned maps to the bare operator (item 2), signed is routed through the unsigned view of the same width. 4. **`char` round-trips.** Rust `char` → Nim `Rune`, confirmed for ASCII and non-ASCII scalars in both `{}` and `{:?}`, and across `as u32` (`tests/cases/014`). ### Still open 5. `checked_*` and `saturating_*` are not mapped yet; they are currently rejected as unsupported methods rather than approximated. 6. Generics, traits, enums, closures, iterator adaptors and `?` are all rejected with a reason. `base16ct` needs enums and `Result`-carrying functions, so those are next. 7. Float formatting matches Rust for ordinary values and for `inf`/`NaN`, but the exponent-form thresholds have only been checked at `1e21`. ## Testing: differential, not golden The bar is **behavioural equivalence with rustc**, not that the output looks plausible. For each case in `tests/cases/`: ``` rustc case.rs && ./case > expected rustnim case.rs -o case.nim && nim c -r case.nim > actual diff expected actual ``` A case only counts as passing when both binaries build *and* produce identical stdout *and* exit with the same status. `tests/differential.rs` implements this, and checks each stage separately so a failure says where it went wrong: `rustnim`, `rustc`, `nim`, or `diff`. Three guards exist specifically because of how the other transpiler failed: - `rustnim` exiting 0 while writing **no output file** is a failure. - `rustnim` exiting 0 while writing an **empty output file** is a failure. - An **empty corpus** is a failure, so the runner cannot pass by finding nothing to do. All three have been verified by deliberately breaking the transpiler and confirming the runner goes red. Cases carry directives in leading `//@` comments: | directive | meaning | |---|---| | `//@ reject: ` | `rustnim` must *fail*, with this in its message | | `//@ skip: ` | not run; reported as skipped | | `//@ args: ` | passed to both binaries | | `//@ stdin: ` | fed to both binaries | `reject` cases are how the "fail loudly" rule is tested rather than merely stated: `900`–`904` pin the rejections of `i128`, an unmapped standard-library method, a float→int cast, an unimplemented format spec, and a closure. Run one case with `RUSTNIM_CASE=005 cargo test --test differential -- --nocapture`. Nim is found at `.nim-toolchain/bin/nim` in the repository root or any parent, or via `RUSTNIM_NIM`. ## Toolchain - `rustc` / `cargo` 1.98.1 — system. - Nim 2.2.4 — vendored at `.nim-toolchain/` (gitignored; downloaded from nim-lang.org, not installed system-wide). Binary: `.nim-toolchain/bin/nim`. ## Milestone 1 Transpile `base16ct` 1.0.0 — the crate the other transpiler failed on — and have its decoder produce byte-identical output to the Rust original. It is a good target: 613 lines, `no_std`, no dependencies, and its constant-time integer arithmetic is exactly the kind of thing a sloppy transpiler gets wrong.