How to Build and Run Swift Package Binaries in Bazel
NOTE: This article is about using rules_spm
to leverage Swift packages in Bazel. The rules_spm
project is being sunset. It has been superseded by swift_bazel. I have written a blog post on how to get started.
This article is the second in a series describing the features and usage of rules_spm. Check out my first post to learn how to get started with rules_spm
and import modules from external Swift packages in your Bazel project.
Table of Contents
Executables in Swift Packages
Most Swift developers know that Swift packages are convenient for defining reusable library modules (e.g., Apple’s swift-log, Apple’s swift-nio). However, they also can contain executable products. Two important examples are Realm’s SwiftLint and Nick Lockwood’s SwiftFormat. This article will show you how to build and execute these utilities as part of your Bazel project.
Teamwork + Tool Standardization = Sanity
Let’s take a fairly common situation. You are part of a development team building the next great product/service/app written in Swift. You select Bazel as your build system for all of the obvious reasons. Naturally, you will define your build targets using rules_spm. If you have any dependencies on external Swift modules, you can use rules_spm to import them and make them available to your rules_swift
targets. At this point, your team is ready to implement the code and tests for your project.
Anyone who has worked on a team knows that every developer has a preferred coding style. While creatively this is great, it can make code maintenance a big headache. The following quote summarizes the issues nicely.
Consistently using the same style throughout your code makes it easier to read. Code that is easy to read is easier to understand by you as well as by potential collaborators. Therefore, adhering to a coding style reduces the risk of mistakes and makes it easier to work together on software.
So, the team agrees to a set of coding standards and styles. To make things easier for everyone on the team, they codify their coding standards into rules for a linter like SwiftLint. Now, every team member can check their code and receive faster feedback. In addition, since no developer in their right mind wants to spend extra time formatting their code by hand, the team configures a code formatting utility like SwiftFormat to ease the burden and keep team morale high.
Now, since we are trying to apply a level of standardization to the code across the team, it will be necessary for everyone on the team to have the same version of the linter, the code formatter, and their configuration files. One obvious choice is to use the same source control repository used to store the project’s code. Hence, when a team member brings down the latest source code, they will have the team’s vetted utility files.
The only remaining issues are:
- How do we build the linter and code formatter?
- How do we execute the linter and code formatter?
Build and Execute the Utilities in Bazel
You can find the code for this example and many other rules_spm examples on Github.
Declare the External Swift Packages
To start, we need to load rules_spm into the WORKSPACE
file.
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
name = "cgrindel_rules_spm",
sha256 = "fab28a41793744f3944ad2606cdd9c0f8e2f4861dd29fb1d61aa4263c7a1400a",
strip_prefix = "rules_spm-0.6.0",
urls = ["https://github.com/cgrindel/rules_spm/archive/v0.6.0.tar.gz"],
)
load(
"@cgrindel_rules_spm//spm:deps.bzl",
"spm_rules_dependencies",
)
spm_rules_dependencies()
load(
"@build_bazel_rules_swift//swift:repositories.bzl",
"swift_rules_dependencies",
)
swift_rules_dependencies()
load(
"@build_bazel_rules_swift//swift:extras.bzl",
"swift_rules_extra_dependencies",
)
swift_rules_extra_dependencies()
Note: At the time of writing, the latest version of rules_spm was 0.6.0. Before using rules_spm
in your project, check out the README.md for the most up-to-date version and initialization code.
Note: The rules_spm
rules use rules_swift
under the covers. If your project requires a specific version of rules_spm
, add the appropriate rules_swift
declarations before running spm_rules_dependencies
.
Next, we need to declare which Swift packages that we want to use.
spm_repositories(
name = "swift_utils",
dependencies = [
spm_pkg(
"https://github.com/realm/SwiftLint.git",
from_version = "0.0.0",
products = ["swiftlint"],
),
spm_pkg(
"https://github.com/nicklockwood/SwiftFormat.git",
from_version = "0.0.0",
products = [
"swiftformat",
],
),
],
platforms = [
".macOS(.v10_12)",
],
)
You may notice some similarities between this syntax and the package declaration syntax in a Package.swift
file.
- The spm_repositories rule encapsulates the declarations for a collection of dependent packages much like the Package declaration in
Package.swift
. - The spm_pkg function declares a dependency on an external package.
- The
platforms
attribute lists the supported platforms.
So, as you may have guessed, the above snippet will load the latest 0.X.X
versions of SwiftLint and SwiftFormat from their respective repositories.
One point worth mentioning, especially if you read my previous article on importing Swift modules from external Swift packages, is that you should declare your code dependencies separate from your developer utilities. The build requirements for the utilities that we plan to run on the developer and CI machines are almost certainly different from those for our target platforms (e.g., develop on macOS and target iOS). There is an example in the rules_spm
GitHub repository that demonstrates how to do this.
Create Some Aliases
Now that we have declared which Swift packages we want to use let’s create some Bazel aliases for easy reference. Technically, this is optional. However, it will make building and executing the utilities much easier.
For this example, we will create the aliases in a BUILD.bazel
file at the root of our Bazel workspace. Feel free to put them wherever you would like.
alias(
name = "swiftlint",
actual = "@swift_utils//SwiftLint:swiftlint",
)
alias(
name = "swiftformat",
actual = "@swift_utils//SwiftFormat:swiftformat",
)
Now, we can build and execute our utilities from the command-line:
# Build the world, including the utilities
$ bazel build //...
# Execute swiftlint
$ bazel run //:swiftlint -- --version
# Execute swiftformat
$ bazel run //:swiftformat -- --version
Developer Usage: Command-Line and IDE Integration
Developers need to be able to run the utilities from the command-line and the IDE. We won’t get into the specifics of IDE integration. Most reputable IDEs provide a means for integrating command-line applications. So, we will focus on running the utilities from the command-line.
Those of you who are familiar with Bazel and all of the nuances of using bazel run
to execute a binary target will know that running the following from the workspace directory will not lint the files in the current directory:
# THIS WILL NOT WORK AS YOU EXPECT; no source files will be found
$ bazel run //:swiftlint
The working directory for the execution of the utility will be somewhere in the bowels of Bazel’s output directories. Instead, we need a script or shell alias which will allow us to run the utilities naturally.
# We want this to work from the workspace directory.
$ ./swiftlint
Option #1: Simple Bazel Run Script
Our first crack at creating a utility script will leverage Bazel’s --run_under
flag. This flag tells Bazel to prepend some code to the execution of the binary. We can use it to change the current working directory before executing the binary.
Here is a shell script called swiftlint
that demonstrates how to call the SwiftLint utility.
#!/usr/bin/env bash
set -uo pipefail
# The basename for this script determines the target being run.
target=$(basename "$0")
# Execute the target such that the shell is relative to the current directory
bazel run --run_under="cd $(pwd) &&" "//:${target}" -- "${@:-}"
The above script works pretty well, except Bazel has to do its thing before every run. If you are linting or formatting while coding, this could be pretty heavy. Also, it is unnecessary. Most of our code changes should not affect the build of the utility.
Option #2: "Smart" Bazel Run Script
Our second take on the script will use Bazel’s --script_path
flag. This flag, when used with bazel run
, will not execute the binary. Instead, it will generate a script that can execute the binary.
This generated script sounds perfect. Unfortunately, it does something that we don’t want. It changes the current working directory to the bowels of Bazel’s output directories. Bummer.
However, the generated script has something beneficial. It contains the full path to the built binary. So, our second take at the utility script will generate the Bazel execution script, parse the output to get the binary path, and then execute our command directly with the binary.
Let’s replace our swiftlint
script with the following:
#!/usr/bin/env bash
set -uo pipefail
gen_exec_script() {
local exec_script="${1}"
local target="${2}"
bazel run --script_path "${exec_script}" "//:${target}"
}
get_exec_binary() {
local exec_script="${1}"
local exec_last_line=$(tail -n 1 "${exec_script}")
local exec_last_line_parts=(${exec_last_line})
echo "${exec_last_line_parts[0]:-}"
}
script_dir=$(cd `dirname "$0"` && pwd)
target=$(basename "$0")
exec_script="${script_dir}/_exec_${target}"
# Generate Bazel's script for the binary, if it does not exist
[ ! -f "${exec_script}" ] && gen_exec_script "${exec_script}" "${target}"
# Find the executable path in the script
binary=$(get_exec_binary "${exec_script}")
# If the actual binary does not exist, generate the script. Generating the
# script will build the binary, as well.
if [[ ! -f "${binary}" ]]; then
gen_exec_script "${exec_script}" "${target}"
binary=$(get_exec_binary "${exec_script}")
fi
# Execute the command
"${binary}" "${@:-}"
The above script does the following:
- Generates the Bazel execution script if it does not exist.
- Parses the execution script and returns the binary path.
- If the binary does not exist on disk, it generates the script, which will build the binary.
- Finally, it executes the binary, passing along any arguments.
This script gives us the best of both worlds. It builds the utility from its source, and then it executes the utility directly with the provided arguments. Now, it is just a matter of configuring your favorite IDE to call it.
Conclusion
This article shows how you can download, build and execute binaries defined in external Swift packages using rules_spm and Bazel. For more information on rules_spm
, check out the documentation on GitHub and my previous article about importing Swift modules.