Non-Incremental Context

We set up the Task and Context API in such a way that we can implement incrementality. However, incrementality is hard, so let’s start with an extremely simple non-incremental Context implementation to get a feeling for the API.

File Dependencies: Next Chapter

We will implement file dependencies in the next chapter, as file dependencies only become important with incrementality.

Context module

Since we will be implementing three different contexts in this tutorial, we will separate them in different modules. Create the context module by adding a module to pie/src/lib.rs:

This is a diff over pie/src/lib.rs where lines with a green background are additions, lines with a red background are removals, lines without a special background are context on where to add/remove lines, and lines starting with @@ denote changed lines (in unified diff style). This is similar to diffs on source code hubs like GitHub.

Create the pie/src/context directory, and in it, create the pie/src/context/mod.rs file with the following contents:

pub mod non_incremental;

Both modules are public so that users of our library can access context implementations.

Create the pie/src/context/non_incremental.rs file, it will be empty for now. Your project structure should now look like:

pibs
├── pie
│   ├── Cargo.toml
│   └── src
│       ├── context
│       │   ├── non_incremental.rs
│       │   └── mod.rs
│       └── lib.rs
└── Cargo.toml

Confirm your module structure is correct by building with cargo build.

Rust Help: Modules, Visibility

Modules are typically separated into different files. Modules are declared with mod context. Then, the contents of a module are defined either by creating a sibling file with the same name: context.rs, or by creating a sibling directory with the same name, with a mod.rs file in it: context/mod.rs. Use the latter if you intend to nest modules, otherwise use the former.

Like traits, modules also have visibility.

Implementation

Implement the non-incremental context in pie/src/context/non_incremental.rs by adding:

use crate::{Context, Task};

pub struct NonIncrementalContext;

impl<T: Task> Context<T> for NonIncrementalContext {
  fn require_task(&mut self, task: &T) -> T::Output {
    task.execute(self)
  }
}

This NonIncrementalContext is extremely simple: in require_task we unconditionally execute the task, and pass self along so the task we’re calling can require additional tasks. Let’s write some tests to see if this does what we expect.

Rust Help: Crates (Libraries), Structs, Trait Implementations, Last Expression

In Rust, libraries are called crates. We import the Context and Task traits from the root of your crate (i.e., the src/lib.rs file) using crate:: as a prefix.

Structs are concrete types that can contain data through fields and implement traits, similar to classes in class-oriented languages. Since we don’t need any data in NonIncrementalContext, we define it as a unit-like struct.

Traits are implemented for a type with impl Context for NonIncrementalContext { ... }, where we then have to implement all methods and associated types of the trait.

The Context trait is generic over Task, so in the impl block we introduce a type parameter T with impl<T>, and use trait bounds as impl<T: Task> to declare that T must implement Task.

The last expression of a function – in this case task.execute(self) in require_task which is an expression because it does not end with ; – is used as the return value. We could also write that as return task.execute(self);, but that is more verbose.

Simple Test

Add the following test to pie/src/context/non_incremental.rs:

#[cfg(test)]
mod test {
  use super::*;

  #[test]
  fn test_require_task_direct() {
    #[derive(Clone, PartialEq, Eq, Hash, Debug)]
    struct ReturnHelloWorld;

    impl Task for ReturnHelloWorld {
      type Output = String;
      fn execute<C: Context<Self>>(&self, _context: &mut C) -> Self::Output {
        "Hello World!".to_string()
      }
    }

    let mut context = NonIncrementalContext;
    assert_eq!("Hello World!", context.require_task(&ReturnHelloWorld));
  }
}

In this test, we create a struct ReturnHelloWorld which is the “hello world” of the build system. We implement Task for it, set its Output associated type to be String, and implement the execute method to just return "Hello World!". We derive the Clone, Eq, Hash, and Debug traits for ReturnHelloWorld as they are required for all Task implementations.

We require the task with our context by creating a NonIncrementalContext, calling its require_task method, passing in a reference to the task. It returns the output of the task, which we test with assert_eq!.

Run the test by running cargo test. The output should look something like:

   Compiling pie v0.1.0 (/pie)
    Finished test [unoptimized + debuginfo] target(s) in 0.23s
     Running unittests src/lib.rs (target/debug/deps/pie-4dd489880a9416ea)

running 1 test
test context::non_incremental::test::test_require_task_direct ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests pie

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Which indicates that the test indeed succeeds! You can experiment by returning a different string from ReturnHelloWorld::execute to see what a failed test looks like.

Rust Help: Unit Testing, Nested Items, Unused Parameters, Assertion Macros

Unit tests for a module are typically defined by creating a nested module named test with the #[cfg(test)] attribute applied to it. In that test module, you apply #[test] to testing functions, which then get executed when you run cargo test.

The #[cfg(...)] attribute provides conditional compilation for the item it is applied to. In this case, #[cfg(test)] ensures that the module is only compiled when we run cargo test.

We import all definitions from the parent module (i.e., the non_incremental module) into the test module with use super::*;.

In Rust, items — that is, functions, structs, implementations, etc. — can be nested inside functions. We use that in test_require_task_direct to scope ReturnHelloWorld and its implementation to the test function, so it can’t clash with other test functions.

