Reussir Language Reference

Polymorphic FFI

Polymorphic FFI

Reussir can place systems code behind a functional interface without choosing one boxed representation for every generic value. The compiler specializes a Rust wrapper for each concrete use, compiles it to LLVM bitcode, and links it into the same program. To the optimizer, the foreign operation can become an ordinary call that is visible to inlining and whole-program optimization.

Two foreign boundaries

The language currently exposes two related mechanisms:

Mechanism Direction Use
extern "C" trampolineextern "C" trampoline C or Rust host Reussir Export one concrete Reussir function under a stable symbol that foreign code can call.
PolyFFI #[ffi(import)]#[ffi(import)] Reussir generated Rust implementation Implement an owning Reussir function in inline Rust, including generic functions specialized at their call sites.

The word import describes the direction of the call, not where the source text lives. An ordinary trampoline is an export: a host calls the boundary symbol, which forwards into Reussir. A PolyFFI function is an import: Reussir calls a native declaration, whose generated import trampoline forwards to the Rust wrapper. Managed arguments cross either boundary as owned values; neither mechanism passes Reussir borrows into foreign code.

Exporting an ordinary function

An extern "C" trampolineextern "C" trampoline gives a concrete function a stable external name:

fn add(a: i64, b: i64) -> i64 {
a + b
}
extern "C" trampoline "reussir_add" = add;
fn add(a: i64, b: i64) -> i64 {
a + b
}
extern "C" trampoline "reussir_add" = add;

The host links the generated object and calls reussir_addreussir_add. Because this signature is entirely integer-like, a C declaration has the direct shape one would expect:

#include <stdint.h>
extern int64_t reussir_add(int64_t a, int64_t b);
#include <stdint.h>
extern int64_t reussir_add(int64_t a, int64_t b);

A generic target must be specialized in the trampoline declaration because a foreign caller cannot supply Reussir type arguments:

fn identity<T>(value: T) -> T { value }
extern "C" trampoline "identity_u64" = identity<u64>;
fn identity<T>(value: T) -> T { value }
extern "C" trampoline "identity_u64" = identity<u64>;

#[main]#[main] is the convenient executable form of the same idea. It exports the marked, parameterless function under Reussir’s fixed launcher symbol, so normal programs do not need to write a trampoline declaration by hand.

The trampoline ABI

The word trampoline is significant. This boundary deliberately avoids the platform-specific rules for passing C aggregates. Its stable payload consists only of integers and pointers:

  • A call with fewer than four integer-like parameters and an integer-like or unit result uses a direct signature. Integer-like values include integers, boolbool, charchar, and managed pointers.
  • Other argument lists are packed into a #[repr(C)]#[repr(C)] structure and passed by pointer.
  • A result that is not integer-like is written through a leading output pointer.

For example, floating-point parameters make this export nontrivial:

fn weighted(x: f64, y: f64) -> f64 {
x * 0.25 + y * 0.75
}
extern "C" trampoline "reussir_weighted" = weighted;
fn weighted(x: f64, y: f64) -> f64 {
x * 0.25 + y * 0.75
}
extern "C" trampoline "reussir_weighted" = weighted;

Its equivalent C boundary is a result pointer followed by one pointer to the packed arguments:

struct ReussirWeightedArgs {
double x;
double y;
};
extern void reussir_weighted(
double *result,
struct ReussirWeightedArgs *arguments
);
struct ReussirWeightedArgs {
double x;
double y;
};
extern void reussir_weighted(
double *result,
struct ReussirWeightedArgs *arguments
);

The order of fields is the Reussir parameter order. Four integer-like parameters also use an argument structure, although an integer-like result still returns directly. Consequently, a hand-written C or Rust caller must use the trampoline shape rather than the platform’s native aggregate calling rules. Scalar examples are the easiest exports to call manually. Records, arrays, and closures can be marshalled by a trampoline after lowering too, but the host must then reproduce their exact lowered layouts and ownership rules. The generated program launcher and PolyFFI wrappers already use the correct shape.

Your first Rust-backed value

The following program builds a copy-on-write vector in Rust and consumes it to calculate a total:

