Using bindgen to generate Rust bindings for Objective-c

Introduction§

This is a tutorial how to build uikit-sys using bindgen and how to avoid some of the pitfalls of generating objective-c bindings.

I got into this rather niche topic because I used to work on a react-native app and found the build system to be too fragile and wanted to have some of the stability that rust offers. I spent a little bit of time looking at SSheldon/rust-uikit but it became clear that this would rather manual and still rather brittle. So, I stumbled upon the mild objective-c support in rust-bindgen and have been adding more features since.

Usage of the uikit-sys crate will be saved for another post.

Setting up the project§

First off, you'll need to cargo new --lib uikit-sys. In your Cargo.toml, you'll need:

[dependencies]
objc = "*"
block = "*"

[build-dependencies]
bindgen = "*"

But you should probably set the versions so that this is a more stable package.

Your src/lib.rs really only needs:

include!(concat!(env!("OUT_DIR"), "/uikit.rs"));

but you probably don't neeed rustc telling you all about the non-{snake,camel,upper-case-glabals} you should do:

#![allow(non_camel_case_types)]
#![allow(non_snake_case)]
#![allow(non_upper_case_globals)]

include!(concat!(env!("OUT_DIR"), "/uikit.rs"));

If you're unfamiliar, include!(concat!(env!("OUT_DIR"), "file-name.rs")) is a pretty common pattern for cargo build scripts.

Adding a build.rs§

Now, on to the build.rs:

use std::env;
use std::path::Path;
fn main() {

    let target = std::env::var("TARGET").unwrap();
    let target = if target == "aarch64-apple-ios" {
        "arm64-apple-ios"
    } else {
        &target
    };

    let sdk_path = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.6.sdk";

    println!("cargo:rustc-link-lib=framework=UIKit");
    let builder = bindgen::Builder::default()
        .rustfmt_bindings(true)
        .header_contents("UIKit.h", "#include<UIKit/UIKit.h>")

        .clang_args(&[&format!("--target={}", target)])
        .clang_args(&["-isysroot", sdk_path])

        .block_extern_crate(true)
        .generate_block(true)
        .clang_args(&["-fblocks"])

        .objc_extern_crate(true)
        .clang_args(&["-x", "objective-c"])

        .blacklist_item("timezone")
        .blacklist_item("IUIStepper")
        .blacklist_function("dividerImageForLeftSegmentState_rightSegmentState_")
        .blacklist_item("objc_object");

    let bindings = builder.generate().expect("unable to generate bindings");

    let out_dir = env::var_os("OUT_DIR").unwrap();
    bindings
        .write_to_file(Path::new(&out_dir).join("uikit.rs"))
        .expect("could not write bindings");
}

This is an abridged version and it doesn't handle different iOS platforms due to the sdk_path. So, let's break this down.

Getting the right target.§

First off, Cargo has a few environment variables such as TARGET and OUT_DIR and that's why

let target = std::env::var("TARGET").unwrap();
let out_dir = env::var_os("OUT_DIR").unwrap();

are needed.

Then we need to turn arch64-apple-ios into arm64-apple-ios because the clang triple doesn't match. Once rust-lang/rust-bindgen#1211 is resolved, this bit won't be needed:

let target = if target == "aarch64-apple-ios" {
    "arm64-apple-ios"
} else {
    &target
};

Getting the sysroot§

Following this, you'll notice that I've hard coded the sdk_path:

let sdk_path = "/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.6.sdk";

I'm taking a shortcut here for the purposes of brevity but this is effectively the command xcrun --sdk iphoneos --show-sdk-path. Doing this right, you should look at the TARGET triple, if it's prefixed with x86_64, use iphonesimulator rather than iphoneos. This should be done with std::process::Command in your build script because this path changes with the Xcode/iPhone SDK version. This is I did it for uikit-sys.

This is then passed to the clang_args of bindgen to set the sysroot for the precompiled headers.

Plugging it into bindgen§

So, this is the meat of this project and is really quite dense. I've added some spacing to give you an idea of the groupings.

