Skip to content

Commit a94c426

Browse files
feat: Add typed reference API (#4)
* feat: Add typed reference API * chore: Move tests to top level * Simplify CI and Nix build * Add `LeanShared` multi-threading object * Update docs * fmt * Cleanup * Fixup * Cleanup * Clippy
1 parent 9118518 commit a94c426

24 files changed

Lines changed: 3483 additions & 1741 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,38 +29,16 @@ jobs:
2929
- name: Check clippy warnings
3030
run: cargo xclippy
3131
- name: Check *everything* compiles
32-
run: cargo check --all-targets --all-features --workspace
33-
# Restore and then save ./test/.lake to the GitHub cache
32+
run: cargo check --all-targets --all-features
33+
# Restore and then save .lake to the GitHub cache
3434
# Essentially the same as lean-action but without re-downloading the Lean toolchain
3535
- uses: actions/cache@v5
3636
with:
37-
path: ./test/.lake
38-
key: lake-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('lean-toolchain') }}-${{ hashFiles('test/lake-manifest.json') }}-${{ github.sha }}
39-
restore-keys: lake-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('lean-toolchain') }}-${{ hashFiles('test/lake-manifest.json') }}
37+
path: ./.lake
38+
key: lake-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('lean-toolchain') }}-${{ hashFiles('lake-manifest.json') }}-${{ github.sha }}
39+
restore-keys: lake-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('lean-toolchain') }}-${{ hashFiles('lake-manifest.json') }}
4040
- run: lake build --wfail -v
41-
working-directory: ./test
4241
- run: lake test
43-
working-directory: ./test
44-
45-
valgrind:
46-
needs: test
47-
runs-on: ubuntu-latest
48-
steps:
49-
- uses: actions/checkout@v6
50-
- uses: actions-rust-lang/setup-rust-toolchain@v1
51-
# Only restore the cache, since the `test` job will save the test binary to the cache first
52-
- uses: actions/cache/restore@v5
53-
with:
54-
path: ./test/.lake
55-
key: lake-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('lean-toolchain') }}-${{ hashFiles('test/lake-manifest.json') }}-${{ github.sha }}
56-
restore-keys: lake-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('lean-toolchain') }}-${{ hashFiles('test/lake-manifest.json') }}
57-
- uses: leanprover/lean-action@v1
58-
with:
59-
auto-config: false
60-
build: true
61-
build-args: "LeanFFITests"
62-
use-github-cache: false
63-
lake-package-directory: "./test"
6442
- name: Install valgrind
6543
run: sudo apt-get update && sudo apt-get install -y valgrind
6644
- name: Run tests under valgrind
@@ -72,7 +50,6 @@ jobs:
7250
--track-origins=yes \
7351
--error-exitcode=1 \
7452
.lake/build/bin/LeanFFITests
75-
working-directory: ./test
7653
7754
nix:
7855
runs-on: ubuntu-latest

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Rust
2-
/target
2+
target
3+
4+
# Lean
5+
.lake
36

47
# Nix
58
result

Cargo.lock

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1-
[workspace]
2-
members = ["test"]
3-
41
[package]
52
name = "lean-ffi"
63
version = "0.1.0"
74
edition = "2024"
85

6+
[lib]
7+
crate-type = ["lib", "staticlib"]
8+
9+
[features]
10+
test-ffi = []
11+
912
[dependencies]
1013
num-bigint = "0.4.6"
1114

1215
[build-dependencies]
1316
bindgen = "0.72"
1417
cc = "1"
18+
19+
[profile.dev]
20+
panic = "abort"
21+
22+
[profile.release]
23+
panic = "abort"

README.md

Lines changed: 126 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,113 @@
11
# lean-ffi
22

