Source generation
Bleep has first-class support for generated sources and resources. A sourcegen script is a normal program (Java, Kotlin, or Scala) that writes files; bleep tracks its inputs, runs it before compilation when needed, and cleans up after itself if the script fails.
"Tracks its inputs" is a precise claim with a precise boundary: bleep tracks the files you declared, and it does not watch your generator to find out what it actually opens. If your generator reads a file that lives outside the script project, one line of YAML makes bleep see it, and without that line nothing will. What counts as an input spells out the rule and shows the declaration.
Unlike sbt source generators (which run inside the build definition), bleep sourcegen scripts are:
- Real programs you can run and debug from your IDE
- Typed against a first-class API (
bleepscript.BleepCodegenScript) - Isolated in their own JVM, with their own classpath, no classloader conflicts with the build server
- Incremental, with automatic invalidation based on file timestamps
- Atomic: a failed script never leaves stale generated code behind
Declaring a sourcegen script
A sourcegen script lives in its own project and is referenced from the projects that consume its output. The script project depends on bleepscript. That one dependency brings the script API and the runtime implementation needed to dispatch from your script class.
The sourcegen: field on the consumer accepts either the shorthand projectName/fully.qualified.Main or the full object form:
projects:
myapp:
sourcegen:
project: scripts
main: mypkg.MyGen
A project can list multiple sourcegen scripts. Each script writes into its own isolated output directory, so they can never step on each other's files.
Write your first one
The shape is identical across languages: extend
bleepscript.BleepCodegenScript, write files under
target.sources(), list it under sourcegen: on the consuming
project. Only the scaffolding differs per language. Pick the
language you’ll write the generator in:
- Write your first sourcegen (Java)
- Write your first sourcegen (Kotlin)
- Write your first sourcegen (Scala)
The rest of this page is reference material once you’ve written that first one.
API reference
Every sourcegen script extends bleepscript.BleepCodegenScript and implements run:
public abstract class BleepCodegenScript {
protected BleepCodegenScript(String name);
public abstract void run(Started started, Commands commands, List<CodegenTarget> targets, List<String> args);
}
CodegenTarget is a record:
public record CodegenTarget(CrossProjectName project, Path sources, Path resources) { }
Each entry in targets corresponds to one project that declared this script under sourcegen:. Write generated source files (.java, .kt, .scala) under target.sources(), and any other files (config, assets, protobuf descriptors, etc.) under target.resources(). Bleep includes them in the consuming project's source and resource paths automatically.
If a script doesn't need to produce resources, it doesn't write to target.resources(); the directory stays empty.
Cross builds
When a consumer is cross-built, you get one CodegenTarget per cross ID:
for (CodegenTarget target : targets) {
String id = target.project().crossId().orElse("");
if (id.startsWith("jvm3")) {
// generate Scala 3 code
} else if (id.startsWith("jvm213")) {
// generate Scala 2.13 code
} else {
// platform default
}
}
Accessing the build model
started.build().explodedProjects() gives the full, resolved build model. started.buildPaths() exposes paths. commands can run other bleep commands (commands.compile, commands.test) if your script needs them.
Execution model
Where output goes
Each script gets its own isolated output directory under .bleep/:
.bleep/generated-sources/<project>/<script-main-class>/
.bleep/generated-resources/<project>/<script-main-class>/
For example, myscripts.GenConstants generating for myapp writes to:
.bleep/generated-sources/myapp/myscripts.GenConstants/
.bleep/generated-resources/myapp/myscripts.GenConstants/
These directories are automatically added to the consuming project's source/resource paths.
Invalidation
Before compilation, bleep decides whether each sourcegen script needs to run by comparing timestamps:
- Inputs = all sources and resources of the script's project plus all of its transitive dependencies, plus any directory the consuming project declared under
sourceGlobs - Outputs = the
.bleep/generated-sources/...and.bleep/generated-resources/...directories for the consuming project
If any input is newer than the most-recent output, the script re-runs. If the output directory doesn't exist, it runs. Otherwise, it's skipped.
A .sourcegen-stamp file is written to the output directory on every successful run, so that the output timestamp advances even when the script produced identical content.
This means: if you change anything the script transitively depends on, it re-runs. Which raises the question the next section answers, because "transitively depends on" is a statement about the build graph, and your generator probably reads files too.
What counts as an input
Here is the rule, and it is worth committing to memory:
A file is an input if and only if it lives under a directory you declared: a source or resource directory of the script project (or of one of that project's transitive dependencies), or a
sourceGlobsentry on thesourcegen:block that names the script.
That is the only thing bleep looks at. It is not a heuristic and there is no filesystem tracing behind it: bleep does not observe which files your generator opens. It reads the build graph, and the build graph is the set of directories you declared.
Four separate mechanisms depend on that rule, and they all apply it identically:
| Mechanism | What it does | Where it looks |
|---|---|---|
Re-run decision (bleep compile, bleep test, your IDE via BSP) | newest input mtime vs newest output mtime | script project + transitive deps: source and resource dirs; plus the consumer's sourceGlobs |
Remote cache key (ProjectDigest) | SHA-256 over config + file contents | same, folded into the consumer's digest |
CI project selection (bleep build invalidated) | which projects a git diff touched | same, plus the sourcegen edge to the consumer |
--watch | which directories to wake up on | same |
So a generator that only reads its own source tree — a version stamper, a code emitter driven by an annotation on a dependency — is fully tracked, and you never have to think about any of this.
A generator that reads a file outside every declared directory is not. And nothing warns you.
The trap: reading a file bleep can't see
This is the shape that bites, so let's be blunt about it. Say your schema lives at the repo root and your generator reads it:
myrepo/
schema/users.sql ← the real input
scripts/src/scala/... ← the generator
myapp/src/scala/... ← the consumer
projects:
myapp:
sourcegen: scripts/mypkg.GenTables # ← reads ../schema/users.sql
Note what is missing: nothing in that YAML mentions schema/. Now edit schema/users.sql and leave the generator code alone. Every mechanism in the table above reports clean, because schema/ is not a source or resource directory of any project and no sourcegen: entry declares it:
bleep compileskips the generator (its inputs are older than its outputs) — your generated code is stale- the remote cache key is unchanged, so a cache hit serves the previously-built classes
bleep build invalidated -b origin/masterlists nothing, so CI compiles nothing and goes green
Three green signals and one wrong artifact. The fix is one line.
Declaring an external input
Name the directory under sourceGlobs, on the sourcegen: block that reads it. This requires the long form of sourcegen: rather than the project/Main shorthand:
projects:
myapp:
sourcegen:
project: scripts
main: mypkg.GenTables
sourceGlobs: ../schema # relative to myapp/, the project declaring sourcegen
Paths are relative to the folder of the project that declares sourcegen: — myapp/ here, not the script project — and .. is allowed, so ../schema from myapp/ resolves to <build root>/schema. sourceGlobs also takes a list if the generator reads several directories.
Despite the name, entries are plain directory paths. There is no glob matching, so ../schema/*.sql is not something you can write; name the directory and every file under it counts.
With that one line, editing schema/users.sql:
- re-runs the generator on the next
compileortest, and in your IDE - changes
myapp's remote-cache digest, so no stale cache hit - shows up in
bleep build invalidated, asmyapp - wakes up
bleep compile --watchandbleep sourcegen --watch
Declaring a directory you don't read is harmless but will re-run the generator when it changes, so keep the declaration honest.
The other way: sources on the script project
Declaring the directory as sources (or resources) on the script project works too, and is the better fit when several generators read the same directory or when the generator wants the files on its own classpath:
projects:
scripts:
dependencies: build.bleep:bleepscript:${BLEEP_VERSION}
# `schema/` is not Scala, but declaring it here is what puts it in the build graph.
# Compilers select sources by extension, so the .sql files are simply ignored by compile.
sources: ../schema
myapp:
sourcegen: scripts/mypkg.GenTables
Here the path is relative to the script project's folder, and the effect reaches every consumer of that script, because each consumer's invalidation folds in the script project's. Use resources: ../schema instead of sources: if the generator loads the files with getResourceAsStream rather than reading from disk. For invalidation the two are equivalent; both are hashed, neither is treated as compilable code.
Which to pick: sourceGlobs when one consumer has one external input and you want the declaration next to the sourcegen: entry that reads it; sources on the script project when the input belongs to the generator itself and every consumer should react to it. Both feed all four mechanisms, so there is no correctness difference — only blast radius, and which file you would rather read the declaration in.
Concurrency
Multiple concurrent operations (e.g. parallel compile and test) that target projects sharing a sourcegen script are coordinated by a per-script semaphore. The second waiter re-checks timestamps after the first completes and skips if the outputs are already fresh.
Isolation: forked JVM
Each script runs in its own forked JVM with a classpath built from the script project and its resolved dependencies. This is deliberate:
- No classloader conflicts between the script and bleep's BSP server
- Scripts can freely depend on any library, nothing is shared with the build server
Failure handling
Sourcegen is designed to fail cleanly. Stale generated code from a previous successful run would cause confusing compilation errors on the next build, so bleep aggressively cleans up.
The temp-directory dance
When a script runs, the sources and resources paths it sees point to temp directories under .bleep/generated-sources-tmp/ and .bleep/generated-resources-tmp/, not the real output. The script writes freely to these temp dirs.
Only if run returns normally, the framework synchronises temp → real with a soft-sync:
- New files are written
- Changed files overwrite the existing
- Unchanged files are left alone, their mtime is preserved, which matters for incremental compilation
- Orphan files in the real directory that aren't in the temp dir are deleted
- A fresh
.sourcegen-stampis written at the end
The temp directories are deleted in a finally block regardless of success.
Cleanup on failure
If the script exits non-zero, crashes (signal), or is killed, bleep:
- Recursively deletes the real
generated-sources/<project>/<script>/andgenerated-resources/<project>/<script>/directories - Deletes any
.sourcegen-stampfile (so the next build will re-run the script rather than trust stale state) - Reports the error back through the BSP pipeline
The rationale: a half-generated directory is worse than no directory at all. It can produce baffling compile errors. Empty state is recoverable; stale state isn't.
Running sourcegen explicitly
Sourcegen normally runs implicitly as part of bleep compile / bleep test, but you can invoke it directly:
# Run sourcegen for specific projects
bleep sourcegen myapp
# Watch mode: re-run when inputs change
bleep sourcegen --watch myapp
When to use it
Sourcegen is the right tool when you have:
- A code or resource file that's derived from something else in the repo (a schema, a protobuf descriptor, a version string, a data file)
- A dependency on the output of a third-party generator (scalapb, guardrail, openapi generators)
- A task that historically lived in an sbt
Compile / sourceGeneratorshook
For one-off code generation that doesn't need build integration, use a plain scripts: entry (see Scripts) and run it manually.