println!("cargo:rustc-link-lib=framework=UIKit");
let builder = bindgen::Builder::default()
    .rustfmt_bindings(true)
    .header_contents("UIKit.h", "#include<UIKit/UIKit.h>")

    .clang_args(&[&format!("--target={}", target)])
    .clang_args(&["-isysroot", sdk_path])

    .block_extern_crate(true)
    .generate_block(true)
    .clang_args(&["-fblocks"])

    .objc_extern_crate(true)
    .clang_args(&["-x", "objective-c"])

    .blacklist_item("timezone")
    .blacklist_item("IUIStepper")
    .blacklist_function("dividerImageForLeftSegmentState_rightSegmentState_")
    .blacklist_item("objc_object");

let bindings = builder.generate().expect("unable to generate bindings");

In my actual of uikit-sys, I've got this section littered with comments.

  • The cargo:rustc-link-lib=framework=UIKit is how cargo tell's rustc to link against the UIKit framework.

  • rustfmt_bindings(true) is pretty obvious and also optional. I'd strongly recommend this because you will almost certainly be jumping around the generated bindings to figure out what types you will need when trying to use this.

  • .header_contents("UIKit.h", "#include<UIKit/UIKit.h>") is pretty obvious. I think you might be able to use .header("UIKit.h") but I've had issues with it.

  • These two were mentioned in the previous sections about target and sdk path.

    .clang_args(&[&format!("--target={}", target)])
    .clang_args(&["-isysroot", sdk_path])
  • .block_extern_crate(true).generate_block(true).clang_args(&["-fblocks"]) is about adding generation of Blocks that are commonly used with objective-c frameworks.

  • .objc_extern_crate(true).clang_args(&["-x", "objective-c"]) is to tell clang to return the objective-c AST to bindgen and actually produce the bindings we want for this project.

  • The objc_extern_crate(true) and block_extern_crate(true) tell bindgen that prfix the generation with extern crate objc and extern crate block.

Blacklists§

So, this is one of the more annoying issues with generating bindings that one can pretty much only learn by trial and error. If you uncomment any of these you will almost certainly have an error:

    .blacklist_item("timezone")
    .blacklist_item("IUIStepper")
    .blacklist_function("dividerImageForLeftSegmentState_rightSegmentState_")
    .blacklist_item("objc_object");

Here's pretty much the issue with each:

  • time.h as has a variable called timezone that conflicts with some of the objective-c calls from NSCalendar.h in the Foundation framework.
  • The issues with IUIStepper and dividerImageForLeftSegmentState_rightSegmentState_ are documented at rust-lang/rust-bindgen#1705.
  • objc_object is a bit odd and is because Objectdoesn't implement Copy. If you look at the generation, it's not used anywhere else so it's safe to say that you don't really need it.

This is a much smaller list than I had when I first started this project due to some of the fixes I added to bindgen.

Let's build it!§

To build this whole thing, we now run cargo build --target aarch64-apple-ios and wait for an unfortunately long time. The long compile time is because UIKit relies on a number of other Objective-c frameworks.

You can find the code for this exact example in the build-uikit-sys directory of github.com/simlay/blog-post-examples.

So, what have we built?§

This is now a uikit-sys crate and has a lot of things in it. Mine has 134133 lines but it will depend on your iOS SDK.

To reiterate what's in the Objective-c section of the bindgen guide, for a given objective-c class Foo, bindgen will produce a struct Foo and a trait IFoo. Foo will impl IFoo. If the objective-c class Foo inherits from Bar, the struct Foo will also implement IBar along with any thing that the Bar class also inherits. The same idea applies to protocols and categories.

The trait IFoo will actually have the getters, setters and methods that match the Objective-c class Foo.

This class Foo is a repr(transparent) which means that it acts as the object it returns. As of rust-lang/rust-bindgen#1847, this means that if a objective-c method returns a Baz class, the rust bindgen will also return a Baz struct.

I could add many more words here but I will leave that for another time.