You're viewing the readable version of this site. The interactive extras (search, diagrams, read-aloud) need JavaScript and a current browser. Enable JavaScript; if it is already enabled, update your browser.

Learn · Learning Bazel

seedling

When the Generator Meets the Macro

A tool that writes your build files for you, a hard failure the moment it meets the macro from last chapter, and the rule that falls out of it.

bazel, gazelle, code-generation, go, learn

By the end you will have a tool generating build files from your source code, a hard failure you caused on purpose, and a one-line fix. You will be able to say which parts of a repository a generator should own and which it should not.

Writing build files by hand does not scale

You have hand-written eight build files. That was the right way to learn — you now read a target declaration without effort — and it is not how anyone maintains a large repository.

The information is redundant, which is the tell. Your Go source already says what it imports. Writing those same edges again in a deps list is transcription, and transcription drifts.

So there is a generator. It reads the source, resolves the imports to labels, and writes the declarations:

BUILD.bazel

load("@gazelle//:def.bzl", "gazelle")

# gazelle:prefix example.com/greet
gazelle(name = "gazelle")

The generator gets a target of its own at the workspace root. The comment line configures it — here, the module path your import paths start with.

It also needs a file the build system itself does not use:

go.mod

module example.com/greet

go 1.26.5

A standard Go module file. Nothing in the build reads it; the generator does, to learn the module path.

The tool is Gazelle. Jay Conrod split it into its own repository in 2017 and was its principal early maintainer; it had existed inside the Go rules before that.

Run it, and watch it break your build

$ bazel run //:gazelle
INFO: Running command line: bazel-bin/gazelle

The generator runs, reports nothing unusual, and rewrites several build files.

No error. Now build:

$ bazel build //server
ERROR: Error in go_library: go_library rule 'server_lib' conflicts with
existing go_library rule, defined at server/BUILD.bazel:4:11
ERROR: Skipping '//server': Package 'server' contains errors

The build fails to even load the package. Two declarations claim the same target name.

Look at what the generator did to the file:

server/BUILD.bazel

load("//tools:go_command.bzl", "go_command")

# gazelle:ignore
#
# The macro expands to a go_library that gazelle cannot see: it reads
# build files as text, finds no library for main.go, and helpfully adds
# one — which then collides with the macro's own. Generators and macros
# both write targets, so exactly one of them may own a package.
go_command(
    name = "server",
    importpath = "example.com/greet/server",
    deps = ["//server/greeting"],
)

The fixed version, after the directive. Before the fix, the generator had appended its own go_library for main.go below the macro call.

The generator read the build file as text. It saw no go_library declaring main.go, because there isn't one — there is a go_command call that expands into one, and expansion happens later, during evaluation, which a text-level tool never sees.

So it helpfully added the missing library. Now two things declare server_lib, and the package cannot load.

Neither tool is wrong

This is worth being precise about, because the instinct is to blame something.

The generator behaved correctly given what it can see. Reading build files as text rather than evaluating them is a deliberate choice — it is what makes the tool fast, and what lets it preserve your formatting and comments. Evaluating them would mean loading the whole workspace to regenerate one file.

The macro is also fine. It is chapter seven's macro, and it still expands to exactly what it did before.

The collision is structural. Both tools write targets into the same file, and neither can see the other's output.

Generators and macros both write targets, so exactly one of them may own a package. This is not a matter of taste — it is a duplicate declaration, and the build refuses to load.

The fix is one line

Tell the generator to leave that package alone:

# gazelle:ignore

The directive, at the top of the file the macro owns. Ownership is now explicit, and the generator honours it on every subsequent run.

Rerun the generator and the file survives untouched. Build, and both commands work again.

That one line is the whole fix, and the discipline it encodes is worth carrying: in any repository with generated build files, every package is owned either by the generator or by a human, and the ownership is written down where both can see it.

What the generator preserves

Before leaving, look at what it did to the package it does own:

