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.