extern "rust" [{
use reussir_rt::collections::vec::Vec as RVec;
}];
#[ffi(rust = "::reussir_rt::collections::vec::Vec")]
pub struct Vec<T>;
#[ffi(import)]
fn new<T>() -> Vec<T> [{ RVec::new() }];
#[ffi(import)]
fn push<T>(items: Vec<T>, value: T) -> Vec<T> [{
RVec::push(items, value)
}];
#[ffi(import)]
fn total(items: Vec<f64>) -> f64 [{
let mut sum = 0.0;
for i in 0..items.len() {
sum += items.get(i).unwrap();
}
sum
}];
#[main]
fn entry() {
let items = new<f64>();
let items = push(items, 1.5);
let items = push(items, 2.25);
let answer = total(items);
}
extern "rust" [{
use reussir_rt::collections::vec::Vec as RVec;
}];
#[ffi(rust = "::reussir_rt::collections::vec::Vec")]
pub struct Vec<T>;
#[ffi(import)]
fn new<T>() -> Vec<T> [{ RVec::new() }];
#[ffi(import)]
fn push<T>(items: Vec<T>, value: T) -> Vec<T> [{
RVec::push(items, value)
}];
#[ffi(import)]
fn total(items: Vec<f64>) -> f64 [{
let mut sum = 0.0;
for i in 0..items.len() {
sum += items.get(i).unwrap();
}
sum
}];
#[main]
fn entry() {
let items = new<f64>();
let items = push(items, 1.5);
let items = push(items, 2.25);
let answer = total(items);
}

There are three new pieces of syntax.

The Rust prelude

extern "rust" [{ ... }];extern "rust" [{ ... }]; adds file-scoped Rust source before every wrapper generated from this file. It is the place for useuse declarations, helper functions, and foreign declarations shared by several imported functions. Only the rustrust source ABI is supported here.

Opaque FFI records

#[ffi(rust = "::reussir_rt::collections::vec::Vec")]
pub struct Vec<T>;
#[ffi(rust = "::reussir_rt::collections::vec::Vec")]
pub struct Vec<T>;

This fieldless declaration gives a Rust type a Reussir name. An opaque FFI record has no Reussir constructor, fields, projections, or patterns. It is always a [shared][shared] record and is represented by a managed foreign payload.

The named Rust type cannot be arbitrary. It must follow Reussir’s runtime contract: a #[repr(transparent)]#[repr(transparent)] functional wrapper over reussir_rt::rc::Rcreussir_rt::rc::Rc, with owning operations that take selfself and return a new wrapper. std::rc::Rcstd::rc::Rc, std::sync::Arcstd::sync::Arc, and an arbitrary pointer wrapper do not satisfy this contract. The compiler relies on the declared representation but cannot prove that a Rust path obeys it; a false declaration is an ABI and memory-safety error. The runtime VecVec and StringString wrappers are the reference models.

What the reference-counted vector wraps

The runtime type is named reussir_rt::collections::vec::Vecreussir_rt::collections::vec::Vec; the examples alias it to RVecRVec to distinguish this RC-backed vector from Rust’s ordinary std::vec::Vecstd::vec::Vec. The wrapper is intentionally thin. Renaming the ordinary vector to StdVecStdVec makes the two levels visible:

use crate::rc::Rc;
type StdVec<T> = std::vec::Vec<T>;
#[derive(Clone)]
#[repr(transparent)]
pub struct Vec<T: Clone>(Rc<StdVec<T>>);
impl<T: Clone> Vec<T> {
pub fn new() -> Self {
Self(Rc::new(StdVec::new()))
}
pub fn push(mut self, value: T) -> Self {
self.0.make_mut().push(value);
self
}
pub fn get(&self, index: usize) -> Option<T> {
self.0.data_ref().get(index).cloned()
}
}
use crate::rc::Rc;
type StdVec<T> = std::vec::Vec<T>;
#[derive(Clone)]
#[repr(transparent)]
pub struct Vec<T: Clone>(Rc<StdVec<T>>);
impl<T: Clone> Vec<T> {
pub fn new() -> Self {
Self(Rc::new(StdVec::new()))
}
pub fn push(mut self, value: T) -> Self {
self.0.make_mut().push(value);
self
}
pub fn get(&self, index: usize) -> Option<T> {
self.0.data_ref().get(index).cloned()
}
}

