8.2.165-stable Switch to dev

SDK Rust

The Rust SDK is for writing native GreyCat libraries: a shared object that the greycat runtime loads, so a native fn declared in GCL runs Rust code.

It is not a client SDK. If you want to call a running GreyCat server over HTTP, use the js, python or java SDKs instead.

Setup

The SDK ships as a GreyCat library, so you pull it the same way as any other: declare it in project.gcl and run greycat install.

@library("std", "0.0.0");
@library("rust", "0.0.0");
greycat install

That unpacks the crates into lib/rust/, next to the headers and archive in lib/std/. Cargo then reaches the SDK by path:

[dependencies]
greycat = { path = "lib/rust/greycat" }

[build-dependencies]
greycat-build = { path = "lib/rust/greycat-build" }

lib/rust/ also carries the SDK’s full reference documentation, written to be read by a person or loaded by an AI agent: start at lib/rust/skills/SKILL.md.

Declaring both is what keeps the two halves in step. The SDK reads C structures from the runtime by memory offset, so an SDK and a runtime from different versions would misread each other. Pinning both to the same version in the same file makes that visible, and greycat install refuses a version that was never published.

A minimal library

Six files. The example declares one native fn and implements it in Rust.

project.gcl

@library("std", "0.0.0");
@library("rust", "0.0.0");

@library("hello");

fn main() {
    println(greet("world"));
}

The third pragma is the one people forget. std and rust are dependencies; @library("hello") is what makes the runtime look for lib/hello/hello.gclib and load it. Without it the compiler never sees the module and fails with unresolved function: greet.

lib/hello/hello.gcl

The contract. This is where the functions your Rust code implements are declared.

native fn greet(name: String): String;

Cargo.toml

[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
greycat = { path = "lib/rust/greycat" }

[build-dependencies]
greycat-build = { path = "lib/rust/greycat-build" }

[profile.release]
opt-level = 3
strip = true

crate-type = ["cdylib"] is required. Without it Cargo builds an rlib and there is no shared object to install.

Note what the release profile does not set. panic = "abort" and lto are both unsafe here; Building for release explains why.

build.rs

fn main() {
    greycat_build::configure();
}

Every library needs this file. configure() emits the link settings for the target platform: on macOS the gc_* symbols have to be marked as resolved at load time, on Windows the host’s import library has to be linked, and on Linux the export list is narrowed to the single entry point.

It cannot live in the SDK itself. Cargo does not pass a dependency’s link settings on to the crate that depends on it, so they have to be emitted by your own build script. Skip this file and the library builds on Linux but fails to link on macOS and Windows with undefined gc_* symbols.

Generated bindings adds a second line here.

src/lib.rs

use greycat::prelude::*;

#[native_fn]
fn greet(name: &str) -> String {
    format!("hello, {name}")
}

struct Hello;

impl Library for Hello {
    const NAME: &'static str = "hello";

    fn link(program: Program<'_>) -> greycat::Result<()> {
        let module = program.resolve_module("hello")?;
        program.link_module_fn(module, "greet", greet::NATIVE)?;
        Ok(())
    }
}

export_library!(hello, Hello);

build.sh

The runtime looks for lib/<name>/<name>.gclib, so the build output is copied there:

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
cargo build
cp target/debug/libhello.so lib/hello/hello.gclib

On macOS the artifact is libhello.dylib, on Windows hello.dll; the destination name is always <name>.gclib.

Run it

./build.sh
greycat run
hello, world

How it fits together

#[native_fn] leaves your function as ordinary Rust - still callable and unit testable - and emits greet::NATIVE beside it, the extern "C" entry point the runtime calls. That shim:

  • reads each argument positionally and converts it,
  • converts the return value back, reporting an Err as a GreyCat error,
  • catches panics. This matters: an extern "C" function that unwinds aborts the process, so without it one panicking call would take down the whole server.

export_library! emits gc_lib_hello__link, the symbol the loader resolves after dlopen. The name must match Library::NAME and the .gclib file name; a mismatch is a compile error rather than a load failure.

Types

Arguments and return values convert automatically:

GreyCat Rust
bool char int bool char i64
float f64
String &str reading, String writing
time duration geo Time Duration Geo
node<T> and friends NodeRef
Array<T> Map<K, V> Array Map
a user type ObjectRef, or a generated view
anything nullable Option<T>

A function that needs the runtime itself - to allocate a String, build an object, or log - takes a &Machine as its first parameter:

#[native_fn]
fn shout<'gc>(m: &Machine<'gc>, text: &str) -> greycat::Result<Object<'gc>> {
    m.create_string(&text.to_uppercase())
}