In execute, we use _context as the parameter name for the context, as the parameter is unused. Unused parameters give a warning in Rust, unless it is prefixed by a _.

assert_eq! is a macro that checks if its two expressions are equal. If not, it panics. This macro is typically used in tests for assertions, as a panic marks a test as failed.

Test with Multiple Tasks

Our first test only tests a single task that does not use the context, so let’s write a test with two tasks where one requires the other to increase our test coverage. Add the following test:

We use the same ReturnHelloWorld task as before, but now also have a ToLowerCase task which requires ReturnHelloWorld and then turn its string lowercase. However, due to the way we’ve set up the types between Task and Context, we will run into a problem. Running cargo test, you should get these errors:

   Compiling pie v0.1.0 (/pie)
error[E0308]: mismatched types
  --> pie/src/context/non_incremental.rs:47:30
   |
47 |         context.require_task(&ReturnHelloWorld).to_lowercase()
   |                 ------------ ^^^^^^^^^^^^^^^^^ expected `&ToLowerCase`, found `&ReturnHelloWorld`
   |                 |
   |                 arguments to this method are incorrect
   |
   = note: expected reference `&ToLowerCase`
              found reference `&non_incremental::test::test_require_task_problematic::ReturnHelloWorld`
note: method defined here
  --> pie/src/lib.rs:18:6
   |
18 |   fn require_task(&mut self, task: &T) -> T::Output;
   |      ^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `pie` (lib test) due to previous error

The problem is that execute of ToLowerCase takes a Context<Self>, so in impl Task for ToLowerCase it takes a Context<ToLowerCase>, while we’re trying to require &ReturnHelloWorld through the context. This doesn’t work as Context<ToLowerCase>::require_task only takes a &ToLowerCase as input.

We could change execute of ToLowerCase to take Context<ReturnHelloWorld>:

But that is not allowed:

   Compiling pie v0.1.0 (/pie)
error[E0276]: impl has stricter requirements than trait
  --> pie/src/context/non_incremental.rs:46:21
   |
46 |       fn execute<C: Context<ReturnHelloWorld>>(&self, context: &mut C) -> Self::Output {
   |                     ^^^^^^^^^^^^^^^^^^^^^^^^^ impl has extra requirement `C: Context<non_incremental::test::test_require_task_problematic::ReturnHelloWorld>`
   |
  ::: pie/src/lib.rs:11:3
   |
11 |   fn execute<C: Context<Self>>(&self, context: &mut C) -> Self::Output;
   |   --------------------------------------------------------------------- definition of `execute` from trait

For more information about this error, try `rustc --explain E0276`.
error: could not compile `pie` (lib test) due to previous error

This is because the Task trait defines execute to take a Context<Self>, thus every implementation of Task must adhere to this, so we can’t solve it this way.

Effectively, due to the way we defined Task and Context, we can only use a single task implementation. This is to simplify the implementation in this tutorial, as supporting multiple task types complicates matters a lot.

Why only a Single Task Type?

Currently, our context is parameterized by the type of tasks: Context<T>. Again, this is for simplicity.

An incremental context wants to build a single dependency graph and cache task outputs, so that we can figure out from the graph whether a task is affected by a change, and just return its output if it is not affected. Therefore, a context implementation will maintain a Store<T>.

Consider the case with two different task types A Context<ReturnHelloWorld> and Context<ToLowerCase> would then have a Store<ReturnHelloWorld> and Store<ToLowerCase> respectively. These two stores would then maintain two different dependency graphs, one where the nodes in the graph are ReturnHelloWorld and one where the nodes are ToLowerCase. But that won’t work, as we need a single dependency graph over all tasks to figure out what is affected. Therefore, we are restricted to a single task type in this tutorial.

To solve this, we would need to remove the T generic parameter from Context, and instead use trait objects. However, this introduces a whole slew of problems because many traits that we use are not inherently trait-object safe. Clone is not object safe because it requires Sized. Eq is not object safe because it uses Self. Serializing trait objects is problematic. There are workarounds for all these things, but it is not pretty and very complicated.

The actual PIE library supports arbitrary task types through trait objects. We very carefully control where generic types are introduced, and which traits need to be object-safe. Check out the PIE library if you want to know more!

For now, we will solve this by just using a single task type which is an enumeration of the different possible tasks. First remove the problematic test:

Then add the following test:

Here, we instead define a single task Test which is an enum with two variants. In its Task implementation, we match ourselves and return "Hello World!" when the variant is ReturnHelloWorld. When the variant is ReturnHelloWorld, we require &Self::ReturnHelloWorld through the context, which is now valid because it is an instance of Test, and turn its string lowercase and return that. This now works due to only having a single task type. Run the test with cargo test to confirm it is working.

Rust Help: Enum

Enums define a type by a set of variants, similar to enums in other languages, sometimes called tagged unions in other languages. The match expression matches the variant and dispatches based on that, similar to switch statements in other languages.

We have defined the API for the build system and implemented a non-incremental version of it. We’re now ready to start implementing an incremental context in the next chapter.

Download source code