nandi/rustnimpublic Fork 0
1a218c2
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.

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>
nandi committed 2026-09-18T18:40:03-07:00 Browse files
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+}