Errors and panics

A native fn can fail with any error type that implements Display, so a library is free to use its own:

#[native_fn]
fn fetch(url: &str) -> Result<String, MyError> {
    Ok(ureq::get(url).call()?.body_mut().read_to_string()?)
}

The Err becomes a GreyCat error that GCL can catch:

try {
    fetch("https://example.com");
} catch (e) {
    println("failed: ${e.message}");
}

A panic is caught and reported the same way, so a bug in one function fails that call rather than the process. That guarantee depends on the release profile, so read the next section before publishing anything.

Building for release

cargo build --release
cp target/release/libhello.so lib/hello/hello.gclib

Two settings must stay out of [profile.release].

panic = "abort" removes the safety net described above. Catching a panic needs unwinding, and an abort cannot be caught: the panic raises SIGABRT, the runtime’s handler prints a backtrace and exits, and every request in flight on every worker dies with it. One misbehaving native call takes down the server. The SDK rejects this setting at compile time rather than let it ship.

lto, in either "thin" or "fat" form, merges the SDK’s bundled unwinder into your crate, after which the linker can no longer keep its internal symbols out of the library’s export table. The library still works; it just exports symbols it has no business exporting. Leave lto unset.

What a released library may import

A .gclib should import only what the operating system guarantees. On Linux, Rust’s unwinder normally lives in libgcc_s.so.1, which the greycat binary does not use and a minimal container image need not carry; the SDK links its own copy instead, so your library does not depend on it. This is automatic.

Check before publishing:

readelf -d lib/hello/hello.gclib | grep NEEDED    # linux
otool -L lib/hello/hello.gclib                    # macos

Linux should list libc, ld-linux and whatever your own dependencies need, and not libgcc_s.so.1. macOS should list libSystem and nothing else. If libgcc_s appears, something in the profile has displaced the SDK’s unwinder.

One build per platform

A .gclib is native code, so a published library needs one build per platform its users run. greycat install downloads the one matching the host:

GreyCat target Rust target
x64-linux x86_64-unknown-linux-gnu
arm64-linux aarch64-unknown-linux-gnu
x64-apple x86_64-apple-darwin
arm64-apple aarch64-apple-darwin
x64-windows x86_64-pc-windows-gnullvm

Because the SDK reads the runtime’s structures by memory offset, publish against the same version your users pin, and rebuild when it moves.

Custom types

Declare the type in GCL, then build instances from Rust:

type Point { x: int; y: int; label: String?; }

native fn make_point(x: int, y: int): Point;
#[native_fn]
fn make_point<'gc>(m: &Machine<'gc>, x: i64, y: i64) -> greycat::Result<Object<'gc>> {
    let point = m.create_object(point_type)?;
    point.set_field("x", Value::Int(x), m)?;
    point.set_field("y", Value::Int(y), m)?;
    Ok(point)
}

Reading works the same way, on a value you were handed:

let x = point.get_field("x", m)?;

There is no cast. An object already knows its type, so checking it is a question, not a conversion:

if obj.is_instance_of(point_type, m) { ... }

Generated bindings

Writing field names as strings gets tedious and is not checked. greycat codegen rust reads your compiled program and writes src/gc.rs, giving every declared type a typed view whose field offsets are compile-time constants.

Add it to build.rs rather than running it by hand, so the bindings cannot go stale:

fn main() {
    greycat_build::codegen();
    greycat_build::configure();
}

codegen() re-runs whenever any .gcl in the project changes, so editing a declaration in GCL is enough. It uses the project’s own bin/greycat - the one greycat install pinned to the version in project.gcl - rather than whichever greycat happens to be on PATH, which matters because the SDK reads the runtime’s structures by memory offset.

mod gc;

#[native_fn]
fn make_point<'gc>(m: &Machine<'gc>, x: i64, y: i64) -> greycat::Result<Object<'gc>> {
    let owned = gc::hello::Point::create(m)?;
    let point = unsafe { gc::hello::Point::from_ref_unchecked(owned.as_ref()) };
    point.set_x(x, m)?;
    point.set_y(y, m)?;
    Ok(owned)
}

#[native_fn]
fn describe<'gc>(m: &Machine<'gc>, p: gc::hello::Point<'gc>) -> greycat::Result<String> {
    let x: i64 = p.x(m)?;                        // int       -> i64
    let label: Option<GcStr<'_>> = p.label(m)?;  // String?   -> Option<GcStr>
    Ok(format!("{x} {label:?}"))
}

It also generates the linking, so Library::link becomes one line:

fn link(program: Program<'_>) -> greycat::Result<()> {
    gc::link(program)
}

