How to generate code with Bazel

Multi-step builds with code generation.

Golang uses code generation heavily. Here-and-there developers generate gRPC stubs, parts of source code, documentation, commit messages, etc. Go even has a special //go:generate directive. Bazel moves code generation to the next level. In this post, I’ll show you how to produce and build Go code with Bazel and with generators written in Go.

genrule + cat

Our process will look like this:

  • Generate a hello.go file with cat utility.
  • Build a binary out of hello.go file.
  • Run it.

It looks simple enough. Let’s see how it could be done. I’m going to generate and then execute the following Hello World application.

package main

import "fmt"

func main() {
  fmt.Printf("Hello World!")
}

As we know from my previous post, to build a binary, we need to use go_binary Bazel target:

go_binary(
    name = "hello-world",
    embed = [":hello-world_lib"],
    importpath = "github.com/ekhabarov/hello-world",
)

It embeds another target, go_library:

go_library(
    name = "hello-world_lib",
    srcs = ["hello.go"],
    importpath = "github.com/ekhabarov/hello-world",
)

Which in turn accepts a source file hello.go. Usually, we have such source files, but not today. So, now we have to generate it. For this, we will be using built-in Bazel genrule. As per documentation:

A genrule generates one or more files using a user-defined Bash command.

A-ha, that’s what we need. Here is how it can be used:

genrule(
    name = "generate_hello_go",
    outs = ["hello.go"],
    cmd = """
    cat << EOF >> $@
package main

import "fmt"

func main() {
  fmt.Printf("Hello World!")
}
EOF
""",
)

This rule creates the file hello.go, by running cat command and redirecting output to $@, which is a file defined in the outs attribute. More about Bazel Make variables here.

Ok, let’s build it (link to the repo under the post description):

% bazel build //cat:generate_hello_go
INFO: Analyzed target //cat:generate_hello_go (1 packages loaded, 1 target configured).
INFO: Found 1 target...
Target //cat:generate_hello_go up-to-date:
  bazel-bin/cat/hello.go                           <<< HERE IS OUR GENERATED FILE
INFO: Elapsed time: 0.125s, Critical Path: 0.02s
INFO: 2 processes: 1 internal, 1 darwin-sandbox.
INFO: Build completed successfully, 2 total actions

% cat bazel-bin/cat/hello.go
package main

import "fmt"

func main() {
  fmt.Printf("Hello World!")
}

As you can see, the generated file is available a) after the build only and b) in the Bazel cache only. Now, we can use this file as a source for go_library. But let’s look at genrule docs again to see how these rules could be linked:

When referencing the genrule in other rules, you can use either the genrule’s label or the labels of individual output files. Sometimes the one approach is more readable, sometimes the other: referencing outputs by name in a consuming rule’s srcs will avoid unintentionally picking up other outputs of the genrule, but can be tedious if the genrule produces many outputs.

i.e., we can use go_library “as is”, or we can modify it this way:

go_library(
    ...
    srcs = [":generate_hello_go"],
    ...
)

And the final test, let’s run our generated binary:

 % bazel run //cat:hello-world
INFO: Analyzed target //cat:hello-world (1 packages loaded, 4 targets configured).
INFO: Found 1 target...
Target //cat:hello-world up-to-date:
  bazel-bin/cat/hello-world_/hello-world                <<< GENERATED BINARY
INFO: Elapsed time: 0.121s, Critical Path: 0.00s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Running command line: bazel-bin/cat/hello-world_/hello-world
Hello World!%                                           <<< HERE IS AN OUTPUT

Woohoo, it works! Now, let’s look at a bit more complex example.

genrule + Go binary

What if, instead of the cat command, we would have a Go binary to generate our code? How will our process change?

  • Build a generator: the first Go binary.
  • Generate a hello.go file with the built generator.
  • Build a binary out of hello.go file.
  • Run it.

We need one more additional step, generator build. All other steps are the same.

For this example, I’ll be using hello-world-generator, which prints out the following Go code:

// hello-world-generator

package main

import "fmt"

var tpl = `package main

import (
	"flag"
	"fmt"
)

var name = flag.String("name", "World", "Just a name")

func main() {
	flag.Parse()

	fmt.Printf("Hello, %s!\n", *name)
}`

func main() {
	fmt.Println(tpl)
}

To utilize it, we need the following:

  • Add hello-world-generator as a dependency to our project.
  • Modify the genrule that produces hello.go.

Add a generator dependency

hello-world-generator is a Go binary, so we can add it as any other Go dependency by calling go_repository rule. This call can be added to the WORKSPACE file, or, as I did, we can add it to go_deps.bzl.

# go_geps.bzl

load("@bazel_gazelle//:deps.bzl", "go_repository")

def go_deps():
    go_repository(
        name = "com_github_ekhabarov_helloworld_generator",
        importpath = "github.com/ekhabarov/helloworld-generator",
        sum = "h1:MrREQgX6I0/4cstUhbuqfALzUF3W2Nz8kVZRq6A4q+E=",
        version = "v0.0.1",
    )

It’s a Go binary, i.e., we can run it right away on its own:

% bazel run @com_github_ekhabarov_helloworld_generator//:helloworld-generator
...skipped...
package main

import (
        "flag"
        "fmt"
)

var name = flag.String("name", "World", "Just a name")

func main() {
        flag.Parse()

        fmt.Printf("Hello, %s!\n", *name)
}

And here ^ is the source code we have to build. We have a generator. Now it’s time to modify our genrule:

genrule(
    name = "generate_hello_go",
    outs = ["hello.go"],
    cmd = "$(execpath @com_github_ekhabarov_helloworld_generator//:helloworld-generator) > $@",
    tools = [
        "@com_github_ekhabarov_helloworld_generator//:helloworld-generator",
    ],
)

There are two main changes:

  • tools attribute, which contains a list of tools that must be built before Bazel will run genrule.
  • cmd attribute that has a call for our generator. execpath denotes the path beneath the execroot where Bazel runs build actions (c).

go_library and go_binary targets remain unchanged.

% bazel run //go:hello-world -- -name Go
...bazel info output skipped...
Hello, Go!

For a generator, technically, we can use any other binary or a script we can build with Bazel.

Have fun!

See Also