Repository: casey/just
Pull request #917 (2021-07-25 09:56:25)

Add loader and refactor errors


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 0d6b68b6..07888d8a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -77,6 +77,15 @@ dependencies = [
  "vec_map",
 ]
 
+[[package]]
+name = "cradle"
+version = "0.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2352f0ca05779da0791a0ea204cc7bfddf83ee6e6277c919d8c0a5801d27f0e4"
+dependencies = [
+ "rustversion",
+]
+
 [[package]]
 name = "ctor"
 version = "0.1.20"
@@ -200,6 +209,7 @@ dependencies = [
  "atty",
  "camino",
  "clap",
+ "cradle",
  "ctrlc",
  "derivative",
  "dotenv",
@@ -211,12 +221,14 @@ dependencies = [
  "libc",
  "log",
  "pretty_assertions",
+ "regex",
  "snafu",
  "strum",
  "strum_macros",
  "target",
  "tempfile",
  "temptree",
+ "typed-arena",
  "unicode-width",
  "which",
  "yaml-rust",
@@ -426,6 +438,12 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "rustversion"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61b3909d758bb75c79f23d4736fac9433868679d3ad2ea7a61e3c25cfda9a088"
+
 [[package]]
 name = "snafu"
 version = "0.6.10"
@@ -556,6 +574,12 @@ dependencies = [
  "unicode-width",
 ]
 
+[[package]]
+name = "typed-arena"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0685c84d5d54d1c26f7d3eb96cd41550adb97baed141a761cf335d3d33bcd0ae"
+
 [[package]]
 name = "unicode-segmentation"
 version = "1.8.0"
diff --git a/Cargo.toml b/Cargo.toml
index 98404a2c..8cb1b365 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ snafu         = "0.6.0"
 strum_macros  = "0.21.1"
 target        = "1.0.0"
 tempfile      = "3.0.0"
+typed-arena   = "2.0.1"
 unicode-width = "0.1.0"
 
 [dependencies.ctrlc]
@@ -44,8 +45,10 @@ version  = "0.21.0"
 features = ["derive"]
 
 [dev-dependencies]
+cradle            = "0.0.13"
 executable-path   = "1.0.0"
 pretty_assertions = "0.7.0"
+regex             = "1.5.4"
 temptree          = "0.1.0"
 which             = "4.0.0"
 yaml-rust         = "0.4.5"
diff --git a/justfile b/justfile
index dbdab50c..1359d2fd 100755
--- a/justfile
+++ b/justfile
@@ -40,7 +40,7 @@ build:
 fmt:
   cargo +nightly fmt --all
 
-watch +COMMAND='test':
+watch +COMMAND='ltest':
   cargo watch --clear --exec "{{COMMAND}}"
 
 man:
@@ -61,7 +61,7 @@ version := `sed -En 's/version[[:space:]]*=[[:space:]]*"([^"]+)"/\1/p' Cargo.tom
 changes:
   git log --pretty=format:%s >> CHANGELOG.md
 
-check: clippy test forbid
+check: clippy fmt test forbid
   git diff --no-ext-diff --quiet --exit-code
   grep '^\[{{ version }}\]' CHANGELOG.md
   cargo +nightly generate-lockfile -Z minimal-versions
diff --git a/src/analyzer.rs b/src/analyzer.rs
index 001412b7..83eba48f 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-use CompilationErrorKind::*;
+use CompileErrorKind::*;
 
 #[derive(Default)]
 pub(crate) struct Analyzer<'src> {
@@ -11,11 +11,11 @@ pub(crate) struct Analyzer<'src> {
 }
 
 impl<'src> Analyzer<'src> {
-  pub(crate) fn analyze(ast: Ast<'src>) -> CompilationResult<'src, Justfile> {
+  pub(crate) fn analyze(ast: Ast<'src>) -> CompileResult<'src, Justfile> {
     Analyzer::default().justfile(ast)
   }
 
-  pub(crate) fn justfile(mut self, ast: Ast<'src>) -> CompilationResult<'src, Justfile<'src>> {
+  pub(crate) fn justfile(mut self, ast: Ast<'src>) -> CompileResult<'src, Justfile<'src>> {
     for item in ast.items {
       match item {
         Item::Alias(alias) => {
@@ -88,7 +88,7 @@ impl<'src> Analyzer<'src> {
     })
   }
 
-  fn analyze_recipe(&self, recipe: &UnresolvedRecipe<'src>) -> CompilationResult<'src, ()> {
+  fn analyze_recipe(&self, recipe: &UnresolvedRecipe<'src>) -> CompileResult<'src, ()> {
     if let Some(original) = self.recipes.get(recipe.name.lexeme()) {
       return Err(recipe.name.token().error(DuplicateRecipe {
         recipe: original.name(),
@@ -140,7 +140,7 @@ impl<'src> Analyzer<'src> {
     Ok(())
   }
 
-  fn analyze_assignment(&self, assignment: &Assignment<'src>) -> CompilationResult<'src, ()> {
+  fn analyze_assignment(&self, assignment: &Assignment<'src>) -> CompileResult<'src, ()> {
     if self.assignments.contains_key(assignment.name.lexeme()) {
       return Err(assignment.name.token().error(DuplicateVariable {
         variable: assignment.name.lexeme(),
@@ -149,7 +149,7 @@ impl<'src> Analyzer<'src> {
     Ok(())
   }
 
-  fn analyze_alias(&self, alias: &Alias<'src, Name<'src>>) -> CompilationResult<'src, ()> {
+  fn analyze_alias(&self, alias: &Alias<'src, Name<'src>>) -> CompileResult<'src, ()> {
     let name = alias.name.lexeme();
 
     if let Some(original) = self.aliases.get(name) {
@@ -162,7 +162,7 @@ impl<'src> Analyzer<'src> {
     Ok(())
   }
 
-  fn analyze_set(&self, set: &Set<'src>) -> CompilationResult<'src, ()> {
+  fn analyze_set(&self, set: &Set<'src>) -> CompileResult<'src, ()> {
     if let Some(original) = self.sets.get(set.name.lexeme()) {
       return Err(set.name.error(DuplicateSet {
         setting: original.name.lexeme(),
@@ -176,7 +176,7 @@ impl<'src> Analyzer<'src> {
   fn resolve_alias(
     recipes: &Table<'src, Rc<Recipe<'src>>>,
     alias: Alias<'src, Name<'src>>,
-  ) -> CompilationResult<'src, Alias<'src>> {
+  ) -> CompileResult<'src, Alias<'src>> {
     let token = alias.name.token();
     // Make sure the alias doesn't conflict with any recipe
     if let Some(recipe) = recipes.get(alias.name.lexeme()) {
diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs
index 4e0b8b33..c802e399 100644
--- a/src/assignment_resolver.rs
+++ b/src/assignment_resolver.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-use CompilationErrorKind::*;
+use CompileErrorKind::*;
 
 pub(crate) struct AssignmentResolver<'src: 'run, 'run> {
   assignments: &'run Table<'src, Assignment<'src>>,
@@ -11,7 +11,7 @@ pub(crate) struct AssignmentResolver<'src: 'run, 'run> {
 impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
   pub(crate) fn resolve_assignments(
     assignments: &Table<'src, Assignment<'src>>,
-  ) -> CompilationResult<'src, ()> {
+  ) -> CompileResult<'src, ()> {
     let mut resolver = AssignmentResolver {
       stack: Vec::new(),
       evaluated: BTreeSet::new(),
@@ -25,7 +25,7 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
     Ok(())
   }
 
-  fn resolve_assignment(&mut self, name: &'src str) -> CompilationResult<'src, ()> {
+  fn resolve_assignment(&mut self, name: &'src str) -> CompileResult<'src, ()> {
     if self.evaluated.contains(name) {
       return Ok(());
     }
@@ -45,7 +45,7 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
         length: 0,
         kind:   TokenKind::Unspecified,
       };
-      return Err(CompilationError {
+      return Err(CompileError {
         kind: Internal { message },
         token,
       });
@@ -56,7 +56,7 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
     Ok(())
   }
 
-  fn resolve_expression(&mut self, expression: &Expression<'src>) -> CompilationResult<'src, ()> {
+  fn resolve_expression(&mut self, expression: &Expression<'src>) -> CompileResult<'src, ()> {
     match expression {
       Expression::Variable { name } => {
         let variable = name.lexeme();
diff --git a/src/ast.rs b/src/ast.rs
index 632110a7..cff7a632 100644
--- a/src/ast.rs
+++ b/src/ast.rs
@@ -1,9 +1,8 @@
 use crate::common::*;
 
-/// The top-level type produced by the parser.Not all successful parses result
+/// The top-level type produced by the parser. Not all successful parses result
 /// in valid justfiles, so additional consistency checks and name resolution
-/// are performed by the `Analyzer`, which produces a `Justfile` from an
-/// `Ast`.
+/// are performed by the `Analyzer`, which produces a `Justfile` from an `Ast`.
 #[derive(Debug, Clone)]
 pub(crate) struct Ast<'src> {
   /// Items in the justfile
diff --git a/src/common.rs b/src/common.rs
index 6609eb8b..a37d91f8 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -11,7 +11,7 @@ pub(crate) use std::{
   mem,
   ops::{Index, Range, RangeInclusive},
   path::{Path, PathBuf},
-  process::{self, Command, Stdio},
+  process::{self, Command, ExitStatus, Stdio},
   rc::Rc,
   str::{self, Chars},
   sync::{Mutex, MutexGuard},
@@ -27,6 +27,7 @@ pub(crate) use libc::EXIT_FAILURE;
 pub(crate) use log::{info, warn};
 pub(crate) use snafu::{ResultExt, Snafu};
 pub(crate) use strum::{Display, EnumString, IntoStaticStr};
+pub(crate) use typed_arena::Arena;
 pub(crate) use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
 
 // modules
@@ -37,24 +38,23 @@ pub(crate) use crate::{load_dotenv::load_dotenv, output::output, unindent::unind
 
 // traits
 pub(crate) use crate::{
-  command_ext::CommandExt, error::Error, error_result_ext::ErrorResultExt, keyed::Keyed,
-  ordinal::Ordinal, platform_interface::PlatformInterface, range_ext::RangeExt,
+  command_ext::CommandExt, keyed::Keyed, ordinal::Ordinal, platform_interface::PlatformInterface,
+  range_ext::RangeExt,
 };
 
 // structs and enums
 pub(crate) use crate::{
   alias::Alias, analyzer::Analyzer, assignment::Assignment,
   assignment_resolver::AssignmentResolver, ast::Ast, binding::Binding, color::Color,
-  compilation_error::CompilationError, compilation_error_kind::CompilationErrorKind,
-  config::Config, config_error::ConfigError, count::Count, delimiter::Delimiter,
-  dependency::Dependency, enclosure::Enclosure, evaluator::Evaluator, expression::Expression,
+  compile_error::CompileError, compile_error_kind::CompileErrorKind, config::Config,
+  config_error::ConfigError, count::Count, delimiter::Delimiter, dependency::Dependency,
+  enclosure::Enclosure, error::Error, evaluator::Evaluator, expression::Expression,
   fragment::Fragment, function::Function, function_context::FunctionContext,
   interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item,
-  justfile::Justfile, keyword::Keyword, lexer::Lexer, line::Line, list::List,
-  load_error::LoadError, name::Name, output_error::OutputError, parameter::Parameter,
-  parameter_kind::ParameterKind, parser::Parser, platform::Platform, position::Position,
-  positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
-  recipe_resolver::RecipeResolver, runtime_error::RuntimeError, scope::Scope, search::Search,
+  justfile::Justfile, keyword::Keyword, lexer::Lexer, line::Line, list::List, loader::Loader,
+  name::Name, output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind,
+  parser::Parser, platform::Platform, position::Position, positional::Positional, recipe::Recipe,
+  recipe_context::RecipeContext, recipe_resolver::RecipeResolver, scope::Scope, search::Search,
   search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting,
   settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace, string_kind::StringKind,
   string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table,
@@ -64,9 +64,9 @@ pub(crate) use crate::{
 };
 
 // type aliases
-pub(crate) type CompilationResult<'a, T> = Result<T, CompilationError<'a>>;
+pub(crate) type CompileResult<'a, T> = Result<T, CompileError<'a>>;
 pub(crate) type ConfigResult<T> = Result<T, ConfigError>;
-pub(crate) type RunResult<'a, T> = Result<T, RuntimeError<'a>>;
+pub(crate) type RunResult<'a, T> = Result<T, Error<'a>>;
 pub(crate) type SearchResult<T> = Result<T, SearchError>;
 
 // modules used in tests
diff --git a/src/compilation_error.rs b/src/compile_error.rs
similarity index 71%
rename from src/compilation_error.rs
rename to src/compile_error.rs
index 56d23b12..d3d9d89b 100644
--- a/src/compilation_error.rs
+++ b/src/compile_error.rs
@@ -1,23 +1,24 @@
 use crate::common::*;
 
 #[derive(Debug, PartialEq)]
-pub(crate) struct CompilationError<'src> {
+pub(crate) struct CompileError<'src> {
   pub(crate) token: Token<'src>,
-  pub(crate) kind:  CompilationErrorKind<'src>,
+  pub(crate) kind:  CompileErrorKind<'src>,
 }
 
-impl Error for CompilationError<'_> {}
+impl<'src> CompileError<'src> {
+  pub(crate) fn context(&self) -> Token<'src> {
+    self.token
+  }
+}
 
-impl Display for CompilationError<'_> {
+impl Display for CompileError<'_> {
   fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
-    use CompilationErrorKind::*;
-    let message = Color::fmt(f).message();
-
-    write!(f, "{}", message.prefix())?;
+    use CompileErrorKind::*;
 
     match &self.kind {
       AliasShadowsRecipe { alias, recipe_line } => {
-        writeln!(
+        write!(
           f,
           "Alias `{}` defined on line {} shadows recipe `{}` defined on line {}",
           alias,
@@ -27,13 +28,13 @@ impl Display for CompilationError<'_> {
         )?;
       },
       BacktickShebang => {
-        writeln!(f, "Backticks may not start with `#!`")?;
+        write!(f, "Backticks may not start with `#!`")?;
       },
       CircularRecipeDependency { recipe, ref circle } =>
         if circle.len() == 2 {
-          writeln!(f, "Recipe `{}` depends on itself", recipe)?;
+          write!(f, "Recipe `{}` depends on itself", recipe)?;
         } else {
-          writeln!(
+          write!(
             f,
             "Recipe `{}` has circular dependency `{}`",
             recipe,
@@ -45,54 +46,50 @@ impl Display for CompilationError<'_> {
         ref circle,
       } =>
         if circle.len() == 2 {
-          writeln!(f, "Variable `{}` is defined in terms of itself", variable)?;
+          write!(f, "Variable `{}` is defined in terms of itself", variable)?;
         } else {
-          writeln!(
+          write!(
             f,
             "Variable `{}` depends on its own value: `{}`",
             variable,
             circle.join(" -> ")
           )?;
         },
+      DependencyArgumentCountMismatch {
+        dependency,
+        found,
+        min,
+        max,
+      } => {
+        write!(
+          f,
+          "Dependency `{}` got {} {} but takes ",
+          dependency,
+          found,
+          Count("argument", *found),
+        )?;
 
-      InvalidEscapeSequence { character } => {
-        let representation = match character {
-          '`' => r"\`".to_owned(),
-          '\\' => r"\".to_owned(),
-          '\'' => r"'".to_owned(),
-          '"' => r#"""#.to_owned(),
-          _ => character.escape_default().collect(),
-        };
-        writeln!(f, "`\\{}` is not a valid escape sequence", representation)?;
+        if min == max {
+          let expected = min;
+          write!(f, "{} {}", expected, Count("argument", *expected))?;
+        } else if found < min {
+          write!(f, "at least {} {}", min, Count("argument", *min))?;
+        } else {
+          write!(f, "at most {} {}", max, Count("argument", *max))?;
+        }
       },
       DeprecatedEquals => {
         writeln!(
           f,
           "`=` in assignments, exports, and aliases has been phased out on favor of `:=`"
         )?;
-        writeln!(
+        write!(
           f,
           "Please see this issue for more details: https://github.com/casey/just/issues/379"
         )?;
       },
-      DuplicateParameter { recipe, parameter } => {
-        writeln!(
-          f,
-          "Recipe `{}` has duplicate parameter `{}`",
-          recipe, parameter
-        )?;
-      },
-      DuplicateVariable { variable } => {
-        writeln!(f, "Variable `{}` has multiple definitions", variable)?;
-      },
-      UnexpectedToken {
-        ref expected,
-        found,
-      } => {
-        writeln!(f, "Expected {}, but found {}", List::or(expected), found)?;
-      },
       DuplicateAlias { alias, first } => {
-        writeln!(
+        write!(
           f,
           "Alias `{}` first defined on line {} is redefined on line {}",
           alias,
@@ -100,8 +97,15 @@ impl Display for CompilationError<'_> {
           self.token.line.ordinal(),
         )?;
       },
+      DuplicateParameter { recipe, parameter } => {
+        write!(
+          f,
+          "Recipe `{}` has duplicate parameter `{}`",
+          recipe, parameter
+        )?;
+      },
       DuplicateRecipe { recipe, first } => {
-        writeln!(
+        write!(
           f,
           "Recipe `{}` first defined on line {} is redefined on line {}",
           recipe,
@@ -110,7 +114,7 @@ impl Display for CompilationError<'_> {
         )?;
       },
       DuplicateSet { setting, first } => {
-        writeln!(
+        write!(
           f,
           "Setting `{}` first set on line {} is redefined on line {}",
           setting,
@@ -118,69 +122,24 @@ impl Display for CompilationError<'_> {
           self.token.line.ordinal(),
         )?;
       },
-      DependencyArgumentCountMismatch {
-        dependency,
-        found,
-        min,
-        max,
-      } => {
-        write!(
-          f,
-          "Dependency `{}` got {} {} but takes ",
-          dependency,
-          found,
-          Count("argument", *found),
-        )?;
-
-        if min == max {
-          let expected = min;
-          writeln!(f, "{} {}", expected, Count("argument", *expected))?;
-        } else if found < min {
-          writeln!(f, "at least {} {}", min, Count("argument", *min))?;
-        } else {
-          writeln!(f, "at most {} {}", max, Count("argument", *max))?;
-        }
+      DuplicateVariable { variable } => {
+        write!(f, "Variable `{}` has multiple definitions", variable)?;
       },
-      ExpectedKeyword { expected, found } => writeln!(
+      ExpectedKeyword { expected, found } => write!(
         f,
         "Expected keyword {} but found identifier `{}`",
         List::or_ticked(expected),
         found
       )?,
-      ParameterShadowsVariable { parameter } => {
-        writeln!(
-          f,
-          "Parameter `{}` shadows variable of the same name",
-          parameter
-        )?;
-      },
-      RequiredParameterFollowsDefaultParameter { parameter } => {
-        writeln!(
-          f,
-          "Non-default parameter `{}` follows default parameter",
-          parameter
-        )?;
-      },
-      ParameterFollowsVariadicParameter { parameter } => {
-        writeln!(f, "Parameter `{}` follows variadic parameter", parameter)?;
-      },
-      MixedLeadingWhitespace { whitespace } => {
-        writeln!(
-          f,
-          "Found a mix of tabs and spaces in leading whitespace: `{}`\nLeading whitespace may \
-           consist of tabs or spaces, but not both",
-          ShowWhitespace(whitespace)
-        )?;
-      },
       ExtraLeadingWhitespace => {
-        writeln!(f, "Recipe line has extra leading whitespace")?;
+        write!(f, "Recipe line has extra leading whitespace")?;
       },
       FunctionArgumentCountMismatch {
         function,
         found,
         expected,
       } => {
-        writeln!(
+        write!(
           f,
           "Function `{}` called with {} {} but takes {}",
           function,
@@ -190,7 +149,7 @@ impl Display for CompilationError<'_> {
         )?;
       },
       InconsistentLeadingWhitespace { expected, found } => {
-        writeln!(
+        write!(
           f,
           "Recipe line has inconsistent leading whitespace. Recipe started with `{}` but found \
            line with `{}`",
@@ -198,40 +157,30 @@ impl Display for CompilationError<'_> {
           ShowWhitespace(found)
         )?;
       },
-      UnknownAliasTarget { alias, target } => {
-        writeln!(f, "Alias `{}` has an unknown target `{}`", alias, target)?;
-      },
-      UnknownDependency { recipe, unknown } => {
-        writeln!(
+      Internal { ref message } => {
+        write!(
           f,
-          "Recipe `{}` has unknown dependency `{}`",
-          recipe, unknown
+          "Internal error, this may indicate a bug in just: {}\n\
+           consider filing an issue: https://github.com/casey/just/issues/new",
+          message
         )?;
       },
-      UndefinedVariable { variable } => {
-        writeln!(f, "Variable `{}` not defined", variable)?;
-      },
-      UnknownFunction { function } => {
-        writeln!(f, "Call to unknown function `{}`", function)?;
-      },
-      UnknownSetting { setting } => {
-        writeln!(f, "Unknown setting `{}`", setting)?;
-      },
-      UnexpectedCharacter { expected } => {
-        writeln!(f, "Expected character `{}`", expected)?;
-      },
-      UnknownStartOfToken => {
-        writeln!(f, "Unknown start of token:")?;
-      },
-      UnexpectedEndOfToken { expected } => {
-        writeln!(f, "Expected character `{}` but found end-of-file", expected)?;
+      InvalidEscapeSequence { character } => {
+        let representation = match character {
+          '`' => r"\`".to_owned(),
+          '\\' => r"\".to_owned(),
+          '\'' => r"'".to_owned(),
+          '"' => r#"""#.to_owned(),
+          _ => character.escape_default().collect(),
+        };
+        write!(f, "`\\{}` is not a valid escape sequence", representation)?;
       },
       MismatchedClosingDelimiter {
         open,
         open_line,
         close,
       } => {
-        writeln!(
+        write!(
           f,
           "Mismatched closing delimiter `{}`. (Did you mean to close the `{}` on line {}?)",
           close.close(),
@@ -239,33 +188,82 @@ impl Display for CompilationError<'_> {
           open_line.ordinal(),
         )?;
       },
-      UnexpectedClosingDelimiter { close } => {
-        writeln!(f, "Unexpected closing delimiter `{}`", close.close())?;
+      MixedLeadingWhitespace { whitespace } => {
+        write!(
+          f,
+          "Found a mix of tabs and spaces in leading whitespace: `{}`\nLeading whitespace may \
+           consist of tabs or spaces, but not both",
+          ShowWhitespace(whitespace)
+        )?;
       },
-      UnpairedCarriageReturn => {
-        writeln!(f, "Unpaired carriage return")?;
+      ParameterFollowsVariadicParameter { parameter } => {
+        write!(f, "Parameter `{}` follows variadic parameter", parameter)?;
       },
-      UnterminatedInterpolation => {
-        writeln!(f, "Unterminated interpolation")?;
+      ParameterShadowsVariable { parameter } => {
+        write!(
+          f,
+          "Parameter `{}` shadows variable of the same name",
+          parameter
+        )?;
       },
-      UnterminatedString => {
-        writeln!(f, "Unterminated string")?;
+      RequiredParameterFollowsDefaultParameter { parameter } => {
+        write!(
+          f,
+          "Non-default parameter `{}` follows default parameter",
+          parameter
+        )?;
       },
-      UnterminatedBacktick => {
-        writeln!(f, "Unterminated backtick")?;
+      UndefinedVariable { variable } => {
+        write!(f, "Variable `{}` not defined", variable)?;
       },
-      Internal { ref message } => {
-        writeln!(
+      UnexpectedCharacter { expected } => {
+        write!(f, "Expected character `{}`", expected)?;
+      },
+      UnexpectedClosingDelimiter { close } => {
+        write!(f, "Unexpected closing delimiter `{}`", close.close())?;
+      },
+      UnexpectedEndOfToken { expected } => {
+        write!(f, "Expected character `{}` but found end-of-file", expected)?;
+      },
+      UnexpectedToken {
+        ref expected,
+        found,
+      } => {
+        write!(f, "Expected {}, but found {}", List::or(expected), found)?;
+      },
+      UnknownAliasTarget { alias, target } => {
+        write!(f, "Alias `{}` has an unknown target `{}`", alias, target)?;
+      },
+      UnknownDependency { recipe, unknown } => {
+        write!(
           f,
-          "Internal error, this may indicate a bug in just: {}\n\
-           consider filing an issue: https://github.com/casey/just/issues/new",
-          message
+          "Recipe `{}` has unknown dependency `{}`",
+          recipe, unknown
         )?;
       },
+      UnknownFunction { function } => {
+        write!(f, "Call to unknown function `{}`", function)?;
+      },
+      UnknownSetting { setting } => {
+        write!(f, "Unknown setting `{}`", setting)?;
+      },
+      UnknownStartOfToken => {
+        write!(f, "Unknown start of token:")?;
+      },
+      UnpairedCarriageReturn => {
+        write!(f, "Unpaired carriage return")?;
+      },
+      UnterminatedBacktick => {
+        write!(f, "Unterminated backtick")?;
+      },
+      UnterminatedInterpolation => {
+        write!(f, "Unterminated interpolation")?;
+      },
+      UnterminatedString => {
+        write!(f, "Unterminated string")?;
+      },
     }
 
-    write!(f, "{}", message.suffix())?;
-
-    self.token.write_context(f, Color::fmt(f).error())
+    Ok(())
   }
 }
diff --git a/src/compilation_error_kind.rs b/src/compile_error_kind.rs
similarity index 98%
rename from src/compilation_error_kind.rs
rename to src/compile_error_kind.rs
index b15c858d..5909480f 100644
--- a/src/compilation_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -1,7 +1,7 @@
 use crate::common::*;
 
 #[derive(Debug, PartialEq)]
-pub(crate) enum CompilationErrorKind<'src> {
+pub(crate) enum CompileErrorKind<'src> {
   AliasShadowsRecipe {
     alias:       &'src str,
     recipe_line: usize,
@@ -34,13 +34,13 @@ pub(crate) enum CompilationErrorKind<'src> {
     recipe: &'src str,
     first:  usize,
   },
-  DuplicateVariable {
-    variable: &'src str,
-  },
   DuplicateSet {
     setting: &'src str,
     first:   usize,
   },
+  DuplicateVariable {
+    variable: &'src str,
+  },
   ExpectedKeyword {
     expected: Vec<Keyword>,
     found:    &'src str,
@@ -61,6 +61,11 @@ pub(crate) enum CompilationErrorKind<'src> {
   InvalidEscapeSequence {
     character: char,
   },
+  MismatchedClosingDelimiter {
+    close:     Delimiter,
+    open:      Delimiter,
+    open_line: usize,
+  },
   MixedLeadingWhitespace {
     whitespace: &'src str,
   },
@@ -76,6 +81,15 @@ pub(crate) enum CompilationErrorKind<'src> {
   UndefinedVariable {
     variable: &'src str,
   },
+  UnexpectedCharacter {
+    expected: char,
+  },
+  UnexpectedClosingDelimiter {
+    close: Delimiter,
+  },
+  UnexpectedEndOfToken {
+    expected: char,
+  },
   UnexpectedToken {
     expected: Vec<TokenKind>,
     found:    TokenKind,
@@ -91,26 +105,12 @@ pub(crate) enum CompilationErrorKind<'src> {
   UnknownFunction {
     function: &'src str,
   },
-  UnknownStartOfToken,
-  UnexpectedCharacter {
-    expected: char,
-  },
-  UnexpectedEndOfToken {
-    expected: char,
-  },
   UnknownSetting {
     setting: &'src str,
   },
+  UnknownStartOfToken,
   UnpairedCarriageReturn,
-  UnexpectedClosingDelimiter {
-    close: Delimiter,
-  },
-  MismatchedClosingDelimiter {
-    close:     Delimiter,
-    open:      Delimiter,
-    open_line: usize,
-  },
+  UnterminatedBacktick,
   UnterminatedInterpolation,
   UnterminatedString,
-  UnterminatedBacktick,
 }
diff --git a/src/compiler.rs b/src/compiler.rs
index acba9aee..f19536eb 100644
--- a/src/compiler.rs
+++ b/src/compiler.rs
@@ -3,7 +3,7 @@ use crate::common::*;
 pub(crate) struct Compiler;
 
 impl Compiler {
-  pub(crate) fn compile(src: &str) -> CompilationResult<Justfile> {
+  pub(crate) fn compile(src: &str) -> CompileResult<Justfile> {
     let tokens = Lexer::lex(src)?;
 
     let ast = Parser::parse(&tokens)?;
diff --git a/src/config.rs b/src/config.rs
index e68d9812..3e1d0280 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -532,7 +532,7 @@ impl Config {
     })
   }
 
-  pub(crate) fn run_subcommand(self) -> Result<(), i32> {
+  pub(crate) fn run_subcommand<'src>(self, loader: &'src Loader) -> Result<(), Error<'src>> {
     use Subcommand::*;
 
     if self.subcommand == Init {
@@ -540,34 +540,24 @@ impl Config {
     }
 
     if let Completions { shell } = self.subcommand {
-      return Subcommand::completions(self.verbosity, &shell);
+      return Subcommand::completions(&shell);
     }
 
-    let search =
-      Search::find(&self.search_config, &self.invocation_directory).eprint(self.color)?;
+    let search = Search::find(&self.search_config, &self.invocation_directory)?;
 
     if self.subcommand == Edit {
-      return self.edit(&search);
+      return Self::edit(&search);
     }
 
-    let src = fs::read_to_string(&search.justfile)
-      .map_err(|io_error| LoadError {
-        io_error,
-        path: &search.justfile,
-      })
-      .eprint(self.color)?;
+    let src = loader.load(&search.justfile)?;
 
-    let tokens = Lexer::lex(&src).eprint(self.color)?;
-    let ast = Parser::parse(&tokens).eprint(self.color)?;
-    let justfile = Analyzer::analyze(ast.clone()).eprint(self.color)?;
+    let tokens = Lexer::lex(&src)?;
+    let ast = Parser::parse(&tokens)?;
+    let justfile = Analyzer::analyze(ast.clone())?;
 
     if self.verbosity.loud() {
       for warning in &justfile.warnings {
-        if self.color.stderr().active() {
-          eprintln!("{:#}", warning);
-        } else {
-          eprintln!("{}", warning);
-        }
+        warning.write(&mut io::stderr(), self.color.stderr()).ok();
       }
     }
 
@@ -575,7 +565,7 @@ impl Config {
       Choose { overrides, chooser } =>
         self.choose(justfile, &search, overrides, chooser.as_deref())?,
       Command { overrides, .. } => self.run(justfile, &search, overrides, &[])?,
-      Dump => Self::dump(ast)?,
+      Dump => Self::dump(ast),
       Evaluate { overrides, .. } => self.run(justfile, &search, overrides, &[])?,
       Format => self.format(ast, &search)?,
       List => self.list(justfile),
@@ -583,7 +573,7 @@ impl Config {
         arguments,
         overrides,
       } => self.run(justfile, &search, overrides, arguments)?,
-      Show { ref name } => self.show(&name, justfile)?,
+      Show { ref name } => Self::show(&name, justfile)?,
       Summary => self.summary(justfile),
       Variables => Self::variables(justfile),
       Completions { .. } | Edit | Init => unreachable!(),
@@ -592,13 +582,13 @@ impl Config {
     Ok(())
   }
 
-  fn choose(
+  fn choose<'src>(
     &self,
-    justfile: Justfile,
+    justfile: Justfile<'src>,
     search: &Search,
     overrides: &BTreeMap<String, String>,
     chooser: Option<&str>,
-  ) -> Result<(), i32> {
+  ) -> Result<(), Error<'src>> {
     let recipes = justfile
       .public_recipes(self.unsorted)
       .iter()
@@ -607,10 +597,7 @@ impl Config {
       .collect::<Vec<&Recipe<Dependency>>>();
 
     if recipes.is_empty() {
-      if self.verbosity.loud() {
-        eprintln!("Justfile contains no choosable recipes.");
-      }
-      return Err(EXIT_FAILURE);
+      return Err(Error::NoChoosableRecipes);
     }
 
     let chooser = chooser
@@ -629,61 +616,39 @@ impl Config {
 
     let mut child = match result {
       Ok(child) => child,
-      Err(error) => {
-        if self.verbosity.loud() {
-          eprintln!(
-            "Chooser `{} {} {}` invocation failed: {}",
-            justfile.settings.shell_binary(self),
-            justfile.settings.shell_arguments(self).join(" "),
-            chooser.to_string_lossy(),
-            error
-          );
-        }
-        return Err(EXIT_FAILURE);
+      Err(io_error) => {
+        return Err(Error::ChooserInvoke {
+          shell_binary: justfile.settings.shell_binary(self).to_owned(),
+          shell_arguments: justfile.settings.shell_arguments(self).join(" "),
+          chooser,
+          io_error,
+        });
       },
     };
 
     for recipe in recipes {
-      if let Err(error) = child
+      if let Err(io_error) = child
         .stdin
         .as_mut()
         .expect("Child was created with piped stdio")
         .write_all(format!("{}\n", recipe.name).as_bytes())
       {
-        if self.verbosity.loud() {
-          eprintln!(
-            "Failed to write to chooser `{}`: {}",
-            chooser.to_string_lossy(),
-            error
-          );
-        }
-        return Err(EXIT_FAILURE);
+        return Err(Error::ChooserWrite { io_error, chooser });
       }
     }
 
     let output = match child.wait_with_output() {
       Ok(output) => output,
-      Err(error) => {
-        if self.verbosity.loud() {
-          eprintln!(
-            "Failed to read output from chooser `{}`: {}",
-            chooser.to_string_lossy(),
-            error
-          );
-        }
-        return Err(EXIT_FAILURE);
+      Err(io_error) => {
+        return Err(Error::ChooserRead { io_error, chooser });
       },
     };
 
     if !output.status.success() {
-      if self.verbosity.loud() {
-        eprintln!(
-          "Chooser `{}` returned error: {}",
-          chooser.to_string_lossy(),
-          output.status
-        );
-      }
-      return Err(output.status.code().unwrap_or(EXIT_FAILURE));
+      return Err(Error::ChooserStatus {
+        status: output.status,
+        chooser,
+      });
     }
 
     let stdout = String::from_utf8_lossy(&output.stdout);
@@ -697,12 +662,11 @@ impl Config {
     self.run(justfile, search, overrides, &recipes)
   }
 
-  fn dump(ast: Ast) -> Result<(), i32> {
+  fn dump(ast: Ast) {
     print!("{}", ast);
-    Ok(())
   }
 
-  pub(crate) fn edit(&self, search: &Search) -> Result<(), i32> {
+  pub(crate) fn edit(search: &Search) -> Result<(), Error<'static>> {
     let editor = env::var_os("VISUAL")
       .or_else(|| env::var_os("EDITOR"))
       .unwrap_or_else(|| "vim".into());
@@ -712,47 +676,38 @@ impl Config {
       .arg(&search.justfile)
       .status();
 
-    match error {
-      Ok(status) =>
-        if status.success() {
-          Ok(())
-        } else {
-          if self.verbosity.loud() {
-            eprintln!("Editor `{}` failed: {}", editor.to_string_lossy(), status);
-          }
-          Err(status.code().unwrap_or(EXIT_FAILURE))
-        },
-      Err(error) => {
-        if self.verbosity.loud() {
-          eprintln!(
-            "Editor `{}` invocation failed: {}",
-            editor.to_string_lossy(),
-            error
-          );
-        }
-        Err(EXIT_FAILURE)
-      },
+    let status = match error {
+      Err(io_error) => return Err(Error::EditorInvoke { editor, io_error }),
+      Ok(status) => status,
+    };
+
+    if !status.success() {
+      return Err(Error::EditorStatus { editor, status });
     }
+
+    Ok(())
   }
 
-  fn format(&self, ast: Ast, search: &Search) -> Result<(), i32> {
-    if !self.unstable {
-      eprintln!(
-        "The `--fmt` command is currently unstable. Pass the `--unstable` flag to enable it."
-      );
-      return Err(EXIT_FAILURE);
+  fn require_unstable(&self, message: &str) -> Result<(), Error<'static>> {
+    if self.unstable {
+      Ok(())
+    } else {
+      Err(Error::Unstable {
+        message: message.to_owned(),
+      })
     }
+  }
 
-    if let Err(error) = File::create(&search.justfile).and_then(|mut file| write!(file, "{}", ast))
+  fn format(&self, ast: Ast, search: &Search) -> Result<(), Error<'static>> {
+    self.require_unstable("The `--fmt` command is currently unstable.")?;
+
+    if let Err(io_error) =
+      File::create(&search.justfile).and_then(|mut file| write!(file, "{}", ast))
     {
-      if self.verbosity.loud() {
-        eprintln!(
-          "Failed to write justfile to `{}`: {}",
-          search.justfile.display(),
-          error
-        );
-      }
-      Err(EXIT_FAILURE)
+      Err(Error::WriteJustfile {
+        justfile: search.justfile.clone(),
+        io_error,
+      })
     } else {
       if self.verbosity.loud() {
         eprintln!("Wrote justfile to `{}`", search.justfile.display());
@@ -761,24 +716,18 @@ impl Config {
     }
   }
 
-  pub(crate) fn init(&self) -> Result<(), i32> {
-    let search =
-      Search::init(&self.search_config, &self.invocation_directory).eprint(self.color)?;
+  pub(crate) fn init(&self) -> Result<(), Error<'static>> {
+    let search = Search::init(&self.search_config, &self.invocation_directory)?;
 
-    if search.justfile.exists() {
-      if self.verbosity.loud() {
-        eprintln!("Justfile `{}` already exists", search.justfile.display());
-      }
-      Err(EXIT_FAILURE)
-    } else if let Err(err) = fs::write(&search.justfile, INIT_JUSTFILE) {
-      if self.verbosity.loud() {
-        eprintln!(
-          "Failed to write justfile to `{}`: {}",
-          search.justfile.display(),
-          err
-        );
-      }
-      Err(EXIT_FAILURE)
+    if search.justfile.is_file() {
+      Err(Error::InitExists {
+        justfile: search.justfile,
+      })
+    } else if let Err(io_error) = fs::write(&search.justfile, INIT_JUSTFILE) {
+      Err(Error::WriteJustfile {
+        justfile: search.justfile,
+        io_error,
+      })
     } else {
       if self.verbosity.loud() {
         eprintln!("Wrote justfile to `{}`", search.justfile.display());
@@ -871,27 +820,21 @@ impl Config {
     }
   }
 
-  fn run(
+  fn run<'src>(
     &self,
-    justfile: Justfile,
+    justfile: Justfile<'src>,
     search: &Search,
     overrides: &BTreeMap<String, String>,
     arguments: &[String],
-  ) -> Result<(), i32> {
+  ) -> Result<(), Error<'src>> {
     if let Err(error) = InterruptHandler::install(self.verbosity) {
       warn!("Failed to set CTRL-C handler: {}", error);
     }
 
-    let result = justfile.run(&self, search, overrides, arguments);
-
-    if !self.verbosity.quiet() {
-      result.eprint(self.color)
-    } else {
-      result.map_err(|err| err.code())
-    }
+    justfile.run(&self, search, overrides, arguments)
   }
 
-  fn show(&self, name: &str, justfile: Justfile) -> Result<(), i32> {
+  fn show<'src>(name: &str, justfile: Justfile<'src>) -> Result<(), Error<'src>> {
     if let Some(alias) = justfile.get_alias(name) {
       let recipe = justfile.get_recipe(alias.target.name.lexeme()).unwrap();
       println!("{}", alias);
@@ -901,13 +844,10 @@ impl Config {
       println!("{}", recipe);
       Ok(())
     } else {
-      if self.verbosity.loud() {
-        eprintln!("Justfile does not contain recipe `{}`.", name);
-        if let Some(suggestion) = justfile.suggest_recipe(name) {
-          eprintln!("{}", suggestion);
-        }
-      }
-      Err(EXIT_FAILURE)
+      Err(Error::UnknownRecipes {
+        recipes:    vec![name.to_owned()],
+        suggestion: justfile.suggest_recipe(name),
+      })
     }
   }
 
diff --git a/src/config_error.rs b/src/config_error.rs
index 76ab066b..82cc1bbb 100644
--- a/src/config_error.rs
+++ b/src/config_error.rs
@@ -3,14 +3,14 @@ use crate::common::*;
 #[derive(Debug, Snafu)]
 #[snafu(visibility(pub(crate)))]
 pub(crate) enum ConfigError {
+  #[snafu(display("Failed to get current directory: {}", source))]
+  CurrentDir { source: io::Error },
   #[snafu(display(
     "Internal config error, this may indicate a bug in just: {} \
      consider filing an issue: https://github.com/casey/just/issues/new",
     message
   ))]
   Internal { message: String },
-  #[snafu(display("Failed to get current directory: {}", source))]
-  CurrentDir { source: io::Error },
   #[snafu(display(
     "Path-prefixed recipes may not be used with `--working-directory` or `--justfile`."
   ))]
@@ -26,24 +26,24 @@ pub(crate) enum ConfigError {
     arguments:  Vec<String>,
   },
   #[snafu(display(
-      "`--{}` used with unexpected overrides: {}; and arguments: {}",
+      "`--{}` used with unexpected overrides: {}",
       subcommand.to_lowercase(),
       List::and_ticked(overrides.iter().map(|(key, value)| format!("{}={}", key, value))),
-      List::and_ticked(arguments)))
-  ]
-  SubcommandOverridesAndArguments {
+  ))]
+  SubcommandOverrides {
     subcommand: &'static str,
     overrides:  BTreeMap<String, String>,
-    arguments:  Vec<String>,
   },
   #[snafu(display(
-      "`--{}` used with unexpected overrides: {}",
+      "`--{}` used with unexpected overrides: {}; and arguments: {}",
       subcommand.to_lowercase(),
       List::and_ticked(overrides.iter().map(|(key, value)| format!("{}={}", key, value))),
-  ))]
-  SubcommandOverrides {
+      List::and_ticked(arguments)))
+  ]
+  SubcommandOverridesAndArguments {
     subcommand: &'static str,
     overrides:  BTreeMap<String, String>,
+    arguments:  Vec<String>,
   },
 }
 
@@ -54,5 +54,3 @@ impl ConfigError {
     }
   }
 }
-
-impl Error for ConfigError {}
diff --git a/src/error.rs b/src/error.rs
index 98b7d51c..b9437a06 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,7 +1,624 @@
 use crate::common::*;
 
-pub(crate) trait Error: Display {
-  fn code(&self) -> i32 {
-    EXIT_FAILURE
+#[derive(Debug)]
+pub(crate) enum Error<'src> {
+  ArgumentCountMismatch {
+    recipe:     &'src str,
+    parameters: Vec<Parameter<'src>>,
+    found:      usize,
+    min:        usize,
+    max:        usize,
+  },
+  Backtick {
+    token:        Token<'src>,
+    output_error: OutputError,
+  },
+  ChooserInvoke {
+    shell_binary:    String,
+    shell_arguments: String,
+    chooser:         OsString,
+    io_error:        io::Error,
+  },
+  ChooserRead {
+    chooser:  OsString,
+    io_error: io::Error,
+  },
+  ChooserStatus {
+    chooser: OsString,
+    status:  ExitStatus,
+  },
+  ChooserWrite {
+    chooser:  OsString,
+    io_error: io::Error,
+  },
+  Code {
+    recipe:      &'src str,
+    line_number: Option<usize>,
+    code:        i32,
+  },
+  CommandInvoke {
+    binary:    OsString,
+    arguments: Vec<OsString>,
+    io_error:  io::Error,
+  },
+  CommandStatus {
+    binary:    OsString,
+    arguments: Vec<OsString>,
+    status:    ExitStatus,
+  },
+  Compile {
+    compile_error: CompileError<'src>,
+  },
+  Config {
+    config_error: ConfigError,
+  },
+  Cygpath {
+    recipe:       &'src str,
+    output_error: OutputError,
+  },
+  DefaultRecipeRequiresArguments {
+    recipe:        &'src str,
+    min_arguments: usize,
+  },
+  Dotenv {
+    dotenv_error: dotenv::Error,
+  },
+  EditorInvoke {
+    editor:   OsString,
+    io_error: io::Error,
+  },
+  EditorStatus {
+    editor: OsString,
+    status: ExitStatus,
+  },
+  EvalUnknownVariable {
+    variable:   String,
+    suggestion: Option<Suggestion<'src>>,
+  },
+  FunctionCall {
+    function: Name<'src>,
+    message:  String,
+  },
+  InitExists {
+    justfile: PathBuf,
+  },
+  Internal {
+    message: String,
+  },
+  Io {
+    recipe:   &'src str,
+    io_error: io::Error,
+  },
+  Load {
+    path:     PathBuf,
+    io_error: io::Error,
+  },
+  NoChoosableRecipes,
+  NoRecipes,
+  Search {
+    search_error: SearchError,
+  },
+  Shebang {
+    recipe:   &'src str,
+    command:  String,
+    argument: Option<String>,
+    io_error: io::Error,
+  },
+  Signal {
+    recipe:      &'src str,
+    line_number: Option<usize>,
+    signal:      i32,
+  },
+  TmpdirIo {
+    recipe:   &'src str,
+    io_error: io::Error,
+  },
+  Unknown {
+    recipe:      &'src str,
+    line_number: Option<usize>,
+  },
+  UnknownOverrides {
+    overrides: Vec<String>,
+  },
+  UnknownRecipes {
+    recipes:    Vec<String>,
+    suggestion: Option<Suggestion<'src>>,
+  },
+  Unstable {
+    message: String,
+  },
+  WriteJustfile {
+    justfile: PathBuf,
+    io_error: io::Error,
+  },
+}
+
+impl<'src> Error<'src> {
+  pub(crate) fn code(&self) -> Option<i32> {
+    match self {
+      Self::Code { code, .. }
+      | Self::Backtick {
+        output_error: OutputError::Code(code),
+        ..
+      } => Some(*code),
+      Self::ChooserStatus { status, .. } | Self::EditorStatus { status, .. } => status.code(),
+      _ => None,
+    }
+  }
+
+  fn context(&self) -> Option<Token<'src>> {
+    match self {
+      Self::Backtick { token, .. } => Some(*token),
+      Self::Compile { compile_error } => Some(compile_error.context()),
+      Self::FunctionCall { function, .. } => Some(function.token()),
+      _ => None,
+    }
+  }
+
+  pub(crate) fn internal(message: impl Into<String>) -> Self {
+    Self::Internal {
+      message: message.into(),
+    }
+  }
+
+  pub(crate) fn write(&self, w: &mut dyn Write, color: Color) -> io::Result<()> {
+    let color = color.stderr();
+
+    if color.active() {
+      writeln!(
+        w,
+        "{}: {}{:#}{}",
+        color.error().paint("error"),
+        color.message().prefix(),
+        self,
+        color.message().suffix()
+      )?;
+    } else {
+      writeln!(w, "error: {}", self)?;
+    }
+
+    if let Some(token) = self.context() {
+      token.write_context(w, color.error())?;
+      writeln!(w)?;
+    }
+
+    Ok(())
+  }
+}
+
+impl<'src> From<CompileError<'src>> for Error<'src> {
+  fn from(compile_error: CompileError<'src>) -> Self {
+    Self::Compile { compile_error }
+  }
+}
+
+impl<'src> From<ConfigError> for Error<'src> {
+  fn from(config_error: ConfigError) -> Self {
+    Self::Config { config_error }
+  }
+}
+
+impl<'src> From<dotenv::Error> for Error<'src> {
+  fn from(dotenv_error: dotenv::Error) -> Error<'src> {
+    Self::Dotenv { dotenv_error }
+  }
+}
+
+impl<'src> From<SearchError> for Error<'src> {
+  fn from(search_error: SearchError) -> Self {
+    Self::Search { search_error }
+  }
+}
+
+impl<'src> Display for Error<'src> {
+  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
+    use Error::*;
+
+    match self {
+      ArgumentCountMismatch {
+        recipe,
+        parameters,
+        found,
+        min,
+        max,
+      } => {
+        if min == max {
+          let expected = min;
+          write!(
+            f,
+            "Recipe `{}` got {} {} but {}takes {}",
+            recipe,
+            found,
+            Count("argument", *found),
+            if expected < found { "only " } else { "" },
+            expected
+          )?;
+        } else if found < min {
+          write!(
+            f,
+            "Recipe `{}` got {} {} but takes at least {}",
+            recipe,
+            found,
+            Count("argument", *found),
+            min
+          )?;
+        } else if found > max {
+          write!(
+            f,
+            "Recipe `{}` got {} {} but takes at most {}",
+            recipe,
+            found,
+            Count("argument", *found),
+            max
+          )?;
+        }
+        write!(f, "\nusage:\n    just {}", recipe)?;
+        for param in parameters {
+          write!(f, " {}", param)?;
+        }
+      },
+      Backtick { output_error, .. } => match output_error {
+        OutputError::Code(code) => {
+          write!(f, "Backtick failed with exit code {}", code)?;
+        },
+        OutputError::Signal(signal) => {
+          write!(f, "Backtick was terminated by signal {}", signal)?;
+        },
+        OutputError::Unknown => {
+          write!(f, "Backtick failed for an unknown reason")?;
+        },
+        OutputError::Io(io_error) => {
+          match io_error.kind() {
+            io::ErrorKind::NotFound => write!(
+              f,
+              "Backtick could not be run because just could not find `sh`:\n{}",
+              io_error
+            ),
+            io::ErrorKind::PermissionDenied => write!(
+              f,
+              "Backtick could not be run because just could not run `sh`:\n{}",
+              io_error
+            ),
+            _ => write!(
+              f,
+              "Backtick could not be run because of an IO error while launching `sh`:\n{}",
+              io_error
+            ),
+          }?;
+        },
+        OutputError::Utf8(utf8_error) => {
+          write!(
+            f,
+            "Backtick succeeded but stdout was not utf8: {}",
+            utf8_error
+          )?;
+        },
+      },
+      ChooserInvoke {
+        shell_binary,
+        shell_arguments,
+        chooser,
+        io_error,
+      } => {
+        write!(
+          f,
+          "Chooser `{} {} {}` invocation failed: {}",
+          shell_binary,
+          shell_arguments,
+          chooser.to_string_lossy(),
+          io_error,
+        )?;
+      },
+      ChooserRead { chooser, io_error } => {
+        write!(
+          f,
+          "Failed to read output from chooser `{}`: {}",
+          chooser.to_string_lossy(),
+          io_error
+        )?;
+      },
+      ChooserStatus { chooser, status } => {
+        write!(
+          f,
+          "Chooser `{}` failed: {}",
+          chooser.to_string_lossy(),
+          status
+        )?;
+      },
+      ChooserWrite { chooser, io_error } => {
+        write!(
+          f,
+          "Failed to write to chooser `{}`: {}",
+          chooser.to_string_lossy(),
+          io_error
+        )?;
+      },
+      Code {
+        recipe,
+        line_number,
+        code,
+      } =>
+        if let Some(n) = line_number {
+          write!(
+            f,
+            "Recipe `{}` failed on line {} with exit code {}",
+            recipe, n, code
+          )?;
+        } else {
+          write!(f, "Recipe `{}` failed with exit code {}", recipe, code)?;
+        },
+      CommandInvoke {
+        binary,
+        arguments,
+        io_error,
+      } => {
+        write!(
+          f,
+          "Failed to invoke {}: {}",
+          iter::once(binary)
+            .chain(arguments)
+            .map(|value| Enclosure::tick(value.to_string_lossy()).to_string())
+            .collect::<Vec<String>>()
+            .join(" "),
+          io_error,
+        )?;
+      },
+      CommandStatus {
+        binary,
+        arguments,
+        status,
+      } => {
+        write!(
+          f,
+          "Command {} failed: {}",
+          iter::once(binary)
+            .chain(arguments)
+            .map(|value| Enclosure::tick(value.to_string_lossy()).to_string())
+            .collect::<Vec<String>>()
+            .join(" "),
+          status,
+        )?;
+      },
+      Compile { compile_error } => Display::fmt(compile_error, f)?,
+      Config { config_error } => Display::fmt(config_error, f)?,
+      Cygpath {
+        recipe,
+        output_error,
+      } => match output_error {
+        OutputError::Code(code) => {
+          write!(
+            f,
+            "Cygpath failed with exit code {} while translating recipe `{}` shebang interpreter \
+             path",
+            code, recipe
+          )?;
+        },
+        OutputError::Signal(signal) => {
+          write!(
+            f,
+            "Cygpath terminated by signal {} while translating recipe `{}` shebang interpreter \
+             path",
+            signal, recipe
+          )?;
+        },
+        OutputError::Unknown => {
+          write!(
+            f,
+            "Cygpath experienced an unknown failure while translating recipe `{}` shebang \
+             interpreter path",
+            recipe
+          )?;
+        },
+        OutputError::Io(io_error) => {
+          match io_error.kind() {
+            io::ErrorKind::NotFound => write!(
+              f,
+              "Could not find `cygpath` executable to translate recipe `{}` shebang interpreter \
+               path:\n{}",
+              recipe, io_error
+            ),
+            io::ErrorKind::PermissionDenied => write!(
+              f,
+              "Could not run `cygpath` executable to translate recipe `{}` shebang interpreter \
+               path:\n{}",
+              recipe, io_error
+            ),
+            _ => write!(f, "Could not run `cygpath` executable:\n{}", io_error),
+          }?;
+        },
+        OutputError::Utf8(utf8_error) => {
+          write!(
+            f,
+            "Cygpath successfully translated recipe `{}` shebang interpreter path, but output was \
+             not utf8: {}",
+            recipe, utf8_error
+          )?;
+        },
+      },
+      DefaultRecipeRequiresArguments {
+        recipe,
+        min_arguments,
+      } => {
+        write!(
+          f,
+          "Recipe `{}` cannot be used as default recipe since it requires at least {} {}.",
+          recipe,
+          min_arguments,
+          Count("argument", *min_arguments),
+        )?;
+      },
+      Dotenv { dotenv_error } => {
+        write!(f, "Failed to load .env: {}", dotenv_error)?;
+      },
+      EditorInvoke { editor, io_error } => {
+        write!(
+          f,
+          "Editor `{}` invocation failed: {}",
+          editor.to_string_lossy(),
+          io_error
+        )?;
+      },
+      EditorStatus { editor, status } => {
+        write!(
+          f,
+          "Editor `{}` failed: {}",
+          editor.to_string_lossy(),
+          status
+        )?;
+      },
+      EvalUnknownVariable {
+        variable,
+        suggestion,
+      } => {
+        write!(f, "Justfile does not contain variable `{}`.", variable,)?;
+        if let Some(suggestion) = *suggestion {
+          write!(f, "\n{}", suggestion)?;
+        }
+      },
+      FunctionCall { function, message } => {
+        write!(
+          f,
+          "Call to function `{}` failed: {}",
+          function.lexeme(),
+          message
+        )?;
+      },
+      InitExists { justfile } => {
+        write!(f, "Justfile `{}` already exists", justfile.display())?;
+      },
+      Internal { message } => {
+        write!(
+          f,
+          "Internal runtime error, this may indicate a bug in just: {} \
+           consider filing an issue: https://github.com/casey/just/issues/new",
+          message
+        )?;
+      },
+      Io { recipe, io_error } => {
+        match io_error.kind() {
+          io::ErrorKind::NotFound => write!(
+            f,
+            "Recipe `{}` could not be run because just could not find `sh`: {}",
+            recipe, io_error
+          ),
+          io::ErrorKind::PermissionDenied => write!(
+            f,
+            "Recipe `{}` could not be run because just could not run `sh`: {}",
+            recipe, io_error
+          ),
+          _ => write!(
+            f,
+            "Recipe `{}` could not be run because of an IO error while launching `sh`: {}",
+            recipe, io_error
+          ),
+        }?;
+      },
+      Load { io_error, path } => {
+        write!(
+          f,
+          "Failed to read justfile at `{}`: {}",
+          path.display(),
+          io_error
+        )?;
+      },
+      NoChoosableRecipes => {
+        write!(f, "Justfile contains no choosable recipes.")?;
+      },
+      NoRecipes => {
+        write!(f, "Justfile contains no recipes.")?;
+      },
+      Search { search_error } => Display::fmt(search_error, f)?,
+      Shebang {
+        recipe,
+        command,
+        argument,
+        io_error,
+      } =>
+        if let Some(argument) = argument {
+          write!(
+            f,
+            "Recipe `{}` with shebang `#!{} {}` execution error: {}",
+            recipe, command, argument, io_error
+          )?;
+        } else {
+          write!(
+            f,
+            "Recipe `{}` with shebang `#!{}` execution error: {}",
+            recipe, command, io_error
+          )?;
+        },
+      Signal {
+        recipe,
+        line_number,
+        signal,
+      } =>
+        if let Some(n) = line_number {
+          write!(
+            f,
+            "Recipe `{}` was terminated on line {} by signal {}",
+            recipe, n, signal
+          )?;
+        } else {
+          write!(f, "Recipe `{}` was terminated by signal {}", recipe, signal)?;
+        },
+      TmpdirIo { recipe, io_error } => write!(
+        f,
+        "Recipe `{}` could not be run because of an IO error while trying to create a temporary \
+         directory or write a file to that directory`:{}",
+        recipe, io_error
+      )?,
+      Unknown {
+        recipe,
+        line_number,
+      } =>
+        if let Some(n) = line_number {
+          write!(
+            f,
+            "Recipe `{}` failed on line {} for an unknown reason",
+            recipe, n
+          )?;
+        } else {
+          write!(f, "Recipe `{}` failed for an unknown reason", recipe)?;
+        },
+      UnknownOverrides { overrides } => {
+        write!(
+          f,
+          "{} {} overridden on the command line but not present in justfile",
+          Count("Variable", overrides.len()),
+          List::and_ticked(overrides),
+        )?;
+      },
+      UnknownRecipes {
+        recipes,
+        suggestion,
+      } => {
+        write!(
+          f,
+          "Justfile does not contain {} {}.",
+          Count("recipe", recipes.len()),
+          List::or_ticked(recipes),
+        )?;
+        if let Some(suggestion) = *suggestion {
+          write!(f, "\n{}", suggestion)?;
+        }
+      },
+      Unstable { message } => {
+        write!(
+          f,
+          "{} Invoke `just` with the `--unstable` flag to enable unstable features.",
+          message
+        )?;
+      },
+      WriteJustfile { justfile, io_error } => {
+        write!(
+          f,
+          "Failed to write justfile to `{}`: {}",
+          justfile.display(),
+          io_error
+        )?;
+      },
+    }
+
+    Ok(())
   }
 }
diff --git a/src/error_result_ext.rs b/src/error_result_ext.rs
deleted file mode 100644
index 5ed62375..00000000
--- a/src/error_result_ext.rs
+++ /dev/null
@@ -1,22 +0,0 @@
-use crate::common::*;
-
-pub(crate) trait ErrorResultExt<T> {
-  fn eprint(self, color: Color) -> Result<T, i32>;
-}
-
-impl<T, E: Error> ErrorResultExt<T> for Result<T, E> {
-  fn eprint(self, color: Color) -> Result<T, i32> {
-    match self {
-      Ok(ok) => Ok(ok),
-      Err(error) => {
-        if color.stderr().active() {
-          eprintln!("{}: {:#}", color.stderr().error().paint("error"), error);
-        } else {
-          eprintln!("error: {}", error);
-        }
-
-        Err(error.code())
-      },
-    }
-  }
-}
diff --git a/src/evaluator.rs b/src/evaluator.rs
index e19a1cca..08fcfb50 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -60,7 +60,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
         {
           Ok(self.evaluate_assignment(assignment)?.to_owned())
         } else {
-          Err(RuntimeError::Internal {
+          Err(Error::Internal {
             message: format!("attempted to evaluate undefined variable `{}`", variable),
           })
         }
@@ -76,7 +76,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
 
         match thunk {
           Nullary { name, function, .. } =>
-            function(&context).map_err(|message| RuntimeError::FunctionCall {
+            function(&context).map_err(|message| Error::FunctionCall {
               function: *name,
               message,
             }),
@@ -86,7 +86,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
             arg,
             ..
           } => function(&context, &self.evaluate_expression(arg)?).map_err(|message| {
-            RuntimeError::FunctionCall {
+            Error::FunctionCall {
               function: *name,
               message,
             }
@@ -101,7 +101,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
             &self.evaluate_expression(a)?,
             &self.evaluate_expression(b)?,
           )
-          .map_err(|message| RuntimeError::FunctionCall {
+          .map_err(|message| Error::FunctionCall {
             function: *name,
             message,
           }),
@@ -116,7 +116,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
             &self.evaluate_expression(b)?,
             &self.evaluate_expression(c)?,
           )
-          .map_err(|message| RuntimeError::FunctionCall {
+          .map_err(|message| Error::FunctionCall {
             function: *name,
             message,
           }),
@@ -169,7 +169,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
     });
 
     InterruptHandler::guard(|| {
-      output(cmd).map_err(|output_error| RuntimeError::Backtick {
+      output(cmd).map_err(|output_error| Error::Backtick {
         token: *token,
         output_error,
       })
@@ -233,7 +233,7 @@ impl<'src, 'run> Evaluator<'src, 'run> {
         } else if parameter.kind == ParameterKind::Star {
           String::new()
         } else {
-          return Err(RuntimeError::Internal {
+          return Err(Error::Internal {
             message: "missing parameter without default".to_owned(),
           });
         }
@@ -285,7 +285,7 @@ mod tests {
        echo {{`f() { return 100; }; f`}}
     ",
     args: ["a"],
-    error: RuntimeError::Backtick {
+    error: Error::Backtick {
       token,
       output_error: OutputError::Code(code),
     },
@@ -305,7 +305,7 @@ mod tests {
         echo {{b}}
     "#,
     args: ["--quiet", "recipe"],
-    error: RuntimeError::Backtick {
+    error: Error::Backtick {
         token,
         output_error: OutputError::Code(_),
     },
diff --git a/src/fuzzing.rs b/src/fuzzing.rs
index b4da7d2c..54bba266 100644
--- a/src/fuzzing.rs
+++ b/src/fuzzing.rs
@@ -2,7 +2,7 @@ use crate::common::*;
 
 pub(crate) fn compile(text: &str) {
   if let Err(error) = Parser::parse(text) {
-    if let CompilationErrorKind::Internal { .. } = error.kind {
+    if let CompileErrorKind::Internal { .. } = error.kind {
       panic!("{}", error)
     }
   }
diff --git a/src/interrupt_handler.rs b/src/interrupt_handler.rs
index a0d161ea..a3f2dc7b 100644
--- a/src/interrupt_handler.rs
+++ b/src/interrupt_handler.rs
@@ -21,7 +21,7 @@ impl InterruptHandler {
     match INSTANCE.lock() {
       Ok(guard) => guard,
       Err(poison_error) => {
-        eprintln!("{}", RuntimeError::Internal {
+        eprintln!("{}", Error::Internal {
           message: format!("interrupt handler mutex poisoned: {}", poison_error),
         });
         std::process::exit(EXIT_FAILURE);
@@ -58,7 +58,7 @@ impl InterruptHandler {
   pub(crate) fn unblock(&mut self) {
     if self.blocks == 0 {
       if self.verbosity.loud() {
-        eprintln!("{}", RuntimeError::Internal {
+        eprintln!("{}", Error::Internal {
           message: "attempted to unblock interrupt handler, but handler was not blocked".to_owned(),
         });
       }
diff --git a/src/justfile.rs b/src/justfile.rs
index 709a29ec..ee6cd7b3 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -10,7 +10,7 @@ pub(crate) struct Justfile<'src> {
 }
 
 impl<'src> Justfile<'src> {
-  pub(crate) fn first(&self) -> Option<&Recipe> {
+  pub(crate) fn first(&self) -> Option<&Recipe<'src>> {
     let mut first: Option<&Recipe<Dependency>> = None;
     for recipe in self.recipes.values() {
       if let Some(first_recipe) = first {
@@ -28,7 +28,7 @@ impl<'src> Justfile<'src> {
     self.recipes.len()
   }
 
-  pub(crate) fn suggest_recipe(&self, input: &str) -> Option<Suggestion> {
+  pub(crate) fn suggest_recipe(&self, input: &str) -> Option<Suggestion<'src>> {
     let mut suggestions = self
       .recipes
       .keys()
@@ -54,7 +54,7 @@ impl<'src> Justfile<'src> {
       .next()
   }
 
-  pub(crate) fn suggest_variable(&self, input: &str) -> Option<Suggestion> {
+  pub(crate) fn suggest_variable(&self, input: &str) -> Option<Suggestion<'src>> {
     let mut suggestions = self
       .assignments
       .keys()
@@ -74,21 +74,21 @@ impl<'src> Justfile<'src> {
       .next()
   }
 
-  pub(crate) fn run<'run>(
-    &'run self,
-    config: &'run Config,
-    search: &'run Search,
-    overrides: &'run BTreeMap<String, String>,
-    arguments: &'run [String],
-  ) -> RunResult<'run, ()> {
+  pub(crate) fn run(
+    &self,
+    config: &Config,
+    search: &Search,
+    overrides: &BTreeMap<String, String>,
+    arguments: &[String],
+  ) -> RunResult<'src, ()> {
     let unknown_overrides = overrides
       .keys()
       .filter(|name| !self.assignments.contains_key(name.as_str()))
-      .map(String::as_str)
-      .collect::<Vec<&str>>();
+      .cloned()
+      .collect::<Vec<String>>();
 
     if !unknown_overrides.is_empty() {
-      return Err(RuntimeError::UnknownOverrides {
+      return Err(Error::UnknownOverrides {
         overrides: unknown_overrides,
       });
     }
@@ -107,12 +107,12 @@ impl<'src> Justfile<'src> {
         if let Some(assignment) = self.assignments.get(name) {
           scope.bind(assignment.export, assignment.name, value.clone());
         } else {
-          unknown_overrides.push(name.as_ref());
+          unknown_overrides.push(name.clone());
         }
       }
 
       if !unknown_overrides.is_empty() {
-        return Err(RuntimeError::UnknownOverrides {
+        return Err(Error::UnknownOverrides {
           overrides: unknown_overrides,
         });
       }
@@ -148,7 +148,7 @@ impl<'src> Justfile<'src> {
         command.export(&self.settings, &dotenv, &scope);
 
         let status = InterruptHandler::guard(|| command.status()).map_err(|io_error| {
-          RuntimeError::CommandInvocation {
+          Error::CommandInvoke {
             binary: binary.clone(),
             arguments: arguments.clone(),
             io_error,
@@ -156,7 +156,11 @@ impl<'src> Justfile<'src> {
         })?;
 
         if !status.success() {
-          process::exit(status.code().unwrap_or(EXIT_FAILURE));
+          return Err(Error::CommandStatus {
+            binary: binary.clone(),
+            arguments: arguments.clone(),
+            status,
+          });
         };
 
         return Ok(());
@@ -166,7 +170,7 @@ impl<'src> Justfile<'src> {
           if let Some(value) = scope.value(variable) {
             print!("{}", value);
           } else {
-            return Err(RuntimeError::EvalUnknownVariable {
+            return Err(Error::EvalUnknownVariable {
               suggestion: self.suggest_variable(&variable),
               variable:   variable.clone(),
             });
@@ -198,14 +202,14 @@ impl<'src> Justfile<'src> {
     } else if let Some(recipe) = self.first() {
       let min_arguments = recipe.min_arguments();
       if min_arguments > 0 {
-        return Err(RuntimeError::DefaultRecipeRequiresArguments {
+        return Err(Error::DefaultRecipeRequiresArguments {
           recipe: recipe.name.lexeme(),
           min_arguments,
         });
       }
       vec![recipe.name()]
     } else {
-      return Err(RuntimeError::NoRecipes);
+      return Err(Error::NoRecipes);
     };
 
     let arguments = argvec.as_slice();
@@ -222,9 +226,9 @@ impl<'src> Justfile<'src> {
           let argument_range = recipe.argument_range();
           let argument_count = cmp::min(tail.len(), recipe.max_arguments());
           if !argument_range.range_contains(&argument_count) {
-            return Err(RuntimeError::ArgumentCountMismatch {
+            return Err(Error::ArgumentCountMismatch {
               recipe:     recipe.name(),
-              parameters: recipe.parameters.iter().collect(),
+              parameters: recipe.parameters.clone(),
               found:      tail.len(),
               min:        recipe.min_arguments(),
               max:        recipe.max_arguments(),
@@ -234,7 +238,7 @@ impl<'src> Justfile<'src> {
           tail = &tail[argument_count..];
         }
       } else {
-        missing.push(*argument);
+        missing.push((*argument).to_owned());
       }
       rest = tail;
     }
@@ -245,7 +249,7 @@ impl<'src> Justfile<'src> {
       } else {
         None
       };
-      return Err(RuntimeError::UnknownRecipes {
+      return Err(Error::UnknownRecipes {
         recipes: missing,
         suggestion,
       });
@@ -266,7 +270,7 @@ impl<'src> Justfile<'src> {
     Ok(())
   }
 
-  pub(crate) fn get_alias(&self, name: &str) -> Option<&Alias> {
+  pub(crate) fn get_alias(&self, name: &str) -> Option<&Alias<'src>> {
     self.aliases.get(name)
   }
 
@@ -278,13 +282,13 @@ impl<'src> Justfile<'src> {
       .or_else(|| self.aliases.get(name).map(|alias| alias.target.as_ref()))
   }
 
-  fn run_recipe<'run>(
+  fn run_recipe(
     &self,
-    context: &'run RecipeContext<'src, 'run>,
+    context: &RecipeContext<'src, '_>,
     recipe: &Recipe<'src>,
-    arguments: &[&'run str],
+    arguments: &[&str],
     dotenv: &BTreeMap<String, String>,
-    search: &'run Search,
+    search: &Search,
     ran: &mut BTreeSet<Vec<String>>,
   ) -> RunResult<'src, ()> {
     let (outer, positional) = Evaluator::evaluate_parameters(
@@ -351,7 +355,7 @@ impl<'src> Justfile<'src> {
     Ok(())
   }
 
-  pub(crate) fn public_recipes(&self, source_order: bool) -> Vec<&Recipe<Dependency>> {
+  pub(crate) fn public_recipes(&self, source_order: bool) -> Vec<&Recipe<'src, Dependency>> {
     let mut recipes = self
       .recipes
       .values()
@@ -403,7 +407,7 @@ mod tests {
   use super::*;
 
   use testing::compile;
-  use RuntimeError::*;
+  use Error::*;
 
   run_error! {
     name: unknown_recipes,
diff --git a/src/lexer.rs b/src/lexer.rs
index 04527d38..6e57cef5 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-use CompilationErrorKind::*;
+use CompileErrorKind::*;
 use TokenKind::*;
 
 /// Just language lexer
@@ -38,7 +38,7 @@ pub(crate) struct Lexer<'src> {
 
 impl<'src> Lexer<'src> {
   /// Lex `text`
-  pub(crate) fn lex(src: &str) -> CompilationResult<Vec<Token>> {
+  pub(crate) fn lex(src: &'src str) -> CompileResult<Vec<Token<'src>>> {
     Lexer::new(src).tokenize()
   }
 
@@ -70,7 +70,7 @@ impl<'src> Lexer<'src> {
 
   /// Advance over the character in `self.next`, updating `self.token_end`
   /// accordingly.
-  fn advance(&mut self) -> CompilationResult<'src, ()> {
+  fn advance(&mut self) -> CompileResult<'src, ()> {
     match self.next {
       Some(c) => {
         let len_utf8 = c.len_utf8();
@@ -92,7 +92,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Advance over N characters.
-  fn skip(&mut self, n: usize) -> CompilationResult<'src, ()> {
+  fn skip(&mut self, n: usize) -> CompileResult<'src, ()> {
     for _ in 0..n {
       self.advance()?;
     }
@@ -110,7 +110,7 @@ impl<'src> Lexer<'src> {
     self.token_end.offset - self.token_start.offset
   }
 
-  fn accepted(&mut self, c: char) -> CompilationResult<'src, bool> {
+  fn accepted(&mut self, c: char) -> CompileResult<'src, bool> {
     if self.next_is(c) {
       self.advance()?;
       Ok(true)
@@ -119,7 +119,7 @@ impl<'src> Lexer<'src> {
     }
   }
 
-  fn presume(&mut self, c: char) -> CompilationResult<'src, ()> {
+  fn presume(&mut self, c: char) -> CompileResult<'src, ()> {
     if !self.next_is(c) {
       return Err(self.internal_error(format!("Lexer presumed character `{}`", c)));
     }
@@ -129,7 +129,7 @@ impl<'src> Lexer<'src> {
     Ok(())
   }
 
-  fn presume_str(&mut self, s: &str) -> CompilationResult<'src, ()> {
+  fn presume_str(&mut self, s: &str) -> CompileResult<'src, ()> {
     for c in s.chars() {
       self.presume(c)?;
     }
@@ -199,7 +199,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Create an internal error with `message`
-  fn internal_error(&self, message: impl Into<String>) -> CompilationError<'src> {
+  fn internal_error(&self, message: impl Into<String>) -> CompileError<'src> {
     // Use `self.token_end` as the location of the error
     let token = Token {
       src:    self.src,
@@ -209,8 +209,8 @@ impl<'src> Lexer<'src> {
       length: 0,
       kind:   Unspecified,
     };
-    CompilationError {
-      kind: CompilationErrorKind::Internal {
+    CompileError {
+      kind: CompileErrorKind::Internal {
         message: message.into(),
       },
       token,
@@ -218,7 +218,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Create a compilation error with `kind`
-  fn error(&self, kind: CompilationErrorKind<'src>) -> CompilationError<'src> {
+  fn error(&self, kind: CompileErrorKind<'src>) -> CompileError<'src> {
     // Use the in-progress token span as the location of the error.
 
     // The width of the error site to highlight depends on the kind of error:
@@ -244,11 +244,11 @@ impl<'src> Lexer<'src> {
       length,
     };
 
-    CompilationError { token, kind }
+    CompileError { token, kind }
   }
 
-  fn unterminated_interpolation_error(interpolation_start: Token<'src>) -> CompilationError<'src> {
-    CompilationError {
+  fn unterminated_interpolation_error(interpolation_start: Token<'src>) -> CompileError<'src> {
+    CompileError {
       token: interpolation_start,
       kind:  UnterminatedInterpolation,
     }
@@ -289,7 +289,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Consume the text and produce a series of tokens
-  fn tokenize(mut self) -> CompilationResult<'src, Vec<Token<'src>>> {
+  fn tokenize(mut self) -> CompileResult<'src, Vec<Token<'src>>> {
     loop {
       if self.token_start.column == 0 {
         self.lex_line_start()?;
@@ -327,7 +327,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Handle blank lines and indentation
-  fn lex_line_start(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_line_start(&mut self) -> CompileResult<'src, ()> {
     enum Indentation<'src> {
       // Line only contains whitespace
       Blank,
@@ -477,7 +477,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex token beginning with `start` outside of a recipe body
-  fn lex_normal(&mut self, start: char) -> CompilationResult<'src, ()> {
+  fn lex_normal(&mut self, start: char) -> CompileResult<'src, ()> {
     match start {
       '&' => self.lex_digraph('&', '&', AmpersandAmpersand),
       '!' => self.lex_digraph('!', '=', BangEquals),
@@ -513,7 +513,7 @@ impl<'src> Lexer<'src> {
     &mut self,
     interpolation_start: Token<'src>,
     start: char,
-  ) -> CompilationResult<'src, ()> {
+  ) -> CompileResult<'src, ()> {
     if self.rest_starts_with("}}") {
       // end current interpolation
       if self.interpolation_stack.pop().is_none() {
@@ -536,7 +536,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex token while in recipe body
-  fn lex_body(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_body(&mut self) -> CompileResult<'src, ()> {
     enum Terminator {
       Newline,
       NewlineCarriageReturn,
@@ -599,14 +599,14 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex a single-character token
-  fn lex_single(&mut self, kind: TokenKind) -> CompilationResult<'src, ()> {
+  fn lex_single(&mut self, kind: TokenKind) -> CompileResult<'src, ()> {
     self.advance()?;
     self.token(kind);
     Ok(())
   }
 
   /// Lex a double-character token
-  fn lex_double(&mut self, kind: TokenKind) -> CompilationResult<'src, ()> {
+  fn lex_double(&mut self, kind: TokenKind) -> CompileResult<'src, ()> {
     self.advance()?;
     self.advance()?;
     self.token(kind);
@@ -621,7 +621,7 @@ impl<'src> Lexer<'src> {
     second: char,
     then: TokenKind,
     otherwise: TokenKind,
-  ) -> CompilationResult<'src, ()> {
+  ) -> CompileResult<'src, ()> {
     self.advance()?;
 
     if self.accepted(second)? {
@@ -634,7 +634,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex an opening or closing delimiter
-  fn lex_delimiter(&mut self, kind: TokenKind) -> CompilationResult<'src, ()> {
+  fn lex_delimiter(&mut self, kind: TokenKind) -> CompileResult<'src, ()> {
     use Delimiter::*;
 
     match kind {
@@ -663,7 +663,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Pop a delimiter from the open delimiter stack and error if incorrect type
-  fn close_delimiter(&mut self, close: Delimiter) -> CompilationResult<'src, ()> {
+  fn close_delimiter(&mut self, close: Delimiter) -> CompileResult<'src, ()> {
     match self.open_delimiters.pop() {
       Some((open, _)) if open == close => Ok(()),
       Some((open, open_line)) => Err(self.error(MismatchedClosingDelimiter {
@@ -681,12 +681,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex a two-character digraph
-  fn lex_digraph(
-    &mut self,
-    left: char,
-    right: char,
-    token: TokenKind,
-  ) -> CompilationResult<'src, ()> {
+  fn lex_digraph(&mut self, left: char, right: char, token: TokenKind) -> CompileResult<'src, ()> {
     self.presume(left)?;
 
     if self.accepted(right)? {
@@ -708,7 +703,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex a token starting with ':'
-  fn lex_colon(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_colon(&mut self) -> CompileResult<'src, ()> {
     self.presume(':')?;
 
     if self.accepted('=')? {
@@ -722,7 +717,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex a carriage return and line feed
-  fn lex_eol(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_eol(&mut self) -> CompileResult<'src, ()> {
     if self.accepted('\r')? {
       if !self.accepted('\n')? {
         return Err(self.error(UnpairedCarriageReturn));
@@ -743,7 +738,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex name: [a-zA-Z_][a-zA-Z0-9_]*
-  fn lex_identifier(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_identifier(&mut self) -> CompileResult<'src, ()> {
     self.advance()?;
 
     while let Some(c) = self.next {
@@ -760,7 +755,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex comment: #[^\r\n]
-  fn lex_comment(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_comment(&mut self) -> CompileResult<'src, ()> {
     self.presume('#')?;
 
     while !self.at_eol_or_eof() {
@@ -773,7 +768,7 @@ impl<'src> Lexer<'src> {
   }
 
   /// Lex whitespace: [ \t]+
-  fn lex_whitespace(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_whitespace(&mut self) -> CompileResult<'src, ()> {
     while self.next_is_whitespace() {
       self.advance()?;
     }
@@ -788,7 +783,7 @@ impl<'src> Lexer<'src> {
   /// Backtick:      `[^`]*`
   /// Cooked string: "[^"]*" # also processes escape sequences
   /// Raw string:    '[^']*'
-  fn lex_string(&mut self) -> CompilationResult<'src, ()> {
+  fn lex_string(&mut self) -> CompileResult<'src, ()> {
     let kind = if let Some(kind) = StringKind::from_token_start(self.rest()) {
       kind
     } else {
@@ -980,12 +975,12 @@ mod tests {
     line: usize,
     column: usize,
     length: usize,
-    kind: CompilationErrorKind,
+    kind: CompileErrorKind,
   ) {
     match Lexer::lex(src) {
       Ok(_) => panic!("Lexing succeeded but expected"),
       Err(have) => {
-        let want = CompilationError {
+        let want = CompileError {
           token: Token {
             kind: have.token.kind,
             src,
@@ -2285,9 +2280,10 @@ mod tests {
 
   #[test]
   fn presume_error() {
+    let compile_error = Lexer::new("!").presume('-').unwrap_err();
     assert_matches!(
-      Lexer::new("!").presume('-').unwrap_err(),
-      CompilationError {
+      compile_error,
+      CompileError {
         token: Token {
           offset: 0,
           line:   0,
@@ -2297,22 +2293,22 @@ mod tests {
           kind:   Unspecified,
         },
         kind:  Internal {
-          message,
+          ref message,
         },
       } if message == "Lexer presumed character `-`"
     );
 
+    let mut cursor = Cursor::new(Vec::new());
+
+    Error::Compile { compile_error }
+      .write(&mut cursor, Color::never())
+      .unwrap();
+
     assert_eq!(
-      Lexer::new("!").presume('-').unwrap_err().to_string(),
-      unindent(
-        "
-        Internal error, this may indicate a bug in just: Lexer presumed character `-`
-        \
-         consider filing an issue: https://github.com/casey/just/issues/new
-          |
-        1 | !
-          | ^"
-      ),
+      str::from_utf8(&cursor.into_inner()).unwrap(),
+      "error: Internal error, this may indicate a bug in just: \
+      Lexer presumed character `-`\nconsider filing an issue: \
+      https://github.com/casey/just/issues/new\n  |\n1 | !\n  | ^\n"
     );
   }
 }
diff --git a/src/lib.rs b/src/lib.rs
index b6bed73c..8af492b5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,6 +20,7 @@
   clippy::missing_docs_in_private_items,
   clippy::missing_errors_doc,
   clippy::missing_inline_in_public_items,
+  clippy::needless_lifetimes,
   clippy::needless_pass_by_value,
   clippy::non_ascii_literal,
   clippy::option_if_let_else,
@@ -66,8 +67,8 @@ mod binding;
 mod color;
 mod command_ext;
 mod common;
-mod compilation_error;
-mod compilation_error_kind;
+mod compile_error;
+mod compile_error_kind;
 mod compiler;
 mod config;
 mod config_error;
@@ -76,7 +77,6 @@ mod delimiter;
 mod dependency;
 mod enclosure;
 mod error;
-mod error_result_ext;
 mod evaluator;
 mod expression;
 mod fragment;
@@ -92,7 +92,7 @@ mod lexer;
 mod line;
 mod list;
 mod load_dotenv;
-mod load_error;
+mod loader;
 mod name;
 mod ordinal;
 mod output;
@@ -109,7 +109,6 @@ mod recipe;
 mod recipe_context;
 mod recipe_resolver;
 mod run;
-mod runtime_error;
 mod scope;
 mod search;
 mod search_config;
diff --git a/src/load_error.rs b/src/load_error.rs
deleted file mode 100644
index 2a4bedaa..00000000
--- a/src/load_error.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-use crate::common::*;
-
-pub(crate) struct LoadError<'path> {
-  pub(crate) path:     &'path Path,
-  pub(crate) io_error: io::Error,
-}
-
-impl Error for LoadError<'_> {}
-
-impl Display for LoadError<'_> {
-  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-    write!(
-      f,
-      "Failed to read justfile at `{}`: {}",
-      self.path.display(),
-      self.io_error
-    )
-  }
-}
diff --git a/src/loader.rs b/src/loader.rs
new file mode 100644
index 00000000..37bd1c89
--- /dev/null
+++ b/src/loader.rs
@@ -0,0 +1,21 @@
+use crate::common::*;
+
+pub(crate) struct Loader {
+  arena: Arena<String>,
+}
+
+impl Loader {
+  pub(crate) fn new() -> Self {
+    Loader {
+      arena: Arena::new(),
+    }
+  }
+
+  pub(crate) fn load<'src>(&'src self, path: &Path) -> RunResult<&'src str> {
+    let src = fs::read_to_string(path).map_err(|io_error| Error::Load {
+      path: path.to_owned(),
+      io_error,
+    })?;
+    Ok(self.arena.alloc(src))
+  }
+}
diff --git a/src/name.rs b/src/name.rs
index f1030b44..935c1324 100644
--- a/src/name.rs
+++ b/src/name.rs
@@ -40,7 +40,7 @@ impl<'src> Name<'src> {
     }
   }
 
-  pub(crate) fn error(&self, kind: CompilationErrorKind<'src>) -> CompilationError<'src> {
+  pub(crate) fn error(&self, kind: CompileErrorKind<'src>) -> CompileError<'src> {
     self.token().error(kind)
   }
 }
diff --git a/src/parser.rs b/src/parser.rs
index ac3264bd..16446ece 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -36,7 +36,7 @@ pub(crate) struct Parser<'tokens, 'src> {
 
 impl<'tokens, 'src> Parser<'tokens, 'src> {
   /// Parse `tokens` into an `Ast`
-  pub(crate) fn parse(tokens: &'tokens [Token<'src>]) -> CompilationResult<'src, Ast<'src>> {
+  pub(crate) fn parse(tokens: &'tokens [Token<'src>]) -> CompileResult<'src, Ast<'src>> {
     Self::new(tokens).parse_ast()
   }
 
@@ -49,27 +49,21 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     }
   }
 
-  fn error(
-    &self,
-    kind: CompilationErrorKind<'src>,
-  ) -> CompilationResult<'src, CompilationError<'src>> {
+  fn error(&self, kind: CompileErrorKind<'src>) -> CompileResult<'src, CompileError<'src>> {
     Ok(self.next()?.error(kind))
   }
 
   /// Construct an unexpected token error with the token returned by
   /// `Parser::next`
-  fn unexpected_token(&self) -> CompilationResult<'src, CompilationError<'src>> {
-    self.error(CompilationErrorKind::UnexpectedToken {
+  fn unexpected_token(&self) -> CompileResult<'src, CompileError<'src>> {
+    self.error(CompileErrorKind::UnexpectedToken {
       expected: self.expected.iter().cloned().collect::<Vec<TokenKind>>(),
       found:    self.next()?.kind,
     })
   }
 
-  fn internal_error(
-    &self,
-    message: impl Into<String>,
-  ) -> CompilationResult<'src, CompilationError<'src>> {
-    self.error(CompilationErrorKind::Internal {
+  fn internal_error(&self, message: impl Into<String>) -> CompileResult<'src, CompileError<'src>> {
+    self.error(CompileErrorKind::Internal {
       message: message.into(),
     })
   }
@@ -83,7 +77,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// The next significant token
-  fn next(&self) -> CompilationResult<'src, Token<'src>> {
+  fn next(&self) -> CompileResult<'src, Token<'src>> {
     if let Some(token) = self.rest().next() {
       Ok(token)
     } else {
@@ -118,7 +112,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Get the `n`th next significant token
-  fn get(&self, n: usize) -> CompilationResult<'src, Token<'src>> {
+  fn get(&self, n: usize) -> CompileResult<'src, Token<'src>> {
     match self.rest().nth(n) {
       Some(token) => Ok(token),
       None => Err(self.internal_error("`Parser::get()` advanced past end of token stream")?),
@@ -126,7 +120,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Advance past one significant token, clearing the expected token set.
-  fn advance(&mut self) -> CompilationResult<'src, Token<'src>> {
+  fn advance(&mut self) -> CompileResult<'src, Token<'src>> {
     self.expected.clear();
 
     for skipped in &self.tokens[self.next..] {
@@ -142,7 +136,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Return the next token if it is of kind `expected`, otherwise, return an
   /// unexpected token error
-  fn expect(&mut self, expected: TokenKind) -> CompilationResult<'src, Token<'src>> {
+  fn expect(&mut self, expected: TokenKind) -> CompileResult<'src, Token<'src>> {
     if let Some(token) = self.accept(expected)? {
       Ok(token)
     } else {
@@ -151,7 +145,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Return an unexpected token error if the next token is not an EOL
-  fn expect_eol(&mut self) -> CompilationResult<'src, ()> {
+  fn expect_eol(&mut self) -> CompileResult<'src, ()> {
     self.accept(Comment)?;
 
     if self.next_is(Eof) {
@@ -161,14 +155,14 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     self.expect(Eol).map(|_| ())
   }
 
-  fn expect_keyword(&mut self, expected: Keyword) -> CompilationResult<'src, ()> {
+  fn expect_keyword(&mut self, expected: Keyword) -> CompileResult<'src, ()> {
     let identifier = self.expect(Identifier)?;
     let found = identifier.lexeme();
 
     if expected == found {
       Ok(())
     } else {
-      Err(identifier.error(CompilationErrorKind::ExpectedKeyword {
+      Err(identifier.error(CompileErrorKind::ExpectedKeyword {
         expected: vec![expected],
         found,
       }))
@@ -177,7 +171,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Return an internal error if the next token is not of kind `Identifier`
   /// with lexeme `lexeme`.
-  fn presume_keyword(&mut self, keyword: Keyword) -> CompilationResult<'src, ()> {
+  fn presume_keyword(&mut self, keyword: Keyword) -> CompileResult<'src, ()> {
     let next = self.advance()?;
 
     if next.kind != Identifier {
@@ -197,7 +191,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Return an internal error if the next token is not of kind `kind`.
-  fn presume(&mut self, kind: TokenKind) -> CompilationResult<'src, Token<'src>> {
+  fn presume(&mut self, kind: TokenKind) -> CompileResult<'src, Token<'src>> {
     let next = self.advance()?;
 
     if next.kind != kind {
@@ -211,7 +205,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Return an internal error if the next token is not one of kinds `kinds`.
-  fn presume_any(&mut self, kinds: &[TokenKind]) -> CompilationResult<'src, Token<'src>> {
+  fn presume_any(&mut self, kinds: &[TokenKind]) -> CompileResult<'src, Token<'src>> {
     let next = self.advance()?;
     if !kinds.contains(&next.kind) {
       Err(self.internal_error(format!(
@@ -225,7 +219,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Accept and return a token of kind `kind`
-  fn accept(&mut self, kind: TokenKind) -> CompilationResult<'src, Option<Token<'src>>> {
+  fn accept(&mut self, kind: TokenKind) -> CompileResult<'src, Option<Token<'src>>> {
     if self.next_is(kind) {
       Ok(Some(self.advance()?))
     } else {
@@ -234,9 +228,9 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Return an error if the next token is of kind `forbidden`
-  fn forbid<F>(&self, forbidden: TokenKind, error: F) -> CompilationResult<'src, ()>
+  fn forbid<F>(&self, forbidden: TokenKind, error: F) -> CompileResult<'src, ()>
   where
-    F: FnOnce(Token) -> CompilationError,
+    F: FnOnce(Token) -> CompileError,
   {
     let next = self.next()?;
 
@@ -248,7 +242,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Accept a token of kind `Identifier` and parse into a `Name`
-  fn accept_name(&mut self) -> CompilationResult<'src, Option<Name<'src>>> {
+  fn accept_name(&mut self) -> CompileResult<'src, Option<Name<'src>>> {
     if self.next_is(Identifier) {
       Ok(Some(self.parse_name()?))
     } else {
@@ -256,7 +250,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     }
   }
 
-  fn accepted_keyword(&mut self, keyword: Keyword) -> CompilationResult<'src, bool> {
+  fn accepted_keyword(&mut self, keyword: Keyword) -> CompileResult<'src, bool> {
     let next = self.next()?;
 
     if next.kind == Identifier && next.lexeme() == keyword.lexeme() {
@@ -268,7 +262,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Accept a dependency
-  fn accept_dependency(&mut self) -> CompilationResult<'src, Option<UnresolvedDependency<'src>>> {
+  fn accept_dependency(&mut self) -> CompileResult<'src, Option<UnresolvedDependency<'src>>> {
     if let Some(recipe) = self.accept_name()? {
       Ok(Some(UnresolvedDependency {
         arguments: Vec::new(),
@@ -290,12 +284,12 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Accept and return `true` if next token is of kind `kind`
-  fn accepted(&mut self, kind: TokenKind) -> CompilationResult<'src, bool> {
+  fn accepted(&mut self, kind: TokenKind) -> CompileResult<'src, bool> {
     Ok(self.accept(kind)?.is_some())
   }
 
   /// Parse a justfile, consumes self
-  fn parse_ast(mut self) -> CompilationResult<'src, Ast<'src>> {
+  fn parse_ast(mut self) -> CompileResult<'src, Ast<'src>> {
     fn pop_doc_comment<'src>(
       items: &mut Vec<Item<'src>>,
       eol_since_last_comment: bool,
@@ -330,7 +324,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
         match Keyword::from_lexeme(next.lexeme()) {
           Some(Keyword::Alias) =>
             if self.next_are(&[Identifier, Identifier, Equals]) {
-              return Err(self.get(2)?.error(CompilationErrorKind::DeprecatedEquals));
+              return Err(self.get(2)?.error(CompileErrorKind::DeprecatedEquals));
             } else if self.next_are(&[Identifier, Identifier, ColonEquals]) {
               items.push(Item::Alias(self.parse_alias()?));
             } else {
@@ -339,7 +333,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
             },
           Some(Keyword::Export) =>
             if self.next_are(&[Identifier, Identifier, Equals]) {
-              return Err(self.get(2)?.error(CompilationErrorKind::DeprecatedEquals));
+              return Err(self.get(2)?.error(CompileErrorKind::DeprecatedEquals));
             } else if self.next_are(&[Identifier, Identifier, ColonEquals]) {
               self.presume_keyword(Keyword::Export)?;
               items.push(Item::Assignment(self.parse_assignment(true)?));
@@ -359,7 +353,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
             },
           _ =>
             if self.next_are(&[Identifier, Equals]) {
-              return Err(self.get(1)?.error(CompilationErrorKind::DeprecatedEquals));
+              return Err(self.get(1)?.error(CompileErrorKind::DeprecatedEquals));
             } else if self.next_are(&[Identifier, ColonEquals]) {
               items.push(Item::Assignment(self.parse_assignment(false)?));
             } else {
@@ -389,7 +383,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse an alias, e.g `alias name := target`
-  fn parse_alias(&mut self) -> CompilationResult<'src, Alias<'src, Name<'src>>> {
+  fn parse_alias(&mut self) -> CompileResult<'src, Alias<'src, Name<'src>>> {
     self.presume_keyword(Keyword::Alias)?;
     let name = self.parse_name()?;
     self.presume_any(&[Equals, ColonEquals])?;
@@ -399,7 +393,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse an assignment, e.g. `foo := bar`
-  fn parse_assignment(&mut self, export: bool) -> CompilationResult<'src, Assignment<'src>> {
+  fn parse_assignment(&mut self, export: bool) -> CompileResult<'src, Assignment<'src>> {
     let name = self.parse_name()?;
     self.presume_any(&[Equals, ColonEquals])?;
     let value = self.parse_expression()?;
@@ -412,7 +406,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse an expression, e.g. `1 + 2`
-  fn parse_expression(&mut self) -> CompilationResult<'src, Expression<'src>> {
+  fn parse_expression(&mut self) -> CompileResult<'src, Expression<'src>> {
     if self.accepted_keyword(Keyword::If)? {
       self.parse_conditional()
     } else {
@@ -429,7 +423,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse a conditional, e.g. `if a == b { "foo" } else { "bar" }`
-  fn parse_conditional(&mut self) -> CompilationResult<'src, Expression<'src>> {
+  fn parse_conditional(&mut self) -> CompileResult<'src, Expression<'src>> {
     let lhs = self.parse_expression()?;
 
     let inverted = self.accepted(BangEquals)?;
@@ -467,7 +461,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse a value, e.g. `(bar)`
-  fn parse_value(&mut self) -> CompilationResult<'src, Expression<'src>> {
+  fn parse_value(&mut self) -> CompileResult<'src, Expression<'src>> {
     if self.next_is(StringToken) {
       Ok(Expression::StringLiteral {
         string_literal: self.parse_string_literal()?,
@@ -485,7 +479,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
       };
 
       if contents.starts_with("#!") {
-        return Err(next.error(CompilationErrorKind::BacktickShebang));
+        return Err(next.error(CompileErrorKind::BacktickShebang));
       }
 
       Ok(Expression::Backtick { contents, token })
@@ -511,7 +505,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse a string literal, e.g. `"FOO"`
-  fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> {
+  fn parse_string_literal(&mut self) -> CompileResult<'src, StringLiteral<'src>> {
     let token = self.expect(StringToken)?;
 
     let kind = StringKind::from_string_or_backtick(token)?;
@@ -540,7 +534,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
             '"' => cooked.push('"'),
             other => {
               return Err(
-                token.error(CompilationErrorKind::InvalidEscapeSequence { character: other }),
+                token.error(CompileErrorKind::InvalidEscapeSequence { character: other }),
               );
             },
           }
@@ -560,12 +554,12 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse a name from an identifier token
-  fn parse_name(&mut self) -> CompilationResult<'src, Name<'src>> {
+  fn parse_name(&mut self) -> CompileResult<'src, Name<'src>> {
     self.expect(Identifier).map(Name::from_identifier)
   }
 
   /// Parse sequence of comma-separated expressions
-  fn parse_sequence(&mut self) -> CompilationResult<'src, Vec<Expression<'src>>> {
+  fn parse_sequence(&mut self) -> CompileResult<'src, Vec<Expression<'src>>> {
     self.presume(ParenL)?;
 
     let mut elements = Vec::new();
@@ -588,7 +582,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     &mut self,
     doc: Option<&'src str>,
     quiet: bool,
-  ) -> CompilationResult<'src, UnresolvedRecipe<'src>> {
+  ) -> CompileResult<'src, UnresolvedRecipe<'src>> {
     let name = self.parse_name()?;
 
     let mut positional = Vec::new();
@@ -609,7 +603,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
       let variadic = self.parse_parameter(kind)?;
 
       self.forbid(Identifier, |token| {
-        token.error(CompilationErrorKind::ParameterFollowsVariadicParameter {
+        token.error(CompileErrorKind::ParameterFollowsVariadicParameter {
           parameter: token.lexeme(),
         })
       })?;
@@ -661,7 +655,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse a recipe parameter
-  fn parse_parameter(&mut self, kind: ParameterKind) -> CompilationResult<'src, Parameter<'src>> {
+  fn parse_parameter(&mut self, kind: ParameterKind) -> CompileResult<'src, Parameter<'src>> {
     let export = self.accepted(Dollar)?;
 
     let name = self.parse_name()?;
@@ -681,7 +675,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse the body of a recipe
-  fn parse_body(&mut self) -> CompilationResult<'src, Vec<Line<'src>>> {
+  fn parse_body(&mut self) -> CompileResult<'src, Vec<Line<'src>>> {
     let mut lines = Vec::new();
 
     if self.accepted(Indent)? {
@@ -721,7 +715,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse a boolean setting value
-  fn parse_set_bool(&mut self) -> CompilationResult<'src, bool> {
+  fn parse_set_bool(&mut self) -> CompileResult<'src, bool> {
     if !self.accepted(ColonEquals)? {
       return Ok(true);
     }
@@ -733,7 +727,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     } else if Keyword::False == identifier.lexeme() {
       false
     } else {
-      return Err(identifier.error(CompilationErrorKind::ExpectedKeyword {
+      return Err(identifier.error(CompileErrorKind::ExpectedKeyword {
         expected: vec![Keyword::True, Keyword::False],
         found:    identifier.lexeme(),
       }));
@@ -743,7 +737,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
   }
 
   /// Parse a setting
-  fn parse_set(&mut self) -> CompilationResult<'src, Set<'src>> {
+  fn parse_set(&mut self) -> CompileResult<'src, Set<'src>> {
     self.presume_keyword(Keyword::Set)?;
     let name = Name::from_identifier(self.presume(Identifier)?);
     let lexeme = name.lexeme();
@@ -794,7 +788,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
         name,
       })
     } else {
-      Err(name.error(CompilationErrorKind::UnknownSetting {
+      Err(name.error(CompileErrorKind::UnknownSetting {
         setting: name.lexeme(),
       }))
     }
@@ -806,7 +800,7 @@ mod tests {
   use super::*;
 
   use pretty_assertions::assert_eq;
-  use CompilationErrorKind::*;
+  use CompileErrorKind::*;
 
   macro_rules! test {
     {
@@ -860,14 +854,14 @@ mod tests {
     line: usize,
     column: usize,
     length: usize,
-    kind: CompilationErrorKind,
+    kind: CompileErrorKind,
   ) {
     let tokens = Lexer::lex(src).expect("Lexing failed in parse test...");
 
     match Parser::parse(&tokens) {
       Ok(_) => panic!("Parsing unexpectedly succeeded"),
       Err(have) => {
-        let want = CompilationError {
+        let want = CompileError {
           token: Token {
             kind: have.token.kind,
             src,
diff --git a/src/recipe.rs b/src/recipe.rs
index bc3832a8..184f7113 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -2,20 +2,16 @@ use crate::common::*;
 
 use std::process::{ExitStatus, Stdio};
 
-/// Return a `RuntimeError::Signal` if the process was terminated by a signal,
-/// otherwise return an `RuntimeError::UnknownFailure`
-fn error_from_signal(
-  recipe: &str,
-  line_number: Option<usize>,
-  exit_status: ExitStatus,
-) -> RuntimeError {
+/// Return a `Error::Signal` if the process was terminated by a signal,
+/// otherwise return an `Error::UnknownFailure`
+fn error_from_signal(recipe: &str, line_number: Option<usize>, exit_status: ExitStatus) -> Error {
   match Platform::signal_from_exit_status(exit_status) {
-    Some(signal) => RuntimeError::Signal {
+    Some(signal) => Error::Signal {
       recipe,
       line_number,
       signal,
     },
-    None => RuntimeError::Unknown {
+    None => Error::Unknown {
       recipe,
       line_number,
     },
@@ -108,20 +104,18 @@ impl<'src, D> Recipe<'src, D> {
         return Ok(());
       }
 
-      let shebang_line = evaluated_lines
-        .first()
-        .ok_or_else(|| RuntimeError::Internal {
-          message: "evaluated_lines was empty".to_owned(),
-        })?;
+      let shebang_line = evaluated_lines.first().ok_or_else(|| Error::Internal {
+        message: "evaluated_lines was empty".to_owned(),
+      })?;
 
-      let shebang = Shebang::new(shebang_line).ok_or_else(|| RuntimeError::Internal {
+      let shebang = Shebang::new(shebang_line).ok_or_else(|| Error::Internal {
         message: format!("bad shebang line: {}", shebang_line),
       })?;
 
       let tmp = tempfile::Builder::new()
         .prefix("just")
         .tempdir()
-        .map_err(|error| RuntimeError::TmpdirIoError {
+        .map_err(|error| Error::TmpdirIo {
           recipe:   self.name(),
           io_error: error,
         })?;
@@ -130,7 +124,7 @@ impl<'src, D> Recipe<'src, D> {
       path.push(shebang.script_filename(self.name()));
 
       {
-        let mut f = fs::File::create(&path).map_err(|error| RuntimeError::TmpdirIoError {
+        let mut f = fs::File::create(&path).map_err(|error| Error::TmpdirIo {
           recipe:   self.name(),
           io_error: error,
         })?;
@@ -158,14 +152,14 @@ impl<'src, D> Recipe<'src, D> {
         }
 
         f.write_all(text.as_bytes())
-          .map_err(|error| RuntimeError::TmpdirIoError {
+          .map_err(|error| Error::TmpdirIo {
             recipe:   self.name(),
             io_error: error,
           })?;
       }
 
       // make the script executable
-      Platform::set_execute_permission(&path).map_err(|error| RuntimeError::TmpdirIoError {
+      Platform::set_execute_permission(&path).map_err(|error| Error::TmpdirIo {
         recipe:   self.name(),
         io_error: error,
       })?;
@@ -173,7 +167,7 @@ impl<'src, D> Recipe<'src, D> {
       // create a command to run the script
       let mut command =
         Platform::make_shebang_command(&path, &context.search.working_directory, shebang).map_err(
-          |output_error| RuntimeError::Cygpath {
+          |output_error| Error::Cygpath {
             recipe: self.name(),
             output_error,
           },
@@ -190,7 +184,7 @@ impl<'src, D> Recipe<'src, D> {
         Ok(exit_status) =>
           if let Some(code) = exit_status.code() {
             if code != 0 {
-              return Err(RuntimeError::Code {
+              return Err(Error::Code {
                 recipe: self.name(),
                 line_number: None,
                 code,
@@ -200,7 +194,7 @@ impl<'src, D> Recipe<'src, D> {
             return Err(error_from_signal(self.name(), None, exit_status));
           },
         Err(io_error) => {
-          return Err(RuntimeError::Shebang {
+          return Err(Error::Shebang {
             recipe: self.name(),
             command: shebang.interpreter.to_owned(),
             argument: shebang.argument.map(String::from),
@@ -283,7 +277,7 @@ impl<'src, D> Recipe<'src, D> {
           Ok(exit_status) =>
             if let Some(code) = exit_status.code() {
               if code != 0 && !infallable_command {
-                return Err(RuntimeError::Code {
+                return Err(Error::Code {
                   recipe: self.name(),
                   line_number: Some(line_number),
                   code,
@@ -297,7 +291,7 @@ impl<'src, D> Recipe<'src, D> {
               ));
             },
           Err(io_error) => {
-            return Err(RuntimeError::IoError {
+            return Err(Error::Io {
               recipe: self.name(),
               io_error,
             });
diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs
index 0c5e6cb9..bb8215b1 100644
--- a/src/recipe_resolver.rs
+++ b/src/recipe_resolver.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-use CompilationErrorKind::*;
+use CompileErrorKind::*;
 
 pub(crate) struct RecipeResolver<'src: 'run, 'run> {
   unresolved_recipes: Table<'src, UnresolvedRecipe<'src>>,
@@ -12,7 +12,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
   pub(crate) fn resolve_recipes(
     unresolved_recipes: Table<'src, UnresolvedRecipe<'src>>,
     assignments: &Table<'src, Assignment<'src>>,
-  ) -> CompilationResult<'src, Table<'src, Rc<Recipe<'src>>>> {
+  ) -> CompileResult<'src, Table<'src, Rc<Recipe<'src>>>> {
     let mut resolver = RecipeResolver {
       resolved_recipes: Table::new(),
       unresolved_recipes,
@@ -58,7 +58,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
     &self,
     variable: &Token<'src>,
     parameters: &[Parameter],
-  ) -> CompilationResult<'src, ()> {
+  ) -> CompileResult<'src, ()> {
     let name = variable.lexeme();
     let undefined =
       !self.assignments.contains_key(name) && !parameters.iter().any(|p| p.name.lexeme() == name);
@@ -74,7 +74,7 @@ impl<'src: 'run, 'run> RecipeResolver<'src, 'run> {
     &mut self,
     stack: &mut Vec<&'src str>,
     recipe: UnresolvedRecipe<'src>,
-  ) -> CompilationResult<'src, Rc<Recipe<'src>>> {
+  ) -> CompileResult<'src, Rc<Recipe<'src>>> {
     if let Some(resolved) = self.resolved_recipes.get(recipe.name()) {
       return Ok(Rc::clone(resolved));
     }
diff --git a/src/run.rs b/src/run.rs
index 84e787b5..f9162b0e 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -16,7 +16,22 @@ pub fn run() -> Result<(), i32> {
   info!("Parsing command line arguments…");
   let matches = app.get_matches();
 
-  let config = Config::from_matches(&matches).eprint(Color::auto())?;
+  let loader = Loader::new();
 
-  config.run_subcommand()
+  let mut color = Color::auto();
+  let mut verbosity = Verbosity::default();
+
+  Config::from_matches(&matches)
+    .map_err(Error::from)
+    .and_then(|config| {
+      color = config.color;
+      verbosity = config.verbosity;
+      config.run_subcommand(&loader)
+    })
+    .map_err(|error| {
+      if !verbosity.quiet() {
+        error.write(&mut io::stderr(), color).ok();
+      }
+      error.code().unwrap_or(EXIT_FAILURE)
+    })
 }
diff --git a/src/runtime_error.rs b/src/runtime_error.rs
deleted file mode 100644
index 7aab4c85..00000000
--- a/src/runtime_error.rs
+++ /dev/null
@@ -1,437 +0,0 @@
-use crate::common::*;
-
-#[derive(Debug)]
-pub(crate) enum RuntimeError<'src> {
-  ArgumentCountMismatch {
-    recipe:     &'src str,
-    parameters: Vec<&'src Parameter<'src>>,
-    found:      usize,
-    min:        usize,
-    max:        usize,
-  },
-  Backtick {
-    token:        Token<'src>,
-    output_error: OutputError,
-  },
-  Code {
-    recipe:      &'src str,
-    line_number: Option<usize>,
-    code:        i32,
-  },
-  CommandInvocation {
-    binary:    OsString,
-    arguments: Vec<OsString>,
-    io_error:  io::Error,
-  },
-  Cygpath {
-    recipe:       &'src str,
-    output_error: OutputError,
-  },
-  Dotenv {
-    dotenv_error: dotenv::Error,
-  },
-  EvalUnknownVariable {
-    variable:   String,
-    suggestion: Option<Suggestion<'src>>,
-  },
-  FunctionCall {
-    function: Name<'src>,
-    message:  String,
-  },
-  Internal {
-    message: String,
-  },
-  IoError {
-    recipe:   &'src str,
-    io_error: io::Error,
-  },
-  Shebang {
-    recipe:   &'src str,
-    command:  String,
-    argument: Option<String>,
-    io_error: io::Error,
-  },
-  Signal {
-    recipe:      &'src str,
-    line_number: Option<usize>,
-    signal:      i32,
-  },
-  TmpdirIoError {
-    recipe:   &'src str,
-    io_error: io::Error,
-  },
-  UnknownOverrides {
-    overrides: Vec<&'src str>,
-  },
-  UnknownRecipes {
-    recipes:    Vec<&'src str>,
-    suggestion: Option<Suggestion<'src>>,
-  },
-  Unknown {
-    recipe:      &'src str,
-    line_number: Option<usize>,
-  },
-  NoRecipes,
-  DefaultRecipeRequiresArguments {
-    recipe:        &'src str,
-    min_arguments: usize,
-  },
-}
-
-impl<'src> Error for RuntimeError<'src> {
-  fn code(&self) -> i32 {
-    match *self {
-      Self::Code { code, .. }
-      | Self::Backtick {
-        output_error: OutputError::Code(code),
-        ..
-      } => code,
-      _ => EXIT_FAILURE,
-    }
-  }
-}
-
-impl<'src> RuntimeError<'src> {
-  fn context(&self) -> Option<Token> {
-    use RuntimeError::*;
-    match self {
-      FunctionCall { function, .. } => Some(function.token()),
-      Backtick { token, .. } => Some(*token),
-      _ => None,
-    }
-  }
-}
-
-impl<'src> Display for RuntimeError<'src> {
-  fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
-    use RuntimeError::*;
-
-    let color = if f.alternate() {
-      Color::always()
-    } else {
-      Color::never()
-    };
-    let message = color.message();
-    write!(f, "{}", message.prefix())?;
-
-    match self {
-      EvalUnknownVariable {
-        variable,
-        suggestion,
-      } => {
-        write!(f, "Justfile does not contain variable `{}`.", variable,)?;
-        if let Some(suggestion) = *suggestion {
-          write!(f, "\n{}", suggestion)?;
-        }
-      },
-      UnknownRecipes {
-        recipes,
-        suggestion,
-      } => {
-        write!(
-          f,
-          "Justfile does not contain {} {}.",
-          Count("recipe", recipes.len()),
-          List::or_ticked(recipes),
-        )?;
-        if let Some(suggestion) = *suggestion {
-          write!(f, "\n{}", suggestion)?;
-        }
-      },
-      UnknownOverrides { overrides } => {
-        write!(
-          f,
-          "{} {} overridden on the command line but not present in justfile",
-          Count("Variable", overrides.len()),
-          List::and_ticked(overrides),
-        )?;
-      },
-      ArgumentCountMismatch {
-        recipe,
-        parameters,
-        found,
-        min,
-        max,
-      } => {
-        if min == max {
-          let expected = min;
-          write!(
-            f,
-            "Recipe `{}` got {} {} but {}takes {}",
-            recipe,
-            found,
-            Count("argument", *found),
-            if expected < found { "only " } else { "" },
-            expected
-          )?;
-        } else if found < min {
-          write!(
-            f,
-            "Recipe `{}` got {} {} but takes at least {}",
-            recipe,
-            found,
-            Count("argument", *found),
-            min
-          )?;
-        } else if found > max {
-          write!(
-            f,
-            "Recipe `{}` got {} {} but takes at most {}",
-            recipe,
-            found,
-            Count("argument", *found),
-            max
-          )?;
-        }
-        write!(f, "\nusage:\n    just {}", recipe)?;
-        for param in parameters {
-          if color.stderr().active() {
-            write!(f, " {:#}", param)?;
-          } else {
-            write!(f, " {}", param)?;
-          }
-        }
-      },
-      Code {
-        recipe,
-        line_number,
-        code,
-      } =>
-        if let Some(n) = line_number {
-          write!(
-            f,
-            "Recipe `{}` failed on line {} with exit code {}",
-            recipe, n, code
-          )?;
-        } else {
-          write!(f, "Recipe `{}` failed with exit code {}", recipe, code)?;
-        },
-      CommandInvocation {
-        binary,
-        arguments,
-        io_error,
-      } => {
-        write!(
-          f,
-          "Failed to invoke {}: {}",
-          iter::once(binary)
-            .chain(arguments)
-            .map(|value| Enclosure::tick(value.to_string_lossy()).to_string())
-            .collect::<Vec<String>>()
-            .join(" "),
-          io_error,
-        )?;
-      },
-      Cygpath {
-        recipe,
-        output_error,
-      } => match output_error {
-        OutputError::Code(code) => {
-          write!(
-            f,
-            "Cygpath failed with exit code {} while translating recipe `{}` shebang interpreter \
-             path",
-            code, recipe
-          )?;
-        },
-        OutputError::Signal(signal) => {
-          write!(
-            f,
-            "Cygpath terminated by signal {} while translating recipe `{}` shebang interpreter \
-             path",
-            signal, recipe
-          )?;
-        },
-        OutputError::Unknown => {
-          write!(
-            f,
-            "Cygpath experienced an unknown failure while translating recipe `{}` shebang \
-             interpreter path",
-            recipe
-          )?;
-        },
-        OutputError::Io(io_error) => {
-          match io_error.kind() {
-            io::ErrorKind::NotFound => write!(
-              f,
-              "Could not find `cygpath` executable to translate recipe `{}` shebang interpreter \
-               path:\n{}",
-              recipe, io_error
-            ),
-            io::ErrorKind::PermissionDenied => write!(
-              f,
-              "Could not run `cygpath` executable to translate recipe `{}` shebang interpreter \
-               path:\n{}",
-              recipe, io_error
-            ),
-            _ => write!(f, "Could not run `cygpath` executable:\n{}", io_error),
-          }?;
-        },
-        OutputError::Utf8(utf8_error) => {
-          write!(
-            f,
-            "Cygpath successfully translated recipe `{}` shebang interpreter path, but output was \
-             not utf8: {}",
-            recipe, utf8_error
-          )?;
-        },
-      },
-      Dotenv { dotenv_error } => {
-        writeln!(f, "Failed to load .env: {}", dotenv_error)?;
-      },
-      FunctionCall { function, message } => {
-        writeln!(
-          f,
-          "Call to function `{}` failed: {}",
-          function.lexeme(),
-          message
-        )?;
-      },
-      Shebang {
-        recipe,
-        command,
-        argument,
-        io_error,
-      } =>
-        if let Some(argument) = argument {
-          write!(
-            f,
-            "Recipe `{}` with shebang `#!{} {}` execution error: {}",
-            recipe, command, argument, io_error
-          )?;
-        } else {
-          write!(
-            f,
-            "Recipe `{}` with shebang `#!{}` execution error: {}",
-            recipe, command, io_error
-          )?;
-        },
-      Signal {
-        recipe,
-        line_number,
-        signal,
-      } =>
-        if let Some(n) = line_number {
-          write!(
-            f,
-            "Recipe `{}` was terminated on line {} by signal {}",
-            recipe, n, signal
-          )?;
-        } else {
-          write!(f, "Recipe `{}` was terminated by signal {}", recipe, signal)?;
-        },
-      Unknown {
-        recipe,
-        line_number,
-      } =>
-        if let Some(n) = line_number {
-          write!(
-            f,
-            "Recipe `{}` failed on line {} for an unknown reason",
-            recipe, n
-          )?;
-        } else {
-          write!(f, "Recipe `{}` failed for an unknown reason", recipe)?;
-        },
-      IoError { recipe, io_error } => {
-        match io_error.kind() {
-          io::ErrorKind::NotFound => writeln!(
-            f,
-            "Recipe `{}` could not be run because just could not find `sh`:{}",
-            recipe, io_error
-          ),
-          io::ErrorKind::PermissionDenied => writeln!(
-            f,
-            "Recipe `{}` could not be run because just could not run `sh`:{}",
-            recipe, io_error
-          ),
-          _ => writeln!(
-            f,
-            "Recipe `{}` could not be run because of an IO error while launching `sh`:{}",
-            recipe, io_error
-          ),
-        }?;
-      },
-      TmpdirIoError { recipe, io_error } => writeln!(
-        f,
-        "Recipe `{}` could not be run because of an IO error while trying to create a temporary \
-         directory or write a file to that directory`:{}",
-        recipe, io_error
-      )?,
-      Backtick { output_error, .. } => match output_error {
-        OutputError::Code(code) => {
-          writeln!(f, "Backtick failed with exit code {}", code)?;
-        },
-        OutputError::Signal(signal) => {
-          writeln!(f, "Backtick was terminated by signal {}", signal)?;
-        },
-        OutputError::Unknown => {
-          writeln!(f, "Backtick failed for an unknown reason")?;
-        },
-        OutputError::Io(io_error) => {
-          match io_error.kind() {
-            io::ErrorKind::NotFound => write!(
-              f,
-              "Backtick could not be run because just could not find `sh`:\n{}",
-              io_error
-            ),
-            io::ErrorKind::PermissionDenied => write!(
-              f,
-              "Backtick could not be run because just could not run `sh`:\n{}",
-              io_error
-            ),
-            _ => write!(
-              f,
-              "Backtick could not be run because of an IO error while launching `sh`:\n{}",
-              io_error
-            ),
-          }?;
-        },
-        OutputError::Utf8(utf8_error) => {
-          writeln!(
-            f,
-            "Backtick succeeded but stdout was not utf8: {}",
-            utf8_error
-          )?;
-        },
-      },
-      NoRecipes => {
-        writeln!(f, "Justfile contains no recipes.",)?;
-      },
-      DefaultRecipeRequiresArguments {
-        recipe,
-        min_arguments,
-      } => {
-        writeln!(
-          f,
-          "Recipe `{}` cannot be used as default recipe since it requires at least {} {}.",
-          recipe,
-          min_arguments,
-          Count("argument", *min_arguments),
-        )?;
-      },
-      Internal { message } => {
-        write!(
-          f,
-          "Internal runtime error, this may indicate a bug in just: {} \
-           consider filing an issue: https://github.com/casey/just/issues/new",
-          message
-        )?;
-      },
-    }
-
-    write!(f, "{}", message.suffix())?;
-
-    if let Some(token) = self.context() {
-      token.write_context(f, Color::fmt(f).error())?;
-    }
-
-    Ok(())
-  }
-}
-
-impl<'src> From<dotenv::Error> for RuntimeError<'src> {
-  fn from(dotenv_error: dotenv::Error) -> RuntimeError<'src> {
-    RuntimeError::Dotenv { dotenv_error }
-  }
-}
diff --git a/src/search_error.rs b/src/search_error.rs
index c058f23a..6a8e708b 100644
--- a/src/search_error.rs
+++ b/src/search_error.rs
@@ -3,6 +3,17 @@ use crate::common::*;
 #[derive(Debug, Snafu)]
 #[snafu(visibility(pub(crate)))]
 pub(crate) enum SearchError {
+  #[snafu(display(
+    "I/O error reading directory `{}`: {}",
+    directory.display(),
+    io_error
+  ))]
+  Io {
+    directory: PathBuf,
+    io_error:  io::Error,
+  },
+  #[snafu(display("Justfile path had no parent: {}", path.display()))]
+  JustfileHadNoParent { path: PathBuf },
   #[snafu(display(
     "Multiple candidate justfiles found in `{}`: {}",
     candidates[0].parent().unwrap().display(),
@@ -13,23 +24,10 @@ pub(crate) enum SearchError {
     ),
   ))]
   MultipleCandidates { candidates: Vec<PathBuf> },
-  #[snafu(display(
-    "I/O error reading directory `{}`: {}",
-    directory.display(),
-    io_error
-  ))]
-  Io {
-    directory: PathBuf,
-    io_error:  io::Error,
-  },
   #[snafu(display("No justfile found"))]
   NotFound,
-  #[snafu(display("Justfile path had no parent: {}", path.display()))]
-  JustfileHadNoParent { path: PathBuf },
 }
 
-impl Error for SearchError {}
-
 #[cfg(test)]
 mod tests {
   use super::*;
diff --git a/src/string_kind.rs b/src/string_kind.rs
index 2b1cca2b..04aee7e7 100644
--- a/src/string_kind.rs
+++ b/src/string_kind.rs
@@ -55,11 +55,11 @@ impl StringKind {
     }
   }
 
-  pub(crate) fn unterminated_error_kind(self) -> CompilationErrorKind<'static> {
+  pub(crate) fn unterminated_error_kind(self) -> CompileErrorKind<'static> {
     match self.delimiter {
       StringDelimiter::QuoteDouble | StringDelimiter::QuoteSingle =>
-        CompilationErrorKind::UnterminatedString,
-      StringDelimiter::Backtick => CompilationErrorKind::UnterminatedBacktick,
+        CompileErrorKind::UnterminatedString,
+      StringDelimiter::Backtick => CompileErrorKind::UnterminatedBacktick,
     }
   }
 
@@ -74,9 +74,9 @@ impl StringKind {
     self.indented
   }
 
-  pub(crate) fn from_string_or_backtick(token: Token) -> CompilationResult<Self> {
+  pub(crate) fn from_string_or_backtick(token: Token) -> CompileResult<Self> {
     Self::from_token_start(token.lexeme()).ok_or_else(|| {
-      token.error(CompilationErrorKind::Internal {
+      token.error(CompileErrorKind::Internal {
         message: "StringKind::from_token: Expected String or Backtick".to_owned(),
       })
     })
diff --git a/src/subcommand.rs b/src/subcommand.rs
index ee5bd1c1..f03556e8 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -210,26 +210,18 @@ const BASH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[(
 )];
 
 impl Subcommand {
-  pub(crate) fn completions(verbosity: Verbosity, shell: &str) -> Result<(), i32> {
+  pub(crate) fn completions(shell: &str) -> RunResult<'static, ()> {
     use clap::Shell;
 
-    fn replace(
-      verbosity: Verbosity,
-      haystack: &mut String,
-      needle: &str,
-      replacement: &str,
-    ) -> Result<(), i32> {
+    fn replace(haystack: &mut String, needle: &str, replacement: &str) -> RunResult<'static, ()> {
       if let Some(index) = haystack.find(needle) {
         haystack.replace_range(index..index + needle.len(), replacement);
         Ok(())
       } else {
-        if verbosity.loud() {
-          eprintln!("Failed to find text:");
-          eprintln!("{}", needle);
-          eprintln!("…in completion script:");
-          eprintln!("{}", haystack);
-        }
-        Err(EXIT_FAILURE)
+        Err(Error::internal(format!(
+          "Failed to find text:\n{}\n…in completion script:\n{}",
+          needle, haystack
+        )))
       }
     }
 
@@ -246,19 +238,19 @@ impl Subcommand {
     match shell {
       Shell::Bash =>
         for (needle, replacement) in BASH_COMPLETION_REPLACEMENTS {
-          replace(verbosity, &mut script, needle, replacement)?;
+          replace(&mut script, needle, replacement)?;
         },
       Shell::Fish => {
         script.insert_str(0, FISH_RECIPE_COMPLETIONS);
       },
       Shell::PowerShell =>
         for (needle, replacement) in POWERSHELL_COMPLETION_REPLACEMENTS {
-          replace(verbosity, &mut script, needle, replacement)?;
+          replace(&mut script, needle, replacement)?;
         },
 
       Shell::Zsh =>
         for (needle, replacement) in ZSH_COMPLETION_REPLACEMENTS {
-          replace(verbosity, &mut script, needle, replacement)?;
+          replace(&mut script, needle, replacement)?;
         },
       Shell::Elvish => {},
     }
diff --git a/src/thunk.rs b/src/thunk.rs
index 5a50483c..3a039ca3 100644
--- a/src/thunk.rs
+++ b/src/thunk.rs
@@ -32,7 +32,7 @@ impl<'src> Thunk<'src> {
   pub(crate) fn resolve(
     name: Name<'src>,
     mut arguments: Vec<Expression<'src>>,
-  ) -> CompilationResult<'src, Thunk<'src>> {
+  ) -> CompileResult<'src, Thunk<'src>> {
     if let Some(function) = crate::function::TABLE.get(&name.lexeme()) {
       match (function, arguments.len()) {
         (Function::Nullary(function), 0) => Ok(Thunk::Nullary {
@@ -63,16 +63,14 @@ impl<'src> Thunk<'src> {
             name,
           })
         },
-        _ => Err(
-          name.error(CompilationErrorKind::FunctionArgumentCountMismatch {
-            function: name.lexeme(),
-            found:    arguments.len(),
-            expected: function.argc(),
-          }),
-        ),
+        _ => Err(name.error(CompileErrorKind::FunctionArgumentCountMismatch {
+          function: name.lexeme(),
+          found:    arguments.len(),
+          expected: function.argc(),
+        })),
       }
     } else {
-      Err(name.error(CompilationErrorKind::UnknownFunction {
+      Err(name.error(CompileErrorKind::UnknownFunction {
         function: name.lexeme(),
       }))
     }
diff --git a/src/token.rs b/src/token.rs
index 47a7b3d2..8905db8b 100644
--- a/src/token.rs
+++ b/src/token.rs
@@ -15,11 +15,11 @@ impl<'src> Token<'src> {
     &self.src[self.offset..self.offset + self.length]
   }
 
-  pub(crate) fn error(&self, kind: CompilationErrorKind<'src>) -> CompilationError<'src> {
-    CompilationError { token: *self, kind }
+  pub(crate) fn error(&self, kind: CompileErrorKind<'src>) -> CompileError<'src> {
+    CompileError { token: *self, kind }
   }
 
-  pub(crate) fn write_context(&self, f: &mut Formatter, color: Color) -> fmt::Result {
+  pub(crate) fn write_context(&self, w: &mut dyn Write, color: Color) -> io::Result<()> {
     let width = if self.length == 0 { 1 } else { self.length };
 
     let line_number = self.line.ordinal();
@@ -50,11 +50,11 @@ impl<'src> Token<'src> {
           i += c.len_utf8();
         }
         let line_number_width = line_number.to_string().len();
-        writeln!(f, "{0:1$} |", "", line_number_width)?;
-        writeln!(f, "{} | {}", line_number, space_line)?;
-        write!(f, "{0:1$} |", "", line_number_width)?;
+        writeln!(w, "{0:1$} |", "", line_number_width)?;
+        writeln!(w, "{} | {}", line_number, space_line)?;
+        write!(w, "{0:1$} |", "", line_number_width)?;
         write!(
-          f,
+          w,
           " {0:1$}{2}{3:^<4$}{5}",
           "",
           space_column,
@@ -67,12 +67,13 @@ impl<'src> Token<'src> {
       None =>
         if self.offset != self.src.len() {
           write!(
-            f,
+            w,
             "internal error: Error has invalid line number: {}",
             line_number
           )?;
         },
     }
+
     Ok(())
   }
 }
diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs
index 125eb02e..f08b5383 100644
--- a/src/unresolved_recipe.rs
+++ b/src/unresolved_recipe.rs
@@ -6,7 +6,7 @@ impl<'src> UnresolvedRecipe<'src> {
   pub(crate) fn resolve(
     self,
     resolved: Vec<Rc<Recipe<'src>>>,
-  ) -> CompilationResult<'src, Recipe<'src>> {
+  ) -> CompileResult<'src, Recipe<'src>> {
     assert_eq!(
       self.dependencies.len(),
       resolved.len(),
@@ -21,14 +21,16 @@ impl<'src> UnresolvedRecipe<'src> {
         .argument_range()
         .contains(&unresolved.arguments.len())
       {
-        return Err(unresolved.recipe.error(
-          CompilationErrorKind::DependencyArgumentCountMismatch {
-            dependency: unresolved.recipe.lexeme(),
-            found:      unresolved.arguments.len(),
-            min:        resolved.min_arguments(),
-            max:        resolved.max_arguments(),
-          },
-        ));
+        return Err(
+          unresolved
+            .recipe
+            .error(CompileErrorKind::DependencyArgumentCountMismatch {
+              dependency: unresolved.recipe.lexeme(),
+              found:      unresolved.arguments.len(),
+              min:        resolved.min_arguments(),
+              max:        resolved.max_arguments(),
+            }),
+        );
       }
     }
 
diff --git a/src/warning.rs b/src/warning.rs
index da3c8b13..fbba65be 100644
--- a/src/warning.rs
+++ b/src/warning.rs
@@ -13,19 +13,17 @@ impl Warning {
       Self::DotenvLoad => None,
     }
   }
-}
 
-impl Display for Warning {
-  fn fmt(&self, f: &mut Formatter) -> fmt::Result {
-    let warning = Color::fmt(f).warning();
-    let message = Color::fmt(f).message();
+  pub(crate) fn write(&self, w: &mut dyn Write, color: Color) -> io::Result<()> {
+    let warning = color.warning();
+    let message = color.message();
 
-    write!(f, "{} {}", warning.paint("warning:"), message.prefix())?;
+    write!(w, "{} {}", warning.paint("warning:"), message.prefix())?;
 
     match self {
       Self::DotenvLoad => {
         #[rustfmt::skip]
-        write!(f, "\
+        write!(w, "\
 A `.env` file was found and loaded, but this behavior will change in the future.
 To silence this warning and continue loading `.env` files, add:
 
@@ -39,11 +37,10 @@ See https://github.com/casey/just/issues/469 for more details.")?;
       },
     }
 
-    write!(f, "{}", message.suffix())?;
+    writeln!(w, "{}", message.suffix())?;
 
     if let Some(token) = self.context() {
-      writeln!(f)?;
-      token.write_context(f, Color::fmt(f).warning())?;
+      token.write_context(w, warning)?;
     }
 
     Ok(())


Test patch:
diff --git a/src/testing.rs b/src/testing.rs
index 14d8c301..a69dcc40 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -61,7 +61,7 @@ pub(crate) fn analysis_error(
   line: usize,
   column: usize,
   length: usize,
-  kind: CompilationErrorKind,
+  kind: CompileErrorKind,
 ) {
   let tokens = Lexer::lex(src).expect("Lexing failed in parse test...");
 
@@ -70,7 +70,7 @@ pub(crate) fn analysis_error(
   match Analyzer::analyze(ast) {
     Ok(_) => panic!("Analysis unexpectedly succeeded"),
     Err(have) => {
-      let want = CompilationError {
+      let want = CompileError {
         token: Token {
           kind: have.token.kind,
           src,
diff --git a/tests/choose.rs b/tests/choose.rs
index 0d990873..45954ea2 100644
--- a/tests/choose.rs
+++ b/tests/choose.rs
@@ -95,7 +95,7 @@ test! {
   ",
   args: ("--choose"),
   stdout: "",
-  stderr: "Justfile contains no choosable recipes.\n",
+  stderr: "error: Justfile contains no choosable recipes.\n",
   status: EXIT_FAILURE,
 }
 
@@ -113,6 +113,61 @@ test! {
   stderr: "echo foo\necho bar\n",
 }
 
+#[test]
+fn invoke_error_function() {
+  Test::new()
+    .justfile(
+      "
+        foo:
+          echo foo
+
+        bar:
+          echo bar
+      ",
+    )
+    .stderr(if cfg!(windows) {
+      "error: Chooser `/ -cu fzf` invocation failed: Access is denied. (os error 5)\n"
+    } else {
+      "error: Chooser `/ -cu fzf` invocation failed: Permission denied (os error 13)\n"
+    })
+    .status(EXIT_FAILURE)
+    .shell(false)
+    .args(&["--shell", "/", "--choose"])
+    .run();
+}
+
+#[test]
+#[cfg(not(windows))]
+fn status_error() {
+  let tmp = temptree! {
+    justfile: "foo:\n echo foo\nbar:\n echo bar\n",
+    "exit-2": "#!/usr/bin/env bash\nexit 2\n",
+  };
+
+  cmd_unit!(%"chmod +x", tmp.path().join("exit-2"));
+
+  let path = env::join_paths(
+    iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())),
+  )
+  .unwrap();
+
+  let output = Command::new(executable_path("just"))
+    .current_dir(tmp.path())
+    .arg("--choose")
+    .arg("--chooser")
+    .arg("exit-2")
+    .env("PATH", path)
+    .output()
+    .unwrap();
+
+  assert_eq!(
+    String::from_utf8_lossy(&output.stderr),
+    "error: Chooser `exit-2` failed: exit code: 2\n",
+  );
+
+  assert_eq!(output.status.code().unwrap(), 2);
+}
+
 #[test]
 fn default() {
   let tmp = temptree! {
diff --git a/tests/command.rs b/tests/command.rs
index f3caca95..13019e9d 100644
--- a/tests/command.rs
+++ b/tests/command.rs
@@ -91,6 +91,7 @@ test! {
       echo XYZ
   ",
   args: ("--command", "false"),
+  stderr: "error: Command `false` failed: exit code: 1\n",
   status: EXIT_FAILURE,
 }
 
diff --git a/tests/common.rs b/tests/common.rs
index 96b0d17d..f874084e 100644
--- a/tests/common.rs
+++ b/tests/common.rs
@@ -2,21 +2,26 @@ pub(crate) use std::{
   collections::BTreeMap,
   env::{self, consts::EXE_SUFFIX},
   error::Error,
+  fmt::Debug,
   fs,
   io::Write,
   iter,
-  path::Path,
+  path::{Path, PathBuf},
   process::{Command, Output, Stdio},
   str,
 };
 
+pub(crate) use cradle::cmd_unit;
 pub(crate) use executable_path::executable_path;
 pub(crate) use just::unindent;
 pub(crate) use libc::{EXIT_FAILURE, EXIT_SUCCESS};
+pub(crate) use pretty_assertions::Comparison;
+pub(crate) use regex::Regex;
+pub(crate) use tempfile::TempDir;
 pub(crate) use temptree::temptree;
 pub(crate) use which::which;
 pub(crate) use yaml_rust::YamlLoader;
 
 pub(crate) use crate::{
-  assert_stdout::assert_stdout, assert_success::assert_success, tempdir::tempdir,
+  assert_stdout::assert_stdout, assert_success::assert_success, tempdir::tempdir, test::Test,
 };
diff --git a/tests/edit.rs b/tests/edit.rs
index 78e77d3a..c85bcb60 100644
--- a/tests/edit.rs
+++ b/tests/edit.rs
@@ -26,6 +26,67 @@ fn invalid_justfile() {
   assert_stdout(&output, JUSTFILE);
 }
 
+#[test]
+fn invoke_error() {
+  let tmp = temptree! {
+    justfile: JUSTFILE,
+  };
+
+  let output = Command::new(executable_path("just"))
+    .current_dir(tmp.path())
+    .output()
+    .unwrap();
+
+  assert!(!output.status.success());
+
+  let output = Command::new(executable_path("just"))
+    .current_dir(tmp.path())
+    .arg("--edit")
+    .env("VISUAL", "/")
+    .output()
+    .unwrap();
+
+  assert_eq!(
+    String::from_utf8_lossy(&output.stderr),
+    if cfg!(windows) {
+      "error: Editor `/` invocation failed: Access is denied. (os error 5)\n"
+    } else {
+      "error: Editor `/` invocation failed: Permission denied (os error 13)\n"
+    }
+  );
+}
+
+#[test]
+#[cfg(not(windows))]
+fn status_error() {
+  let tmp = temptree! {
+    justfile: JUSTFILE,
+    "exit-2": "#!/usr/bin/env bash\nexit 2\n",
+  };
+
+  cmd_unit!(%"chmod +x", tmp.path().join("exit-2"));
+
+  let path = env::join_paths(
+    iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())),
+  )
+  .unwrap();
+
+  let output = Command::new(executable_path("just"))
+    .current_dir(tmp.path())
+    .arg("--edit")
+    .env("PATH", path)
+    .env("VISUAL", "exit-2")
+    .output()
+    .unwrap();
+
+  assert_eq!(
+    String::from_utf8_lossy(&output.stderr),
+    "error: Editor `exit-2` failed: exit code: 2\n"
+  );
+
+  assert_eq!(output.status.code().unwrap(), 2);
+}
+
 /// Test that editor is $VISUAL, $EDITOR, or "vim" in that order
 #[test]
 fn editor_precedence() {
diff --git a/tests/fmt.rs b/tests/fmt.rs
index 392bca2e..779f3810 100644
--- a/tests/fmt.rs
+++ b/tests/fmt.rs
@@ -5,7 +5,8 @@ test! {
   justfile: "",
   args: ("--fmt"),
   stderr: "
-    The `--fmt` command is currently unstable. Pass the `--unstable` flag to enable it.
+    error: The `--fmt` command is currently unstable. \
+    Invoke `just` with the `--unstable` flag to enable unstable features.
   ",
   status: EXIT_FAILURE,
 }
@@ -34,6 +35,34 @@ fn unstable_passed() {
   assert_eq!(fs::read_to_string(&justfile).unwrap(), "x := 'hello'\n",);
 }
 
+#[test]
+fn write_error() {
+  let tempdir = temptree! {
+    justfile: "x    :=    'hello'   ",
+  };
+
+  let test = Test::with_tempdir(tempdir)
+    .no_justfile()
+    .args(&["--fmt", "--unstable"])
+    .status(EXIT_FAILURE)
+    .stderr_regex(if cfg!(windows) {
+      r"error: Failed to write justfile to `.*`: Access is denied. \(os error 5\)\n"
+    } else {
+      r"error: Failed to write justfile to `.*`: Permission denied \(os error 13\)\n"
+    });
+
+  let justfile_path = test.justfile_path();
+
+  cmd_unit!(%"chmod 400", &justfile_path);
+
+  let _tempdir = test.run();
+
+  assert_eq!(
+    fs::read_to_string(&justfile_path).unwrap(),
+    "x    :=    'hello'   "
+  );
+}
+
 test! {
   name: alias_good,
   justfile: "
diff --git a/tests/init.rs b/tests/init.rs
index efcbc6ae..0fc8ce49 100644
--- a/tests/init.rs
+++ b/tests/init.rs
@@ -22,23 +22,38 @@ fn current_dir() {
 
 #[test]
 fn exists() {
-  let tmp = tempdir();
-
-  let output = Command::new(executable_path("just"))
-    .current_dir(tmp.path())
+  let tempdir = Test::new()
+    .no_justfile()
     .arg("--init")
-    .output()
-    .unwrap();
+    .stderr_regex("Wrote justfile to `.*`\n")
+    .run();
 
-  assert!(output.status.success());
-
-  let output = Command::new(executable_path("just"))
-    .current_dir(tmp.path())
+  Test::with_tempdir(tempdir)
+    .no_justfile()
     .arg("--init")
-    .output()
-    .unwrap();
+    .status(EXIT_FAILURE)
+    .stderr_regex("error: Justfile `.*` already exists\n")
+    .run();
+}
 
-  assert!(!output.status.success());
+#[test]
+fn write_error() {
+  let test = Test::new();
+
+  let justfile_path = test.justfile_path();
+
+  fs::create_dir(&justfile_path).unwrap();
+
+  test
+    .no_justfile()
+    .args(&["--init"])
+    .status(EXIT_FAILURE)
+    .stderr_regex(if cfg!(windows) {
+      r"error: Failed to write justfile to `.*`: Access is denied. \(os error 5\)\n"
+    } else {
+      r"error: Failed to write justfile to `.*`: Is a directory \(os error 21\)\n"
+    })
+    .run();
 }
 
 #[test]
@@ -47,18 +62,17 @@ fn invocation_directory() {
     ".git": {},
   };
 
-  let output = Command::new(executable_path("just"))
-    .current_dir(tmp.path())
-    .arg("--init")
-    .output()
-    .unwrap();
+  let test = Test::with_tempdir(tmp);
 
-  assert!(output.status.success());
+  let justfile_path = test.justfile_path();
 
-  assert_eq!(
-    fs::read_to_string(tmp.path().join("justfile")).unwrap(),
-    EXPECTED
-  );
+  let _tmp = test
+    .no_justfile()
+    .stderr_regex("Wrote justfile to `.*`\n")
+    .arg("--init")
+    .run();
+
+  assert_eq!(fs::read_to_string(justfile_path).unwrap(), EXPECTED);
 }
 
 #[test]
diff --git a/tests/lib.rs b/tests/lib.rs
index 46aacfbb..fe775008 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -27,6 +27,7 @@ mod readme;
 mod search;
 mod shebang;
 mod shell;
+mod show;
 mod string;
 mod sublime_syntax;
 mod subsequents;
diff --git a/tests/misc.rs b/tests/misc.rs
index cc4b75d6..1a478801 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -123,30 +123,6 @@ test! {
   status: EXIT_FAILURE,
 }
 
-test! {
-  name: alias_show,
-  justfile: "foo:\n    bar\nalias f := foo",
-  args: ("--show", "f"),
-  stdout: "
-    alias f := foo
-    foo:
-        bar
-  ",
-}
-
-test! {
-  name: alias_show_missing_target,
-  justfile: "alias f := foo",
-  args: ("--show", "f"),
-  stderr: "
-    error: Alias `f` has an unknown target `foo`
-      |
-    1 | alias f := foo
-      |       ^
-  ",
-  status: EXIT_FAILURE,
-}
-
 test! {
   name:     default,
   justfile: "default:\n echo hello\nother: \n echo bar",
@@ -256,19 +232,6 @@ c:
   stderr:   "echo d\necho c\n",
 }
 
-test! {
-  name:     show,
-  justfile: r#"hello := "foo"
-bar := hello + hello
-recipe:
- echo {{hello + "bar" + bar}}"#,
-  args:     ("--show", "recipe"),
-  stdout:   r#"
-    recipe:
-        echo {{ hello + "bar" + bar }}
-  "#,
-}
-
 test! {
   name:     status_passthrough,
   justfile: "
@@ -700,8 +663,8 @@ test! {
   justfile: "b := a\na := `exit 100`\nbar:\n echo '{{`exit 200`}}'",
   args:     ("--color", "always"),
   stdout:   "",
-  stderr:   "\u{1b}[1;31merror\u{1b}[0m: \u{1b}[1mBacktick failed with exit code 100
-\u{1b}[0m  |\n2 | a := `exit 100`\n  |      \u{1b}[1;31m^^^^^^^^^^\u{1b}[0m\n",
+  stderr:   "\u{1b}[1;31merror\u{1b}[0m: \u{1b}[1mBacktick failed with exit code 100\u{1b}[0m
+  |\n2 | a := `exit 100`\n  |      \u{1b}[1;31m^^^^^^^^^^\u{1b}[0m\n",
   status:   100,
 }
 
@@ -1021,66 +984,6 @@ b:
   "#,
 }
 
-test! {
-  name:     show_suggestion,
-  justfile: r#"
-hello a b='B	' c='C':
-  echo {{a}} {{b}} {{c}}
-
-a Z="\t z":
-"#,
-  args:     ("--show", "hell"),
-  stdout:   "",
-  stderr:   "Justfile does not contain recipe `hell`.\nDid you mean `hello`?\n",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     show_alias_suggestion,
-  justfile: r#"
-hello a b='B	' c='C':
-  echo {{a}} {{b}} {{c}}
-
-alias foo := hello
-
-a Z="\t z":
-"#,
-  args:     ("--show", "fo"),
-  stdout:   "",
-  stderr:   "Justfile does not contain recipe `fo`.\nDid you mean `foo`, an alias for `hello`?\n",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     show_no_suggestion,
-  justfile: r#"
-helloooooo a b='B	' c='C':
-  echo {{a}} {{b}} {{c}}
-
-a Z="\t z":
-"#,
-  args:     ("--show", "hell"),
-  stdout:   "",
-  stderr:   "Justfile does not contain recipe `hell`.\n",
-  status:   EXIT_FAILURE,
-}
-
-test! {
-  name:     show_no_alias_suggestion,
-  justfile: r#"
-hello a b='B	' c='C':
-  echo {{a}} {{b}} {{c}}
-
-alias foo := hello
-
-a Z="\t z":
-"#,
-  args:     ("--show", "fooooooo"),
-  stdout:   "",
-  stderr:   "Justfile does not contain recipe `fooooooo`.\n",
-  status:   EXIT_FAILURE,
-}
-
 test! {
   name:     run_suggestion,
   justfile: r#"
diff --git a/tests/show.rs b/tests/show.rs
new file mode 100644
index 00000000..309e53a6
--- /dev/null
+++ b/tests/show.rs
@@ -0,0 +1,101 @@
+use crate::common::*;
+
+test! {
+  name:     show,
+  justfile: r#"hello := "foo"
+bar := hello + hello
+recipe:
+ echo {{hello + "bar" + bar}}"#,
+  args:     ("--show", "recipe"),
+  stdout:   r#"
+    recipe:
+        echo {{ hello + "bar" + bar }}
+  "#,
+}
+
+test! {
+  name: alias_show,
+  justfile: "foo:\n    bar\nalias f := foo",
+  args: ("--show", "f"),
+  stdout: "
+    alias f := foo
+    foo:
+        bar
+  ",
+}
+
+test! {
+  name: alias_show_missing_target,
+  justfile: "alias f := foo",
+  args: ("--show", "f"),
+  stderr: "
+    error: Alias `f` has an unknown target `foo`
+      |
+    1 | alias f := foo
+      |       ^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name:     show_suggestion,
+  justfile: r#"
+hello a b='B	' c='C':
+  echo {{a}} {{b}} {{c}}
+
+a Z="\t z":
+"#,
+  args:     ("--show", "hell"),
+  stdout:   "",
+  stderr:   "error: Justfile does not contain recipe `hell`.\nDid you mean `hello`?\n",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     show_alias_suggestion,
+  justfile: r#"
+hello a b='B	' c='C':
+  echo {{a}} {{b}} {{c}}
+
+alias foo := hello
+
+a Z="\t z":
+"#,
+  args:     ("--show", "fo"),
+  stdout:   "",
+  stderr:   "
+    error: Justfile does not contain recipe `fo`.
+    Did you mean `foo`, an alias for `hello`?
+  ",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     show_no_suggestion,
+  justfile: r#"
+helloooooo a b='B	' c='C':
+  echo {{a}} {{b}} {{c}}
+
+a Z="\t z":
+"#,
+  args:     ("--show", "hell"),
+  stdout:   "",
+  stderr:   "error: Justfile does not contain recipe `hell`.\n",
+  status:   EXIT_FAILURE,
+}
+
+test! {
+  name:     show_no_alias_suggestion,
+  justfile: r#"
+hello a b='B	' c='C':
+  echo {{a}} {{b}} {{c}}
+
+alias foo := hello
+
+a Z="\t z":
+"#,
+  args:     ("--show", "fooooooo"),
+  stdout:   "",
+  stderr:   "error: Justfile does not contain recipe `fooooooo`.\n",
+  status:   EXIT_FAILURE,
+}
diff --git a/tests/test.rs b/tests/test.rs
index 7d86de64..35cf9c88 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -1,13 +1,13 @@
 use crate::common::*;
 
+use pretty_assertions::assert_eq;
+
 macro_rules! test {
   (
-    name:     $name:ident,
-    justfile: $justfile:expr,
-    $(args:     ($($arg:tt)*),)?
-    $(env:      {
-      $($env_key:literal : $env_value:literal,)*
-    },)?
+    name:       $name:ident,
+    $(justfile: $justfile:expr,)?
+    $(args:     ($($arg:tt),*),)?
+    $(env:      { $($env_key:literal : $env_value:literal,)* },)?
     $(stdin:    $stdin:expr,)?
     $(stdout:   $stdout:expr,)?
     $(stderr:   $stderr:expr,)?
@@ -16,66 +16,128 @@ macro_rules! test {
   ) => {
     #[test]
     fn $name() {
-      #[allow(unused_mut)]
-      let mut env = std::collections::BTreeMap::new();
-
-      $($(env.insert($env_key.to_string(), $env_value.to_string());)*)?
-
-      crate::test::Test {
-        justfile: $justfile,
-        $(args: &[$($arg)*],)?
-        $(stdin: $stdin,)?
-        $(stdout: $stdout,)?
-        $(stderr: $stderr,)?
-        $(status: $status,)?
-        $(shell: $shell,)?
-        env,
-        ..crate::test::Test::default()
-      }.run();
+      let test = crate::test::Test::new();
+
+      $($(let test = test.arg($arg);)*)?
+      $($(let test = test.env($env_key, $env_value);)*)?
+      $(let test = test.justfile($justfile);)?
+      $(let test = test.shell($shell);)?
+      $(let test = test.status($status);)?
+      $(let test = test.stderr($stderr);)?
+      $(let test = test.stdin($stdin);)?
+      $(let test = test.stdout($stdout);)?
+
+      test.run();
     }
   }
 }
 
-pub(crate) struct Test<'a> {
-  pub(crate) justfile: &'a str,
-  pub(crate) args:     &'a [&'a str],
-  pub(crate) env:      BTreeMap<String, String>,
-  pub(crate) stdin:    &'a str,
-  pub(crate) stdout:   &'a str,
-  pub(crate) stderr:   &'a str,
-  pub(crate) status:   i32,
-  pub(crate) shell:    bool,
+pub(crate) struct Test {
+  pub(crate) tempdir:      TempDir,
+  pub(crate) justfile:     Option<String>,
+  pub(crate) args:         Vec<String>,
+  pub(crate) env:          BTreeMap<String, String>,
+  pub(crate) stdin:        String,
+  pub(crate) stdout:       String,
+  pub(crate) stderr:       String,
+  pub(crate) stderr_regex: Option<Regex>,
+  pub(crate) status:       i32,
+  pub(crate) shell:        bool,
 }
 
-impl<'a> Default for Test<'a> {
-  fn default() -> Test<'a> {
-    Test {
-      justfile: "",
-      args:     &[],
-      env:      BTreeMap::new(),
-      stdin:    "",
-      stdout:   "",
-      stderr:   "",
-      status:   EXIT_SUCCESS,
-      shell:    true,
+impl Test {
+  pub(crate) fn new() -> Self {
+    Self::with_tempdir(tempdir())
+  }
+
+  pub(crate) fn with_tempdir(tempdir: TempDir) -> Self {
+    Self {
+      args: Vec::new(),
+      env: BTreeMap::new(),
+      justfile: Some(String::new()),
+      stderr_regex: None,
+      shell: true,
+      status: EXIT_SUCCESS,
+      stderr: String::new(),
+      stdin: String::new(),
+      stdout: String::new(),
+      tempdir,
     }
   }
-}
 
-impl<'a> Test<'a> {
-  pub(crate) fn run(self) {
-    let tmp = tempdir();
+  pub(crate) fn arg(mut self, val: &str) -> Self {
+    self.args.push(val.to_owned());
+    self
+  }
+
+  pub(crate) fn args(mut self, args: &[&str]) -> Self {
+    for arg in args {
+      self = self.arg(arg);
+    }
+    self
+  }
+
+  pub(crate) fn env(mut self, key: &str, val: &str) -> Self {
+    self.env.insert(key.to_string(), val.to_string());
+    self
+  }
 
-    let justfile = unindent(self.justfile);
+  pub(crate) fn justfile(mut self, justfile: impl Into<String>) -> Self {
+    self.justfile = Some(justfile.into());
+    self
+  }
 
-    let stdout = unindent(self.stdout);
-    let stderr = unindent(self.stderr);
+  pub(crate) fn justfile_path(&self) -> PathBuf {
+    self.tempdir.path().join("justfile")
+  }
 
-    let mut justfile_path = tmp.path().to_path_buf();
-    justfile_path.push("justfile");
-    fs::write(&justfile_path, justfile).unwrap();
+  pub(crate) fn no_justfile(mut self) -> Self {
+    self.justfile = None;
+    self
+  }
 
-    let mut dotenv_path = tmp.path().to_path_buf();
+  pub(crate) fn shell(mut self, shell: bool) -> Self {
+    self.shell = shell;
+    self
+  }
+
+  pub(crate) fn status(mut self, exit_status: i32) -> Self {
+    self.status = exit_status;
+    self
+  }
+
+  pub(crate) fn stderr(mut self, stderr: impl Into<String>) -> Self {
+    self.stderr = stderr.into();
+    self
+  }
+
+  pub(crate) fn stderr_regex(mut self, stderr_regex: impl AsRef<str>) -> Self {
+    self.stderr_regex = Some(Regex::new(&format!("^{}$", stderr_regex.as_ref())).unwrap());
+    self
+  }
+
+  pub(crate) fn stdin(mut self, stdin: impl Into<String>) -> Self {
+    self.stdin = stdin.into();
+    self
+  }
+
+  pub(crate) fn stdout(mut self, stdout: impl Into<String>) -> Self {
+    self.stdout = stdout.into();
+    self
+  }
+}
+
+impl Test {
+  pub(crate) fn run(self) -> TempDir {
+    if let Some(justfile) = &self.justfile {
+      let justfile = unindent(justfile);
+      fs::write(self.justfile_path(), justfile).unwrap();
+    }
+
+    let stdout = unindent(&self.stdout);
+    let stderr = unindent(&self.stderr);
+
+    let mut dotenv_path = self.tempdir.path().to_path_buf();
     dotenv_path.push(".env");
     fs::write(dotenv_path, "DOTENV_KEY=dotenv-value").unwrap();
 
@@ -87,8 +149,8 @@ impl<'a> Test<'a> {
 
     let mut child = command
       .args(self.args)
-      .envs(self.env)
-      .current_dir(tmp.path())
+      .envs(&self.env)
+      .current_dir(self.tempdir.path())
       .stdin(Stdio::piped())
       .stdout(Stdio::piped())
       .stderr(Stdio::piped())
@@ -107,23 +169,37 @@ impl<'a> Test<'a> {
       .wait_with_output()
       .expect("failed to wait for just process");
 
-    let have = Output {
-      status: output.status.code().unwrap(),
-      stdout: str::from_utf8(&output.stdout).unwrap(),
-      stderr: str::from_utf8(&output.stderr).unwrap(),
-    };
+    fn compare<T: PartialEq + Debug>(name: &str, have: T, want: T) -> bool {
+      let equal = have == want;
+      if !equal {
+        eprintln!("Bad {}: {}", name, Comparison::new(&have, &want));
+      }
+      equal
+    }
+
+    let output_stderr = str::from_utf8(&output.stderr).unwrap();
 
-    let want = Output {
-      status: self.status,
-      stdout: &stdout,
-      stderr: &stderr,
-    };
+    if let Some(ref stderr_regex) = self.stderr_regex {
+      if !stderr_regex.is_match(output_stderr) {
+        panic!(
+          "Stderr regex mismatch: {} !~= /{}/",
+          output_stderr, stderr_regex
+        );
+      }
+    }
 
-    assert_eq!(have, want, "bad output");
+    if !compare("status", output.status.code().unwrap(), self.status)
+      | !compare("stdout", str::from_utf8(&output.stdout).unwrap(), &stdout)
+      | (self.stderr_regex.is_none() && !compare("stderr", output_stderr, &stderr))
+    {
+      panic!("Output mismatch.");
+    }
 
     if self.status == EXIT_SUCCESS {
-      test_round_trip(tmp.path());
+      test_round_trip(self.tempdir.path());
     }
+
+    self.tempdir
   }
 }