Skip to main content

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:

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 sourceGlobs entry on the sourcegen: 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:

MechanismWhat it doesWhere it looks
Re-run decision (bleep compile, bleep test, your IDE via BSP)newest input mtime vs newest output mtimescript project + transitive deps: source and resource dirs; plus the consumer's sourceGlobs
Remote cache key (ProjectDigest)SHA-256 over config + file contentssame, folded into the consumer's digest
CI project selection (bleep build invalidated)which projects a git diff touchedsame, plus the sourcegen edge to the consumer
--watchwhich directories to wake up onsame

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 compile skips 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/master lists 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 compile or test, and in your IDE
  • changes myapp's remote-cache digest, so no stale cache hit
  • shows up in bleep build invalidated, as myapp
  • wakes up bleep compile --watch and bleep 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-stamp is 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:

  1. Recursively deletes the real generated-sources/<project>/<script>/ and generated-resources/<project>/<script>/ directories
  2. Deletes any .sourcegen-stamp file (so the next build will re-run the script rather than trust stale state)
  3. 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 / sourceGenerators hook

For one-off code generation that doesn't need build integration, use a plain scripts: entry (see Scripts) and run it manually.