That is worth more than the typing it saves. The generated code names your Rust items directly, so a native fn you declared but never implemented is a compile error:

error[E0433]: cannot find `forgotten` in `hello`
  --> src/gc.rs:64:60
   |
64 |     __prg.link_module_fn(__mod, "forgotten", crate::hello::forgotten::NATIVE)?;
   |                                                            ^^^^^^^^^

Without it, an unimplemented native fn compiles fine and fails only when GCL calls it.

Module names must match

For that to work, the Rust module tree mirrors GreyCat’s:

GreyCat Rust
module m (from m.gcl) crate::m
native fn f in module m crate::m::f

So lib/hello/hello.gcl pairs with src/hello.rs, and a library with several .gcl files has one Rust module each.

#[native_fn] has to be applied at module scope, not inside a function body.

A native fn declared inside a type is the exception, and does not follow this rule at all. It uses a generated trait instead.

Native methods on a type

Declare the functions in the type, with or without a receiver:

type SchemaBuilder {
    text: String;

    native static fn value_count(rows: int, columns: int): int;

    native fn add_column(definition: String);
    native fn print();
}

Codegen emits a SchemaBuilderNative trait beside the SchemaBuilder view, with each signature taken from the GCL declaration, and binds shims that dispatch to it. You implement the trait:

use crate::gc::parquet::{SchemaBuilder, SchemaBuilderNative};

impl<'gc> SchemaBuilderNative<'gc> for SchemaBuilder<'gc> {
    fn add_column(&self, m: &Machine<'gc>, definition: &'gc str) -> greycat::Result<()> {
        let joined = format!("{}{}", self.text(m)?.to_str()?, definition);
        self.set_text(&joined, m)
    }

    fn print(&self, m: &Machine<'gc>) -> greycat::Result<()> {
        println!("{}", self.text(m)?.to_str()?);
        Ok(())
    }

    fn value_count(_m: &Machine<'gc>, rows: i64, columns: i64) -> greycat::Result<i64> {
        Ok(rows * columns)
    }
}

The impl can live anywhere in the crate, because nothing refers to it by path - which is why the module rule does not apply here. What the trait buys instead is stronger: GCL dictates the signature, so arity, parameter types, the return type and static-versus-instance are all checked by the Rust compiler.

  • a missing implementation is the trait bound `SchemaBuilder<'_>: SchemaBuilderNative<'_>` is not satisfied
  • a wrong parameter type is expected i64, found i32, reported on your method
  • &self where GCL declared static is method `value_count` has a `&self` declaration in the impl, but not in the trait

Three things follow from being a trait rather than an attribute macro:

  • Every method takes &Machine<'gc>, statics included. A trait cannot make a parameter conditional the way #[native_fn] does, and codegen cannot know in advance whether a body will need one.
  • The return is always greycat::Result<T>, with T from the GCL return type and () when there is none. That is what makes the return-type check possible, and it costs the freedom #[native_fn] has to fail with any Display type: convert with ? and a From impl, or with Error::external.
  • A String parameter arrives as &'gc str rather than GcStr, and a returned object is an Object<'gc> rather than a view, since a view borrows from something that already holds a reference.

#[native_fn] is unchanged, and remains what a module-level native fn uses.

Project layout

my-library/
├── project.gcl              @library std, rust, and your own
├── Cargo.toml               cdylib + the path dependencies
├── build.rs                 greycat_build::configure()
├── build.sh                 build, then copy into lib/<name>/
├── src/
│   ├── lib.rs               Library impl and export_library!
│   ├── hello.rs             mirrors lib/hello/hello.gcl
│   └── gc.rs                generated; do not edit
└── lib/
    ├── std/                 installed
    ├── rust/                installed: the SDK crates
    └── hello/
        ├── hello.gcl        your native fn declarations
        └── hello.gclib      built by build.sh

lib/ is install and build output, so it belongs in .gitignore apart from your own .gcl files:

/target
/lib/std
/lib/rust
/lib/hello/hello.gclib

Going further

This page covers writing and shipping a library. The SDK ships its own reference documentation under lib/rust/skills/, installed alongside the crates:

File Covers
SKILL.md link modes, ownership, common pitfalls
reference/build.md the release profile and per-target artifacts, in detail
reference/codegen.md greycat codegen rust and the generated bindings
reference/state.md holding a Rust value inside a GreyCat object
reference/types.md values, casting, collections
reference/host.md crypto, calling back into GCL, the scheduler
reference/program.md reading the compiled program

If you work with an AI agent, add @./lib/rust/skills/SKILL.md to the project’s AGENTS.md next to the GreyCat skill at @./lib/std/skills/SKILL.md.