Skip to content

Repository files navigation

> Note: despite the `.org` extension this file is written in Markdown, like the > rest of the documentation in this repository.

## Overview

`cl-rust-generator` turns Common Lisp s-expressions into Rust source code. You write Lisp, get Rust. The point is not to hide Rust — the generated code is plain, readable Rust — but to have Lisp macros, `#+feature` conditionals and ordinary Lisp data available while assembling a program.

The generator is deliberately shallow: it is a syntax transformer, not a compiler. It does not type check, it does not track ownership, and it does not know Rust’s standard library. Anything it has no form for can be written as a plain string, which is passed through verbatim.

Two files matter:

FilePurpose
`rs.lisp`the whole translator: `emit-rs` and `write-source`
`transpiler-tests.lisp`the test suite, and the source of `SUPPORTED_FORMS.md`

## Quick start

“`lisp (ql:quickload :cl-rust-generator) (in-package :cl-rust-generator)

(emit-rs :code ‘(defun main () (println! (string “hello”)))) ;; => “fn main() { println!("hello") }” “`

To write a file (and format it with `rustfmt`) pass the s-expression, not the emitted string:

“`lisp (write-source “/tmp/demo/src/main.rs” ‘(defun main () (println! (string “hello”)))) “`

`write-source` hashes the emitted text and only rewrites the file when it actually changed, so a watching `cargo` does not rebuild for nothing. Set `*rustfmt-program*` to `nil` to skip formatting, or to an absolute path if `rustfmt` is not on `PATH`. `*rustfmt-arguments*` can carry things like `’(“–edition” “2018”)`.

## Two rules that explain most surprises

  1. **`-` in a symbol becomes `:`.** That is how paths are written: `–` turns into `::`, so `std–sync–Arc–new` emits `std::sync::Arc::new`. As a consequence identifiers in generated code must use `_`, never `-`.
  2. **Strings are emitted verbatim.** `”&mut self”`, `”vec![]”`, `”#[derive(Clone)]”` and `”io::Result<()>”` are the escape hatch for everything the generator has no form for. Nothing inside a string is rewritten — in particular `–` stays `–`.

Loading `rs.lisp` also sets `(readtable-case readtable) :invert` on the current readtable, which is what makes `Ok`, `Some` and `CamelCase` survive. That is a global side effect; it is required by every example.

## Supported forms

`SUPPORTED_FORMS.md` lists every form with an example of the Rust it produces. That file is generated from the test suite, so the examples in it are verified:

“`sh ./generate-docs.sh # regenerate SUPPORTED_FORMS.md ./run-tests.sh # run the test suite “`

A short map of what exists:

GroupForms
literals`string`, `string-r` (raw), `string-b` (byte string), `char`, `byte`, `hex`, integers, `f32`/`f64`
arithmetic`+ - * / %`
bit operations`logand`, `logior`, `logxor`, `<<`, `>>`
comparison`== != < > <= >=`, `and`, `or`, `not`
assignment`=`, `setf`, `incf`, `decf`, `/=` (divide-assign), `*=`, `^=`, `%=`, `<<=`, `>>=`, `&=`, `\=`
references`ref`, `ref-mut`, `deref`, `coerce` (`as`), `?`
collections`paren`/`values` (tuple), `bracket`/`list`, `array-repeat`, `vec!`, `curly`, `comma`, `semicolon`, `space`
indexing`aref`, `range`, `range-inclusive`, `range-from`, `range-to`, `range-to-inclusive`, `range-full`, `dot`
control flow`if`, `when`, `unless`, `if-let`, `while-let`, `while`, `loop`, `break`, `continue`, `for`, `dotimes`, `case` (`match`), `return`
blocks`do0`, `progn`, `block`, `do`, `stmt`, `expr`, `unsafe`, `extern`
bindings`let`, `let*`, `let-else`, `declare` (`type`, `values`, `mutable`, `immutable`)
functions`defun`, `defun-async`, `await`, `lambda`, plain function call
items`defstruct0`, `defenum`, `deftrait`, `make-instance`, `impl`, `deftype`, `use`, `mod`, `macroexpand`, `struct`, `attr`

### Mutability

`let` binds immutably, `let*` binds mutably — that is the only difference between the two. Inside either one, `(declare (mutable x))` and `(declare (immutable x))` override the default per variable.

“`lisp (let ((a 1)) …) ; let a = 1; (let* ((a 1)) …) ; let mut a = 1; “`

For function parameters the `mut` ends up where Rust wants it: a mutable by-value parameter is `mut x: T`, a mutable reference is `x: &mut T`. A leading `&` on a declared parameter name asks for a reference:

“`lisp (defun f (&a b) (declare (type i32 &a b) (mutable &a b)) (return 0)) ;; fn f(a: &mut i32, mut b: i32) { return 0 } “`

### Parentheses

Operators parenthesise both their operands and their result, so grouping is never ambiguous:

“`lisp (dot (% a b) (to_string)) ;; ((a)%(b)).to_string() “`

The redundant outer pair is removed again in the places where `rustc`’s `unused_parens` lint would fire: `if`/`while` conditions, `match` subjects, `for` iterators, `return` values and the right hand side of an assignment. Tuples are left alone. Everything else is left to `rustfmt` and `cargo fix`.

### Eliding parentheses