Vec<T>Vec<T> itself is represented exactly like the one Rc<StdVec<T>>Rc<StdVec<T>> field. Read operations borrow the inner vector. An owning update takes the wrapper by value, asks the reference-counted box for mutable access, performs an ordinary Rust VecVec operation, and returns the wrapper.

The essential copy-on-write branch lives in Rc::make_mutRc::make_mut:

impl<T: Clone> Rc<T> {
pub fn make_mut(&mut self) -> &mut T {
if !self.is_unique() {
let data = self.data_ref().clone();
*self = Self::new(data);
}
unsafe { self.data_mut() }
}
}
impl<T: Clone> Rc<T> {
pub fn make_mut(&mut self) -> &mut T {
if !self.is_unique() {
let data = self.data_ref().clone();
*self = Self::new(data);
}
unsafe { self.data_mut() }
}
}

With a reference count of one, make_mutmake_mut returns mutable access to the existing payload without cloning it. pushpush can then update the same Rust vector buffer, although Rust may still grow that buffer when its capacity is exhausted.

If the count is greater than one, make_mutmake_mut first clones the StdVecStdVec into a new RC box. Assigning that box back to selfself releases this wrapper’s reference to the old box while every other owner continues to see the old vector. The new box has count one, so returning data_mut()data_mut() is safe. Cloning a StdVec<T>StdVec<T> copies its element sequence as well; each element’s Rust CloneClone implementation runs. This small branch is where Reussir’s persistent surface and Rust’s efficient imperative collection meet.

Imported implementations

An imported function keeps an ordinary Reussir signature but replaces the Reussir body with Rust source:

#[ffi(import)]
fn push<T>(items: Vec<T>, value: T) -> Vec<T> [{
RVec::push(items, value)
}];
#[ffi(import)]
fn push<T>(items: Vec<T>, value: T) -> Vec<T> [{
RVec::push(items, value)
}];

The parameters are Rust variables inside the foreign body. Calling push<i64>push<i64> and push<f64>push<f64> requests two ground instances. For each one, the compiler renders a self-contained Rust texture, invokes the configured nightly Rust compiler, links the resulting bitcode, and connects it through an import trampoline.

The generated instance contains two symbols with different jobs. Reussir code calls a bodyless function with the normal native Reussir signature. The import trampoline implements that function by packing its values and calling a Rust extern "C"extern "C" boundary symbol whose name ends in _ffi_ffi. The generated Rust wrapper receives a trivial signature directly or unpacks a nontrivial one, evaluates the inline body, and returns or writes its result according to the same convention described above. These symbols are compiler plumbing rather than a second API for applications to call.

[:T:][:T:] substitutes the generated Rust spelling of a generic type. It is most useful when the Rust expression has no value from which to infer TT:

#[ffi(import)]
fn rust_size_of<T>() -> u64 [{
std::mem::size_of::<[:T:]>() as u64
}];
fn bytes_in_i64() -> u64 {
rust_size_of<i64>()
}
#[ffi(import)]
fn rust_size_of<T>() -> u64 [{
std::mem::size_of::<[:T:]>() as u64
}];
fn bytes_in_i64() -> u64 {
rust_size_of<i64>()
}

For an opaque record or shared Reussir record, the substitution is the compiler-generated Rust wrapper name, not its Reussir source spelling.

Ownership crosses the boundary

Every imported call consumes its arguments just like an ordinary Reussir function call. The wrapper owns one reference to every managed parameter; Rust move semantics then determine when that reference is returned, cloned, or dropped. A Rust borrow such as items.len()items.len() is local to the wrapper and ends before the wrapper returns.

This makes a functional copy-on-write API natural:

fn three() -> Vec<i64> {
let items = new<i64>();
let items = push(items, 1);
let items = push(items, 2);
push(items, 3)
}
fn three() -> Vec<i64> {
let items = new<i64>();
let items = push(items, 1);
let items = push(items, 2);
push(items, 3)
}