server/greeting/BUILD.bazel

load("@rules_go//go:def.bzl", "go_library")

go_library(
    name = "greeting",
    srcs = ["greeting.go"],
    # go:embed reads this file, so the build has to be told about it.
    # Without this line the compiler cannot see it, even though it sits
    # right next to greeting.go on disk.
    embedsrcs = ["name.txt"],
    importpath = "example.com/greet/server/greeting",
    # //cmd/hello needs this too, so the package list grows to name it.
    # Widening to //visibility:public would work and would also stop the
    # build system from ever asking this question again.
    visibility = [
        "//cmd:__subpackages__",
        "//server:__subpackages__",
    ],
)

The generated version of a hand-written file. Your comments survived. So did embedsrcs from chapter five and the widened visibility list from chapter six.

It merged rather than overwrote. Your hand-written comments are still there. The embedsrcs line — which the generator has no way to infer, since it comes from a compiler directive — is untouched. The visibility list you edited in chapter six is exactly as you left it.

This matters because fear of losing hand-written work is the main reason people refuse generators, and that fear is mostly unfounded. The tool updates what it understands and leaves the rest alone.

Where the merge stops

That merging is real, and it has a boundary worth knowing before it surprises you.

Suppose a package needs something at runtime that it does not import — a plugin registered by side effect, a binary invoked as a tool. You add the edge by hand and explain why:

go_library(
    name = "greeting",
    srcs = ["greeting.go"],
    # Added by hand: needed at runtime, not imported.
    deps = ["//server/format"],
    importpath = "example.com/greet/server/greeting",
)

A dependency the source does not import, with a comment saying so.

Run the generator again:

$ bazel run //:gazelle
INFO: Running command line: bazel-bin/gazelle

It reports nothing unusual.

The dependency is gone. So is the comment explaining it. No warning, no diagnostic, and a build that still succeeds — until whatever needed that edge stops working.

The rule underneath is precise. The generator derives deps from the import statements, so deps is its attribute: anything there that the imports do not justify is, from its point of view, stale and worth removing. Attributes it does not derive are left alone entirely — the embedsrcs line from chapter five survives every regeneration, comment included, because nothing in the Go source tells the generator that attribute should exist.

"Merges rather than overwrites" is true per attribute, not per file. The generator preserves what it does not own and rewrites what it does. Knowing which is which is the difference between trusting the tool and being quietly undone by it.

When you genuinely need an edge the imports cannot justify, say so in the generator's own vocabulary rather than in prose it does not read:

deps = ["//server/format"],  # keep

A # keep comment on the line. Verified: this survives every regeneration, while the same line without it does not.

That is the difference between an instruction and a hope. The comment above the attribute was for humans; this one is for the tool, and it is the only kind it will honour.

A very fast assistant who tidies your desk by reading the labels on things. If you invent your own shorthand label, they will not understand it and will helpfully add what they think is missing — which is why you put a note on that drawer saying "leave this one to me."

A generator writes build files from your source, removing the transcription that would otherwise drift. It reads build files as text, so it cannot see inside a macro — and will duplicate what a macro already declares, failing the load. A per-package ignore directive settles ownership. In packages it owns, it merges: comments, hand-added attributes, and edited visibility all survive.

Try this in your own repository

Find out what your generators own. For every tool that writes files into your repository — build-file generators, schema compilers, formatters, dependency bots — determine which parts of those files it will rewrite and which it leaves alone. Most projects have never written this down, and it is exactly what a newcomer needs.

Test the boundary. Add something by hand to a generated file, rerun the tool, and see whether it survives. Do it deliberately now, in a scratch branch, rather than discovering it during an incident.

What you can now do

Generate build files from Go source, diagnose a duplicate-target failure, and decide package by package whether a generator or a person owns the declarations. Part two is done: you can read the graph, write the graph, and now have it written for you. Part three leaves the workspace — for the first time, your build will need something from the outside world.