33
Rust bindings to the `lean.h` Lean C FFI, generated with [`rust-bindgen`](https://github.com/rust-lang/rust-bindgen).
4-
Bindgen runs in `build.rs` and generates unsafe Rust functions that link to
4+
Bindgen runs in `build.rs` and generates unsafe Rust functions that link to
55
Lean's `lean.h` C library. This external module can then be found at
66
`target/release/lean-ffi-<hash>/out/lean.rs`.
77

8-
These bindings are then wrapped in a typed Rust API based on the underlying Lean type,
9-
in order to make facilitate ergonomic handling of Lean objects in Rust.
10-
11-
## `LeanObject` API
12-
13-
The fundamental building block is `LeanObject`, a wrapper around an opaque
14-
Lean value represented in Rust as `*const c_void`. This value is either a
15-
pointer to a heap-allocated object or a tagged scalar (a raw value that fits
16-
into one pointer's width, e.g. a `Bool` or small `Nat`). `LeanObject` is
17-
then itself wrapped into Lean types such as `LeanCtor` for inductives,
18-
`LeanArray` for arrays, etc.
19-
20-
A `lean_domain_type!` macro is also defined to allow for easy construction
21-
of arbitrary Lean object types, which can then be used directly in FFI
22-
functions to disambiguate between other `LeanObject`s. Some examples can be found in the
23-
[Ix project](https://github.com/argumentcomputer/ix/blob/main/src/lean.rs).
24-
To construct custom data in Rust, the user can define their own constructor methods
25-
using `LeanCtor` (e.g. [`PutResponse`](https://github.com/argumentcomputer/ix/blob/main/src/ffi/iroh.rs)).
26-
It is possible to use `LeanObject` or `*const c_void` directly in an `extern "C" fn`,
27-
but this is generally not recommended as internal Rust functions may pass in the wrong object
28-
more easily, and any low-level constructors would not be hidden behind the
29-
API boundary. To enforce this, the `From<LeanType> for LeanObject` trait is
30-
implemented to get the underlying `LeanObject`, but creating a wrapper type
31-
from a `LeanObject` requires an explicit constructor for clarity.
32-
33-
A key concept in this design is that ownership of the data is transferred to
34-
Lean, making it responsible for deallocation. If the data type is intended to be
35-
used as a black box by Lean, `ExternalClass` is a useful abstraction. It
36-
requires a function pointer for deallocation, meaning the Rust code must
37-
provide a function that properly frees the object's memory by dropping it.
38-
See [`KECCAK_CLASS`](https://github.com/argumentcomputer/ix/blob/main/src/ffi/keccak.rs) for an example.
8+
These bindings are then wrapped in a typed Rust API that models Lean's
9+
ownership conventions (`lean_obj_arg` vs `b_lean_obj_arg`) using Rust's
10+
type system.
3911

40-
## Notes
12+
## Ownership Model
13+
14+
The core types are:
15+
16+
- **`LeanOwned`** — An owned reference to a Lean object. `Drop` calls `lean_dec`,
17+
`Clone` calls `lean_inc`. Not `Copy`. Corresponds to `lean_obj_arg` (input) and
18+
`lean_obj_res` (output) in the C FFI.
19+
20+
- **`LeanBorrowed<'a>`** — A borrowed reference. `Copy`, no `Drop`, lifetime-bounded.
21+
Corresponds to `b_lean_obj_arg` in the C FFI. Used when Lean declares a parameter
22+
with `@&`.
23+
24+
- **`LeanShared`** — A thread-safe owned reference. Wraps `LeanOwned` after calling
25+
`lean_mark_mt` on the object graph, which transitions all reachable objects to
26+
multi-threaded mode with atomic refcounting. `Send + Sync`. Use `borrow()` to get
27+
a `LeanBorrowed<'_>` for reading, `into_owned()` to unwrap back to `LeanOwned`.
28+
29+
- **`LeanRef`** — Trait implemented by `LeanOwned`, `LeanBorrowed`, and `LeanShared`,
30+
providing shared read-only operations like `as_raw()`, `is_scalar()`, `tag()`, and
31+
unboxing methods.
32+
33+
All reference types are safe for persistent objects (`m_rc == 0`) — `lean_inc_ref` and
34+
`lean_dec_ref` are no-ops when `m_rc == 0`.
35+
36+
## Domain Types
4137

42-
### Inductive Types
38+
Domain types wrap the ownership types to provide type safety at FFI boundaries.
39+
Built-in domain types include `LeanArray<R>`, `LeanString<R>`, `LeanCtor<R>`,
40+
`LeanList<R>`, `LeanOption<R>`, `LeanExcept<R>`, `LeanIOResult<R>`, `LeanProd<R>`,
41+
`LeanNat<R>`, `LeanBool<R>`, `LeanByteArray<R>`, and `LeanExternal<T, R>`.
42+
43+
### Naming convention
44+
45+
Domain types are prefixed with `Lean` to distinguish them from Lean-side type names
46+
and to match the built-in types. For example, a Lean `Point` structure becomes
47+
`LeanPoint` in Rust.
48+
49+
### Defining custom domain types
50+
51+
Use the `lean_domain_type!` macro to define newtypes for your Lean types:
52+
53+
```rust
54+
lean_ffi::lean_domain_type! {
55+
/// Lean `Point` — structure Point where x : Nat; y : Nat
56+
LeanPoint;
57+
/// Lean `PutResponse` — structure PutResponse where message : String; hash : String
58+
LeanPutResponse;
59+
}
60+
```
61+
62+
This generates a `#[repr(transparent)]` wrapper with `Clone`, conditional `Copy`,
63+
`inner()`, `as_raw()`, `into_raw()`, and `From` impls. You can then add
64+
accessor methods — readers are generic over `R: LeanRef` (work on both owned
65+
and borrowed), constructors return `LeanOwned`:
66+
67+
```rust
68+
impl<R: LeanRef> LeanPutResponse<R> {
69+
pub fn message(&self) -> LeanBorrowed<'_> {
70+
self.as_ctor().get(0) // borrowed ref into the object, no lean_inc
71+
}
72+
pub fn hash(&self) -> LeanBorrowed<'_> {
73+
self.as_ctor().get(1)
74+
}
75+
}
76+
77+
impl LeanPutResponse<LeanOwned> {
78+
pub fn mk(message: &str, hash: &str) -> Self {
79+
let ctor = LeanCtor::alloc(0, 2, 0);
80+
ctor.set(0, LeanString::new(message));
81+
ctor.set(1, LeanString::new(hash));
82+
Self::new(ctor.into())
83+
}
84+
}
85+
```
86+
87+
### FFI function signatures
88+
89+
Use domain types in `extern "C"` function signatures. The ownership type parameter
90+
tells Rust how to handle reference counting:
91+
92+
```rust
93+
// Lean: @[extern "process"] def process (xs : @& Array Nat) (n : Nat) : Array Nat
94+
#[no_mangle]
95+
extern "C" fn process(
96+
xs: LeanArray<LeanBorrowed<'_>>, // @& → borrowed, no lean_dec
97+
n: LeanNat<LeanOwned>, // owned → lean_dec on drop
98+
) -> LeanArray<LeanOwned> { // returned to Lean, no drop
99+
// ...
100+
}
101+
```
102+
103+
## Inductive Types and Field Layout
43104

44105
Extra care must be taken when dealing with [inductive
45106
types](https://lean-lang.org/doc/reference/latest/The-Type-System/Inductive-Types/#run-time-inductives)
46107
as the runtime memory layout of constructor fields may not match the
47108
declaration order in Lean. Fields are reordered into three groups:
48109

49-
1. Non-scalar fields (lean_object *), in declaration order
110+
1. Non-scalar fields (`lean_object*`), in declaration order
50111
2. `USize` fields, in declaration order
51112
3. Other scalar fields, in decreasing order by size, then declaration order within each size
52113

@@ -59,35 +120,44 @@ structure Reorder where
59120
size : UInt64
60121
```
61122

62-
would be laid out as [obj, size, flag] at runtime — the `UInt64` is placed
123+
would be laid out as `[obj, size, flag]` at runtime — the `UInt64` is placed
63124
before the `Bool`. Trivial wrapper types (e.g. `Char` wraps `UInt32`) count as
64125
their underlying scalar type.
65126

66-
To avoid issues, define Lean structures with fields already in runtime order
67-
(objects first, then scalars in decreasing size), so that declaration order
68-
matches the reordered layout.
127+
Use `LeanCtor` methods to access fields at the correct offsets:
128+
129+
```rust
130+
// 1 object field, scalars: u64 at offset 0, u8 (Bool) at offset 8
131+
let ctor = unsafe { LeanBorrowed::from_raw(ptr.as_raw()) }.as_ctor();
132+
let obj = ctor.get(0); // object field by index
133+
let size = ctor.get_u64(1, 0); // u64 at scalar offset 0 (past 1 non-scalar field)
134+
let flag = ctor.get_bool(1, 8); // bool at scalar offset 8
135+
```
136+
137+
## Notes
138+
139+
### Rust panic behavior
140+
141+
By default, Rust uses stack unwinding for panics. If a panic occurs in a Lean-to-Rust FFI function, the unwinding will try to cross the FFI boundary back into Lean. This is [undefined behavior](https://doc.rust-lang.org/stable/reference/panic.html#unwinding-across-ffi-boundaries). To avoid this, configure Rust to abort on panic in `Cargo.toml`:
142+
143+
```toml
144+
[profile.release]
145+
panic = "abort"
146+
```
69147

70148
### Enum FFI convention
71149

72150
Lean passes simple enums (inductives where all constructors have zero fields,
73151
e.g. `DefKind`, `QuotKind`) as **raw unboxed tag values** (`0`, `1`, `2`, ...)
74-
across the FFI boundary, not as `lean_box(tag)`. To decode, use
75-
`obj.as_ptr() as usize`; to build, use `LeanObject::from_raw(tag as *const c_void)`.
76-
Do **not** use `box_usize`/`unbox_usize` for these — doing so will silently
77-
corrupt the value.
78-
79-
### Reference counting for reused objects
80-
81-
When building a new Lean object, if you construct all fields from scratch (e.g.
82-
`LeanString::new(...)`, `LeanByteArray::from_bytes(...)`), ownership is
83-
straightforward — the freshly allocated objects start with rc=1 and Lean manages
84-
them from there.
85-
86-
However, if you take a Lean object received as a **borrowed** argument (`@&` in
87-
Lean, `b_lean_obj_arg` in C) and store it directly into a new object via
88-
`.set()`, you must call `.inc_ref()` on it first. Otherwise Lean will free the
89-
original while the new object still references it. If you only read/decode the
90-
argument into Rust types and then build fresh Lean objects, this does not apply.
152+
across the FFI boundary, not as `lean_box(tag)`. Use `LeanOwned::from_enum_tag()`
153+
and `LeanRef::as_enum_tag()` for these.
154+
155+
### Persistent objects
156+
157+
Module-level Lean definitions and objects in compact regions are persistent
158+
(`m_rc == 0`). Both `lean_inc_ref` and `lean_dec_ref` are no-ops for persistent
159+
objects, so `LeanOwned`, `LeanBorrowed`, `Clone`, and `Drop` all work correctly
160+
without special handling.
91161

92162
### `lean_string_size` vs `lean_string_byte_size`
93163

Tests.lean

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import Tests.FFI

0 commit comments

Comments
 (0)