Composing Rules with Macros
By the end of this section, you'll know how to make more readable BUILD files by using a simple code-sharing technique provided by Bazel.
Macros
Bazel Macros are like pre-processor definitions, which compose existing rules in a novel way and
provide "syntax sugar" to developers who call them from BUILD
files.
At a BUILD file usage site, you cannot distinguish macro from rule
This is to allow a rule to be wrapped with a macro without a breaking change.
Thanks to this design, we can start by imagining the right way for a user to express their "bare facts" in the BUILD file, then write Starlark code that supports it. We can start with a macro as they are much easier, but we can always introduce a custom rule when the requirements make it necessary.
If you bazel print
(Aspect CLI only) which is a syntactic operation on the BUILD file, you see the
macro as it was called.
However, macros are expanded during the analysis phase, so if you run a bazel query
you'll see the
result of the macro evaluation.
If the macro is named differently from the underlying rule, this can be confusing for users and also affect usability, for example --test_lang_filters
applies to the underlying rule's name.
A macro is just a function definition in a .bzl
file which composes some existing rules.
def my_macro(name, srcs, **kwargs):
some_rule(
name = name,
srcs = srcs,
**kwargs
)
The run_binary
rule introduced earlier is a great candidate for the some_rule
here.
Example 1
This example just wraps a single run_binary
rule, in this case it's a third-party tool called "mocha"
which was fetched from npm.
load("//examples/macro:mocha.bzl", "mocha_test")
mocha_test(
name = "test",
srcs = ["test.js"],
)
"Example macro wrapping the mocha CLI"
load("@npm//examples/macro:mocha/package_json.bzl", "bin")
def mocha_test(name, srcs, args = [], data = [], env = {}, **kwargs):
bin.mocha_test(
name = name,
args = [
"--reporter",
"mocha-multi-reporters",
"--reporter-options",
"configFile=$(location //examples/macro:mocha_reporters.json)",
native.package_name() + "/*test.js",
] + args,
data = data + srcs + [
"//examples/macro:mocha_reporters.json",
"//examples/macro:node_modules/mocha-multi-reporters",
"//examples/macro:node_modules/mocha-junit-reporter",
],
env = dict(env, **{
# Add environment variable so that mocha writes its test xml
# to the location Bazel expects.
"MOCHA_FILE": "$$XML_OUTPUT_FILE",
}),
**kwargs
)
Example 2
This example composes a few building blocks from bazel_skylib and aspect_bazel_lib.
ts_project(
name = "strip",
tsconfig = {
# Demonstrating that rootDir compilerOption works the same as the
# root_dir attribute.
"compilerOptions": {
"rootDir": "subdir",
},
},
)
assert_outputs(
name = "strip_test",
actual = "strip",
expected = [
"examples/root_dir/a.js",
"examples/root_dir/deep/subdir/b.js",
],
)
"helpers for test assertions"
load("@bazel_skylib//rules:diff_test.bzl", "diff_test")
load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("@bazel_skylib//lib:types.bzl", "types")
load("@aspect_bazel_lib//lib:params_file.bzl", "params_file")
def assert_outputs(name, actual, expected):
"""Assert that the default outputs of actual are the expected ones
Args:
name: name of the resulting diff_test
actual: string of the label to check the outputs
expected: expected outputs
"""
if not types.is_list(expected):
fail("expected should be a list of strings")
params_file(
name = "_actual_" + name,
data = [actual],
args = ["$(rootpaths {})".format(actual)],
out = "_{}_outputs.txt".format(name),
)
write_file(
name = "_expected_ " + name,
content = expected,
out = "_expected_{}.txt".format(name),
)
diff_test(
name = name,
file1 = "_expected_ " + name,
file2 = "_actual_" + name,
)
Example 3
This example creates a macro wrapping a repository rule rather than a build rule.
(Actually, it uses alias
which is even shorter than a macro, it passes all attributes through.)
It uses select
to get a binary for the host platform, bypassing the need for toolchains which are
a tricky part of Custom rules.
If you run an un-configured build (for example with bazel query
) then select will eagerly load every
label on the right-hand-side. This causes an eager fetch of tools which don't run on the host platform
and wastes the developers time.
This is a good reason to get in the habit of always using bazel cquery
instead, so that the build is
configured.
Usage:
http_archive(
name = "terraform_macos_aarch64",
build_file_content = "exports_files([\"terraform\"])",
sha256 = "ff92cd79b01d39a890314c2df91355c0b6d6815fbc069ccaee9da5d8b9ff8580",
urls = ["https://releases.hashicorp.com/terraform/{0}/terraform_{0}_darwin_arm64.zip".format(version)],
)
alias(
name = "terraform_binary",
actual = select({
"//platforms/config:linux_x86_64": "@terraform_linux_x86_64//:terraform",
"//platforms/config:macos_aarch64": "@terraform_macos_aarch64//:terraform",
"//platforms/config:macos_x86_64": "@terraform_macos_x86_64//:terraform",
}),
)
When a Macro isn't enough
Rules create actions, which transform inputs to outputs.
Using ts_project
as an example, this couldn't be a macro for several reasons:
- It creates a tree of actions, which might use one tool to transpile
.js
outputs, and a different tool for producing TypeScript types (.d.ts
files). - It requires that
srcs
have aJsInfo
provider so that it can understand their structure. - It produces a
JsInfo
provider for inter-op with downstream rules that depend on it.
Even when Providers get in your way of "just using a macro", you can often write a tiny adapter rule and then put most of your logic in a more easily understood macro.
For example, this code
adapts a ProtoInfo
on its sources to a DefaultInfo
output.
Try it: write a macro
Add any macro in your repository, even a trivial one.
Then change one of your BUILD files to call the macro.