Each binding is used once and immediately replaced by the result. When the vector is uniquely owned, each call follows the fast path:

  1. the call consumes the only VecVec wrapper, whose RC count is one;
  2. Rc::make_mutRc::make_mut returns the existing StdVecStdVec without cloning it; and
  3. pushpush returns the same wrapper for the next call.

The repeated name is ordinary shadowing, not imperative assignment. This is linear use as a programming pattern; it is not a separate linear type.

Keeping an older version is also valid:

fn keep_both() -> Vec<f64> {
let original = push(new<f64>(), 1.0);
let changed = push(original, 2.0);
let old_total = total(original);
changed
}
fn keep_both() -> Vec<f64> {
let original = push(new<f64>(), 1.0);
let changed = push(original, 2.0);
let old_total = total(original);
changed
}

Because originaloriginal remains live after the call, the ownership analysis retains it before pushpush. The wrapper passed to Rust therefore observes count two. make_mutmake_mut clones the inner StdVecStdVec, changedchanged receives the new box, and originaloriginal continues to name the old box. Persistent semantics are preserved, but the copy can be much more expensive than the linear version. Prefer threading an FFI object from one owning operation to the next when the earlier version is not semantically needed.

Which values may cross PolyFFI

PolyFFI signatures are checked again after generic functions are monomorphized, when every boundary type is known. The current boundary accepts:

  • integers, f32f32, f64f64, boolbool, and charchar;
  • unitunit as a result;
  • opaque #[ffi(rust = "...")]#[ffi(rust = "...")] records; and
  • ordinary [shared][shared] Reussir structs and enums.

Among Reussir records, shared is the only capability that can cross. Value and regional records are rejected, as are NullableNullable, ArcArc, strings, cells, arrays, closures, and non-IEEE or reduced-precision floats. unitunit is not a parameter type. Type arguments are checked recursively, so Vec<T>Vec<T> can cross only when the concrete TT also has a supported Rust boundary spelling.

This allow-list belongs to PolyFFI’s generated Rust wrappers; it is not the list of every lowered value an ordinary export trampoline can pack. A generic PolyFFI declaration can therefore look valid and fail only for a particular ground call:

struct [value] Point { x: i64, y: i64 }
#[ffi(import)]
fn consume<T>(value: T) -> i64 [{ 0 }];
fn bad(point: Point) -> i64 {
consume(point)
}
struct [value] Point { x: i64, y: i64 }
#[ffi(import)]
fn consume<T>(value: T) -> i64 [{ 0 }];
fn bad(point: Point) -> i64 {
consume(point)
}
Error: `#[ffi(import)]` parameter `value`: a `[value]` record cannot cross the FFI boundary yet
Error: `#[ffi(import)]` parameter `value`: a `[value]` record cannot cross the FFI boundary yet

Shared records use compiler-generated acquire and release glue when Rust owns them. This allows, for example, a Rust Vec<List>Vec<List> to clone and drop Reussir list nodes while leaving record layout and reference-count behavior under the compiler’s control. If copy-on-write clones that vector’s element buffer, Rust’s generated CloneClone wrapper calls the acquire glue for every copied list element; dropping either vector later calls the matching release glue.

Build-time requirements

PolyFFI is compiled as part of a normal ahead-of-time build. The compiler must be able to find the compatible nightly rustcrustc and the directory containing libreussir_rtlibreussir_rt and its dependencies. Rene supplies the packaged configuration; advanced RRC builds can override it with --polyffi-rust-path--polyffi-rust-path and one or more --polyffi-libdir--polyffi-libdir options. The equivalent environment variables are REUSSIR_RUSTCREUSSIR_RUSTC and REUSSIR_RUSTC_DEPSREUSSIR_RUSTC_DEPS.

At optimization levels that permit it, LLVM can inline through the linked wrapper and optimize the combined Reussir and Rust code. That visibility is the main reason PolyFFI specializes the foreign side instead of forcing every generic value through one universal boxed ABI.