In my previous article I showed how to create a stupidly simple Bazel rule. While that rule was not useful in any way, shape, or form, it provided a gentle introduction to writing Bazel rules and helped build a mental model around them.
This week we will look at toolchains, which is a more advanced concept but becomes extremely important once your rules depend on external tools.
This walkthrough wires up macOS only to keep the example small. Adding Linux or Windows is the same pattern: download a different binary and register another
toolchain(...)target with different compatibility constraints.
Toolchains
A Bazel toolchain is a way of abstracting tools away from rules. Many people describe it as dependency injection.
More precisely: toolchains are dependency injection with platform-based resolution — Bazel selects the right implementation based on the execution and target platforms.
Instead of hard-coding a tool inside a rule, the rule asks Bazel to give it “the correct tool for this platform”.
Example: rules_pkl
We will create oversimplified rule called pkl_library that turns .pkl files into their JSON representation using the PKL compiler. pkl is Apple’s programing language for producing configurations. There is official rules_pkl ruleset and this article doesn’t even scratch the surface of the capabilities that it offers.
To do this we need:
- A way to download the PKL binary
- A toolchain
- A rule that uses the toolchain
Downloading the PKL binary
We start by writing a repository rule that downloads the PKL compiler and exposes it as a Bazel target.
Create repositories.bzl:
def _pkl_download_impl(repository_ctx):
repository_ctx.download(
url = repository_ctx.attr.url,
output = "pkl_bin",
executable = True,
sha256 = repository_ctx.attr.sha256,
)
repository_ctx.file(
"BUILD.bazel",
"""
load("@bazel_skylib//rules:native_binary.bzl", "native_binary")
native_binary(
name = "pkl",
src = "pkl_bin",
out = "pkl",
visibility = ["//visibility:public"],
)
""",
)
pkl_download = repository_rule(
implementation = _pkl_download_impl,
attrs = {
"url": attr.string(mandatory = True),
"sha256": attr.string(mandatory = True),
},
)
````
This does two things:
* Downloads the PKL binary
* Wraps it using `native_binary`, which creates a proper Bazel executable without relying on a shell
The resulting binary is available as `@pkl_macos//:pkl`.
## Exposing the repository via a module extension
We’re still using a [repository rule](https://bazel.build/external/repo) to do the download; [bzlmod module extensions](https://bazel.build/external/extension) are just how we call that repository rule from `MODULE.bazel`.
Create `extensions.bzl`:
```starlark
load("//:repositories.bzl", "pkl_download")
def _pkl_module_extension_impl(ctx):
pkl_download(
name = "pkl_macos",
url = "https://github.com/apple/pkl/releases/download/0.30.2/pkl-macos-aarch64",
sha256 = "75ca92e3eee7746e22b0f8a55bf1ee5c3ea0a78eec14586cd5618a9195707d5c",
)
pkl_extension = module_extension(
implementation = _pkl_module_extension_impl,
)
In MODULE.bazel:
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "bazel_skylib", version = "1.9.0")
pkl_extension = use_extension("//:extensions.bzl", "pkl_extension")
use_repo(pkl_extension, "pkl_macos")
Now the binary can be referenced as @pkl_macos//:pkl.
Creating the toolchain
A toolchain is just a rule that returns a ToolchainInfo provider.
toolchains.bzl:
def _pkl_toolchain_impl(ctx):
return [platform_common.ToolchainInfo(
pkl_binary = ctx.executable.pkl_binary,
)]
pkl_toolchain = rule(
implementation = _pkl_toolchain_impl,
attrs = {
"pkl_binary": attr.label(
executable = True,
cfg = "exec",
allow_files = True,
mandatory = True,
),
},
)
The important part is cfg = "exec", which ensures the binary runs on the execution platform.
registering the toolchain
In BUILD.bazel:
load("//:toolchains.bzl", "pkl_toolchain")
toolchain_type(
name = "pkl_toolchain_type",
visibility = ["//visibility:public"],
)
pkl_toolchain(
name = "pkl_toolchain_macos_impl",
pkl_binary = "@pkl_macos//:pkl",
)
toolchain(
name = "pkl_toolchain_macos",
toolchain = ":pkl_toolchain_macos_impl",
toolchain_type = ":pkl_toolchain_type",
exec_compatible_with = ["@platforms//os:macos"],
target_compatible_with = ["@platforms//os:macos"],
)
To register the toolchain we need to modify our MODULE.bazel:
register_toolchains("//:pkl_toolchain")
Using the toolchain in a rule
Now we can write pkl_library.
Create rules.bzl:
def _pkl_library_impl(ctx):
toolchain = ctx.toolchains["//:pkl_toolchain_type"]
binary = toolchain.pkl_binary
compiled_files = []
for src in ctx.files.srcs:
compiled_file_name = src.basename.replace(".pkl", ".json")
compiled_file = ctx.actions.declare_file(compiled_file_name)
ctx.actions.run(
executable = binary,
tools = [binary],
inputs = [src],
outputs = [compiled_file],
arguments = [
"eval",
src.path,
"--format",
"json",
"-o",
compiled_file.path,
],
mnemonic = "PKLCompile",
)
compiled_files.append(compiled_file)
return [DefaultInfo(files = depset(compiled_files))]
pkl_library = rule(
implementation = _pkl_library_impl,
attrs = {
"srcs": attr.label_list(
allow_files = [".pkl"],
mandatory = True,
),
},
toolchains = ["//:pkl_toolchain_type"],
)
The rule never knows which concrete PKL binary is being used — it only sees the resolved toolchain.
NOTE: Here instead of writing a for loop that creates new action per file is a decision that we need to make on a case by case basis. Sometimes it can be faster to run multiple actions in parallel than invoking a tool once and giving it a list of files to process. It heavily depends on the tool itself and shows us how bazel can be powerful in these scenarios.
Final thoughts
This was a practical look at repository rules, module extensions, toolchains, and how they fit together.
Toolchains are one of Bazel’s most powerful features. Once you start writing rules that depend on real tools (compilers, linters, generators), this pattern becomes unavoidable.
This marks the completion of my second article where I try to give real world examples of using bazel’s various features.