Going beyond build.rs: introducing cargo-px
- 2073 words
- 11 min
TL;DR
cargo-px
(cargo
Power eXtensions) has just been released to crates.io!
It is a new cargo
sub-command (i.e. you invoke it as cargo px [...]
), designed to augment cargo
's capabilities.
This first release is focused on code generation.
cargo
supports build scripts via build.rs
files, but, once you look close enough, you'll find out that they are not flexible enough to support all usecases. In particular, build.rs
files fall short when you want to generate a whole crate (e.g. an API client starting from an OpenAPI spec or data models starting from a set of SQL queries and a database schema).
This is where cargo-px
enters into the picture: it lets you specify a crate generator in the manifest of the soon-to-be-generated crate. It then transparently invokes the code generator when necessary, assuming you route your commands through cargo-px
instead of cargo
(i.e. invoke cargo px build
rather cargo build
).
Check out the rest of the article (and cargo-px
's README) to learn more!
You can comment on r/rust.
Table of contents
Build script limitations
Let's get into the specifics: what are the issues with build.rs
files?
I ran into two key limitations:
- Dependency management is cumbersome.
- Invoking most
cargo
commands in a build script will result in a deadlock.
We are going to dissect both of them in the next sections.
Dependencies
Let's look at a specific scenario: we want to generate some Rust code that should be included as a module of the library crate that "hosts" the build.rs
file.
To make the example easy to visualize, we can assume that the initial project layout looks like this:
# Before code generation
mylib/
src/
lib.rs (👈 empty)
build.rs
Cargo.toml
The build.rs
script is going to create a new generated.rs
file under the src
directory1 and re-export its items from lib.rs
:
//! mylib/src/lib.rs
mod generated;
// Re-export all items.
pub use generated::*;
# After code generation
mylib/
src/
lib.rs (⚠️ modified)
generated.rs (👈 new file)
build.rs
Cargo.toml
So far so good, right?
Maybe! It depends on the code in generated.rs
. Does it need any dependencies?
For any statement importing items from third-party crates (e.g. use serde::Serialize
) there has to be a corresponding entry in the [dependencies]
section of the Cargo.toml
manifest of our project.
If that's not the case, cargo
will fail to compile the project.
Unfortunately, build.rs
can't help us here.
You can try to modify the Cargo.toml
file to add the dependencies you need, but cargo
is not going to regenerate the lockfile (Cargo.lock
) after having executed the main
function in your build.rs
file. The code will fail to compile the first time, even though the Cargo.toml
file has been modified to mention the required crates.
It will start working the second time you try to compile the project, because cargo
will pick up the new manifest and re-run its dependency resolution algorithm.
That's not a great experience and it's in fact fairly uncommon for build.rs
files to modify their Cargo.toml
in any way.
That doesn't stop people from wanting to generate code that requires third-party dependencies though, which is why you find instructions like this one in the documentation of projects that rely heavily on code generation:
The code generated by [redacted] has a few dependencies that you need to [manually] import into your project's Cargo.toml.
It sucks.
It's equivalent to exposing your private modules and implementation details to the consumers of your project.
Do you want to migrate from chrono
to time
for managing timestamp columns? Now all your users need to manually swap one dependency for the other one in the Cargo.toml
of their code-generated crates. The same happens if you want to bump the version of your dependencies due to breaking changes (e.g. from 0.2.x
to 0.3.y
)—perhaps it was an implementation detail, but now your users need to be (painfully) aware of it.
Invoking cargo
There is another corner case to be aware of when it comes to build scripts: invoking cargo
commands from a build script can result in a deadlock.
cargo
commands rely on a few different file locks to claim exclusivity before trying to modify some of the data stored in your filesystem. In particular, cargo
will try to claim a file lock before executing any command that might modify your build cache (a.k.a. "target" directory).
Build scripts are invoked after cargo
has successfully acquired a lock over your build cache.
If the build script tries to invoke a cargo
command that modifies the build cache (e.g. cargo run
or cargo doc
or cargo build
), it has no way of succeeding:
- the lock is held by its parent proceess, which is not going to release it until the build script exits...
- ...but the build script won't exit until it acquires the lock and completes its task.
Boom, deadlock!
As a workaround, you can change the target directory for the cargo
invocations coming from your build script. Different directory = different file lock = no deadlock 🎉
It's not free though—using a separate build cache implies that you won't be able to reuse any of the artefacts that have already been built, potentially having to recompile the entire dependency tree from scratch. Ouch.
The casus belli: pavex
As it happens, I found myself facing these challenges for pavex
, a soon-to-be-launched Rust web framework that I've been working on for a few months.
The framework takes a declarative approach—you specify the requirements of your application using a Blueprint
:
pub fn app_blueprint() -> Blueprint {
let mut bp = Blueprint::new();
bp.constructor(f!(crate::http_client), Lifecycle::Singleton);
bp.constructor(f!(crate::extract_path), Lifecycle::RequestScoped);
bp.constructor(f!(crate::logger), Lifecycle::Transient);
bp.route(GET, "/home", f!(crate::stream_file));
bp
}
pavex
analyzes the Blueprint
(i.e. who needs what inputs, lifecycles, etc) and performs compile-time dependency injection—i.e. it generates a library with all your dependencies correctly wired and a dedicated entrypoint that you can invoke to launch your API server.
That analysis step relies on the information contained in rustdoc's JSON output, which in turn forces us to invoke cargo doc
(or cargo rustdoc
) as part of the code generation step.
If that was not enough, the generated library has to depend on other crates! As a bare minimum, it must depend on the framework itself, but it will also have to import all the constructors and request handlers specified by the user, which might live in other local crates or come from a third-party registry.
I spent a few days torturing build.rs
files. In the end, I threw in the towel: I don't think you can make it work using a build.rs
file while providing a great developer experience.
I could have switched to a "proper" build system (e.g. buck2
or bazel
), but forcing all users of pavex
to say bye bye to cargo
would be an extremely tall ask.
Sad, but not undeterred, I set out to build cargo-px
.
cargo-px
cargo-px
is a cargo
sub-command.
You can install it via:
cargo install --locked cargo-px
It is designed as a cargo
proxy2: instead of invoking cargo <CMD> <ARGS>
, you go for cargo px <CMD> <ARGS>
.
For example, you go for cargo px build --all-features
instead of cargo build --all-features
.
cargo px
examines your workspace.
If any of your crates needs to be generated, it will invoke the respective code generators before forwarding the command and its arguments to cargo
.
This strategy is quite simple, but it solves both our problems:
- The required dependencies have already been added to the respective
Cargo.toml
files whencargo
gets started, so dependency resolution kicks in as usual; - We are not holding the file lock over the build cache, so the code generators are free to invoke
cargo
commands as part of their generation logic.
But let's get to the specifics! How do you specify that a crate should be generated by cargo px
?
It leverages the metadata
section.
In the crate that you want to see generated, you fill in the [package.metadata.px.generate
] section as follows:
[package]
name = "..."
version = "..."
# [...]
[package.metadata.px.generate]
# The generator is a binary in the current workspace.
# It's the only generator type we support at the moment.
generator_type = "cargo_workspace_binary"
# The name of the binary.
generator_name = "bp"
# The arguments to be passed to the binary.
# It can be omitted if there are no arguments.
generator_args = ["--quiet", "--profile", "optimised"]
cargo-px
will detect the configuration and invoke cargo run --bin bp -- --quiet --profile="optimised"
for you.
If there are multiple crates that need to be code-generated, cargo-px
will invoke the respective code-generators in an order that takes into account the dependency graph (i.e. dependencies are always code-generated before their dependents).
cargo-px
will also set two environment variables for the code generator:
CARGO_PX_GENERATED_PKG_MANIFEST_PATH
, the path to theCargo.toml
file of the crate that needs to be generated;CARGO_PX_WORKSPACE_ROOT_DIR
, the path to theCargo.toml
file that defines the current workspace (i.e. the one that contains the[workspace]
section).
That's all we have at the moment!
I'll keep experimenting with cargo-px
in pavex
, but I'm curious to find out if other projects are facing similar needs and might benefit from this solution.
I'd also be happy to engage with the cargo
team if there is an interest in solving some of these problems upstream. cargo
sub-commands are a great tool for experimentation, but standardisation is always preferable (where possible).
What about downstream users?
Actually, one more thing before you go!
Some of you may be asking: what about downstream users? Do they need to start using cargo-px
as well?
It depends!
cargo-px
is a tool designed for contributors to projects that need "whole crate" generation for one of the crates in their workspace.
If your crate is primarily a generator, then it might make sense to ask your users to rely on cargo-px
if you are running into some of the limitations I mentioned above.
But the crate that gets generated by cargo-px
is a "regular" crate—you can build it with cargo build
and simply publish it to the registry.
If you are using cargo-px
as part of your development process (i.e. to keep an API client up-to-date from a specification file), your users don't need to know. As far as they are concerned, they are downloading a normal crate from the registry (i.e. the output of code generation) and everything works as usual.
This is indeed one of the key differences between cargo-px
and a build script: build scripts are executed on the user's machine. This is both a feature and a drawback:
- 👍 You can tune the generated code according to the environment it is being generated from (e.g. detect which SIMD features are available or compile a C library on-the-fly for that specific architecture)
- 👎 Your crate takes longer to compile for your users
- 👎 Build scripts can execute arbitrary code at compile-time, causing both issues in terms of trust (what is that script doing?) and reproducibility (should the build script be re-run? Yes? No? When?)
Keep this in mind when evaluating your options for code generation.
You can comment on r/rust.
You aren't supposed to generate source files using build.rs
—all the generated artefacts should live in the OUT_DIR
folder. But it's a common pattern since there is no proper solution for source file generation, therefore I've chosen to "do it wrong" on purpose here.
It'd be cool if it were possible to alias cargo
to cargo px
in a specific workspace. I probably need to play around with shims or custom toolchains.