I’ve recently started developing in Swift. This can be challenging enough on its own but I’ve started working on something that requires language interoperability to make things even harder: I wanted to call a C library from Swift.

I use a multilingual monorepo for all my projects. This requires a build tool that supports all sorts of projects and languages. I picked Bazel for its distributed build capabilities and explicit dependency management.

Bazel is great for hermetic builds and making sure every dependency can be built as part of the build process. However, some libraries can require significant work to be built with Bazel. For example, a C library that uses extensive automake configuration and has a few dependencies can be a tricky candidate. The rules_foreign_cc can offer some help with this but it might still involve creating build rules for all dependencies and chain them together.

I’ve come up with a stop gap solution for my project that enabled me to prototype the functionality before investing into creating the build pipeline. The approach I’ll demonstrate below is not something you’d want to use when shipping your application to production as it goes against Bazel’s goal of providing hermetic builds. However, it can decouple product prototyping and enable gradually involving more engineers until all dependencies are built with Bazel.

Swift’s native tooling for calling system libraries

Swift Package Manager has been included with Swift since version 3.0. It enables distributing reusable functionality as packages and manage dependencies.

This approach might require creating a system library target in the Package.swift:

// swift-tools-version:5.3
import PackageDescription

let package = Package(
    name: "Your Package",
    products: [
        .executable(name: "example", targets: ["example"])
    ],
    dependencies: [],
    targets: [
        .systemLibrary(
            name: "<SwiftPackageName>",
            pkgConfig: "<name known to pkg-config>",
            providers: [
                .brew(["<Homebrew formula name>"])
            ]
        ),
        .target(name: "example", dependencies: ["<SwiftPackageName>"])
    ]
)

You will also need to create the Sources/SwiftPackageName/module.modulemap file to make it accessible:

$ cat Sources/SwiftPackageName/module.modulemap 
module SwiftPackageName [system] {
  header "shim.h"
  link "SwiftPackageName"
  export *
}

You’re able to control what is exposed to the Swift package via the shim.h file:

#include <example.h>

As you can see, some boilerplate is needed to expose a C library to a Swift package.

When building a package, Swift allows to pass down additional parameters to the compiler. The compiler is based on LLVM. It’s possible to specify the folder for include headers by adding -Xswift -I<path>.

I have an M1 Mac mini as my development machine and I follow the maintainers' recommendation, so I use two Homebrew installations:

  • /opt/homebrew for Apple Silicon and
  • /usr/local for Intel.

Compiling a Swift package with using the Homebrew for Apple Silicon installation looks like this:

$ swift build -Xswiftc -I/opt/homebrew/include

Accessing Homebrew installed packages from Bazel

To make Homebrew installed libraries available via Bazel, we need to create a new repository with the new_local_repository rule in the WORKSPACE file. We’ll actually need two of them:

new_local_repository(
    name = "homebrew_arm64",
    path = "/opt/homebrew",
    build_file = "BUILD.homebrew"
)

new_local_repository(
    name = "homebrew_x86_64",
    path = "/usr/local",
    build_file = "BUILD.homebrew"
)

This definition gives access to anything installed with Homebrew. This approach takes advantage of that the installed libraries with the two different Homebrew setups are made up from the same files but compiled for different architectures (arm64 vs x86 64-bit).

If you’re thinking about that this definition will expose all libraries installed with Homebrew, don’t worry, we’ll scope it down when we add the contents of the BUILD.homebrew file later on in the example section.

If you’re wondering how we’ll use these two repositories, we’ll choose between them automatically based on the environment. Bazel provides the select helper function to pick values based on constraints. To take advantages of this, we need to create two configurations based on the CPU architectures and operating system:

config_setting(
    name = "macos_arm",
    values = {
        "cpu": "darwin_arm64",
    },
    constraint_values = [
        "@platforms//os:macos",
    ],
    visibility = ["//:__subpackages__"],
)

config_setting(
    name = "macos_intel",
    constraint_values = [
        "@platforms//os:macos",
        "@platforms//cpu:x86_64",
    ],
    visibility = ["//:__subpackages__"],
)

A good place for those config_setting definitions is the BUILD.bazel file next to our WORKSPACE file, so we can refer to them like anywhere in our workspace:

select({
    "//:macos_arm": ...,
    "//:macos_intel": ...,
}),

Example - Calculating SHA256 sum with OpenSSL

I chose calculating SHA256 sum of a static string as an example because OpenSSL is a very common dependency for projects (e.g. Tensorflow). However, OpenSSL is a bit of a special case for Homebrew as it’s a keg-only formula. This means it isn’t linked into Homebrew’s include directory, so we need to modify the previous swift build command a little:

swift build -Xswiftc -I/opt/homebrew/opt/openssl@1.1/include

It’s time to make the contents of the openssl/sha.h available via Bazel and add the contents of the BUILD.homebrew file that we specified for the Homebrew local repositories:

cc_library(
    name = "sha",
    hdrs = ["opt/openssl@1.1/include/openssl/sha.h"],
    includes = ["opt/openssl@1.1/include"],
    srcs = ["opt/openssl@1.1/lib/libcrypto.a"],
    tags = ["swift_module=Copenssl"],
    linkstatic = True,
    visibility = ["//visibility:public"],
)

The cc_library build rule only exposes the functions from the openssl/sha.h header. This is intentional as we’re only going to use the SHA256 function.

One key part of the definition is the tags keyword. This specifies the Swift module name we can use later. Swift maintainers recommend prefixing the library name with C, so I chose Copenssl to follow this recommendation.

The convention we hope the community will adopt is to prefix such modules with C and to camelcase the modules as per Swift module name conventions. Then the community is free to name another module simply libgit which contains more “Swifty” function wrappers around the raw C interface.

It’s finally time to write some Swift code. In this example, we’ll just calculate the SHA256 hash of a static string and print it out to the standard output:

import Foundation
import Copenssl

let text = "test string"
let result = text.withCString(encodedAs: UTF8.self) { textAsCString in
    // Returns 32 bytes (256 bits) of data
    SHA256(textAsCString, text.count, nil)
}

let sha256sum = Data(bytes: result!, count: 32)
    // Convert a byte to a hexadecimal string
    .map { String(format: "%02hhx", $0) }
    .joined()

print("SHA256 sum: \(sha256sum)")

rules_swift provide s the swift_binary rule to create executable binaries. You can put the code from above into the sha256.swift file and create a BUILD.bazel file next to it with the following content to be able to run it with bazel run:

load(
    "@build_bazel_rules_swift//swift:swift.bzl",
    "swift_binary",
)

swift_binary(
    name = "sha256",
    srcs = [
        "sha256.swift",
    ],
    deps = select({
        "//:macos_arm": ["@homebrew_arm64//:sha"],
        "//:macos_intel": ["@homebrew_x86_64//:sha"],
    }),
    visibility = ["//visibility:public"],
)

Bazel will create an executable binary from the sha256.swift and it’ll select the correct dependency based on the available CPU or the specified attributes like --cpu and --macos_cpus.

If you store the sha256.swift and BUILD.bazel files for example in the swift/sha256 folder, you can run it with the following command:

$ bazel run swift/sha256
INFO: Analyzed target //swift/sha256:sha256 (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //swift/sha256:sha256 up-to-date:
  dist/bin/swift/sha256/sha256
INFO: Elapsed time: 3.975s, Critical Path: 3.48s
INFO: 3 processes: 1 internal, 1 darwin-sandbox, 1 worker.
INFO: Build completed successfully, 3 total actions
INFO: Build completed successfully, 3 total actions
SHA256 sum: d5579c46dfcc7f18207013e65b44e4cb4e2c2298f4ac457ba8f82743f31e930b

We can verify the output quickly with the sha256sum command:

$ echo -n "test string" | sha256sum
d5579c46dfcc7f18207013e65b44e4cb4e2c2298f4ac457ba8f82743f31e930b  -

Bazel 4.0.0 doesn’t have an arm64 binary release, so it runs with Rosetta 2, thus produces an x86 binary:

$ file dist/bin/swift/sha256/sha256
dist/bin/swift/sha256/sha256: Mach-O 64-bit executable x86_64

To create an M1 native binary, we need to specify the CPU:

$ bazel build swift/sha256 --cpu=darwin_arm64 --macos_cpus=arm64
[^]INFO: Build option --macos_cpus has changed, discarding analysis cache.
INFO: Analyzed target //swift/sha256:sha256 (0 packages loaded, 782 targets configured).
INFO: Found 1 target...
Target //swift/sha256:sha256 up-to-date:
  dist/bin/swift/sha256/sha256
INFO: Elapsed time: 4.277s, Critical Path: 3.23s
INFO: 3 processes: 1 internal, 1 darwin-sandbox, 1 worker.
INFO: Build completed successfully, 3 total actions
$ file dist/bin/swift/sha256/sha256
dist/bin/swift/sha256/sha256: Mach-O 64-bit executable arm64

You can find these examples on GiHub:

Conclusion

Homebrew has an extensive library of formulas with their dependency chain defined. This makes compiling them easy. Homebrew also provides precompiled versions of these packages (called bottle) to make installation even faster. This can enable testing ideas quicker if you don’t have a package already compiled with Bazel and only invest into creating a BUILD file once you’ve validated your approach.

To get ideas how to compile OpenSSL with Bazel, you can take a look at the example from rules_foreign_cc or how Dropbox does this.