Use this page with an LLM

Markdown

Tutorial

This tutorial walks through building a counter library in Rust and using it from Swift, Kotlin, C#, and C.

Create the Rust library

cargo new --lib counter
cd counter

Add BoltFFI to Cargo.toml:

[package]
name = "counter"
version = "0.1.0"
edition = "2021"

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

Install BoltFFI:

cargo add boltffi

Write the Rust code

Create src/lib.rs:

use boltffi::*;
use std::sync::atomic::{AtomicI64, Ordering};

pub struct Counter {
    value: AtomicI64,
}

#[export]
impl Counter {
    pub fn new(initial: i64) -> Self {
        Counter {
            value: AtomicI64::new(initial),
        }
    }

    pub fn increment(&self) -> i64 {
        self.value.fetch_add(1, Ordering::SeqCst) + 1
    }

    pub fn decrement(&self) -> i64 {
        self.value.fetch_sub(1, Ordering::SeqCst) - 1
    }

    pub fn get(&self) -> i64 {
        self.value.load(Ordering::SeqCst)
    }

    pub fn reset(&self) {
        self.value.store(0, Ordering::SeqCst);
    }
}

The #[export] attribute tells BoltFFI to generate bindings for Counter. The struct uses AtomicI64 for thread-safe access without locks.

Generate bindings

For Apple (Swift):

boltffi pack apple

For Android (Kotlin):

boltffi pack android

For C#:

boltffi pack csharp

This produces a .nupkg in dist/csharp/packages/ that you can reference from your .NET project as a local NuGet feed. The package bundles the bindings and the native runtime assets together.

Use from Swift

import Counter

let counter = Counter(initial: 0)

print(counter.increment())  // 1
print(counter.increment())  // 2
print(counter.get())        // 2
print(counter.decrement())  // 1
counter.reset()
print(counter.get())        // 0

Use from Kotlin

import com.example.counter.Counter

val counter = Counter(initial = 0)

println(counter.increment())  // 1
println(counter.increment())  // 2
println(counter.get())        // 2
println(counter.decrement())  // 1
counter.reset()
println(counter.get())        // 0

Use from C#

using Counter counter = new Counter(0);

Console.WriteLine(counter.Increment());  // 1
Console.WriteLine(counter.Increment());  // 2
Console.WriteLine(counter.Get());        // 2
Console.WriteLine(counter.Decrement());  // 1
counter.Reset();
Console.WriteLine(counter.Get());        // 0

Use from C

Enable [targets.c] with enabled = true in boltffi.toml, then run:

boltffi pack c --experimental --deny-skipped

With the package name counter, the class stays Counter because its name already contains the package prefix. Save this as main.c and link it against the packaged library, using counter in place of mylib:

#include <inttypes.h>
#include <stdio.h>
#include "counter.h"

int main(void) {
    Counter counter = counter_new(0);
    printf("%" PRId64 "\n", counter_increment(&counter));
    printf("%" PRId64 "\n", counter_increment(&counter));
    counter_reset(&counter);
    printf("%" PRId64 "\n", counter_get(&counter));
    counter_free(&counter);
    return 0;
}

C owns the class handle until counter_free releases it. Copying the C struct does not retain another reference to the Rust object.

Adding error handling

Extend the counter to reject negative values:

use boltffi::*;
use std::sync::atomic::{AtomicI64, Ordering};

#[error]
pub struct CounterError {
    pub message: String,
}

pub struct Counter {
    value: AtomicI64,
    min: i64,
}

#[export]
impl Counter {
    pub fn new(initial: i64, min: i64) -> Result<Self, CounterError> {
        if initial < min {
            return Err(CounterError {
                message: format!("initial {} below minimum {}", initial, min),
            });
        }
        Ok(Counter {
            value: AtomicI64::new(initial),
            min,
        })
    }

    pub fn decrement(&self) -> Result<i64, CounterError> {
        let current = self.value.load(Ordering::SeqCst);
        if current <= self.min {
            return Err(CounterError {
                message: format!("cannot go below {}", self.min),
            });
        }
        Ok(self.value.fetch_sub(1, Ordering::SeqCst) - 1)
    }
}

In Swift, the error becomes a thrown exception:

do {
    let counter = try Counter(initial: 0, min: 0)
    try counter.decrement()  // throws CounterError
} catch let error as CounterError {
    print(error.message)
}

In Kotlin:

try {
    val counter = Counter(initial = 0, min = 0)
    counter.decrement()  // throws CounterError
} catch (e: CounterError) {
    println(e.message)
}

In C#:

try {
    using Counter counter = new Counter(0, 0);
    counter.Decrement();  // throws CounterErrorException
} catch (CounterErrorException e) {
    Console.WriteLine(e.Error.Message);
}

In C, both the constructor and method return typed results. Each result owns its success or error value:

CounterNewResult created = counter_new(0, 0);
if (created.ok) {
    CounterDecrementResult changed = counter_decrement(&created.data.value);
    if (!changed.ok) {
        CounterString message = changed.data.error.message;
        fwrite(message.ptr, 1, message.len, stderr);
    }
    counter_decrement_result_free(&changed);
}
counter_new_result_free(&created);

The final call releases either the constructed counter or the constructor’s error. The borrowed message above is freed with changed; it must not be freed separately.

Adding async

The C target does not generate async methods yet. This part of the tutorial applies to the other targets; a C build reports those methods as skipped.

Make the counter persist to a file asynchronously:

use boltffi::*;
use std::sync::atomic::{AtomicI64, Ordering};
use std::path::PathBuf;

pub struct PersistentCounter {
    value: AtomicI64,
    path: PathBuf,
}

#[export]
impl PersistentCounter {
    pub fn new(path: String) -> Self {
        PersistentCounter {
            value: AtomicI64::new(0),
            path: PathBuf::from(path),
        }
    }

    pub fn increment(&self) -> i64 {
        self.value.fetch_add(1, Ordering::SeqCst) + 1
    }

    pub async fn save(&self) -> Result<(), String> {
        let value = self.value.load(Ordering::SeqCst);
        tokio::fs::write(&self.path, value.to_string().as_bytes())
            .await
            .map_err(|e| e.to_string())
    }

    pub async fn load(&self) -> Result<i64, String> {
        let contents = tokio::fs::read_to_string(&self.path)
            .await
            .map_err(|e| e.to_string())?;
        let value: i64 = contents.trim().parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
        self.value.store(value, Ordering::SeqCst);
        Ok(value)
    }
}

In Swift, async methods use structured concurrency:

let counter = PersistentCounter(path: "/tmp/counter.txt")
counter.increment()
counter.increment()

try await counter.save()
let loaded = try await counter.load()
print(loaded)  // 2

In Kotlin, they become suspend functions:

val counter = PersistentCounter(path = "/tmp/counter.txt")
counter.increment()
counter.increment()

counter.save()
val loaded = counter.load()
println(loaded)  // 2

In C#, they return Task:

using PersistentCounter counter =
    new PersistentCounter("/tmp/counter.txt");
counter.Increment();
counter.Increment();

await counter.Save();
long loaded = await counter.Load();
Console.WriteLine(loaded);  // 2

Next steps

See Types for the full list of supported types, Async for async patterns, and Streaming for real-time data.