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.
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.