Skip to content

Calling Rust

Gossamer has exactly one foreign-function surface: a binding crate - an ordinary Rust library that depends on gossamer-binding, marks the functions it wants to publish, and is named in the project's project.toml under [rust-bindings]. The toolchain compiles it, links it into the interpreter and into gos build binaries, and the functions become use-able from .gos source like any other module.

There is no source-level extern "C" item form. Writing one reports GP0016, and extern stays a reserved word.

Quick start

Scaffold a project and a binding crate beside it:

gos new example.com/greeter --path greeter
cd greeter
gos new example.com/greeter/native --path native --template binding

--template binding writes a ready-to-edit crate:

native/
├── Cargo.toml
└── src/
    └── lib.rs
// native/src/lib.rs
use gossamer_binding::{GosError, gos_module};

#[gos_module("native")]
mod bindings {
    use super::*;

    /// Greet the supplied name.
    pub fn greet(name: String) -> String {
        format!("hello, {name}")
    }

    /// Fallible example: parse an integer.
    pub fn parse_int(s: String) -> Result<i64, GosError> {
        Ok(s.parse::<i64>()?)
    }
}

gos new prints the manifest entry to add. The key is the Cargo package name, which the template derives from the id's tail:

# project.toml
[project]
id      = "example.com/greeter"
version = "0.1.0"

[rust-bindings]
native-binding = { path = "native" }

The gossamer-binding dependency the template writes is pinned to the toolchain that scaffolded it; the toolchain resolves it against its own copy when it builds the binding, so nothing has to be fetched for it.

Call it from Gossamer. The module is the string in #[gos_module], and - like every other module - it has to be imported before a path through it resolves:

// src/main.gos
use native

fn main() {
    println!("{}", native::greet("world"))
    println!("{:?}", native::parse_int("41"))
}
gos run src/main.gos      # hello, world
                          # Ok(41)
gos build src/main.gos    # a native binary with the binding linked in

The first run against a project builds a per-project runner with Cargo, which takes a while; later runs reuse the cached build under $XDG_CACHE_HOME/gossamer/runners/. Changing [rust-bindings] re-fingerprints the runner and rebuilds it.

Declaring the module

#[gos_module("path")] on a mod { ... } publishes every pub fn inside it. The string is the Gossamer-side module path; use :: for a nested one (#[gos_module("acme::layout")]).

Two rules the block imposes:

  • Keep use imports outside the block. The macro lifts the function bodies into a generated module that pulls in the surrounding scope with use super::*, so imports written inside the block do not travel with them. Everything else - helper fns, types, consts - may stay inside.
  • /// doc comments flow through to the item's registered documentation.

A register_module! macro form also exists and is what the attribute expands to. Prefer the attribute; reach for the macro only for a nested path whose C-ABI symbol prefix you want to spell by hand.

The type vocabulary

A binding function's parameters and return type must be shapes the ABI knows. Anything else is a compile error in the binding crate (the trait bound ...: BindingAbi is not satisfied).

Gossamer Rust in the binding Notes
() ()
bool bool
i64 i64 The default integer on both sides.
f64 f64
char char
String String
[u8] Bytes Newtype over Vec<u8>; b.0 is the vector.
Vec<T> Vec<T> Over any element the table already lists.
Option<T> Option<T>
Result<T, E> Result<T, E> See Errors.
Map<K, V> HashMap<K, V> <i64, i64>, <String, String>, <String, i64>.
tuple tuple (i64, i64), (f64, f64), (i64, String), (String, i64), (String, String), (i64, String, bool).
a struct #[derive(GosStruct)] struct Crosses by value, field by field.
DynValue DynValue A value whose shape the data decides; see Dynamic values.
a declared arm set Type::Variant(&[VariantArm..]) Matched as the Gossamer enum spelling the same arms.
a callback Fn(..) -> .. Call-scoped, interpreter only; see Callbacks.

Shapes outside that set need a hand-written wrapper on the Rust side: take the pieces the table covers and assemble the richer value inside the binding. A binding crate may also add its own BindingAbi impl - the macro discovers it through the trait - which is how a new HashMap key/value pair or a wider tuple is added.

