IntelliJ Rust

Evaluating Build Scripts in the IntelliJ Rust Plugin

Build scripts is a Cargo feature that allows executing any code prior to building a package. We implemented support for build scripts evaluation in the IntelliJ Rust plugin a long time ago, but up until now, we hid it under the org.rust.cargo.evaluate.build.scripts experimental feature. As we are now enabling this feature by default, we’ve decided to explain what it means for our users.

What is a build script, and why should we care?

Most Rust projects use build scripts to deal with native dependencies, configure platform-specific options, or generate some source code. Let’s recall the build script functionality in Cargo. Whenever you put the build.rs file into the root folder of your package (the actual path can be configured), Cargo compiles it (and its dependencies, if any) to an executable and runs that executable before trying to build the package itself. Build script behavior can be configured via environment variables. Build scripts communicate with Cargo by printing lines prefixed with cargo:, thus influencing the rest of the building process.

Some bits of the build script functionality affect the IDE user experience. The code generated during build script evaluation becomes an essential part of the codebase and should be treated as such. The user should be able to explore that code, go to the generated definitions, and see them in code completion suggestions.

For Cargo, evaluating build scripts is just an early step in building the whole package. Once the build script is evaluated, Cargo proceeds with building everything else. If this process fails at any stage, be it either build script evaluation or source code compilation, Cargo will report an error.

For an IDE, though, the overall process is a little bit different. Whenever a user opens a project, the IDE should provide an environment in which the build script is already evaluated, but the package is not built yet. The source code of the package may have compilation errors, or its dependencies may not be ready for you to work with. If the IDE failed to open a project because it had some issues, that would mean we failed to make a good IDE! Of course we can’t have that, so we must make sure the IDE is able to open the project, and our user has a chance to fix all of the issues and make the project ready for building.

The IntelliJ Rust plugin evaluates build scripts every time the project model is loaded (such as when the Cargo project is opened or refreshed). Unfortunately, it’s not enough for the plugin to analyze a build script statically, because it may contain arbitrary code. We have to compile and run it. Moreover, it’s not enough to run it as a standalone program, because its output should be processed by Cargo. Yet another problem is that there is no way to stop Cargo after evaluating a build script.

To alleviate all these difficulties, the plugin runs cargo check on a package but supplies a specially crafted rustc-wrapper that only runs build scripts and builds proc-macro library crates, skipping the rest of the compiling package’s source code. Once a build script is built to a binary and evaluated, Cargo emits a set of configured parameters, some of which are used in the IDE. We also look for the generated source code files and include them in the regular code analysis process.

Example: generating code in build scripts

Let’s look at this small project with a custom build script that generates some code and see how the IntelliJ Rust plugin deals with it. The project has the following structure:

.
├── Cargo.toml
├── build.rs
└── src
    └── main.rs

We want to generate the say_hello function in build.rs and call it later from main.rs. Let’s look at the different components of the solution.

Implementing a build script

Suppose we’ve got the code variable with the desired content, for example:

pub mod generated {
   pub fn say_hello() {
       println!(r#" _______________
< Hello, world! >
---------------
       \   ^__^
        \  (oo)\_______
           (__)\       )\/\
               ||----w |
               ||     ||
"#)
   }
}

This particular content was created with the help of the beautiful cowsay command-line utility. We use it during the execution of build.rs if it is installed in the system. If not, the text printed is a little bit more formal.

Code generation is as easy as writing to the text file:

let out_dir = env::var_os("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("generated.rs");
fs::write(&dest_path, code).unwrap();

First, we consult the OUT_DIR variable from the environment. This is the only directory a build script is supposed to write. It is configured by Cargo itself before executing the build script. Then we create the file and write its content.

If cowsay is available, we also want to generate an additional configuration option that will be available later during the regular package build:

println!("cargo:rustc-cfg=cowsay");

Finally, we instruct Cargo to rerun build.rs only if the build script code is changed:

println!("cargo:rerun-if-changed=build.rs");

Once the code is generated, we are happy with the result. Otherwise, Cargo would execute it on any package file change.

Specifying build script dependencies in Cargo.toml

In order to use external crates in a build script, we should mention them in the dedicated section of the Cargo.toml file as follows:

[build-dependencies]
which = "4.3"

Here we use the which crate to check whether we have cowsay available in the system. This crate won’t be included in the application binary unless it is also specified in the dependencies section of Cargo.toml. In this project, it’s used exclusively in the build script.

Including generated code in the main module

Our package’s main file includes the content of the generated file, defines the auxiliary function depending on the conditional option configured, and calls both the auxiliary and the generated function:

include!(concat!(env!("OUT_DIR"), "/generated.rs"));

#[cfg(cowsay)]
fn print_warning() {
   println!("Beware of cows!")
}

#[cfg(not(cowsay))]
fn print_warning() {}

fn main() {
   print_warning();
   generated::say_hello();
}

Every time the cowsay conditional option is set by Cargo, we get a special warning about cows blocking the road before saying hello.

Exploring the project in the IDE

If the IntelliJ Rust plugin executes the build script, it knows precisely the values of conditional options configured, where to look for the generated code, and how to navigate to it whenever asked to:

As you can see from the screencast above, cowsay is, in fact, installed in the machine used for the demonstration. Running Docker Run configurations Without-cowsay and With-cowsay available in the repository demonstrate the results of building the project in different environments:

Editing build scripts in the IDE

The Rust plugin detects changes to the package’s build script, because they may affect the project model. The plugin can either reload the project automatically or notify the user as follows:

The warnings and errors collected during the build script evaluation are shown in the Build/Sync tool window:

Support for build script evaluation is still a work in progress. Stay tuned for more new features and improvements!

image description