Bazel Rules: Macros

2021-12-30 • edited 2022-01-06

Intro #

Bazel’s evaluation model includes three phases:

  • Loading phase: evaluates and processes the Bazel files (WORKSPACE, BUILD, *.bzl) to create a target graph.
  • Analysis phase: processes the target graph to identify actions, determines their order and builds the action graph.
  • Execution phase: runs the action graph to produce artifacts.

Macros are special StarkLark functions used exclusively in the loading phase that we can use to make rules more convenient for users. As a contrived but simple example, let’s create a macro that will provide a reasonable default for the name of the output file. To users, the macro will look exactly like a rule. Here is the resulting BUILD file:

load(":rules.bzl", "demo_macro")

demo_macro(
    name = "macros",
    srcs = [
        "english.sh",
    ],
    out = "hello.sh",
)

demo_macro(
    name = "bonjour",
    srcs = [
        "french.sh",
    ],
)

As we can see, I replaced the demo_binary name from the previous example and added another target using the same rule, this time without the out attribute. The target should build a bonjour script with a French greeting.

Implementation #

Macros are mostly a convenience mechanism and cannot change the rule internals, so neither the original rule nor its implementation function needs to change in this example. However, we don’t export the demo_binary function any longer, so it would be a good idea to make it private:

_demo_binary = rule(
    ...

Adding a macro to rules.bzl is straight-forward:

def demo_macro(name, srcs, out = None, **kwargs):
    _demo_binary(
        name = name,
        srcs = srcs,
        out = "{}.sh".format(name) if out == None else out,
        **kwargs
    )

We can see that it defines several parameters, including:

  • mandatory (name and srcs);
  • an argument with a default value (out);
  • **kwards so that users could pass an unspecified amount of keyworded parameters to the macro.

The macro then calls the original rule and conditionally passes the target name as the out attribute.

We can build and run both targets now:

$ bazel run //macros
...
Target //macros:macros up-to-date:
  bazel-bin/macros/hello.sh
...
Hello, World!
$ bazel  run //macros:bonjour
...
Target //macros:french up-to-date:
  bazel-bin/macros/bonjour.sh
...
Bonjour monde!

It can be really useful to see what the macros expand into. We can use a Bazel query for that:

$ bz query --output=build //macros:all
# /Users/sb/repo/examples/bazel_rules/macros/BUILD.bazel:3:11
_demo_binary(
  name = "macros",
  generator_name = "macros",
  generator_function = "demo_macro",
  generator_location = "macros/BUILD.bazel:3:11",
  out = "//macros:hello.sh",
  srcs = ["//macros:english.sh"],
)
...
_demo_binary(
  name = "bonjour",
  generator_name = "bonjour",
  generator_function = "demo_macro",
  generator_location = "macros/BUILD.bazel:12:11",
  out = "//macros:bonjour.sh",
  srcs = ["//macros:french.sh"],
)
...

Macros: Best Practices #

It is easy to write a macro that is not convenient to use idiomatically. One of the common mistakes is to hardcode the name of the rule within the macro, which will prevent it from being re-used in the same package:

def dont_do_that():
    some_rule(
        name = "bad_idea",
    ...

Instead, a good macro defines and uses a name argument.

If we need a default value, None is usually the best option, as the macro can then pass it to its rules. They will treat None as an absent parameter.

Re-usable macros should define and pass **kwargs so that the users can control the standard rule parameters (visibility, etc.) It might be a good idea to include visibility explicitly.

Finally, please use a docstring to document your public macros.

The complete code for the example is in the repo.

bazel

Bazel Rules: Multiple Source Files Bazel Rules: Multiple Outputs