Errors

A fallible binding returns Result<T, E>; the Gossamer caller sees the same Result and can ? it. GosError is the error type the binding crate carries, and it converts from any std::error::Error, so ? works inside a binding body:

pub fn parse_int(s: String) -> Result<i64, GosError> {
    Ok(s.parse::<i64>()?)
}
match native::parse_int("nope") {
    Ok(n) => println!("{}", n),
    Err(e) => eprintln!("{}", e),
}

Result<T, String> works too, when a plain message is all the caller needs.

A panic inside a binding is caught at the boundary rather than unwinding into Gossamer code: the thunk returns the output type's default (a null pointer, a zero, an empty value). Return an Err rather than panicking for anything a caller should handle.

Long synchronous work

A binding call runs on the goroutine that made it, so a long blocking call would hold a scheduler thread. #[gos_blocking] moves the body onto the blocking pool:

use gossamer_binding::gos_blocking;

#[gos_blocking]
pub fn read_everything(path: String) -> Result<String, GosError> {
    Ok(std::fs::read_to_string(path)?)
}

Opaque handles

State that should live in Rust - a connection, a parser, a device - stays there behind a handle. #[gos_opaque] on an inherent impl block publishes every pub fn in it:

use gossamer_binding::gos_opaque;

#[derive(Default)]
pub struct Counter {
    n: i64,
}

#[gos_opaque]
impl Counter {
    pub fn new() -> Self {
        Self { n: 0 }
    }
    pub fn bump(&mut self, by: i64) -> i64 {
        self.n += by;
        self.n
    }
    pub fn value(&self) -> i64 {
        self.n
    }
}

The type's name is the Gossamer-side module. A Self-returning associated function registers a new value and answers its i64 handle; every method takes that handle as its first argument:

use Counter

fn main() {
    let c = Counter::new()
    println!("{}", Counter::bump(c, 5))    // 5
    println!("{}", Counter::value(c))      // 5
}

Each type gets its own registry of Mutex-guarded values, so a handle is safe to pass between goroutines.

Structs by value

A struct that should cross as a value derives GosStruct:

use gossamer_binding::GosStruct;

#[derive(Default, Clone, GosStruct)]
pub struct Point {
    pub x: i64,
    pub y: i64,
}

pub fn shift(p: Point, dx: i64) -> Point {
    Point { x: p.x + dx, y: p.y }
}

Dynamic values

A decoder, a database column typed by its own metadata, or an RPC reply has a shape the data decides. DynValue is that value: Nil | Bool | Int | Float | Char | String | Bytes | List | Map | Tagged { name, payload }, where a tagged arm's name is a runtime string. It is a first-class Gossamer type - no import, no mirror enum, and no Rust needed to build one:

let row = DynValue::tagged("Row", #[DynValue::int(9), DynValue::string("ok")])

println!("{} {} {}", row.kind(), row.name(), row.len())   // tagged Row 2
if row.name() == "Row" {
    println!("{:?} {:?}", row.at(0).as_i64(), row.at(1).as_str())
}

kind() answers nil, bool, int, float, char, string, bytes, list, map, or tagged; name() is the arm's runtime name (empty for every other kind); len(), at(i), and key_at(i) read the values it holds; as_i64 / as_f64 / as_bool / as_char / as_str answer Option, and as_bytes a Vec. Two values are equal when their contents are, an arm matching on its name and every payload field.

A binding returning DynValue hands back exactly that value.

Declaring the arms

A binding that knows its arm set declares it, and the program matches that set as the ordinary enum spelling the same names:

const TYPE: Type = Type::Variant(&[
    VariantArm { name: "Integer", payload: &[Type::I64] },
    VariantArm { name: "Text", payload: &[Type::String] },
    VariantArm { name: "Nothing", payload: &[] },
]);
enum Reply { Integer(i64), Text(String), Nothing }

match conn::reply(id) {
    Reply::Integer(n) => println!("int {}", n),
    Reply::Text(s) => println!("text {}", s),
    Reply::Nothing => println!("nothing"),
}

The arm table is an ABI input; the type the program matches on is the enum it declared. The boundary selects that enum's discriminant from the wire's arm name and fills the variant's fields, so the match reads the same arm on every tier. An arm outside the declared set reports itself rather than becoming another variant.

Callbacks

A binding can call back into Gossamer during a call. The handle it receives is call-scoped: it is valid only until the binding function returns, and retaining it past the return is undefined behaviour. A callback that must outlive the call needs a different design - hand the binding a channel, or have Gossamer poll.

Wrapping a crate that knows nothing about Gossamer

Most Rust crates are not Gossamer-aware, and they do not need to be. Write a thin wrapper crate that depends on both:

gos add unic-segment --rust-binding

That records the crate under [rust-bindings] and scaffolds a wrapper under .gos-bindings/, where you select the API surface to expose:

use gossamer_binding::gos_module;
use unic_segment::Graphemes;

#[gos_module("unic_segment")]
mod bindings {
    use super::*;

    /// Split a string into Unicode grapheme clusters.
    pub fn graphemes(s: String) -> Vec<String> {
        Graphemes::new(&s).map(str::to_string).collect()
    }
}

gos bindgen path/to/lib.rs goes one step further: it reads a Rust source file, and for every pub fn whose signature already fits the ABI vocabulary it emits a #[gos_module] item with a todo!() body. Functions it cannot express are listed as comments, so the gap is visible rather than silent.

The [rust-bindings] table

Each entry is keyed by the Cargo crate name. Five source forms:

[rust-bindings]
# A crate in this repository.
native  = { path = "native" }

# A crate from a git repository (branch / tag / rev select the checkout).
remote  = { git = "https://github.com/acme/gos-remote", tag = "v1.2.0" }

# A crate from crates.io.
segment = { version = "0.9" }

# A single Rust source file; `deps` is a verbatim Cargo dependency
# fragment for the crate the toolchain scaffolds around it.
tiny    = { src = "native/tiny.rs", deps = "unic-segment = \"0.9\"" }

# A pre-built static archive, with the binding ABI it was built against.
vendored = { prebuilt = "vendor/libacme.a", abi = "2.0" }

features and default-features are accepted on the path, git, and version forms and are passed straight to Cargo.

gos tidy leaves [rust-bindings] entries alone - they are reached through Rust, not through a Gossamer use, so the import scan cannot see them.

Tiers and the ABI

Binding calls run on the bytecode VM (gos, gos test), the Cranelift JIT, and LLVM AOT (gos build, gos build --release). Values that are pointer-shaped on both sides - String, Vec<T>, an opaque handle - cross unchanged; Bytes, Map<K, V>, tuples, #[derive(GosStruct)] structs, and DynValue (open or with declared arms) are converted between the runtime's own shape and the wire shape at each boundary crossing, so the same program prints the same thing on every tier.

A Fn(..) callback has no compiled-tier representation yet and is reachable only from gos / gos test. A native build of a program that passes one does not report the gap, so keep callbacks out of code you intend to gos build until they land.

gossamer-binding carries an ABI version (currently 2.0) that the runtime checks at load time, so a stale binding is reported rather than silently corrupting memory. Minor bumps add wire shapes; a major bump breaks compatibility, and a binding must be rebuilt against the matching toolchain.

crates/gossamer-binding/ABI_0_4.md in the repository documents each wire shape's layout, ownership, and reclamation rules - read it before writing a BindingAbi impl of your own.

Shipping a library that carries a binding

A published Gossamer package may declare [rust-bindings] like any other project; the consumer's toolchain builds the Rust crate as part of the consumer's runner. Keep the Rust crate inside the published tree (a path entry), or point at a crates.io release, so a consumer fetching the package gets a buildable source tree.

Worked examples live in example-external-libraries/ in the repository: a Gossamer-aware crate, a wrapper around a published crate that is not, and a system-clipboard binding that wraps arboard.