Home

Calling Rust from Swift

I have one project where the domain logic lives in Rust and the iOS app is a thin Swift layer over it. The motivation was sharing that logic with a server and a CLI, and the boundary between the two languages is smaller than I expected.

The C ABI is the bridge

Neither language speaks the other’s ABI, but both speak C. Rust exposes C-compatible functions, Swift imports them through a header.

// src/lib.rs
use std::ffi::{CStr, CString};
use std::os::raw::c_char;

#[unsafe(no_mangle)]
pub extern "C" fn parse_document(input: *const c_char) -> *mut c_char {
    let input = unsafe {
        match CStr::from_ptr(input).to_str() {
            Ok(string) => string,
            Err(_) => return std::ptr::null_mut(),
        }
    };

    let result = core::parse(input);

    match CString::new(result) {
        Ok(string) => string.into_raw(),
        Err(_) => std::ptr::null_mut(),
    }
}

#[unsafe(no_mangle)]
pub extern "C" fn free_string(pointer: *mut c_char) {
    if pointer.is_null() { return; }
    unsafe { drop(CString::from_raw(pointer)); }
}

extern "C" gives it the C calling convention, no_mangle keeps the symbol name, and CString::into_raw hands ownership of the allocation to the caller.

The memory rule

Whichever side allocates must free. Rust’s allocator and Swift’s are different, and calling free() on a Rust CString is undefined behaviour that usually works for a while.

That is why free_string exists, and why every Rust function returning a pointer needs a matching release function. On the Swift side, the wrapper owns that discipline:

func parseDocument(_ input: String) -> String? {
    input.withCString { pointer in
        guard let result = parse_document(pointer) else { return nil }
        defer { free_string(result) }
        return String(cString: result)
    }
}

withCString handles the input’s lifetime; defer handles the output’s. That five-line function is the entire pattern, repeated per call.

Warning

A pointer from withCString is valid only inside the closure. Storing it and using it later is a dangling pointer, and the resulting corruption is intermittent — the worst kind of bug to inherit. Do the work inside the closure, always.

Building it as an xcframework

rustup target add aarch64-apple-ios aarch64-apple-ios-sim

cargo build --release --target aarch64-apple-ios
cargo build --release --target aarch64-apple-ios-sim

xcodebuild -create-xcframework \
  -library target/aarch64-apple-ios/release/libcore.a \
  -headers include/ \
  -library target/aarch64-apple-ios-sim/release/libcore.a \
  -headers include/ \
  -output Core.xcframework

With crate-type = ["staticlib"] in Cargo.toml. Static linking is right here — one binary, no dynamic loading at launch.

Generate the header with cbindgen rather than writing it by hand; a mismatch between the header and the Rust signature compiles cleanly and corrupts memory at runtime.

Keep the boundary narrow

The mistake I made first was exposing a rich API — many functions, many pointer types, structs crossing the boundary. Each one is unsafe code that has to be right.

The version that works passes JSON strings across a handful of functions:

let response = core.call(command: "parse", payload: jsonRequest)

Serialisation costs something and it buys type safety on both sides — Codable in Swift, serde in Rust — with no manual struct layout matching. For a boundary crossed hundreds of times per second that trade is wrong; for one crossed a few times per user action it is free.

Two functions instead of forty is also two functions’ worth of unsafe to review.

Errors

Panics must not cross the boundary — unwinding into Swift is undefined behaviour. Catch them:

#[unsafe(no_mangle)]
pub extern "C" fn parse_document(input: *const c_char) -> *mut c_char {
    let result = std::panic::catch_unwind(|| {
        // …
    });

    match result {
        Ok(value) => value,
        Err(_) => std::ptr::null_mut(),
    }
}

And set panic = "abort" in the release profile if you would rather crash deliberately than continue in an unknown state. Either is defensible; leaving a panic to unwind across FFI is not.

Was it worth it

Yes, for this project. The parsing and rules engine is about 8,000 lines of Rust shared between an iOS app, a server and a CLI. Writing it three times, or maintaining three implementations that must agree, would have been much worse.

No, for a normal app. If the code is only ever going to run on iOS, this is a build system, a toolchain, an FFI boundary and a category of memory bug you did not have. Swift is a good language and the correct default.

The test: is this logic genuinely going to run somewhere Swift will not? If yes, the boundary is worth building. If it is aspirational — “we might do a web version someday” — write it in Swift and port it later if that day arrives.