Scaffold rustnim: Rust->Nim transpiler, type mapping
Frontend is syn -- a correct Rust grammar is not the interesting part of this problem, and hand-rolling one is how Code-Transpiler went wrong. src/ty.rs maps Rust types to Nim with exact integer widths, and rejects i128/u128 rather than silently widening. That rule is the point of the project: see DESIGN.md for why Code-Transpiler's R-shaped Universal AST cannot represent Rust arithmetic. findings/ carries the investigation that led here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1a218c2 added
.gitignore +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +/target | |
| 2 | +/.nim-toolchain | |
| 3 | +/.nimcache | |
| 4 | +/tests/.work | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +/target | ||
| 2 | +/.nim-toolchain | ||
| 3 | +/.nimcache | ||
| 4 | +/tests/.work | ||
added
Cargo.lock +45 -0 | new file mode 100644 | ||
| @@ -0,0 +1,45 @@ | ||
| 1 | +# This file is automatically @generated by Cargo. | |
| 2 | +# It is not intended for manual editing. | |
| 3 | +version = 4 | |
| 4 | + | |
| 5 | +[[package]] | |
| 6 | +name = "proc-macro2" | |
| 7 | +version = "1.0.107" | |
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | |
| 10 | +dependencies = [ | |
| 11 | + "unicode-ident", | |
| 12 | +] | |
| 13 | + | |
| 14 | +[[package]] | |
| 15 | +name = "quote" | |
| 16 | +version = "1.0.47" | |
| 17 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 18 | +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | |
| 19 | +dependencies = [ | |
| 20 | + "proc-macro2", | |
| 21 | +] | |
| 22 | + | |
| 23 | +[[package]] | |
| 24 | +name = "rustnim" | |
| 25 | +version = "0.1.0" | |
| 26 | +dependencies = [ | |
| 27 | + "syn", | |
| 28 | +] | |
| 29 | + | |
| 30 | +[[package]] | |
| 31 | +name = "syn" | |
| 32 | +version = "3.0.5" | |
| 33 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 34 | +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" | |
| 35 | +dependencies = [ | |
| 36 | + "proc-macro2", | |
| 37 | + "quote", | |
| 38 | + "unicode-ident", | |
| 39 | +] | |
| 40 | + | |
| 41 | +[[package]] | |
| 42 | +name = "unicode-ident" | |
| 43 | +version = "1.0.24" | |
| 44 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | |
| new file mode 100644 | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | +# This file is automatically @generated by Cargo. | ||
| 2 | +# It is not intended for manual editing. | ||
| 3 | +version = 4 | ||
| 4 | + | ||
| 5 | +[[package]] | ||
| 6 | +name = "proc-macro2" | ||
| 7 | +version = "1.0.107" | ||
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 9 | +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" | ||
| 10 | +dependencies = [ | ||
| 11 | + "unicode-ident", | ||
| 12 | +] | ||
| 13 | + | ||
| 14 | +[[package]] | ||
| 15 | +name = "quote" | ||
| 16 | +version = "1.0.47" | ||
| 17 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 18 | +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" | ||
| 19 | +dependencies = [ | ||
| 20 | + "proc-macro2", | ||
| 21 | +] | ||
| 22 | + | ||
| 23 | +[[package]] | ||
| 24 | +name = "rustnim" | ||
| 25 | +version = "0.1.0" | ||
| 26 | +dependencies = [ | ||
| 27 | + "syn", | ||
| 28 | +] | ||
| 29 | + | ||
| 30 | +[[package]] | ||
| 31 | +name = "syn" | ||
| 32 | +version = "3.0.5" | ||
| 33 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 34 | +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" | ||
| 35 | +dependencies = [ | ||
| 36 | + "proc-macro2", | ||
| 37 | + "quote", | ||
| 38 | + "unicode-ident", | ||
| 39 | +] | ||
| 40 | + | ||
| 41 | +[[package]] | ||
| 42 | +name = "unicode-ident" | ||
| 43 | +version = "1.0.24" | ||
| 44 | +source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 45 | +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" | ||
added
Cargo.toml +7 -0 | new file mode 100644 | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +[package] | |
| 2 | +name = "rustnim" | |
| 3 | +version = "0.1.0" | |
| 4 | +edition = "2024" | |
| 5 | + | |
| 6 | +[dependencies] | |
| 7 | +syn = { version = "3.0.5", features = ["full", "extra-traits", "visit"] } | |
| new file mode 100644 | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | +[package] | ||
| 2 | +name = "rustnim" | ||
| 3 | +version = "0.1.0" | ||
| 4 | +edition = "2024" | ||
| 5 | + | ||
| 6 | +[dependencies] | ||
| 7 | +syn = { version = "3.0.5", features = ["full", "extra-traits", "visit"] } | ||
added
DESIGN.md +115 -0 | new file mode 100644 | ||
| @@ -0,0 +1,115 @@ | ||
| 1 | +# rustnim — a Rust → Nim transpiler | |
| 2 | + | |
| 3 | +## Status | |
| 4 | + | |
| 5 | +**Early scaffold.** `src/ty.rs` (type mapping) is written. `src/main.rs` is | |
| 6 | +still cargo's default hello-world. Nothing transpiles yet. | |
| 7 | + | |
| 8 | +## Why this exists | |
| 9 | + | |
| 10 | +We tried [tarekwasfy01/Code-Transpiler](https://github.com/tarekwasfy01/Code-Transpiler), | |
| 11 | +which advertises `rust` as a source language, on the `base16ct` crate. It emits | |
| 12 | +empty files and exits 0. The full investigation is in [`findings/`](findings/) | |
| 13 | +and is published at | |
| 14 | +https://rickub.com/nandi/code-transpiler-rust-frontend-findings | |
| 15 | + | |
| 16 | +The decisive finding, and the reason this is a new project rather than a patch: | |
| 17 | +its Universal AST cannot represent Rust. `defaultSemanticTypeContract()` in | |
| 18 | +`internal/backend/semantic_program.go:85` is hardcoded to | |
| 19 | + | |
| 20 | +``` | |
| 21 | +numeric: binary64, integer_width: unknown, truth: r_compatible, | |
| 22 | +ownership: unknown, index_base: 1 | |
| 23 | +``` | |
| 24 | + | |
| 25 | +and `semantic_document.go:1014` *validates* that every contract equals exactly | |
| 26 | +that, while `typed_operation.go:46` rejects any value model that is not | |
| 27 | +`tagged_dynamic_binary64`. There is no integer width and no ownership in the | |
| 28 | +model at all. Code like `base16ct`'s constant-time decoder — | |
| 29 | + | |
| 30 | +```rust | |
| 31 | +ret += (((0x2fi16 - byte) & (byte - 0x3a)) >> 8) & (byte - 47); | |
| 32 | +``` | |
| 33 | + | |
| 34 | +— depends on exact 16-bit signed wrapping and arithmetic shift. Lowering that | |
| 35 | +into a 1-indexed dynamic float64 model produces silently wrong answers. So the | |
| 36 | +first rule of this project is the one that codebase broke: | |
| 37 | + | |
| 38 | +> **Never approximate a semantic you cannot represent. Fail loudly instead.** | |
| 39 | + | |
| 40 | +`src/ty.rs` already does this: `i128`/`u128` are rejected with a reason rather | |
| 41 | +than widened or truncated. | |
| 42 | + | |
| 43 | +## Architecture | |
| 44 | + | |
| 45 | +``` | |
| 46 | +Rust source ──syn──> syn AST ──lower──> Nim source ──nim c──> binary | |
| 47 | +``` | |
| 48 | + | |
| 49 | +**The frontend is `syn`, deliberately.** Hand-rolling a Rust grammar is how the | |
| 50 | +other project went wrong; a correct parser is not the interesting part of this | |
| 51 | +problem. The interesting part is the lowering, which is where all the work goes. | |
| 52 | + | |
| 53 | +Planned modules: | |
| 54 | + | |
| 55 | +| file | role | state | | |
| 56 | +|---|---|---| | |
| 57 | +| `src/ty.rs` | Rust type → Nim type, exact widths, explicit rejections | written | | |
| 58 | +| `src/lower.rs` | items, statements, expressions → Nim | todo | | |
| 59 | +| `src/fmt.rs` | `println!`/`format!` format-string handling | todo | | |
| 60 | +| `src/prelude.nim` | `Option`/`Result`/panic runtime, embedded in output | todo | | |
| 61 | +| `src/main.rs` | CLI: `rustnim <in.rs> -o <out.nim>` | todo | | |
| 62 | + | |
| 63 | +## Mapping decisions made so far | |
| 64 | + | |
| 65 | +- **Integers**: exact width. `i32`→`int32`, `usize`→`uint`, etc. `i128`/`u128` | |
| 66 | + rejected. | |
| 67 | +- **Indexing**: both 0-based. Direct. | |
| 68 | +- **`&T`** → plain value. **`&mut T`** → `var T` parameter. | |
| 69 | +- **`&[T]`** → `openArray[T]` in parameter position, `seq[T]` when owned. | |
| 70 | + `Nim::owned()` performs that conversion. | |
| 71 | +- **Ownership/borrowck**: ignored. Nim is GC'd; for safe Rust this is sound. | |
| 72 | +- **`Option`/`Result`** → object variants in the prelude. | |
| 73 | +- **`match`** → Nim `case` where the arms are simple, `if`/`elif` when arms have | |
| 74 | + guards or bindings. | |
| 75 | +- **Rust's expression-orientation** maps well: Nim `if`/`case` are expressions | |
| 76 | + too, and a proc's trailing expression is its return value. | |
| 77 | + | |
| 78 | +### Open questions to settle empirically (the Nim toolchain is already here) | |
| 79 | + | |
| 80 | +1. Is Nim's `shr` on a **signed** integer arithmetic or logical? `base16ct` | |
| 81 | + needs arithmetic. Write the test before relying on either answer. | |
| 82 | +2. Rust debug builds **panic** on integer overflow; release builds wrap. Nim | |
| 83 | + raises `OverflowDefect` by default. Decide which Rust profile we model, say | |
| 84 | + so in the README, and map `wrapping_*`/`checked_*`/`saturating_*` explicitly. | |
| 85 | +3. `char`: Rust `char` is a Unicode scalar; mapped to `Rune`, which needs | |
| 86 | + `std/unicode`. Confirm round-tripping. | |
| 87 | + | |
| 88 | +## Testing: differential, not golden | |
| 89 | + | |
| 90 | +The bar is **behavioural equivalence with rustc**, not that the output looks | |
| 91 | +plausible. For each case in `tests/cases/`: | |
| 92 | + | |
| 93 | +``` | |
| 94 | +rustc case.rs && ./case > expected | |
| 95 | +rustnim case.rs -o case.nim && nim c -r case.nim > actual | |
| 96 | +diff expected actual | |
| 97 | +``` | |
| 98 | + | |
| 99 | +A case only counts as passing when both binaries build *and* produce identical | |
| 100 | +stdout. `tests/` currently has no runner — writing it is the next step, and it | |
| 101 | +should come before any more of the lowering, so that progress is measured | |
| 102 | +rather than asserted. | |
| 103 | + | |
| 104 | +## Toolchain | |
| 105 | + | |
| 106 | +- `rustc` / `cargo` 1.98.1 — system. | |
| 107 | +- Nim 2.2.4 — vendored at `.nim-toolchain/` (gitignored; downloaded from | |
| 108 | + nim-lang.org, not installed system-wide). Binary: `.nim-toolchain/bin/nim`. | |
| 109 | + | |
| 110 | +## Milestone 1 | |
| 111 | + | |
| 112 | +Transpile `base16ct` 1.0.0 — the crate the other transpiler failed on — and | |
| 113 | +have its decoder produce byte-identical output to the Rust original. It is a | |
| 114 | +good target: 613 lines, `no_std`, no dependencies, and its constant-time | |
| 115 | +integer arithmetic is exactly the kind of thing a sloppy transpiler gets wrong. | |
| new file mode 100644 | |||
| @@ -0,0 +1,115 @@ | |||
| 1 | +# rustnim — a Rust → Nim transpiler | ||
| 2 | + | ||
| 3 | +## Status | ||
| 4 | + | ||
| 5 | +**Early scaffold.** `src/ty.rs` (type mapping) is written. `src/main.rs` is | ||
| 6 | +still cargo's default hello-world. Nothing transpiles yet. | ||
| 7 | + | ||
| 8 | +## Why this exists | ||
| 9 | + | ||
| 10 | +We tried [tarekwasfy01/Code-Transpiler](https://github.com/tarekwasfy01/Code-Transpiler), | ||
| 11 | +which advertises `rust` as a source language, on the `base16ct` crate. It emits | ||
| 12 | +empty files and exits 0. The full investigation is in [`findings/`](findings/) | ||
| 13 | +and is published at | ||
| 14 | +https://rickub.com/nandi/code-transpiler-rust-frontend-findings | ||
| 15 | + | ||
| 16 | +The decisive finding, and the reason this is a new project rather than a patch: | ||
| 17 | +its Universal AST cannot represent Rust. `defaultSemanticTypeContract()` in | ||
| 18 | +`internal/backend/semantic_program.go:85` is hardcoded to | ||
| 19 | + | ||
| 20 | +``` | ||
| 21 | +numeric: binary64, integer_width: unknown, truth: r_compatible, | ||
| 22 | +ownership: unknown, index_base: 1 | ||
| 23 | +``` | ||
| 24 | + | ||
| 25 | +and `semantic_document.go:1014` *validates* that every contract equals exactly | ||
| 26 | +that, while `typed_operation.go:46` rejects any value model that is not | ||
| 27 | +`tagged_dynamic_binary64`. There is no integer width and no ownership in the | ||
| 28 | +model at all. Code like `base16ct`'s constant-time decoder — | ||
| 29 | + | ||
| 30 | +```rust | ||
| 31 | +ret += (((0x2fi16 - byte) & (byte - 0x3a)) >> 8) & (byte - 47); | ||
| 32 | +``` | ||
| 33 | + | ||
| 34 | +— depends on exact 16-bit signed wrapping and arithmetic shift. Lowering that | ||
| 35 | +into a 1-indexed dynamic float64 model produces silently wrong answers. So the | ||
| 36 | +first rule of this project is the one that codebase broke: | ||
| 37 | + | ||
| 38 | +> **Never approximate a semantic you cannot represent. Fail loudly instead.** | ||
| 39 | + | ||
| 40 | +`src/ty.rs` already does this: `i128`/`u128` are rejected with a reason rather | ||
| 41 | +than widened or truncated. | ||
| 42 | + | ||
| 43 | +## Architecture | ||
| 44 | + | ||
| 45 | +``` | ||
| 46 | +Rust source ──syn──> syn AST ──lower──> Nim source ──nim c──> binary | ||
| 47 | +``` | ||
| 48 | + | ||
| 49 | +**The frontend is `syn`, deliberately.** Hand-rolling a Rust grammar is how the | ||
| 50 | +other project went wrong; a correct parser is not the interesting part of this | ||
| 51 | +problem. The interesting part is the lowering, which is where all the work goes. | ||
| 52 | + | ||
| 53 | +Planned modules: | ||
| 54 | + | ||
| 55 | +| file | role | state | | ||
| 56 | +|---|---|---| | ||
| 57 | +| `src/ty.rs` | Rust type → Nim type, exact widths, explicit rejections | written | | ||
| 58 | +| `src/lower.rs` | items, statements, expressions → Nim | todo | | ||
| 59 | +| `src/fmt.rs` | `println!`/`format!` format-string handling | todo | | ||
| 60 | +| `src/prelude.nim` | `Option`/`Result`/panic runtime, embedded in output | todo | | ||
| 61 | +| `src/main.rs` | CLI: `rustnim <in.rs> -o <out.nim>` | todo | | ||
| 62 | + | ||
| 63 | +## Mapping decisions made so far | ||
| 64 | + | ||
| 65 | +- **Integers**: exact width. `i32`→`int32`, `usize`→`uint`, etc. `i128`/`u128` | ||
| 66 | + rejected. | ||
| 67 | +- **Indexing**: both 0-based. Direct. | ||
| 68 | +- **`&T`** → plain value. **`&mut T`** → `var T` parameter. | ||
| 69 | +- **`&[T]`** → `openArray[T]` in parameter position, `seq[T]` when owned. | ||
| 70 | + `Nim::owned()` performs that conversion. | ||
| 71 | +- **Ownership/borrowck**: ignored. Nim is GC'd; for safe Rust this is sound. | ||
| 72 | +- **`Option`/`Result`** → object variants in the prelude. | ||
| 73 | +- **`match`** → Nim `case` where the arms are simple, `if`/`elif` when arms have | ||
| 74 | + guards or bindings. | ||
| 75 | +- **Rust's expression-orientation** maps well: Nim `if`/`case` are expressions | ||
| 76 | + too, and a proc's trailing expression is its return value. | ||
| 77 | + | ||
| 78 | +### Open questions to settle empirically (the Nim toolchain is already here) | ||
| 79 | + | ||
| 80 | +1. Is Nim's `shr` on a **signed** integer arithmetic or logical? `base16ct` | ||
| 81 | + needs arithmetic. Write the test before relying on either answer. | ||
| 82 | +2. Rust debug builds **panic** on integer overflow; release builds wrap. Nim | ||
| 83 | + raises `OverflowDefect` by default. Decide which Rust profile we model, say | ||
| 84 | + so in the README, and map `wrapping_*`/`checked_*`/`saturating_*` explicitly. | ||
| 85 | +3. `char`: Rust `char` is a Unicode scalar; mapped to `Rune`, which needs | ||
| 86 | + `std/unicode`. Confirm round-tripping. | ||
| 87 | + | ||
| 88 | +## Testing: differential, not golden | ||
| 89 | + | ||
| 90 | +The bar is **behavioural equivalence with rustc**, not that the output looks | ||
| 91 | +plausible. For each case in `tests/cases/`: | ||
| 92 | + | ||
| 93 | +``` | ||
| 94 | +rustc case.rs && ./case > expected | ||
| 95 | +rustnim case.rs -o case.nim && nim c -r case.nim > actual | ||
| 96 | +diff expected actual | ||
| 97 | +``` | ||
| 98 | + | ||
| 99 | +A case only counts as passing when both binaries build *and* produce identical | ||
| 100 | +stdout. `tests/` currently has no runner — writing it is the next step, and it | ||
| 101 | +should come before any more of the lowering, so that progress is measured | ||
| 102 | +rather than asserted. | ||
| 103 | + | ||
| 104 | +## Toolchain | ||
| 105 | + | ||
| 106 | +- `rustc` / `cargo` 1.98.1 — system. | ||
| 107 | +- Nim 2.2.4 — vendored at `.nim-toolchain/` (gitignored; downloaded from | ||
| 108 | + nim-lang.org, not installed system-wide). Binary: `.nim-toolchain/bin/nim`. | ||
| 109 | + | ||
| 110 | +## Milestone 1 | ||
| 111 | + | ||
| 112 | +Transpile `base16ct` 1.0.0 — the crate the other transpiler failed on — and | ||
| 113 | +have its decoder produce byte-identical output to the Rust original. It is a | ||
| 114 | +good target: 613 lines, `no_std`, no dependencies, and its constant-time | ||
| 115 | +integer arithmetic is exactly the kind of thing a sloppy transpiler gets wrong. | ||
added
findings/.gitignore +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +.work/ | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +.work/ | ||
added
findings/EXPECTED.md +32 -0 | new file mode 100644 | ||
| @@ -0,0 +1,32 @@ | ||
| 1 | +# Expected output of ./repro.sh | |
| 2 | + | |
| 3 | +Recorded against upstream `main` as of 2026-09-18, Go 1.27.1, linux/amd64. | |
| 4 | + | |
| 5 | +``` | |
| 6 | +=== CLI: rust -> nim (exit code, then stderr tail) === | |
| 7 | +00-comment-fails.rs exit=0 <no error> emitted: | |
| 8 | +00-comment.rs exit=0 <no error> emitted: | |
| 9 | +01-trivial.rs exit=0 <no error> emitted: | |
| 10 | +02-if-block.rs exit=0 <no error> emitted: | |
| 11 | +03-macro.rs exit=0 <no error> emitted: | |
| 12 | +04-lifetime.rs exit=1 r2many: unmatched delimiter ")" at 47 | |
| 13 | + | |
| 14 | +=== Frontend prober: UAST node kinds actually produced === | |
| 15 | +00-comment-fails.rs err: expected expression near "/" | |
| 16 | +00-comment.rs node kinds: map[CALL:1 CONTROL:1 DISPATCH:1 EFFECT:3 MEMORY:1 SYMBOL:2 block:4 call:2 expression:4 identifier:4 lvalue:1 program:1 temporary:1 unknown:9] | |
| 17 | +01-trivial.rs node kinds: map[CALL:1 CONTROL:2 DISPATCH:2 EFFECT:4 MEMORY:1 SYMBOL:7 assign:4 binary:2 block:4 builtin:1 call:2 expression:8 identifier:16 lvalue:1 program:1 temporary:1 unknown:20] | |
| 18 | +02-if-block.rs err: expected "" near "s" | |
| 19 | +03-macro.rs err: expected "" near "," | |
| 20 | +04-lifetime.rs err: expected expression near ")" | |
| 21 | +05-equivalent.R node kinds: map[CLOSURE:1 EFFECT:3 FUNCTION:1 MEMORY:1 OWNERSHIP:1 STORAGE:1 SYMBOL:2 assign:4 block:4 expression:2 function:5 identifier:4 lvalue:1 parameter:2 program:1 unknown:11] | |
| 22 | +``` | |
| 23 | + | |
| 24 | +## Reading this | |
| 25 | + | |
| 26 | +- `emitted:` blank means the Nim file is **one byte: a newline**. A complete, | |
| 27 | + empty program, written on exit 0. | |
| 28 | +- `00-comment.rs` vs `00-comment-fails.rs` differ only by one `//` line. | |
| 29 | +- `01-trivial.rs` reports **no error** and produces `call:2 CALL:1` with no | |
| 30 | + function node: `fn f(...)` was read as a call to `f`. | |
| 31 | +- The last two lines are the same program in Rust and in R. Only R yields | |
| 32 | + `FUNCTION:1 CLOSURE:1 function:5 parameter:2`. | |
| new file mode 100644 | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | +# Expected output of ./repro.sh | ||
| 2 | + | ||
| 3 | +Recorded against upstream `main` as of 2026-09-18, Go 1.27.1, linux/amd64. | ||
| 4 | + | ||
| 5 | +``` | ||
| 6 | +=== CLI: rust -> nim (exit code, then stderr tail) === | ||
| 7 | +00-comment-fails.rs exit=0 <no error> emitted: | ||
| 8 | +00-comment.rs exit=0 <no error> emitted: | ||
| 9 | +01-trivial.rs exit=0 <no error> emitted: | ||
| 10 | +02-if-block.rs exit=0 <no error> emitted: | ||
| 11 | +03-macro.rs exit=0 <no error> emitted: | ||
| 12 | +04-lifetime.rs exit=1 r2many: unmatched delimiter ")" at 47 | ||
| 13 | + | ||
| 14 | +=== Frontend prober: UAST node kinds actually produced === | ||
| 15 | +00-comment-fails.rs err: expected expression near "/" | ||
| 16 | +00-comment.rs node kinds: map[CALL:1 CONTROL:1 DISPATCH:1 EFFECT:3 MEMORY:1 SYMBOL:2 block:4 call:2 expression:4 identifier:4 lvalue:1 program:1 temporary:1 unknown:9] | ||
| 17 | +01-trivial.rs node kinds: map[CALL:1 CONTROL:2 DISPATCH:2 EFFECT:4 MEMORY:1 SYMBOL:7 assign:4 binary:2 block:4 builtin:1 call:2 expression:8 identifier:16 lvalue:1 program:1 temporary:1 unknown:20] | ||
| 18 | +02-if-block.rs err: expected "" near "s" | ||
| 19 | +03-macro.rs err: expected "" near "," | ||
| 20 | +04-lifetime.rs err: expected expression near ")" | ||
| 21 | +05-equivalent.R node kinds: map[CLOSURE:1 EFFECT:3 FUNCTION:1 MEMORY:1 OWNERSHIP:1 STORAGE:1 SYMBOL:2 assign:4 block:4 expression:2 function:5 identifier:4 lvalue:1 parameter:2 program:1 unknown:11] | ||
| 22 | +``` | ||
| 23 | + | ||
| 24 | +## Reading this | ||
| 25 | + | ||
| 26 | +- `emitted:` blank means the Nim file is **one byte: a newline**. A complete, | ||
| 27 | + empty program, written on exit 0. | ||
| 28 | +- `00-comment.rs` vs `00-comment-fails.rs` differ only by one `//` line. | ||
| 29 | +- `01-trivial.rs` reports **no error** and produces `call:2 CALL:1` with no | ||
| 30 | + function node: `fn f(...)` was read as a call to `f`. | ||
| 31 | +- The last two lines are the same program in Rust and in R. Only R yields | ||
| 32 | + `FUNCTION:1 CLOSURE:1 function:5 parameter:2`. | ||
added
findings/LICENSE +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +MIT License | |
| 2 | + | |
| 3 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 4 | +of this software and associated documentation files (the "Software"), to deal | |
| 5 | +in the Software without restriction, including without limitation the rights | |
| 6 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 7 | +copies of the Software, and to permit persons to whom the Software is | |
| 8 | +furnished to do so, subject to the following conditions: | |
| 9 | + | |
| 10 | +The above copyright notice and this permission notice shall be included in all | |
| 11 | +copies or substantial portions of the Software. | |
| 12 | + | |
| 13 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 14 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 15 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 16 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 17 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 18 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 19 | +SOFTWARE. | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +MIT License | ||
| 2 | + | ||
| 3 | +Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 4 | +of this software and associated documentation files (the "Software"), to deal | ||
| 5 | +in the Software without restriction, including without limitation the rights | ||
| 6 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 7 | +copies of the Software, and to permit persons to whom the Software is | ||
| 8 | +furnished to do so, subject to the following conditions: | ||
| 9 | + | ||
| 10 | +The above copyright notice and this permission notice shall be included in all | ||
| 11 | +copies or substantial portions of the Software. | ||
| 12 | + | ||
| 13 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 14 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 15 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 16 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 17 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 18 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 19 | +SOFTWARE. | ||
added
findings/README.md +151 -0 | new file mode 100644 | ||
| @@ -0,0 +1,151 @@ | ||
| 1 | +# Rust → Nim in Code-Transpiler: what actually happens | |
| 2 | + | |
| 3 | +A reproducible bug report for [tarekwasfy01/Code-Transpiler](https://github.com/tarekwasfy01/Code-Transpiler), | |
| 4 | +which lists `rust` as a supported source language. | |
| 5 | + | |
| 6 | +**Summary: Rust cannot be used as a source language, and the CLI does not say so.** | |
| 7 | +Most real Rust fails in the lexer. The rest parses into a tree containing no | |
| 8 | +functions, and the CLI writes an empty-but-valid target file and exits `0`. | |
| 9 | + | |
| 10 | +Nothing here is a criticism of the project's R-derived core, which works. The | |
| 11 | +issue is specifically the Rust frontend, and the silent failure that hides it. | |
| 12 | + | |
| 13 | +## Reproduce | |
| 14 | + | |
| 15 | +```bash | |
| 16 | +./repro.sh | |
| 17 | +``` | |
| 18 | + | |
| 19 | +Clones upstream, builds `cmd/r2many`, runs the minimal cases through the CLI, | |
| 20 | +then re-runs them through a prober that reports which UAST node kinds the | |
| 21 | +frontend actually produced. Needs Go 1.26+ and network access. Recorded output: | |
| 22 | +[`EXPECTED.md`](EXPECTED.md). | |
| 23 | + | |
| 24 | +## What was tried first | |
| 25 | + | |
| 26 | +A random real crate: `base16ct` 1.0.0 — 613 lines, pure Rust, `no_std`, no | |
| 27 | +dependencies. Five of its six source files failed to parse. The sixth, which | |
| 28 | +contains three functions, transpiled to this complete Nim program: | |
| 29 | + | |
| 30 | +```nim | |
| 31 | +{ | |
| 32 | +} | |
| 33 | +``` | |
| 34 | + | |
| 35 | +For the minimal cases in `cases/`, the emitted Nim file is even smaller: one | |
| 36 | +byte, a newline. Exit code 0 in both situations. | |
| 37 | + | |
| 38 | +Full transcript: [`outputs/base16ct-1.0.0-transcript.txt`](outputs/base16ct-1.0.0-transcript.txt). | |
| 39 | + | |
| 40 | +## The four defects | |
| 41 | + | |
| 42 | +### 0. `//` line comments are not supported | |
| 43 | + | |
| 44 | +[`internal/backend/parser.go:157`](https://github.com/tarekwasfy01/Code-Transpiler/blob/main/internal/backend/parser.go#L157) | |
| 45 | + | |
| 46 | +This tokenizer recognises `#` comments only, alongside R operators (`<<-`, | |
| 47 | +`%in%`, `[[`). A Rust `//` becomes two division operators: | |
| 48 | + | |
| 49 | +``` | |
| 50 | +expected expression near "/" | |
| 51 | +``` | |
| 52 | + | |
| 53 | +[`cases/00-comment.rs`](cases/00-comment.rs) and | |
| 54 | +[`cases/00-comment-fails.rs`](cases/00-comment-fails.rs) differ by exactly one | |
| 55 | +comment line, and only the second fails. Every file in a real crate has | |
| 56 | +comments, `base16ct` included — doc comments alone account for much of why it | |
| 57 | +never reaches the later stages. | |
| 58 | + | |
| 59 | +### 1. `'` is always a string opener, so lifetimes break the lexer | |
| 60 | + | |
| 61 | +[`internal/matrixir/lexical.go:261`](https://github.com/tarekwasfy01/Code-Transpiler/blob/main/internal/matrixir/lexical.go#L261) | |
| 62 | + | |
| 63 | +```go | |
| 64 | +if c == '"' || c == '\'' || c == '`' { | |
| 65 | +``` | |
| 66 | + | |
| 67 | +`<'a>` and `Formatter<'_>` are consumed as unterminated char literals. The | |
| 68 | +delimiter pairing in `AnalyzeTokenStructure` then desyncs, which is why the | |
| 69 | +reported error is a bracket complaint at an offset that always turns out to be | |
| 70 | +a lifetime. Case: [`cases/04-lifetime.rs`](cases/04-lifetime.rs). | |
| 71 | + | |
| 72 | +This one looks genuinely small to fix: for `source == "rust"`, treat `'` as a | |
| 73 | +literal only when it matches `'\?.'`, and emit a lifetime token otherwise. | |
| 74 | + | |
| 75 | +### 2. The Rust parser is the R parser | |
| 76 | + | |
| 77 | +[`internal/backend/frontend_fact_parser.go:387`](https://github.com/tarekwasfy01/Code-Transpiler/blob/main/internal/backend/frontend_fact_parser.go#L387) | |
| 78 | + | |
| 79 | +```go | |
| 80 | +func (p *factParser) parseIf() (ParsedNode, error) { | |
| 81 | + p.next() | |
| 82 | + if _, e := p.expect(tokLParen, ""); e != nil { // requires if ( ... ) | |
| 83 | +``` | |
| 84 | + | |
| 85 | +This is a second, separate tokenizer from the one in defect 1 — the two stages | |
| 86 | +disagree — but both are R's. Parenthesised conditions are mandatory, so Rust's `if cond { }` can never | |
| 87 | +parse — it fails with `expected "" near "<first token of cond>"`. Case: | |
| 88 | +[`cases/02-if-block.rs`](cases/02-if-block.rs). Macro invocations are likewise | |
| 89 | +unhandled ([`cases/03-macro.rs`](cases/03-macro.rs)). | |
| 90 | + | |
| 91 | +The emitted semantic contract is hardcoded R for a file declared as Rust: | |
| 92 | + | |
| 93 | +```json | |
| 94 | +"language_profile": "rust", | |
| 95 | +"value_model": "tagged_dynamic_binary64", | |
| 96 | +"index_base": 1, | |
| 97 | +"type_contract": { "truth": "r_compatible", "ownership": "unknown" } | |
| 98 | +``` | |
| 99 | + | |
| 100 | +### 3. `fn` is never recognised as a declaration | |
| 101 | + | |
| 102 | +This is the one that yields empty output. Node kinds produced for two | |
| 103 | +equivalent programs, via [`prober/main.go`](prober/main.go): | |
| 104 | + | |
| 105 | +| input | function nodes | | |
| 106 | +|---|---| | |
| 107 | +| [`cases/05-equivalent.R`](cases/05-equivalent.R) — `f <- function(a) {…}` | `FUNCTION:1, CLOSURE:1, function:5, parameter:2` | | |
| 108 | +| [`cases/01-trivial.rs`](cases/01-trivial.rs) — `fn f(a: i32) -> i32 {…}` | **none** — `call:2, CALL:1` | | |
| 109 | + | |
| 110 | +`fn f(a: i32)` is parsed as a *call* to `f`, with `fn` left as a stray | |
| 111 | +identifier. The tree holds no functions, so the Nim emitter correctly emits | |
| 112 | +nothing. Note that case 01 reports **no parse error** — it is the silent path. | |
| 113 | + | |
| 114 | +## The silent-failure path | |
| 115 | + | |
| 116 | +`-runtime` defaults to `true`. The fallback catches the parse failure, writes a | |
| 117 | +syntactically valid empty program, and exits `0`. The machinery to report this | |
| 118 | +already exists and is correct when asked: | |
| 119 | + | |
| 120 | +``` | |
| 121 | +-native strict native frontend for "rust" is not implemented | |
| 122 | +-no-runtime 1/1 translations failed: [{nim DIRECT_NATIVE_UNAVAILABLE (stage=direct)}] | |
| 123 | +(default) exit 0, empty file | |
| 124 | +``` | |
| 125 | + | |
| 126 | +Refusing to emit a program with zero functions when the input declared some | |
| 127 | +would turn a silent wrong answer into an honest error, without touching the | |
| 128 | +frontend at all. | |
| 129 | + | |
| 130 | +## Scope of a real fix | |
| 131 | + | |
| 132 | +Defects 0 and 1 are patches. Defects 2 and 3 are not: there is no Rust frontend to | |
| 133 | +repair. Supporting Rust means writing one — items, generics, lifetimes, | |
| 134 | +`impl`/traits, `match`, macros, `?` — and lowering it into a semantic model | |
| 135 | +that isn't R-shaped, since the type contract is R throughout. That is | |
| 136 | +comparable in size to the existing ~1,400-line R frontend, and probably larger. | |
| 137 | + | |
| 138 | +## Contents | |
| 139 | + | |
| 140 | +``` | |
| 141 | +cases/ minimal inputs, one per defect, plus the R control | |
| 142 | +prober/ drop-in cmd/dbg that prints UAST node-kind counts | |
| 143 | +outputs/ the base16ct run: transcript and the one emitted .nim | |
| 144 | +repro.sh clone, build, run everything | |
| 145 | +``` | |
| 146 | + | |
| 147 | +`prober/main.go` imports `internal/backend`, so it only compiles from inside | |
| 148 | +the upstream tree; `repro.sh` copies it to `cmd/dbg` for you. | |
| 149 | + | |
| 150 | +Upstream is MIT. No upstream or crate source is vendored here — `repro.sh` | |
| 151 | +fetches what it needs. | |
| new file mode 100644 | |||
| @@ -0,0 +1,151 @@ | |||
| 1 | +# Rust → Nim in Code-Transpiler: what actually happens | ||
| 2 | + | ||
| 3 | +A reproducible bug report for [tarekwasfy01/Code-Transpiler](https://github.com/tarekwasfy01/Code-Transpiler), | ||
| 4 | +which lists `rust` as a supported source language. | ||
| 5 | + | ||
| 6 | +**Summary: Rust cannot be used as a source language, and the CLI does not say so.** | ||
| 7 | +Most real Rust fails in the lexer. The rest parses into a tree containing no | ||
| 8 | +functions, and the CLI writes an empty-but-valid target file and exits `0`. | ||
| 9 | + | ||
| 10 | +Nothing here is a criticism of the project's R-derived core, which works. The | ||
| 11 | +issue is specifically the Rust frontend, and the silent failure that hides it. | ||
| 12 | + | ||
| 13 | +## Reproduce | ||
| 14 | + | ||
| 15 | +```bash | ||
| 16 | +./repro.sh | ||
| 17 | +``` | ||
| 18 | + | ||
| 19 | +Clones upstream, builds `cmd/r2many`, runs the minimal cases through the CLI, | ||
| 20 | +then re-runs them through a prober that reports which UAST node kinds the | ||
| 21 | +frontend actually produced. Needs Go 1.26+ and network access. Recorded output: | ||
| 22 | +[`EXPECTED.md`](EXPECTED.md). | ||
| 23 | + | ||
| 24 | +## What was tried first | ||
| 25 | + | ||
| 26 | +A random real crate: `base16ct` 1.0.0 — 613 lines, pure Rust, `no_std`, no | ||
| 27 | +dependencies. Five of its six source files failed to parse. The sixth, which | ||
| 28 | +contains three functions, transpiled to this complete Nim program: | ||
| 29 | + | ||
| 30 | +```nim | ||
| 31 | +{ | ||
| 32 | +} | ||
| 33 | +``` | ||
| 34 | + | ||
| 35 | +For the minimal cases in `cases/`, the emitted Nim file is even smaller: one | ||
| 36 | +byte, a newline. Exit code 0 in both situations. | ||
| 37 | + | ||
| 38 | +Full transcript: [`outputs/base16ct-1.0.0-transcript.txt`](outputs/base16ct-1.0.0-transcript.txt). | ||
| 39 | + | ||
| 40 | +## The four defects | ||
| 41 | + | ||
| 42 | +### 0. `//` line comments are not supported | ||
| 43 | + | ||
| 44 | +[`internal/backend/parser.go:157`](https://github.com/tarekwasfy01/Code-Transpiler/blob/main/internal/backend/parser.go#L157) | ||
| 45 | + | ||
| 46 | +This tokenizer recognises `#` comments only, alongside R operators (`<<-`, | ||
| 47 | +`%in%`, `[[`). A Rust `//` becomes two division operators: | ||
| 48 | + | ||
| 49 | +``` | ||
| 50 | +expected expression near "/" | ||
| 51 | +``` | ||
| 52 | + | ||
| 53 | +[`cases/00-comment.rs`](cases/00-comment.rs) and | ||
| 54 | +[`cases/00-comment-fails.rs`](cases/00-comment-fails.rs) differ by exactly one | ||
| 55 | +comment line, and only the second fails. Every file in a real crate has | ||
| 56 | +comments, `base16ct` included — doc comments alone account for much of why it | ||
| 57 | +never reaches the later stages. | ||
| 58 | + | ||
| 59 | +### 1. `'` is always a string opener, so lifetimes break the lexer | ||
| 60 | + | ||
| 61 | +[`internal/matrixir/lexical.go:261`](https://github.com/tarekwasfy01/Code-Transpiler/blob/main/internal/matrixir/lexical.go#L261) | ||
| 62 | + | ||
| 63 | +```go | ||
| 64 | +if c == '"' || c == '\'' || c == '`' { | ||
| 65 | +``` | ||
| 66 | + | ||
| 67 | +`<'a>` and `Formatter<'_>` are consumed as unterminated char literals. The | ||
| 68 | +delimiter pairing in `AnalyzeTokenStructure` then desyncs, which is why the | ||
| 69 | +reported error is a bracket complaint at an offset that always turns out to be | ||
| 70 | +a lifetime. Case: [`cases/04-lifetime.rs`](cases/04-lifetime.rs). | ||
| 71 | + | ||
| 72 | +This one looks genuinely small to fix: for `source == "rust"`, treat `'` as a | ||
| 73 | +literal only when it matches `'\?.'`, and emit a lifetime token otherwise. | ||
| 74 | + | ||
| 75 | +### 2. The Rust parser is the R parser | ||
| 76 | + | ||
| 77 | +[`internal/backend/frontend_fact_parser.go:387`](https://github.com/tarekwasfy01/Code-Transpiler/blob/main/internal/backend/frontend_fact_parser.go#L387) | ||
| 78 | + | ||
| 79 | +```go | ||
| 80 | +func (p *factParser) parseIf() (ParsedNode, error) { | ||
| 81 | + p.next() | ||
| 82 | + if _, e := p.expect(tokLParen, ""); e != nil { // requires if ( ... ) | ||
| 83 | +``` | ||
| 84 | + | ||
| 85 | +This is a second, separate tokenizer from the one in defect 1 — the two stages | ||
| 86 | +disagree — but both are R's. Parenthesised conditions are mandatory, so Rust's `if cond { }` can never | ||
| 87 | +parse — it fails with `expected "" near "<first token of cond>"`. Case: | ||
| 88 | +[`cases/02-if-block.rs`](cases/02-if-block.rs). Macro invocations are likewise | ||
| 89 | +unhandled ([`cases/03-macro.rs`](cases/03-macro.rs)). | ||
| 90 | + | ||
| 91 | +The emitted semantic contract is hardcoded R for a file declared as Rust: | ||
| 92 | + | ||
| 93 | +```json | ||
| 94 | +"language_profile": "rust", | ||
| 95 | +"value_model": "tagged_dynamic_binary64", | ||
| 96 | +"index_base": 1, | ||
| 97 | +"type_contract": { "truth": "r_compatible", "ownership": "unknown" } | ||
| 98 | +``` | ||
| 99 | + | ||
| 100 | +### 3. `fn` is never recognised as a declaration | ||
| 101 | + | ||
| 102 | +This is the one that yields empty output. Node kinds produced for two | ||
| 103 | +equivalent programs, via [`prober/main.go`](prober/main.go): | ||
| 104 | + | ||
| 105 | +| input | function nodes | | ||
| 106 | +|---|---| | ||
| 107 | +| [`cases/05-equivalent.R`](cases/05-equivalent.R) — `f <- function(a) {…}` | `FUNCTION:1, CLOSURE:1, function:5, parameter:2` | | ||
| 108 | +| [`cases/01-trivial.rs`](cases/01-trivial.rs) — `fn f(a: i32) -> i32 {…}` | **none** — `call:2, CALL:1` | | ||
| 109 | + | ||
| 110 | +`fn f(a: i32)` is parsed as a *call* to `f`, with `fn` left as a stray | ||
| 111 | +identifier. The tree holds no functions, so the Nim emitter correctly emits | ||
| 112 | +nothing. Note that case 01 reports **no parse error** — it is the silent path. | ||
| 113 | + | ||
| 114 | +## The silent-failure path | ||
| 115 | + | ||
| 116 | +`-runtime` defaults to `true`. The fallback catches the parse failure, writes a | ||
| 117 | +syntactically valid empty program, and exits `0`. The machinery to report this | ||
| 118 | +already exists and is correct when asked: | ||
| 119 | + | ||
| 120 | +``` | ||
| 121 | +-native strict native frontend for "rust" is not implemented | ||
| 122 | +-no-runtime 1/1 translations failed: [{nim DIRECT_NATIVE_UNAVAILABLE (stage=direct)}] | ||
| 123 | +(default) exit 0, empty file | ||
| 124 | +``` | ||
| 125 | + | ||
| 126 | +Refusing to emit a program with zero functions when the input declared some | ||
| 127 | +would turn a silent wrong answer into an honest error, without touching the | ||
| 128 | +frontend at all. | ||
| 129 | + | ||
| 130 | +## Scope of a real fix | ||
| 131 | + | ||
| 132 | +Defects 0 and 1 are patches. Defects 2 and 3 are not: there is no Rust frontend to | ||
| 133 | +repair. Supporting Rust means writing one — items, generics, lifetimes, | ||
| 134 | +`impl`/traits, `match`, macros, `?` — and lowering it into a semantic model | ||
| 135 | +that isn't R-shaped, since the type contract is R throughout. That is | ||
| 136 | +comparable in size to the existing ~1,400-line R frontend, and probably larger. | ||
| 137 | + | ||
| 138 | +## Contents | ||
| 139 | + | ||
| 140 | +``` | ||
| 141 | +cases/ minimal inputs, one per defect, plus the R control | ||
| 142 | +prober/ drop-in cmd/dbg that prints UAST node-kind counts | ||
| 143 | +outputs/ the base16ct run: transcript and the one emitted .nim | ||
| 144 | +repro.sh clone, build, run everything | ||
| 145 | +``` | ||
| 146 | + | ||
| 147 | +`prober/main.go` imports `internal/backend`, so it only compiles from inside | ||
| 148 | +the upstream tree; `repro.sh` copies it to `cmd/dbg` for you. | ||
| 149 | + | ||
| 150 | +Upstream is MIT. No upstream or crate source is vendored here — `repro.sh` | ||
| 151 | +fetches what it needs. | ||
added
findings/cases/00-comment-fails.rs +2 -0 | new file mode 100644 | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +// A plain Rust line comment. | |
| 2 | +fn f() { } | |
| new file mode 100644 | |||
| @@ -0,0 +1,2 @@ | |||
| 1 | +// A plain Rust line comment. | ||
| 2 | +fn f() { } | ||
added
findings/cases/00-comment.rs +1 -0 | new file mode 100644 | ||
| @@ -0,0 +1 @@ | ||
| 1 | +fn f() { } | |
| new file mode 100644 | |||
| @@ -0,0 +1 @@ | |||
| 1 | +fn f() { } | ||
added
findings/cases/01-trivial.rs +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +fn f(a: i32) -> i32 { | |
| 2 | + let mut s = a; | |
| 3 | + s | |
| 4 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +fn f(a: i32) -> i32 { | ||
| 2 | + let mut s = a; | ||
| 3 | + s | ||
| 4 | +} | ||
added
findings/cases/02-if-block.rs +7 -0 | new file mode 100644 | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +fn f(a: i32) -> i32 { | |
| 2 | + let mut s = a; | |
| 3 | + if s < 0 { | |
| 4 | + s = -s; | |
| 5 | + } | |
| 6 | + s | |
| 7 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | +fn f(a: i32) -> i32 { | ||
| 2 | + let mut s = a; | ||
| 3 | + if s < 0 { | ||
| 4 | + s = -s; | ||
| 5 | + } | ||
| 6 | + s | ||
| 7 | +} | ||
added
findings/cases/03-macro.rs +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +fn main() { | |
| 2 | + println!("{}", 1); | |
| 3 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +fn main() { | ||
| 2 | + println!("{}", 1); | ||
| 3 | +} | ||
added
findings/cases/04-lifetime.rs +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +pub fn encode<'a>(src: &[u8], dst: &'a mut [u8]) -> &'a [u8] { | |
| 2 | + dst | |
| 3 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +pub fn encode<'a>(src: &[u8], dst: &'a mut [u8]) -> &'a [u8] { | ||
| 2 | + dst | ||
| 3 | +} | ||
added
findings/cases/05-equivalent.R +4 -0 | new file mode 100644 | ||
| @@ -0,0 +1,4 @@ | ||
| 1 | +f <- function(a) { | |
| 2 | + s <- a | |
| 3 | + s | |
| 4 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | +f <- function(a) { | ||
| 2 | + s <- a | ||
| 3 | + s | ||
| 4 | +} | ||
added
findings/outputs/base16ct-1.0.0-transcript.txt +28 -0 | new file mode 100644 | ||
| @@ -0,0 +1,28 @@ | ||
| 1 | +Crate: base16ct 1.0.0 (crates.io), 613 LOC, pure Rust, no_std | |
| 2 | +Command: r2many transpile -from rust -to nim <file>.rs -o <file>.nim | |
| 3 | +Build: CGO_ENABLED=0 go build ./cmd/r2many (succeeds) | |
| 4 | + | |
| 5 | +src/lib.rs unmatched delimiter ")" at 3346 -> no file written | |
| 6 | +src/error.rs unterminated string at 411 -> no file written | |
| 7 | +src/display.rs unmatched delimiter ")" at 174 -> no file written | |
| 8 | +src/lower.rs unmatched delimiter ")" at 815 -> no file written | |
| 9 | +src/upper.rs unmatched delimiter "(" at 817 -> no file written | |
| 10 | +src/mixed.rs <no error, exit 0> -> mixed.nim (see below) | |
| 11 | + | |
| 12 | +Every failing offset lands on a lifetime: | |
| 13 | + error.rs:411 fmt::Formatter<'_> | |
| 14 | + display.rs:174 pub struct HexDisplay<'a>(pub &'a [u8]); | |
| 15 | + lower.rs:815 pub fn encode<'a>(src: &[u8], dst: &'a mut [u8]) | |
| 16 | + | |
| 17 | +mixed.rs contains three functions (decode, decode_vec, decode_nibble). | |
| 18 | +The complete emitted Nim program was: | |
| 19 | + | |
| 20 | + { | |
| 21 | + } | |
| 22 | + | |
| 23 | +Flag behaviour on the same input: | |
| 24 | + -native -> strict native frontend for "rust" is not implemented | |
| 25 | + -no-runtime -> 1/1 translations failed: [{nim DIRECT_NATIVE_UNAVAILABLE (stage=direct)}] | |
| 26 | + (default) -> exit 0, empty program written | |
| 27 | + | |
| 28 | +Control: R -> Nim on the project's original source language does emit real code. | |
| new file mode 100644 | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | +Crate: base16ct 1.0.0 (crates.io), 613 LOC, pure Rust, no_std | ||
| 2 | +Command: r2many transpile -from rust -to nim <file>.rs -o <file>.nim | ||
| 3 | +Build: CGO_ENABLED=0 go build ./cmd/r2many (succeeds) | ||
| 4 | + | ||
| 5 | +src/lib.rs unmatched delimiter ")" at 3346 -> no file written | ||
| 6 | +src/error.rs unterminated string at 411 -> no file written | ||
| 7 | +src/display.rs unmatched delimiter ")" at 174 -> no file written | ||
| 8 | +src/lower.rs unmatched delimiter ")" at 815 -> no file written | ||
| 9 | +src/upper.rs unmatched delimiter "(" at 817 -> no file written | ||
| 10 | +src/mixed.rs <no error, exit 0> -> mixed.nim (see below) | ||
| 11 | + | ||
| 12 | +Every failing offset lands on a lifetime: | ||
| 13 | + error.rs:411 fmt::Formatter<'_> | ||
| 14 | + display.rs:174 pub struct HexDisplay<'a>(pub &'a [u8]); | ||
| 15 | + lower.rs:815 pub fn encode<'a>(src: &[u8], dst: &'a mut [u8]) | ||
| 16 | + | ||
| 17 | +mixed.rs contains three functions (decode, decode_vec, decode_nibble). | ||
| 18 | +The complete emitted Nim program was: | ||
| 19 | + | ||
| 20 | + { | ||
| 21 | + } | ||
| 22 | + | ||
| 23 | +Flag behaviour on the same input: | ||
| 24 | + -native -> strict native frontend for "rust" is not implemented | ||
| 25 | + -no-runtime -> 1/1 translations failed: [{nim DIRECT_NATIVE_UNAVAILABLE (stage=direct)}] | ||
| 26 | + (default) -> exit 0, empty program written | ||
| 27 | + | ||
| 28 | +Control: R -> Nim on the project's original source language does emit real code. | ||
added
findings/outputs/mixed.nim +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | + | |
| 2 | +{ | |
| 3 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | + | ||
| 2 | +{ | ||
| 3 | +} | ||
added
findings/prober/main.go +26 -0 | new file mode 100644 | ||
| @@ -0,0 +1,26 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "encoding/json" | |
| 5 | + "fmt" | |
| 6 | + "os" | |
| 7 | + "regexp" | |
| 8 | + | |
| 9 | + "github.com/tarekwasfy01/Code-Transpiler/v2/internal/backend" | |
| 10 | +) | |
| 11 | + | |
| 12 | +func main() { | |
| 13 | + code, _ := os.ReadFile(os.Args[2]) | |
| 14 | + p, err := backend.ParseSemantic(os.Args[1], string(code)) | |
| 15 | + fmt.Println("err:", err) | |
| 16 | + if p == nil || p.UniversalAST == nil { | |
| 17 | + return | |
| 18 | + } | |
| 19 | + b, _ := json.Marshal(p.UniversalAST) | |
| 20 | + re := regexp.MustCompile(`"kind":"([A-Za-z]+)"`) | |
| 21 | + counts := map[string]int{} | |
| 22 | + for _, m := range re.FindAllStringSubmatch(string(b), -1) { | |
| 23 | + counts[m[1]]++ | |
| 24 | + } | |
| 25 | + fmt.Println("node kinds:", counts) | |
| 26 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,26 @@ | |||
| 1 | +package main | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "encoding/json" | ||
| 5 | + "fmt" | ||
| 6 | + "os" | ||
| 7 | + "regexp" | ||
| 8 | + | ||
| 9 | + "github.com/tarekwasfy01/Code-Transpiler/v2/internal/backend" | ||
| 10 | +) | ||
| 11 | + | ||
| 12 | +func main() { | ||
| 13 | + code, _ := os.ReadFile(os.Args[2]) | ||
| 14 | + p, err := backend.ParseSemantic(os.Args[1], string(code)) | ||
| 15 | + fmt.Println("err:", err) | ||
| 16 | + if p == nil || p.UniversalAST == nil { | ||
| 17 | + return | ||
| 18 | + } | ||
| 19 | + b, _ := json.Marshal(p.UniversalAST) | ||
| 20 | + re := regexp.MustCompile(`"kind":"([A-Za-z]+)"`) | ||
| 21 | + counts := map[string]int{} | ||
| 22 | + for _, m := range re.FindAllStringSubmatch(string(b), -1) { | ||
| 23 | + counts[m[1]]++ | ||
| 24 | + } | ||
| 25 | + fmt.Println("node kinds:", counts) | ||
| 26 | +} | ||
added
findings/repro.sh +35 -0 | new file mode 100755 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Reproduce every finding. Requires Go 1.26+ and network access. | |
| 3 | +set -u | |
| 4 | +here=$(cd "$(dirname "$0")" && pwd) | |
| 5 | +work=${1:-$here/.work} | |
| 6 | +mkdir -p "$work" && cd "$work" | |
| 7 | + | |
| 8 | +[ -d Code-Transpiler ] || git clone --depth 1 https://github.com/tarekwasfy01/Code-Transpiler | |
| 9 | +cd Code-Transpiler | |
| 10 | +export TMPDIR=$PWD/.tmp && mkdir -p .tmp | |
| 11 | + | |
| 12 | +echo "=== Building CLI ===" | |
| 13 | +CGO_ENABLED=0 go build -o "$work/r2many" ./cmd/r2many || exit 1 | |
| 14 | + | |
| 15 | +echo | |
| 16 | +echo "=== CLI: rust -> nim (exit code, then stderr tail) ===" | |
| 17 | +for f in "$here"/cases/*.rs; do | |
| 18 | + out=$work/$(basename "$f" .rs).nim | |
| 19 | + rm -f "$out" | |
| 20 | + msg=$("$work/r2many" transpile -from rust -to nim "$f" -o "$out" 2>&1) | |
| 21 | + code=$? | |
| 22 | + printf '%-24s exit=%s %s' "$(basename "$f")" "$code" "$(printf '%s' "$msg" | tail -1)" | |
| 23 | + [ -n "$msg" ] || printf '<no error>' | |
| 24 | + if [ -f "$out" ]; then printf ' emitted: %s' "$(tr -s '\n' ' ' < "$out")"; fi | |
| 25 | + printf '\n' | |
| 26 | +done | |
| 27 | + | |
| 28 | +echo | |
| 29 | +echo "=== Frontend prober: UAST node kinds actually produced ===" | |
| 30 | +mkdir -p cmd/dbg && cp "$here/prober/main.go" cmd/dbg/main.go | |
| 31 | +for f in "$here"/cases/*.rs; do | |
| 32 | + printf '%-24s ' "$(basename "$f")"; go run ./cmd/dbg rust "$f" 2>&1 | tail -1 | |
| 33 | +done | |
| 34 | +printf '%-24s ' "05-equivalent.R" | |
| 35 | +go run ./cmd/dbg r "$here/cases/05-equivalent.R" 2>&1 | tail -1 | |
| new file mode 100755 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# Reproduce every finding. Requires Go 1.26+ and network access. | ||
| 3 | +set -u | ||
| 4 | +here=$(cd "$(dirname "$0")" && pwd) | ||
| 5 | +work=${1:-$here/.work} | ||
| 6 | +mkdir -p "$work" && cd "$work" | ||
| 7 | + | ||
| 8 | +[ -d Code-Transpiler ] || git clone --depth 1 https://github.com/tarekwasfy01/Code-Transpiler | ||
| 9 | +cd Code-Transpiler | ||
| 10 | +export TMPDIR=$PWD/.tmp && mkdir -p .tmp | ||
| 11 | + | ||
| 12 | +echo "=== Building CLI ===" | ||
| 13 | +CGO_ENABLED=0 go build -o "$work/r2many" ./cmd/r2many || exit 1 | ||
| 14 | + | ||
| 15 | +echo | ||
| 16 | +echo "=== CLI: rust -> nim (exit code, then stderr tail) ===" | ||
| 17 | +for f in "$here"/cases/*.rs; do | ||
| 18 | + out=$work/$(basename "$f" .rs).nim | ||
| 19 | + rm -f "$out" | ||
| 20 | + msg=$("$work/r2many" transpile -from rust -to nim "$f" -o "$out" 2>&1) | ||
| 21 | + code=$? | ||
| 22 | + printf '%-24s exit=%s %s' "$(basename "$f")" "$code" "$(printf '%s' "$msg" | tail -1)" | ||
| 23 | + [ -n "$msg" ] || printf '<no error>' | ||
| 24 | + if [ -f "$out" ]; then printf ' emitted: %s' "$(tr -s '\n' ' ' < "$out")"; fi | ||
| 25 | + printf '\n' | ||
| 26 | +done | ||
| 27 | + | ||
| 28 | +echo | ||
| 29 | +echo "=== Frontend prober: UAST node kinds actually produced ===" | ||
| 30 | +mkdir -p cmd/dbg && cp "$here/prober/main.go" cmd/dbg/main.go | ||
| 31 | +for f in "$here"/cases/*.rs; do | ||
| 32 | + printf '%-24s ' "$(basename "$f")"; go run ./cmd/dbg rust "$f" 2>&1 | tail -1 | ||
| 33 | +done | ||
| 34 | +printf '%-24s ' "05-equivalent.R" | ||
| 35 | +go run ./cmd/dbg r "$here/cases/05-equivalent.R" 2>&1 | tail -1 | ||
added
src/main.rs +3 -0 | new file mode 100644 | ||
| @@ -0,0 +1,3 @@ | ||
| 1 | +fn main() { | |
| 2 | + println!("Hello, world!"); | |
| 3 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,3 @@ | |||
| 1 | +fn main() { | ||
| 2 | + println!("Hello, world!"); | ||
| 3 | +} | ||
added
src/ty.rs +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +//! Rust type -> Nim type mapping. | |
| 2 | +//! | |
| 3 | +//! Integer width is preserved exactly. Anything that cannot be represented | |
| 4 | +//! faithfully in Nim is reported as an error rather than approximated: a | |
| 5 | +//! silently widened integer would change the meaning of wrapping arithmetic, | |
| 6 | +//! which is precisely the kind of code people write in Rust. | |
| 7 | + | |
| 8 | +use syn::{GenericArgument, PathArguments, Type, TypeParamBound}; | |
| 9 | + | |
| 10 | +#[derive(Debug, Clone, PartialEq)] | |
| 11 | +pub enum Nim { | |
| 12 | + Prim(String), | |
| 13 | + Seq(Box<Nim>), | |
| 14 | + OpenArray(Box<Nim>), | |
| 15 | + Array(usize, Box<Nim>), | |
| 16 | + Tuple(Vec<Nim>), | |
| 17 | + Named(String, Vec<Nim>), | |
| 18 | + Var(Box<Nim>), | |
| 19 | + Unit, | |
| 20 | +} | |
| 21 | + | |
| 22 | +impl Nim { | |
| 23 | + pub fn render(&self) -> String { | |
| 24 | + match self { | |
| 25 | + Nim::Prim(s) => s.clone(), | |
| 26 | + Nim::Seq(t) => format!("seq[{}]", t.render()), | |
| 27 | + Nim::OpenArray(t) => format!("openArray[{}]", t.render()), | |
| 28 | + Nim::Array(n, t) => format!("array[{}, {}]", n, t.render()), | |
| 29 | + Nim::Tuple(ts) => { | |
| 30 | + let inner: Vec<String> = ts.iter().map(|t| t.render()).collect(); | |
| 31 | + format!("({})", inner.join(", ")) | |
| 32 | + } | |
| 33 | + Nim::Named(n, args) if args.is_empty() => n.clone(), | |
| 34 | + Nim::Named(n, args) => { | |
| 35 | + let inner: Vec<String> = args.iter().map(|t| t.render()).collect(); | |
| 36 | + format!("{}[{}]", n, inner.join(", ")) | |
| 37 | + } | |
| 38 | + Nim::Var(t) => format!("var {}", t.render()), | |
| 39 | + Nim::Unit => "void".into(), | |
| 40 | + } | |
| 41 | + } | |
| 42 | + | |
| 43 | + /// Owned form: a borrowed slice parameter is `openArray[T]`, but the same | |
| 44 | + /// type in an owned position (a field, a return value) must be `seq[T]`. | |
| 45 | + pub fn owned(self) -> Nim { | |
| 46 | + match self { | |
| 47 | + Nim::OpenArray(t) => Nim::Seq(t), | |
| 48 | + Nim::Var(t) => t.owned(), | |
| 49 | + other => other, | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + pub fn is_integer(&self) -> bool { | |
| 54 | + matches!(self, Nim::Prim(p) if matches!(p.as_str(), | |
| 55 | + "int8"|"int16"|"int32"|"int64"|"int"| | |
| 56 | + "uint8"|"uint16"|"uint32"|"uint64"|"uint")) | |
| 57 | + } | |
| 58 | + | |
| 59 | + pub fn is_unsigned(&self) -> bool { | |
| 60 | + matches!(self, Nim::Prim(p) if p.starts_with("uint")) | |
| 61 | + } | |
| 62 | +} | |
| 63 | + | |
| 64 | +pub fn prim(name: &str) -> Option<Nim> { | |
| 65 | + let mapped = match name { | |
| 66 | + "i8" => "int8", | |
| 67 | + "i16" => "int16", | |
| 68 | + "i32" => "int32", | |
| 69 | + "i64" => "int64", | |
| 70 | + "isize" => "int", | |
| 71 | + "u8" => "uint8", | |
| 72 | + "u16" => "uint16", | |
| 73 | + "u32" => "uint32", | |
| 74 | + "u64" => "uint64", | |
| 75 | + "usize" => "uint", | |
| 76 | + "f32" => "float32", | |
| 77 | + "f64" => "float64", | |
| 78 | + "bool" => "bool", | |
| 79 | + "char" => "Rune", | |
| 80 | + "str" | "String" => "string", | |
| 81 | + _ => return None, | |
| 82 | + }; | |
| 83 | + Some(Nim::Prim(mapped.into())) | |
| 84 | +} | |
| 85 | + | |
| 86 | +/// Types we refuse rather than approximate. | |
| 87 | +pub fn rejected(name: &str) -> Option<&'static str> { | |
| 88 | + match name { | |
| 89 | + "i128" | "u128" => Some("128-bit integers have no faithful Nim equivalent"), | |
| 90 | + _ => None, | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +pub fn map(t: &Type) -> Result<Nim, String> { | |
| 95 | + match t { | |
| 96 | + Type::Path(p) => { | |
| 97 | + let seg = p | |
| 98 | + .path | |
| 99 | + .segments | |
| 100 | + .last() | |
| 101 | + .ok_or_else(|| "empty type path".to_string())?; | |
| 102 | + let name = seg.ident.to_string(); | |
| 103 | + | |
| 104 | + if let Some(why) = rejected(&name) { | |
| 105 | + return Err(format!("unsupported type `{}`: {}", name, why)); | |
| 106 | + } | |
| 107 | + | |
| 108 | + let args: Vec<Nim> = match &seg.arguments { | |
| 109 | + PathArguments::AngleBracketed(a) => a | |
| 110 | + .args | |
| 111 | + .iter() | |
| 112 | + .filter_map(|g| match g { | |
| 113 | + GenericArgument::Type(t) => Some(map(t)), | |
| 114 | + _ => None, | |
| 115 | + }) | |
| 116 | + .collect::<Result<_, _>>()?, | |
| 117 | + _ => vec![], | |
| 118 | + }; | |
| 119 | + | |
| 120 | + match (name.as_str(), args.len()) { | |
| 121 | + ("Vec", 1) => Ok(Nim::Seq(Box::new(args[0].clone().owned()))), | |
| 122 | + ("Option", 1) => Ok(Nim::Named("Option".into(), args)), | |
| 123 | + ("Result", 2) => Ok(Nim::Named("Result".into(), args)), | |
| 124 | + ("Box", 1) => Ok(args[0].clone()), | |
| 125 | + _ => { | |
| 126 | + if let Some(p) = prim(&name) { | |
| 127 | + Ok(p) | |
| 128 | + } else { | |
| 129 | + Ok(Nim::Named(name, args)) | |
| 130 | + } | |
| 131 | + } | |
| 132 | + } | |
| 133 | + } | |
| 134 | + // &T is a value in Nim; &mut T becomes a `var` parameter. The caller | |
| 135 | + // decides whether a `var` is legal in the position it is used. | |
| 136 | + Type::Reference(r) => { | |
| 137 | + let inner = map(&r.elem)?; | |
| 138 | + if r.mutability.is_some() { | |
| 139 | + Ok(Nim::Var(Box::new(inner))) | |
| 140 | + } else { | |
| 141 | + Ok(inner) | |
| 142 | + } | |
| 143 | + } | |
| 144 | + Type::Slice(s) => Ok(Nim::OpenArray(Box::new(map(&s.elem)?))), | |
| 145 | + Type::Array(a) => { | |
| 146 | + let len = match &a.len { | |
| 147 | + syn::Expr::Lit(syn::ExprLit { | |
| 148 | + lit: syn::Lit::Int(i), | |
| 149 | + .. | |
| 150 | + }) => i | |
| 151 | + .base10_parse::<usize>() | |
| 152 | + .map_err(|e| format!("array length: {}", e))?, | |
| 153 | + _ => return Err("array length must be a literal".into()), | |
| 154 | + }; | |
| 155 | + Ok(Nim::Array(len, Box::new(map(&a.elem)?))) | |
| 156 | + } | |
| 157 | + Type::Tuple(t) if t.elems.is_empty() => Ok(Nim::Unit), | |
| 158 | + Type::Tuple(t) => Ok(Nim::Tuple( | |
| 159 | + t.elems.iter().map(map).collect::<Result<_, _>>()?, | |
| 160 | + )), | |
| 161 | + Type::Paren(p) => map(&p.elem), | |
| 162 | + Type::Group(g) => map(&g.elem), | |
| 163 | + Type::ImplTrait(i) => { | |
| 164 | + // `impl AsRef<[u8]>` and friends: fall back to the bound's own | |
| 165 | + // shape where we can recognise it, since Nim has no impl-trait. | |
| 166 | + for b in &i.bounds { | |
| 167 | + if let TypeParamBound::Trait(tb) = b { | |
| 168 | + if let Some(seg) = tb.path.segments.last() { | |
| 169 | + if seg.ident == "AsRef" || seg.ident == "Into" { | |
| 170 | + if let PathArguments::AngleBracketed(a) = &seg.arguments { | |
| 171 | + for g in &a.args { | |
| 172 | + if let GenericArgument::Type(t) = g { | |
| 173 | + return map(t); | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | + } | |
| 181 | + Err("unsupported `impl Trait` type".into()) | |
| 182 | + } | |
| 183 | + Type::Infer(_) => Err("inferred type in a position that needs a name".into()), | |
| 184 | + other => Err(format!("unsupported type form: {:?}", discriminant(other))), | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +fn discriminant(t: &Type) -> &'static str { | |
| 189 | + match t { | |
| 190 | + Type::BareFn(_) => "bare fn", | |
| 191 | + Type::Ptr(_) => "raw pointer", | |
| 192 | + Type::TraitObject(_) => "trait object", | |
| 193 | + Type::Never(_) => "never", | |
| 194 | + Type::Macro(_) => "macro", | |
| 195 | + _ => "other", | |
| 196 | + } | |
| 197 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +//! Rust type -> Nim type mapping. | ||
| 2 | +//! | ||
| 3 | +//! Integer width is preserved exactly. Anything that cannot be represented | ||
| 4 | +//! faithfully in Nim is reported as an error rather than approximated: a | ||
| 5 | +//! silently widened integer would change the meaning of wrapping arithmetic, | ||
| 6 | +//! which is precisely the kind of code people write in Rust. | ||
| 7 | + | ||
| 8 | +use syn::{GenericArgument, PathArguments, Type, TypeParamBound}; | ||
| 9 | + | ||
| 10 | +#[derive(Debug, Clone, PartialEq)] | ||
| 11 | +pub enum Nim { | ||
| 12 | + Prim(String), | ||
| 13 | + Seq(Box<Nim>), | ||
| 14 | + OpenArray(Box<Nim>), | ||
| 15 | + Array(usize, Box<Nim>), | ||
| 16 | + Tuple(Vec<Nim>), | ||
| 17 | + Named(String, Vec<Nim>), | ||
| 18 | + Var(Box<Nim>), | ||
| 19 | + Unit, | ||
| 20 | +} | ||
| 21 | + | ||
| 22 | +impl Nim { | ||
| 23 | + pub fn render(&self) -> String { | ||
| 24 | + match self { | ||
| 25 | + Nim::Prim(s) => s.clone(), | ||
| 26 | + Nim::Seq(t) => format!("seq[{}]", t.render()), | ||
| 27 | + Nim::OpenArray(t) => format!("openArray[{}]", t.render()), | ||
| 28 | + Nim::Array(n, t) => format!("array[{}, {}]", n, t.render()), | ||
| 29 | + Nim::Tuple(ts) => { | ||
| 30 | + let inner: Vec<String> = ts.iter().map(|t| t.render()).collect(); | ||
| 31 | + format!("({})", inner.join(", ")) | ||
| 32 | + } | ||
| 33 | + Nim::Named(n, args) if args.is_empty() => n.clone(), | ||
| 34 | + Nim::Named(n, args) => { | ||
| 35 | + let inner: Vec<String> = args.iter().map(|t| t.render()).collect(); | ||
| 36 | + format!("{}[{}]", n, inner.join(", ")) | ||
| 37 | + } | ||
| 38 | + Nim::Var(t) => format!("var {}", t.render()), | ||
| 39 | + Nim::Unit => "void".into(), | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + /// Owned form: a borrowed slice parameter is `openArray[T]`, but the same | ||
| 44 | + /// type in an owned position (a field, a return value) must be `seq[T]`. | ||
| 45 | + pub fn owned(self) -> Nim { | ||
| 46 | + match self { | ||
| 47 | + Nim::OpenArray(t) => Nim::Seq(t), | ||
| 48 | + Nim::Var(t) => t.owned(), | ||
| 49 | + other => other, | ||
| 50 | + } | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + pub fn is_integer(&self) -> bool { | ||
| 54 | + matches!(self, Nim::Prim(p) if matches!(p.as_str(), | ||
| 55 | + "int8"|"int16"|"int32"|"int64"|"int"| | ||
| 56 | + "uint8"|"uint16"|"uint32"|"uint64"|"uint")) | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + pub fn is_unsigned(&self) -> bool { | ||
| 60 | + matches!(self, Nim::Prim(p) if p.starts_with("uint")) | ||
| 61 | + } | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +pub fn prim(name: &str) -> Option<Nim> { | ||
| 65 | + let mapped = match name { | ||
| 66 | + "i8" => "int8", | ||
| 67 | + "i16" => "int16", | ||
| 68 | + "i32" => "int32", | ||
| 69 | + "i64" => "int64", | ||
| 70 | + "isize" => "int", | ||
| 71 | + "u8" => "uint8", | ||
| 72 | + "u16" => "uint16", | ||
| 73 | + "u32" => "uint32", | ||
| 74 | + "u64" => "uint64", | ||
| 75 | + "usize" => "uint", | ||
| 76 | + "f32" => "float32", | ||
| 77 | + "f64" => "float64", | ||
| 78 | + "bool" => "bool", | ||
| 79 | + "char" => "Rune", | ||
| 80 | + "str" | "String" => "string", | ||
| 81 | + _ => return None, | ||
| 82 | + }; | ||
| 83 | + Some(Nim::Prim(mapped.into())) | ||
| 84 | +} | ||
| 85 | + | ||
| 86 | +/// Types we refuse rather than approximate. | ||
| 87 | +pub fn rejected(name: &str) -> Option<&'static str> { | ||
| 88 | + match name { | ||
| 89 | + "i128" | "u128" => Some("128-bit integers have no faithful Nim equivalent"), | ||
| 90 | + _ => None, | ||
| 91 | + } | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +pub fn map(t: &Type) -> Result<Nim, String> { | ||
| 95 | + match t { | ||
| 96 | + Type::Path(p) => { | ||
| 97 | + let seg = p | ||
| 98 | + .path | ||
| 99 | + .segments | ||
| 100 | + .last() | ||
| 101 | + .ok_or_else(|| "empty type path".to_string())?; | ||
| 102 | + let name = seg.ident.to_string(); | ||
| 103 | + | ||
| 104 | + if let Some(why) = rejected(&name) { | ||
| 105 | + return Err(format!("unsupported type `{}`: {}", name, why)); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + let args: Vec<Nim> = match &seg.arguments { | ||
| 109 | + PathArguments::AngleBracketed(a) => a | ||
| 110 | + .args | ||
| 111 | + .iter() | ||
| 112 | + .filter_map(|g| match g { | ||
| 113 | + GenericArgument::Type(t) => Some(map(t)), | ||
| 114 | + _ => None, | ||
| 115 | + }) | ||
| 116 | + .collect::<Result<_, _>>()?, | ||
| 117 | + _ => vec![], | ||
| 118 | + }; | ||
| 119 | + | ||
| 120 | + match (name.as_str(), args.len()) { | ||
| 121 | + ("Vec", 1) => Ok(Nim::Seq(Box::new(args[0].clone().owned()))), | ||
| 122 | + ("Option", 1) => Ok(Nim::Named("Option".into(), args)), | ||
| 123 | + ("Result", 2) => Ok(Nim::Named("Result".into(), args)), | ||
| 124 | + ("Box", 1) => Ok(args[0].clone()), | ||
| 125 | + _ => { | ||
| 126 | + if let Some(p) = prim(&name) { | ||
| 127 | + Ok(p) | ||
| 128 | + } else { | ||
| 129 | + Ok(Nim::Named(name, args)) | ||
| 130 | + } | ||
| 131 | + } | ||
| 132 | + } | ||
| 133 | + } | ||
| 134 | + // &T is a value in Nim; &mut T becomes a `var` parameter. The caller | ||
| 135 | + // decides whether a `var` is legal in the position it is used. | ||
| 136 | + Type::Reference(r) => { | ||
| 137 | + let inner = map(&r.elem)?; | ||
| 138 | + if r.mutability.is_some() { | ||
| 139 | + Ok(Nim::Var(Box::new(inner))) | ||
| 140 | + } else { | ||
| 141 | + Ok(inner) | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + Type::Slice(s) => Ok(Nim::OpenArray(Box::new(map(&s.elem)?))), | ||
| 145 | + Type::Array(a) => { | ||
| 146 | + let len = match &a.len { | ||
| 147 | + syn::Expr::Lit(syn::ExprLit { | ||
| 148 | + lit: syn::Lit::Int(i), | ||
| 149 | + .. | ||
| 150 | + }) => i | ||
| 151 | + .base10_parse::<usize>() | ||
| 152 | + .map_err(|e| format!("array length: {}", e))?, | ||
| 153 | + _ => return Err("array length must be a literal".into()), | ||
| 154 | + }; | ||
| 155 | + Ok(Nim::Array(len, Box::new(map(&a.elem)?))) | ||
| 156 | + } | ||
| 157 | + Type::Tuple(t) if t.elems.is_empty() => Ok(Nim::Unit), | ||
| 158 | + Type::Tuple(t) => Ok(Nim::Tuple( | ||
| 159 | + t.elems.iter().map(map).collect::<Result<_, _>>()?, | ||
| 160 | + )), | ||
| 161 | + Type::Paren(p) => map(&p.elem), | ||
| 162 | + Type::Group(g) => map(&g.elem), | ||
| 163 | + Type::ImplTrait(i) => { | ||
| 164 | + // `impl AsRef<[u8]>` and friends: fall back to the bound's own | ||
| 165 | + // shape where we can recognise it, since Nim has no impl-trait. | ||
| 166 | + for b in &i.bounds { | ||
| 167 | + if let TypeParamBound::Trait(tb) = b { | ||
| 168 | + if let Some(seg) = tb.path.segments.last() { | ||
| 169 | + if seg.ident == "AsRef" || seg.ident == "Into" { | ||
| 170 | + if let PathArguments::AngleBracketed(a) = &seg.arguments { | ||
| 171 | + for g in &a.args { | ||
| 172 | + if let GenericArgument::Type(t) = g { | ||
| 173 | + return map(t); | ||
| 174 | + } | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + } | ||
| 179 | + } | ||
| 180 | + } | ||
| 181 | + Err("unsupported `impl Trait` type".into()) | ||
| 182 | + } | ||
| 183 | + Type::Infer(_) => Err("inferred type in a position that needs a name".into()), | ||
| 184 | + other => Err(format!("unsupported type form: {:?}", discriminant(other))), | ||
| 185 | + } | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +fn discriminant(t: &Type) -> &'static str { | ||
| 189 | + match t { | ||
| 190 | + Type::BareFn(_) => "bare fn", | ||
| 191 | + Type::Ptr(_) => "raw pointer", | ||
| 192 | + Type::TraitObject(_) => "trait object", | ||
| 193 | + Type::Never(_) => "never", | ||
| 194 | + Type::Macro(_) => "macro", | ||
| 195 | + _ => "other", | ||
| 196 | + } | ||
| 197 | +} | ||