Leo3 provides safe, ergonomic Rust bindings to the Lean4 theorem prover, inspired by PyO3's architecture.
Requires Lean 4.25.2 (install via elan).
[dependencies]
leo3 = "0.2.1"use leo3::prelude::*;
fn main() -> LeanResult<()> {
leo3::prepare_freethreaded_lean();
leo3::with_lean(|lean| {
let s = LeanString::mk(lean, "Hello from Rust!")?;
println!("{}", LeanString::cstr(&s)?);
let n = LeanNat::from_usize(lean, 42)?;
println!("{}", LeanNat::to_usize(&n)?);
Ok(())
})
}with_lean() is the canonical safe entry point for caller code. It ensures the
shared runtime worker exists and attaches the current thread before handing out a
Lean<'l> token. prepare_freethreaded_lean() is still useful when you want to
pay initialization cost eagerly during startup.
Default features: none. The default build keeps the public surface intentionally minimal: runtime initialization, smart pointers, core type wrappers/conversions, closures, thunks, and synchronization helpers are always available.
| Feature | Enables |
|---|---|
| default (none) | Core runtime/token APIs, conversions, closures, thunks, sync helpers, Lean type wrappers |
experimental-containers |
Experimental container wrappers for HashMap, HashSet, RBMap; all three now use real Lean runtime semantics for a narrow key matrix |
macros |
#[leanfn], #[leanclass], #[leanmodule], #[derive(IntoLean, FromLean)] |
meta |
leo3::meta::* metaprogramming APIs |
io |
leo3::io::* IO / filesystem / process / environment helpers |
task |
leo3::task, leo3::task_combinators, leo3::promise |
module-loading |
leo3::module::* dynamic shared-library loading |
tokio |
leo3::tokio_bridge::* (implies task) |
runtime-tests |
Runtime-dependent integration tests (for development/CI) |
Example dependency declarations:
# Minimal core surface
leo3 = "0.2.1"
# Opt into specific subsystems
leo3 = { version = "0.2.1", features = ["macros", "meta", "task"] }
# Opt into currently experimental container wrappers
leo3 = { version = "0.2.1", features = ["experimental-containers"] }Leo3 build scripts resolve Lean in a single shared order:
LEO3_NO_LEAN=1disables Lean detection and linking entirely.LEO3_CONFIG_FILE=/path/to/leo3-build-config.txtprovides an explicit key/value config.- Host discovery tries
LEO3_CROSS_*, thenLEAN_HOME(with optionalLEAN_LIB_DIR/LEAN_INCLUDE_DIR), thenlake, thenelan, thenPATH.
leo3-ffi is the authoritative detector. It exports the resolved config through Cargo's DEP_LEAN4_LEO3_CONFIG, and leo3 consumes that propagated config so both crates always agree on Lean version cfgs and linker settings.
Explicit overrides are authoritative: if DEP_LEAN4_LEO3_CONFIG, LEO3_CONFIG_FILE, LEO3_CROSS_*, LEAN_HOME, LEAN_LIB_DIR, or LEAN_INCLUDE_DIR is set but invalid, Leo3 reports that error instead of silently falling back to a lower-priority source.
Leo3's built-in IntoLean / FromLean support matrix is intentionally small and explicit:
| Rust shape | Lean shape | IntoLean |
FromLean |
Cost / ownership rule |
|---|---|---|---|---|
u8-u64, usize, i8-i64, isize, f32, f64, bool, char, () |
scalar wrappers / Unit |
yes | yes | value conversion; allocating the Lean object on Rust -> Lean |
String |
String |
yes | yes | allocative copy |
&str |
String |
yes | no | allocative copy into Lean only |
Vec<T> |
Array |
yes | yes | allocates the container and converts elements one by one |
Option<T> |
Option |
yes | yes | recursive container conversion |
Result<T, E> |
Except E T |
yes | yes | recursive container conversion; Lean keeps the error type first |
(A, B) |
Prod A B |
yes | yes | recursive pair conversion; only pairs are built in |
Vec<u8> / &[u8] helpers |
ByteArray |
helper path | helper path | bulk memcpy fast path via vec_u8_*, slice_u8_into_lean, to_lean!, from_lean! |
T: ExternalClass |
Lean external object | yes | yes, if T: Clone |
Rust -> Lean stores ownership in an external object; Lean -> Rust clones the inner value |
Rules behind that table:
FromLeanis not implemented for&str; round-trips useString.Vec<u8>does not use a specialized trait impl on stable Rust. The optimizedByteArraypath is exposed through helper functions and theto_lean!/from_lean!macros.FromLeanforT: ExternalClass + Cloneis clone-based extraction, not borrowing. Borrow-first access goes throughLeanExternal<T>::borrow(),try_get_mut(), andtry_take_inner()(withget_ref()/get_mut()still available as lower-level APIs).- User-defined types can extend the matrix with manual impls or
#[derive(IntoLean, FromLean)].
LeanHashMap, LeanHashSet, and LeanRBMap are available only behind the
experimental-containers feature.
These APIs are still gated explicitly so the default public surface remains semantically honest while container support is being completed.
Current status:
LeanHashMapnow uses Lean's real runtime representation for a narrow key matrix by pairing exportedHashableclosures with boxedDecidableEqclosures.LeanHashSetnow uses the same real runtime path for the same narrow key matrix.LeanRBMapnow uses Lean's real runtime representation and reduced-arity container entry points for a narrow key matrix (Nat,Int,String, and fixed-width signed integer wrappers).- runtime tests now cover duplicate inserts, replacement semantics, string-key support, and cross-family parity for the supported paths.
#[leanfn] — Export Rust functions to Lean:
# #[cfg(feature = "macros")]
# mod leanfn_doctest {
use leo3::prelude::*;
#[leanfn]
fn add(a: u64, b: u64) -> u64 {
a + b
}
# }#[leanclass] — Expose Rust structs as Lean external classes with auto-generated FFI wrappers and Lean source declarations:
# #[cfg(feature = "macros")]
# mod leanclass_doctest {
use leo3::prelude::*;
#[derive(Clone)]
#[leanclass]
struct Counter { value: i64 }
#[leanclass]
impl Counter {
fn new() -> Self { Counter { value: 0 } }
fn get(&self) -> i64 { self.value }
fn increment(&mut self) { self.value += 1; }
}
# }#[leanclass] receiver semantics are fixed:
| Rust receiver | Lean-visible signature rule | Runtime rule |
|---|---|---|
| no receiver | A -> ... -> R |
parameters use FromLean, result uses IntoLean |
&self |
Self -> A -> ... -> R |
shared borrow of the external object |
&mut self, -> () |
Self -> A -> ... -> Self |
copy-on-write mutation; returns the updated object |
&mut self, -> R |
Self -> A -> ... -> Prod Self R |
copy-on-write mutation; preserves both updated object and value |
self |
Self -> A -> ... -> R |
exclusive receivers move out; shared receivers clone first |
&mut self and self methods therefore require the class type to implement
Clone, because the shared-receiver fallback is clone-based.
Generated Lean declaration strings accept a narrower type grammar than "anything that implements conversion traits". The supported declaration shapes are:
- scalars: integers, floats,
bool,char,String,() Self, the current struct name, and other plain non-generic path typesVec<T>,Option<T>,Result<T, E>- pairs
(A, B)
The macro intentionally rejects these declaration shapes at compile time:
- references such as
&stror&T - tuples with arity other than
0or2 - generic path types other than
Vec<T>,Option<T>, andResult<T, E> - generic
#[leanclass]structs, genericimplblocks, generic methods, and non-identifier parameter patterns
Plain non-generic path types are emitted verbatim into the generated Lean type string. That means the Rust side still needs the relevant conversion impls, and the Lean side still needs a matching type name in scope.
#[derive(IntoLean)] / #[derive(FromLean)] — Automatic conversion derive macros.
#[leanmodule] now has an explicit module-registration story too: Leo3 treats
inline #[leanfn] items inside the annotated module as the module's implicit
export set, and generates __leo3_module_metadata() so downstream Rust code can
inspect the chosen Lean-visible names.
For a single runnable example that combines module initialization, exported functions, and an external class, run:
cargo run --example macro_pipeline --features macrosThat example exercises a generated initialize_* module entry point, #[leanfn]
FFI wrappers, and the Lean declaration strings emitted for #[leanclass].
Full access to Lean's kernel and elaborator:
LeanEnvironment— Create and manage declaration environmentsLeanExpr— Build expressions (binders, applications, literals, lambda, forall)LeanDeclaration— Define axioms, definitions, and theoremsMetaMContext— Type inference, type checking, definitional equality, proof validation- Tactic support
io: console, filesystem, environment variables, process, and time helperstask:LeanTask/LeanPromiseplus combinators (join,race,select,timeout)module-loading:LeanModulefor loading compiled Lean shared librariestokio: async bridge for Lean tasks- Core (always available):
LeanClosureandLeanThunk
leo3/
├── leo3/ # Safe high-level abstractions
├── leo3-ffi/ # Raw FFI bindings to Lean4's C API
├── leo3-build-config/ # Build-time Lean4 detection and configuration
├── leo3-macros/ # Procedural macro entry points
├── leo3-macros-backend/ # Procedural macro implementations
└── leo3-ffi-check/ # FFI layout validation (à la pyo3-ffi-check)
Lean<'l>token proves runtime initialization at compile-timeLeanBound<'l, T>(lifetime-bound),LeanRef<T>(unbound),LeanUnbound<T>(thread-safe) smart pointers with automatic reference counting#[repr(transparent)]zero-cost wrappers- Copy-on-write semantics for
&mut selfFFI methods - Worker thread architecture for all Lean runtime calls (avoids mimalloc heap issues)
- Version support: Lean 4.20.0–4.30 with
#[cfg(lean_4_25)]gates
| PyO3 | Leo3 |
|---|---|
Python<'py> |
Lean<'l> |
Bound<'py, T> |
LeanBound<'l, T> |
Py<T> |
LeanRef<T> |
#[pyfunction] |
#[leanfn] |
#[pyclass] |
#[leanclass] |
See TESTING.md for the full tiered CI map, the
current contract notes, architecture notes,
and the contributor guide. Common local commands:
LEO3_NO_LEAN=1 cargo test --locked --workspace --exclude leo3 --lib
LEO3_NO_LEAN=1 cargo test --locked -p leo3 --no-default-features --test test_features
LEO3_NO_LEAN=1 cargo test --locked -p leo3 --no-default-features --test test_surface_contract
LEO3_NO_LEAN=1 cargo test --locked -p leo3 --no-default-features --features experimental-containers --test test_features
LEO3_NO_LEAN=1 cargo test --locked -p leo3 --features macros --test test_compile_error
cargo test --locked --all-features --workspaceMIT OR Apache-2.0