Bind `cl-rust-generator::*omit-redundant-parens*` to true and the operator forms consult a precedence table (`*rust-precedence*`, derived from Table 5-1 of “Programming Rust”, see `operator-precedence.md`) instead of parenthesising everything:

“`lisp (let ((omit-redundant-parens t)) (emit-rs :code ‘(+ 1 (* 2 3)))) ;; “1 + 2 * 3” “`

Same-level nesting stays flat only where Rust allows it: `a - b - c` is fine, `a - (b - c)` keeps its parentheses, and comparisons never chain (`a == (b == c)`, since `a==b==c` would not even parse). Unlike C, Rust’s bitwise `&` binds tighter than `==`, so `(== (logand x mask) 0)` becomes `x & mask == 0`. The fully parenthesised output is the oracle: the value tests in `transpiler-tests.lisp` compile and run in both modes and must compute the same results.

### Async, attributes and `?`

`(defun-async name (args) …)` is `defun` emitting `async fn`, for tokio/axum handlers. `(await expr)` appends `.await`, `(? expr)` appends `?`; both strip redundant outer parentheses like `return` does. `(attr “derive(Clone)” form)` writes one `#[…]` line per string in front of `form`, so derives and attributes keep working through the generator instead of being hand-written strings around whole items. `attr` and `defun-async` are in `*keywords-without-semicolon*`, so `do0` adds no stray `;` after them; `await`/`?` in statement position correctly get one.

“`lisp (defun-async fetch (id) (declare (type i64 id) (values String)) (return (await (db–fetch id)))) ;; async fn fetch(id: i64) -> String { return db::fetch(id).await } “`

### Semicolons

`do0` terminates each form with a semicolon unless one of three things is true: the emission already ends with `;`, the form is a plain string (the escape hatch, passed through verbatim), or the head is a block/item form (`*keywords-without-semicolon*`: `defun`, `if`, `when`, `case`, `for`, `while`, `loop`, `let`, `progn`, `deftrait`, …). `progn` drops the semicolon after the last form, so a block evaluates to it (Rust’s implicit return); `block` keeps it, so the block evaluates to `()`.

When the heuristic cannot know — typically a `(space …)` escape-hatch form in statement position — say so explicitly with `(stmt form)`, which appends exactly one `;`, or with `(expr form)`, which adds none (for the tail of a `progn`, where a missing semicolon means Rust returns the value). `(let-else …)` always terminates itself, because Rust requires the semicolon there in every position.

## Examples

“`lisp (defun gcd (n m) (declare (type u64 n m) (mutable n m) (values u64)) (while (!= 0 m) (when (< m n) (let ((tt m)) (setf m n n tt))) (setf m (% m n))) (return n)) “`

becomes

“`rust fn gcd(mut n: u64, mut m: u64) -> u64 { while (0) != (m) { if (m) < (n) { let tt = m; m = n; n = tt; } m = (m) % (n); } return n; } “`

and

“`lisp (do0 (defstruct0 Point (x f64) (y f64)) (impl Point (space pub (defun length (&self) (declare (values f64)) (return (dot (+ (* self.x self.x) (* self.y self.y)) (sqrt))))))) “`

becomes

“`rust struct Point { x: f64, y: f64, } impl Point { pub fn length(&self) -> f64 { return (((self.x) * (self.x)) + ((self.y) * (self.y))).sqrt(); } } “`

## Example projects

Examples 01–13 are generated: each directory holds a `gen00.lisp` that writes the Rust next to it. Examples 14–20 are hand written Rust and do not use the generator.

ExampleDescriptionGeneratedBuilds offline
`01_gcd`GCD on the command line, with a `#[test]`yesyes
`02_webgcd`iron web serveryesneeds crates
`03_glium`glium triangleyesneeds crates
`04_azul`azul GUIyesneeds crates
`05_imgui`imgui windowyesneeds crates
`06_parallel_text`multi-file full text index with threadsyesneeds crates
`07_glutin_gl`glutin + raw OpenGLyesneeds crates
`08_glfw`GLFW + imguiyesneeds crates
`09_wasm`wasm-bindgen DOMyesneeds crates
`10_wasm_webgl`wasm + WebGL compute shaderyesneeds crates
`11_cuda`rustacudayesneeds crates
`12_imgui_wgpu`imgui on wgpuyesneeds crates
`13_vulkano`vulkano compute + graphicsyesneeds crates
`14_list` … `20_webprox_avif`hand written Rust projectsnosee their READMEs

Regenerating an example is just:

“`sh cd examples/01_gcd && sbcl –load gen00.lisp –quit “`

## Not supported

Rust concepts the generator has no form for; use strings for these:

traits with default bodies or generics (see `deftrait` for plain method

signatures), struct variants in enums (see `defenum` for unit variants), lifetimes, `where` clauses

match guards

`pub`, `const`, `static` — write them as strings, or use

`(space pub …)` (attributes use the `attr` form)

Removed forms fail loudly with the replacement named; see `MIGRATION.md` for the list (`slice`, `cast`, `string#`, `&`, `^`).

Forms inherited from the C/C++ generator this file grew out of (`handler-case`, `throw`, `include`, `defclass`, `protected`, `public`, `->`, `new`) now signal an error that names the Rust alternative, instead of silently emitting C++.

## License

GPL, see `LICENSE`.

About

Common Lisp based converter from s-expressions to Rust language source code

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages