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:
- bazel-swift-sha256sum-example: Bazel based version
- swift-sha256sum-example: Swift Package Manager based version
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.