Repository: casey/just
Pull request #714 (2020-10-26 14:02:45)

Add conditionals
Todo:
- [x] test new error message formatting
- [x] make `}}}` parse as `} }}`
- [x] document that it short-circuits
- [x] document that it can be used inside and outside of interpolations
- [x] document that you can use `==` and `!=`



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index cf506807..fefeb1db 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -156,6 +156,15 @@ dependencies = [
  "wasi",
 ]
 
+[[package]]
+name = "heck"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205"
+dependencies = [
+ "unicode-segmentation",
+]
+
 [[package]]
 name = "hermit-abi"
 version = "0.1.17"
@@ -192,6 +201,7 @@ dependencies = [
  "log",
  "pretty_assertions",
  "snafu",
+ "strum",
  "target",
  "tempfile",
  "test-utilities",
@@ -390,6 +400,27 @@ version = "0.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
 
+[[package]]
+name = "strum"
+version = "0.19.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b89a286a7e3b5720b9a477b23253bc50debac207c8d21505f8e70b36792f11b5"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.19.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e61bb0be289045cb80bfce000512e32d09f8337e54c186725da381377ad1f8d5"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "syn"
 version = "1.0.44"
@@ -475,6 +506,12 @@ dependencies = [
  "lazy_static",
 ]
 
+[[package]]
+name = "unicode-segmentation"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0"
+
 [[package]]
 name = "unicode-width"
 version = "0.1.8"
diff --git a/Cargo.toml b/Cargo.toml
index 1b67d794..1d727907 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,10 @@ unicode-width = "0.1.0"
 version  = "3.1.1"
 features = ["termination"]
 
+[dependencies.strum]
+version  = "0.19.0"
+features = ["derive"]
+
 [dev-dependencies]
 executable-path   = "1.0.0"
 pretty_assertions = "0.6.0"
diff --git a/GRAMMAR.md b/GRAMMAR.md
index 73ecdb0d..1d844f35 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -57,9 +57,13 @@ export        : 'export' assignment
 
 setting       : 'set' 'shell' ':=' '[' string (',' string)* ','? ']'
 
-expression    : value '+' expression
+expression    : 'if' condition '{' expression '}' else '{' expression '}'
+              | value '+' expression
               | value
 
+condition     : expression '==' expression
+              | expression '!=' expression
+
 value         : NAME '(' sequence? ')'
               | STRING
               | RAW_STRING
diff --git a/README.adoc b/README.adoc
index fe987292..558bf9da 100644
--- a/README.adoc
+++ b/README.adoc
@@ -545,6 +545,54 @@ serve:
     ./serve {{localhost}} 8080
 ```
 
+=== Conditional Expressions
+
+`if`/`else` expressions evaluate different branches depending on if two expressions evaluate to the same value:
+
+```make
+foo := if "2" == "2" { "Good!" } else { "1984" }
+
+bar:
+  @echo "{{foo}}"
+```
+
+```sh
+$ just bar
+Good!
+```
+
+It is also possible to test for inequality:
+
+```make
+foo := if "hello" != "goodbye" { "xyz" } else { "abc" }
+
+bar:
+  @echo {{foo}}
+```
+
+```sh
+$ just bar
+abc
+```
+
+Conditional expressions short-circuit, which means they only evaluate one of
+their branches. This can be used to make sure that backtick expressions don't
+run when they shouldn't.
+
+```make
+foo := if env_var("RELEASE") == "true" { `get-something-from-release-database` } else { "dummy-value" }
+```
+
+Conditionals can be used inside of recipes:
+
+```make
+bar foo:
+  echo {{ if foo == "bar" { "hello" } else { "goodbye" } }}
+```
+
+Note the space after the final `}`! Without the space, the interpolation will
+be prematurely closed.
+
 === Setting Variables from the Command Line
 
 Variables can be overridden from the command line.
diff --git a/justfile b/justfile
index 119378f8..90d26369 100755
--- a/justfile
+++ b/justfile
@@ -121,7 +121,7 @@ sloc:
 
 @lint:
 	echo Checking for FIXME/TODO...
-	! grep --color -En 'FIXME|TODO' src/*.rs
+	! grep --color -Ein 'fixme|todo|xxx|#\[ignore\]' src/*.rs
 	echo Checking for long lines...
 	! grep --color -En '.{101}' src/*.rs
 
diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs
index f4798434..9e84c751 100644
--- a/src/assignment_resolver.rs
+++ b/src/assignment_resolver.rs
@@ -87,6 +87,18 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
         self.resolve_expression(lhs)?;
         self.resolve_expression(rhs)
       },
+      Expression::Conditional {
+        lhs,
+        rhs,
+        then,
+        otherwise,
+        ..
+      } => {
+        self.resolve_expression(lhs)?;
+        self.resolve_expression(rhs)?;
+        self.resolve_expression(then)?;
+        self.resolve_expression(otherwise)
+      },
       Expression::StringLiteral { .. } | Expression::Backtick { .. } => Ok(()),
       Expression::Group { contents } => self.resolve_expression(contents),
     }
diff --git a/src/common.rs b/src/common.rs
index bb27b430..059be63f 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -24,10 +24,11 @@ pub(crate) use edit_distance::edit_distance;
 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 unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
 
 // modules
-pub(crate) use crate::{config_error, keyword, setting};
+pub(crate) use crate::{config_error, setting};
 
 // functions
 pub(crate) use crate::{default::default, empty::empty, load_dotenv::load_dotenv, output::output};
@@ -47,12 +48,13 @@ pub(crate) use crate::{
   dependency::Dependency, enclosure::Enclosure, evaluator::Evaluator, expression::Expression,
   fragment::Fragment, function::Function, function_context::FunctionContext,
   interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item,
-  justfile::Justfile, lexer::Lexer, line::Line, list::List, load_error::LoadError, module::Module,
-  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, search_config::SearchConfig, search_error::SearchError, set::Set,
-  setting::Setting, settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace,
+  justfile::Justfile, keyword::Keyword, lexer::Lexer, line::Line, list::List,
+  load_error::LoadError, module::Module, 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,
+  search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting,
+  settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace,
   string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table,
   thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency,
   unresolved_recipe::UnresolvedRecipe, use_color::UseColor, variables::Variables,
diff --git a/src/compilation_error.rs b/src/compilation_error.rs
index 305025cb..c6e72288 100644
--- a/src/compilation_error.rs
+++ b/src/compilation_error.rs
@@ -128,6 +128,11 @@ impl Display for CompilationError<'_> {
           writeln!(f, "at most {} {}", max, Count("argument", max))?;
         }
       },
+      ExpectedKeyword { expected, found } => writeln!(
+        f,
+        "Expected keyword `{}` but found identifier `{}`",
+        expected, found
+      )?,
       ParameterShadowsVariable { parameter } => {
         writeln!(
           f,
@@ -198,6 +203,9 @@ impl Display for CompilationError<'_> {
       UnknownSetting { setting } => {
         writeln!(f, "Unknown setting `{}`", setting)?;
       },
+      UnexpectedCharacter { expected } => {
+        writeln!(f, "Expected character `{}`", expected)?;
+      },
       UnknownStartOfToken => {
         writeln!(f, "Unknown start of token:")?;
       },
diff --git a/src/compilation_error_kind.rs b/src/compilation_error_kind.rs
index 32832ff8..807fc70c 100644
--- a/src/compilation_error_kind.rs
+++ b/src/compilation_error_kind.rs
@@ -39,6 +39,10 @@ pub(crate) enum CompilationErrorKind<'src> {
     setting: &'src str,
     first:   usize,
   },
+  ExpectedKeyword {
+    expected: Keyword,
+    found:    &'src str,
+  },
   ExtraLeadingWhitespace,
   FunctionArgumentCountMismatch {
     function: &'src str,
@@ -86,6 +90,9 @@ pub(crate) enum CompilationErrorKind<'src> {
     function: &'src str,
   },
   UnknownStartOfToken,
+  UnexpectedCharacter {
+    expected: char,
+  },
   UnknownSetting {
     setting: &'src str,
   },
diff --git a/src/evaluator.rs b/src/evaluator.rs
index 0f773dc8..b56a6ad4 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -116,6 +116,22 @@ impl<'src, 'run> Evaluator<'src, 'run> {
         },
       Expression::Concatination { lhs, rhs } =>
         Ok(self.evaluate_expression(lhs)? + &self.evaluate_expression(rhs)?),
+      Expression::Conditional {
+        lhs,
+        rhs,
+        then,
+        otherwise,
+        inverted,
+      } => {
+        let lhs = self.evaluate_expression(lhs)?;
+        let rhs = self.evaluate_expression(rhs)?;
+        let condition = if *inverted { lhs != rhs } else { lhs == rhs };
+        if condition {
+          self.evaluate_expression(then)
+        } else {
+          self.evaluate_expression(otherwise)
+        }
+      },
       Expression::Group { contents } => self.evaluate_expression(contents),
     }
   }
diff --git a/src/expression.rs b/src/expression.rs
index a4595c3e..1616d349 100644
--- a/src/expression.rs
+++ b/src/expression.rs
@@ -20,6 +20,14 @@ pub(crate) enum Expression<'src> {
     lhs: Box<Expression<'src>>,
     rhs: Box<Expression<'src>>,
   },
+  /// `if lhs == rhs { then } else { otherwise }`
+  Conditional {
+    lhs:       Box<Expression<'src>>,
+    rhs:       Box<Expression<'src>>,
+    then:      Box<Expression<'src>>,
+    otherwise: Box<Expression<'src>>,
+    inverted:  bool,
+  },
   /// `(contents)`
   Group { contents: Box<Expression<'src>> },
   /// `"string_literal"` or `'string_literal'`
@@ -39,6 +47,21 @@ impl<'src> Display for Expression<'src> {
     match self {
       Expression::Backtick { contents, .. } => write!(f, "`{}`", contents),
       Expression::Concatination { lhs, rhs } => write!(f, "{} + {}", lhs, rhs),
+      Expression::Conditional {
+        lhs,
+        rhs,
+        then,
+        otherwise,
+        inverted,
+      } => write!(
+        f,
+        "if {} {} {} {{ {} }} else {{ {} }} ",
+        lhs,
+        if *inverted { "!=" } else { "==" },
+        rhs,
+        then,
+        otherwise
+      ),
       Expression::StringLiteral { string_literal } => write!(f, "{}", string_literal),
       Expression::Variable { name } => write!(f, "{}", name.lexeme()),
       Expression::Call { thunk } => write!(f, "{}", thunk),
diff --git a/src/keyword.rs b/src/keyword.rs
index 8bf8371b..4d75305f 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -1,5 +1,28 @@
-pub(crate) const ALIAS: &str = "alias";
-pub(crate) const EXPORT: &str = "export";
-pub(crate) const SET: &str = "set";
+use crate::common::*;
 
-pub(crate) const SHELL: &str = "shell";
+#[derive(Debug, Eq, PartialEq, IntoStaticStr, Display, Copy, Clone, EnumString)]
+#[strum(serialize_all = "kebab_case")]
+pub(crate) enum Keyword {
+  Alias,
+  Else,
+  Export,
+  If,
+  Set,
+  Shell,
+}
+
+impl Keyword {
+  pub(crate) fn from_lexeme(lexeme: &str) -> Option<Keyword> {
+    lexeme.parse().ok()
+  }
+
+  pub(crate) fn lexeme(self) -> &'static str {
+    self.into()
+  }
+}
+
+impl<'a> PartialEq<&'a str> for Keyword {
+  fn eq(&self, other: &&'a str) -> bool {
+    self.lexeme() == *other
+  }
+}
diff --git a/src/lexer.rs b/src/lexer.rs
index c5189f3c..9b763d95 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -98,6 +98,25 @@ impl<'src> Lexer<'src> {
     self.token_end.offset - self.token_start.offset
   }
 
+  fn accepted(&mut self, c: char) -> CompilationResult<'src, bool> {
+    if self.next_is(c) {
+      self.advance()?;
+      Ok(true)
+    } else {
+      Ok(false)
+    }
+  }
+
+  fn presume(&mut self, c: char) -> CompilationResult<'src, ()> {
+    if !self.next_is(c) {
+      return Err(self.internal_error(format!("Lexer presumed character `{}`", c)));
+    }
+
+    self.advance()?;
+
+    Ok(())
+  }
+
   /// Is next character c?
   fn next_is(&self, c: char) -> bool {
     self.next == Some(c)
@@ -430,17 +449,18 @@ impl<'src> Lexer<'src> {
   /// Lex token beginning with `start` outside of a recipe body
   fn lex_normal(&mut self, start: char) -> CompilationResult<'src, ()> {
     match start {
+      '!' => self.lex_bang(),
       '*' => self.lex_single(Asterisk),
       '@' => self.lex_single(At),
       '[' => self.lex_single(BracketL),
       ']' => self.lex_single(BracketR),
-      '=' => self.lex_single(Equals),
+      '=' => self.lex_choice('=', EqualsEquals, Equals),
       ',' => self.lex_single(Comma),
       ':' => self.lex_colon(),
       '(' => self.lex_single(ParenL),
       ')' => self.lex_single(ParenR),
-      '{' => self.lex_brace_l(),
-      '}' => self.lex_brace_r(),
+      '{' => self.lex_single(BraceL),
+      '}' => self.lex_single(BraceR),
       '+' => self.lex_single(Plus),
       '\n' => self.lex_single(Eol),
       '\r' => self.lex_cr_lf(),
@@ -449,13 +469,11 @@ impl<'src> Lexer<'src> {
       ' ' | '\t' => self.lex_whitespace(),
       '\'' => self.lex_raw_string(),
       '"' => self.lex_cooked_string(),
-      _ =>
-        if Self::is_identifier_start(start) {
-          self.lex_identifier()
-        } else {
-          self.advance()?;
-          Err(self.error(UnknownStartOfToken))
-        },
+      _ if Self::is_identifier_start(start) => self.lex_identifier(),
+      _ => {
+        self.advance()?;
+        Err(self.error(UnknownStartOfToken))
+      },
     }
   }
 
@@ -465,7 +483,6 @@ impl<'src> Lexer<'src> {
     interpolation_start: Token<'src>,
     start: char,
   ) -> CompilationResult<'src, ()> {
-    // Check for end of interpolation
     if self.rest_starts_with("}}") {
       // end current interpolation
       self.interpolation_start = None;
@@ -537,14 +554,14 @@ impl<'src> Lexer<'src> {
     self.recipe_body = false;
   }
 
-  /// Lex a single character token
+  /// Lex a single-character token
   fn lex_single(&mut self, kind: TokenKind) -> CompilationResult<'src, ()> {
     self.advance()?;
     self.token(kind);
     Ok(())
   }
 
-  /// Lex a double character token
+  /// Lex a double-character token
   fn lex_double(&mut self, kind: TokenKind) -> CompilationResult<'src, ()> {
     self.advance()?;
     self.advance()?;
@@ -552,58 +569,72 @@ impl<'src> Lexer<'src> {
     Ok(())
   }
 
-  /// Lex a token starting with ':'
-  fn lex_colon(&mut self) -> CompilationResult<'src, ()> {
+  /// Lex a double-character token of kind `then` if the second character of
+  /// that token would be `second`, otherwise lex a single-character token of
+  /// kind `otherwise`
+  fn lex_choice(
+    &mut self,
+    second: char,
+    then: TokenKind,
+    otherwise: TokenKind,
+  ) -> CompilationResult<'src, ()> {
     self.advance()?;
 
-    if self.next_is('=') {
-      self.advance()?;
-      self.token(ColonEquals);
+    if self.accepted(second)? {
+      self.token(then);
     } else {
-      self.token(Colon);
-      self.recipe_body_pending = true;
+      self.token(otherwise);
     }
 
     Ok(())
   }
 
-  /// Lex a token starting with '{'
-  fn lex_brace_l(&mut self) -> CompilationResult<'src, ()> {
-    if !self.rest_starts_with("{{") {
-      self.advance()?;
+  /// Lex a token starting with '!'
+  fn lex_bang(&mut self) -> CompilationResult<'src, ()> {
+    self.presume('!')?;
 
-      return Err(self.error(UnknownStartOfToken));
+    if self.accepted('=')? {
+      self.token(BangEquals);
+      Ok(())
+    } else {
+      // Emit an unspecified token to consume the current character,
+      self.token(Unspecified);
+      // …and advance past another character,
+      self.advance()?;
+      // …so that the error we produce highlights the unexpected character.
+      Err(self.error(UnexpectedCharacter { expected: '=' }))
     }
-
-    self.lex_double(InterpolationStart)
   }
 
-  /// Lex a token starting with '}'
-  fn lex_brace_r(&mut self) -> CompilationResult<'src, ()> {
-    if !self.rest_starts_with("}}") {
-      self.advance()?;
+  /// Lex a token starting with ':'
+  fn lex_colon(&mut self) -> CompilationResult<'src, ()> {
+    self.presume(':')?;
 
-      return Err(self.error(UnknownStartOfToken));
+    if self.accepted('=')? {
+      self.token(ColonEquals);
+    } else {
+      self.token(Colon);
+      self.recipe_body_pending = true;
     }
 
-    self.lex_double(InterpolationEnd)
+    Ok(())
   }
 
   /// Lex a carriage return and line feed
   fn lex_cr_lf(&mut self) -> CompilationResult<'src, ()> {
-    if !self.rest_starts_with("\r\n") {
-      // advance over \r
-      self.advance()?;
+    self.presume('\r')?;
 
+    if !self.accepted('\n')? {
       return Err(self.error(UnpairedCarriageReturn));
     }
 
-    self.lex_double(Eol)
+    self.token(Eol);
+
+    Ok(())
   }
 
   /// Lex name: [a-zA-Z_][a-zA-Z0-9_]*
   fn lex_identifier(&mut self) -> CompilationResult<'src, ()> {
-    // advance over initial character
     self.advance()?;
 
     while let Some(c) = self.next {
@@ -621,8 +652,7 @@ impl<'src> Lexer<'src> {
 
   /// Lex comment: #[^\r\n]
   fn lex_comment(&mut self) -> CompilationResult<'src, ()> {
-    // advance over #
-    self.advance()?;
+    self.presume('#')?;
 
     while !self.at_eol_or_eof() {
       self.advance()?;
@@ -665,8 +695,7 @@ impl<'src> Lexer<'src> {
 
   /// Lex raw string: '[^']*'
   fn lex_raw_string(&mut self) -> CompilationResult<'src, ()> {
-    // advance over opening '
-    self.advance()?;
+    self.presume('\'')?;
 
     loop {
       match self.next {
@@ -678,8 +707,7 @@ impl<'src> Lexer<'src> {
       self.advance()?;
     }
 
-    // advance over closing '
-    self.advance()?;
+    self.presume('\'')?;
 
     self.token(StringRaw);
 
@@ -688,8 +716,7 @@ impl<'src> Lexer<'src> {
 
   /// Lex cooked string: "[^"\n\r]*" (also processes escape sequences)
   fn lex_cooked_string(&mut self) -> CompilationResult<'src, ()> {
-    // advance over opening "
-    self.advance()?;
+    self.presume('"')?;
 
     let mut escape = false;
 
@@ -803,6 +830,9 @@ mod tests {
       // Fixed lexemes
       Asterisk => "*",
       At => "@",
+      BangEquals => "!=",
+      BraceL => "{",
+      BraceR => "}",
       BracketL => "[",
       BracketR => "]",
       Colon => ":",
@@ -810,6 +840,7 @@ mod tests {
       Comma => ",",
       Eol => "\n",
       Equals => "=",
+      EqualsEquals => "==",
       Indent => "  ",
       InterpolationEnd => "}}",
       InterpolationStart => "{{",
@@ -901,6 +932,48 @@ mod tests {
     tokens: (StringCooked:"\"hello\""),
   }
 
+  test! {
+    name:   equals,
+    text:   "=",
+    tokens: (Equals),
+  }
+
+  test! {
+    name:   equals_equals,
+    text:   "==",
+    tokens: (EqualsEquals),
+  }
+
+  test! {
+    name:   bang_equals,
+    text:   "!=",
+    tokens: (BangEquals),
+  }
+
+  test! {
+    name:   brace_l,
+    text:   "{",
+    tokens: (BraceL),
+  }
+
+  test! {
+    name:   brace_r,
+    text:   "}",
+    tokens: (BraceR),
+  }
+
+  test! {
+    name:   brace_lll,
+    text:   "{{{",
+    tokens: (BraceL, BraceL, BraceL),
+  }
+
+  test! {
+    name:   brace_rrr,
+    text:   "}}}",
+    tokens: (BraceR, BraceR, BraceR),
+  }
+
   test! {
     name:   export_concatination,
     text:   "export foo = 'foo' + 'bar'",
@@ -1965,4 +2038,47 @@ mod tests {
     width:  2,
     kind:   UnterminatedInterpolation,
   }
+
+  error! {
+    name:   unexpected_character_after_bang,
+    input:  "!{",
+    offset: 1,
+    line:   0,
+    column: 1,
+    width:  1,
+    kind:   UnexpectedCharacter { expected: '=' },
+  }
+
+  #[test]
+  fn presume_error() {
+    assert_matches!(
+      Lexer::new("!").presume('-').unwrap_err(),
+      CompilationError {
+        token: Token {
+          offset: 0,
+          line:   0,
+          column: 0,
+          length: 0,
+          src:    "!",
+          kind:   Unspecified,
+        },
+        kind:  Internal {
+          message,
+        },
+      } if message == "Lexer presumed character `-`"
+    );
+
+    assert_eq!(
+      Lexer::new("!").presume('-').unwrap_err().to_string(),
+      testing::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 | !
+          | ^"
+      ),
+    );
+  }
 }
diff --git a/src/node.rs b/src/node.rs
index 4a65dff6..55476a57 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -28,7 +28,7 @@ impl<'src> Node<'src> for Item<'src> {
 
 impl<'src> Node<'src> for Alias<'src, Name<'src>> {
   fn tree(&self) -> Tree<'src> {
-    Tree::atom(keyword::ALIAS)
+    Tree::atom(Keyword::Alias.lexeme())
       .push(self.name.lexeme())
       .push(self.target.lexeme())
   }
@@ -37,7 +37,9 @@ impl<'src> Node<'src> for Alias<'src, Name<'src>> {
 impl<'src> Node<'src> for Assignment<'src> {
   fn tree(&self) -> Tree<'src> {
     if self.export {
-      Tree::atom("assignment").push("#").push(keyword::EXPORT)
+      Tree::atom("assignment")
+        .push("#")
+        .push(Keyword::Export.lexeme())
     } else {
       Tree::atom("assignment")
     }
@@ -50,6 +52,25 @@ impl<'src> Node<'src> for Expression<'src> {
   fn tree(&self) -> Tree<'src> {
     match self {
       Expression::Concatination { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()),
+      Expression::Conditional {
+        lhs,
+        rhs,
+        then,
+        otherwise,
+        inverted,
+      } => {
+        let mut tree = Tree::atom(Keyword::If.lexeme());
+        tree.push_mut(lhs.tree());
+        if *inverted {
+          tree.push_mut("!=")
+        } else {
+          tree.push_mut("==")
+        }
+        tree.push_mut(rhs.tree());
+        tree.push_mut(then.tree());
+        tree.push_mut(otherwise.tree());
+        tree
+      },
       Expression::Call { thunk } => {
         let mut tree = Tree::atom("call");
 
@@ -164,7 +185,7 @@ impl<'src> Node<'src> for Fragment<'src> {
 
 impl<'src> Node<'src> for Set<'src> {
   fn tree(&self) -> Tree<'src> {
-    let mut set = Tree::atom(keyword::SET);
+    let mut set = Tree::atom(Keyword::Set.lexeme());
 
     set.push_mut(self.name.lexeme());
 
diff --git a/src/parser.rs b/src/parser.rs
index 02d95519..69dcbfc8 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -172,9 +172,20 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     self.expect(Eol).map(|_| ())
   }
 
+  fn expect_keyword(&mut self, expected: Keyword) -> CompilationResult<'src, ()> {
+    let identifier = self.expect(Identifier)?;
+    let found = identifier.lexeme();
+
+    if expected == found {
+      Ok(())
+    } else {
+      Err(identifier.error(CompilationErrorKind::ExpectedKeyword { expected, found }))
+    }
+  }
+
   /// Return an internal error if the next token is not of kind `Identifier`
   /// with lexeme `lexeme`.
-  fn presume_name(&mut self, lexeme: &str) -> CompilationResult<'src, ()> {
+  fn presume_keyword(&mut self, keyword: Keyword) -> CompilationResult<'src, ()> {
     let next = self.advance()?;
 
     if next.kind != Identifier {
@@ -182,10 +193,10 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
         "Presumed next token would have kind {}, but found {}",
         Identifier, next.kind
       ))?)
-    } else if next.lexeme() != lexeme {
+    } else if keyword != next.lexeme() {
       Err(self.internal_error(format!(
         "Presumed next token would have lexeme \"{}\", but found \"{}\"",
-        lexeme,
+        keyword,
         next.lexeme(),
       ))?)
     } else {
@@ -253,6 +264,17 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     }
   }
 
+  fn accepted_keyword(&mut self, keyword: Keyword) -> CompilationResult<'src, bool> {
+    let next = self.next()?;
+
+    if next.kind == Identifier && next.lexeme() == keyword.lexeme() {
+      self.advance()?;
+      Ok(true)
+    } else {
+      Ok(false)
+    }
+  }
+
   /// Accept a dependency
   fn accept_dependency(&mut self) -> CompilationResult<'src, Option<UnresolvedDependency<'src>>> {
     if let Some(recipe) = self.accept_name()? {
@@ -297,8 +319,8 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
       } else if self.accepted(Eof)? {
         break;
       } else if self.next_is(Identifier) {
-        match next.lexeme() {
-          keyword::ALIAS =>
+        match Keyword::from_lexeme(next.lexeme()) {
+          Some(Keyword::Alias) =>
             if self.next_are(&[Identifier, Identifier, Equals]) {
               warnings.push(Warning::DeprecatedEquals {
                 equals: self.get(2)?,
@@ -309,20 +331,20 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
             } else {
               items.push(Item::Recipe(self.parse_recipe(doc, false)?));
             },
-          keyword::EXPORT =>
+          Some(Keyword::Export) =>
             if self.next_are(&[Identifier, Identifier, Equals]) {
               warnings.push(Warning::DeprecatedEquals {
                 equals: self.get(2)?,
               });
-              self.presume_name(keyword::EXPORT)?;
+              self.presume_keyword(Keyword::Export)?;
               items.push(Item::Assignment(self.parse_assignment(true)?));
             } else if self.next_are(&[Identifier, Identifier, ColonEquals]) {
-              self.presume_name(keyword::EXPORT)?;
+              self.presume_keyword(Keyword::Export)?;
               items.push(Item::Assignment(self.parse_assignment(true)?));
             } else {
               items.push(Item::Recipe(self.parse_recipe(doc, false)?));
             },
-          keyword::SET =>
+          Some(Keyword::Set) =>
             if self.next_are(&[Identifier, Identifier, ColonEquals]) {
               items.push(Item::Set(self.parse_set()?));
             } else {
@@ -363,7 +385,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>>> {
-    self.presume_name(keyword::ALIAS)?;
+    self.presume_keyword(Keyword::Alias)?;
     let name = self.parse_name()?;
     self.presume_any(&[Equals, ColonEquals])?;
     let target = self.parse_name()?;
@@ -386,6 +408,40 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Parse an expression, e.g. `1 + 2`
   fn parse_expression(&mut self) -> CompilationResult<'src, Expression<'src>> {
+    if self.accepted_keyword(Keyword::If)? {
+      let lhs = self.parse_expression()?;
+
+      let inverted = self.accepted(BangEquals)?;
+
+      if !inverted {
+        self.expect(EqualsEquals)?;
+      }
+
+      let rhs = self.parse_expression()?;
+
+      self.expect(BraceL)?;
+
+      let then = self.parse_expression()?;
+
+      self.expect(BraceR)?;
+
+      self.expect_keyword(Keyword::Else)?;
+
+      self.expect(BraceL)?;
+
+      let otherwise = self.parse_expression()?;
+
+      self.expect(BraceR)?;
+
+      return Ok(Expression::Conditional {
+        lhs: Box::new(lhs),
+        rhs: Box::new(rhs),
+        then: Box::new(then),
+        otherwise: Box::new(otherwise),
+        inverted,
+      });
+    }
+
     let value = self.parse_value()?;
 
     if self.accepted(Plus)? {
@@ -619,37 +675,36 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Parse a setting
   fn parse_set(&mut self) -> CompilationResult<'src, Set<'src>> {
-    self.presume_name(keyword::SET)?;
+    self.presume_keyword(Keyword::Set)?;
     let name = Name::from_identifier(self.presume(Identifier)?);
     self.presume(ColonEquals)?;
-    match name.lexeme() {
-      keyword::SHELL => {
-        self.expect(BracketL)?;
+    if name.lexeme() == Keyword::Shell.lexeme() {
+      self.expect(BracketL)?;
 
-        let command = self.parse_string_literal()?;
+      let command = self.parse_string_literal()?;
 
-        let mut arguments = Vec::new();
+      let mut arguments = Vec::new();
 
-        if self.accepted(Comma)? {
-          while !self.next_is(BracketR) {
-            arguments.push(self.parse_string_literal()?);
+      if self.accepted(Comma)? {
+        while !self.next_is(BracketR) {
+          arguments.push(self.parse_string_literal()?);
 
-            if !self.accepted(Comma)? {
-              break;
-            }
+          if !self.accepted(Comma)? {
+            break;
           }
         }
+      }
 
-        self.expect(BracketR)?;
+      self.expect(BracketR)?;
 
-        Ok(Set {
-          value: Setting::Shell(setting::Shell { command, arguments }),
-          name,
-        })
-      },
-      _ => Err(name.error(CompilationErrorKind::UnknownSetting {
+      Ok(Set {
+        value: Setting::Shell(setting::Shell { command, arguments }),
+        name,
+      })
+    } else {
+      Err(name.error(CompilationErrorKind::UnknownSetting {
         setting: name.lexeme(),
-      })),
+      }))
     }
   }
 }
@@ -1509,6 +1564,48 @@ mod tests {
     tree: (justfile (set shell "bash" "-cu" "-l")),
   }
 
+  test! {
+    name: conditional,
+    text: "a := if b == c { d } else { e }",
+    tree: (justfile (assignment a (if b == c d e))),
+  }
+
+  test! {
+    name: conditional_inverted,
+    text: "a := if b != c { d } else { e }",
+    tree: (justfile (assignment a (if b != c d e))),
+  }
+
+  test! {
+    name: conditional_concatinations,
+    text: "a := if b0 + b1 == c0 + c1 { d0 + d1 } else { e0 + e1 }",
+    tree: (justfile (assignment a (if (+ b0 b1) == (+ c0 c1) (+ d0 d1) (+ e0 e1)))),
+  }
+
+  test! {
+    name: conditional_nested_lhs,
+    text: "a := if if b == c { d } else { e } == c { d } else { e }",
+    tree: (justfile (assignment a (if (if b == c d e) == c d e))),
+  }
+
+  test! {
+    name: conditional_nested_rhs,
+    text: "a := if c == if b == c { d } else { e } { d } else { e }",
+    tree: (justfile (assignment a (if c == (if b == c d e) d e))),
+  }
+
+  test! {
+    name: conditional_nested_then,
+    text: "a := if b == c { if b == c { d } else { e } } else { e }",
+    tree: (justfile (assignment a (if b == c (if b == c d e) e))),
+  }
+
+  test! {
+    name: conditional_nested_otherwise,
+    text: "a := if b == c { d } else { if b == c { d } else { e } }",
+    tree: (justfile (assignment a (if b == c d (if b == c d e)))),
+  }
+
   error! {
     name: alias_syntax_multiple_rhs,
     input: "alias foo = bar baz",
@@ -1576,15 +1673,15 @@ mod tests {
   }
 
   error! {
-    name:   interpolation_outside_of_recipe,
+    name:   unexpected_brace,
     input:  "{{",
     offset:  0,
     line:   0,
     column: 0,
-    width:  2,
+    width:  1,
     kind: UnexpectedToken {
       expected: vec![At, Comment, Eof, Eol, Identifier],
-      found: InterpolationStart,
+      found: BraceL,
     },
   }
 
diff --git a/src/summary.rs b/src/summary.rs
index dd8e9986..71d524ae 100644
--- a/src/summary.rs
+++ b/src/summary.rs
@@ -193,6 +193,13 @@ pub enum Expression {
     lhs: Box<Expression>,
     rhs: Box<Expression>,
   },
+  Conditional {
+    lhs:       Box<Expression>,
+    rhs:       Box<Expression>,
+    then:      Box<Expression>,
+    otherwise: Box<Expression>,
+    inverted:  bool,
+  },
   String {
     text: String,
   },
@@ -228,6 +235,19 @@ impl Expression {
         lhs: Box::new(Expression::new(lhs)),
         rhs: Box::new(Expression::new(rhs)),
       },
+      Conditional {
+        lhs,
+        rhs,
+        inverted,
+        then,
+        otherwise,
+      } => Expression::Conditional {
+        lhs:       Box::new(Expression::new(lhs)),
+        rhs:       Box::new(Expression::new(rhs)),
+        then:      Box::new(Expression::new(lhs)),
+        otherwise: Box::new(Expression::new(rhs)),
+        inverted:  *inverted,
+      },
       StringLiteral { string_literal } => Expression::String {
         text: string_literal.cooked.to_string(),
       },
diff --git a/src/token_kind.rs b/src/token_kind.rs
index 30dd34df..ce225aee 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -5,6 +5,9 @@ pub(crate) enum TokenKind {
   Asterisk,
   At,
   Backtick,
+  BangEquals,
+  BraceL,
+  BraceR,
   BracketL,
   BracketR,
   Colon,
@@ -15,6 +18,7 @@ pub(crate) enum TokenKind {
   Eof,
   Eol,
   Equals,
+  EqualsEquals,
   Identifier,
   Indent,
   InterpolationEnd,
@@ -36,6 +40,9 @@ impl Display for TokenKind {
       Asterisk => "'*'",
       At => "'@'",
       Backtick => "backtick",
+      BangEquals => "'!='",
+      BraceL => "'{'",
+      BraceR => "'}'",
       BracketL => "'['",
       BracketR => "']'",
       Colon => "':'",
@@ -46,6 +53,7 @@ impl Display for TokenKind {
       Eof => "end of file",
       Eol => "end of line",
       Equals => "'='",
+      EqualsEquals => "'=='",
       Identifier => "identifier",
       Indent => "indent",
       InterpolationEnd => "'}}'",
diff --git a/src/tree.rs b/src/tree.rs
index b71c6edb..0d929601 100644
--- a/src/tree.rs
+++ b/src/tree.rs
@@ -41,6 +41,18 @@ macro_rules! tree {
   } => {
     $crate::tree::Tree::atom("*")
   };
+
+  {
+    ==
+  } => {
+    $crate::tree::Tree::atom("==")
+  };
+
+  {
+    !=
+  } => {
+    $crate::tree::Tree::atom("!=")
+  };
 }
 
 /// A `Tree` is either…
diff --git a/src/variables.rs b/src/variables.rs
index ff836824..586d928c 100644
--- a/src/variables.rs
+++ b/src/variables.rs
@@ -19,6 +19,19 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
       | Some(Expression::StringLiteral { .. })
       | Some(Expression::Backtick { .. })
       | Some(Expression::Call { .. }) => None,
+      Some(Expression::Conditional {
+        lhs,
+        rhs,
+        then,
+        otherwise,
+        ..
+      }) => {
+        self.stack.push(lhs);
+        self.stack.push(rhs);
+        self.stack.push(then);
+        self.stack.push(otherwise);
+        self.next()
+      },
       Some(Expression::Variable { name, .. }) => Some(name.token()),
       Some(Expression::Concatination { lhs, rhs }) => {
         self.stack.push(lhs);


Test patch:
diff --git a/src/testing.rs b/src/testing.rs
index 28129b45..455ff8bd 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -113,3 +113,16 @@ macro_rules! run_error {
     }
   };
 }
+
+macro_rules! assert_matches {
+  ($expression:expr, $( $pattern:pat )|+ $( if $guard:expr )?) => {
+    match $expression {
+      $( $pattern )|+ $( if $guard )? => {}
+      left => panic!(
+        "assertion failed: (left ~= right)\n  left: `{:?}`\n right: `{}`",
+        left,
+        stringify!($($pattern)|+ $(if $guard)?)
+      ),
+    }
+  }
+}
diff --git a/tests/conditional.rs b/tests/conditional.rs
new file mode 100644
index 00000000..7e02c632
--- /dev/null
+++ b/tests/conditional.rs
@@ -0,0 +1,158 @@
+use crate::common::*;
+
+test! {
+  name: then_branch_unevaluated,
+  justfile: "
+    foo:
+      echo {{ if 'a' == 'b' { `exit 1` } else { 'otherwise' } }}
+  ",
+  stdout: "otherwise\n",
+  stderr: "echo otherwise\n",
+}
+
+test! {
+  name: otherwise_branch_unevaluated,
+  justfile: "
+    foo:
+      echo {{ if 'a' == 'a' { 'then' } else { `exit 1` } }}
+  ",
+  stdout: "then\n",
+  stderr: "echo then\n",
+}
+
+test! {
+  name: otherwise_branch_unevaluated_inverted,
+  justfile: "
+    foo:
+      echo {{ if 'a' != 'b' { 'then' } else { `exit 1` } }}
+  ",
+  stdout: "then\n",
+  stderr: "echo then\n",
+}
+
+test! {
+  name: then_branch_unevaluated_inverted,
+  justfile: "
+    foo:
+      echo {{ if 'a' != 'a' { `exit 1` } else { 'otherwise' } }}
+  ",
+  stdout: "otherwise\n",
+  stderr: "echo otherwise\n",
+}
+
+test! {
+  name: complex_expressions,
+  justfile: "
+    foo:
+      echo {{ if 'a' + 'b' == `echo ab` { 'c' + 'd' } else { 'e' + 'f' } }}
+  ",
+  stdout: "cd\n",
+  stderr: "echo cd\n",
+}
+
+test! {
+  name: undefined_lhs,
+  justfile: "
+    a := if b == '' { '' } else { '' }
+
+    foo:
+      echo {{ a }}
+  ",
+  stdout: "",
+  stderr: "
+    error: Variable `b` not defined
+      |
+    1 | a := if b == '' { '' } else { '' }
+      |         ^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: undefined_rhs,
+  justfile: "
+    a := if '' == b { '' } else { '' }
+
+    foo:
+      echo {{ a }}
+  ",
+  stdout: "",
+  stderr: "
+    error: Variable `b` not defined
+      |
+    1 | a := if '' == b { '' } else { '' }
+      |               ^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: undefined_then,
+  justfile: "
+    a := if '' == '' { b } else { '' }
+
+    foo:
+      echo {{ a }}
+  ",
+  stdout: "",
+  stderr: "
+    error: Variable `b` not defined
+      |
+    1 | a := if '' == '' { b } else { '' }
+      |                    ^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: undefined_otherwise,
+  justfile: "
+    a := if '' == '' { '' } else { b }
+
+    foo:
+      echo {{ a }}
+  ",
+  stdout: "",
+  stderr: "
+    error: Variable `b` not defined
+      |
+    1 | a := if '' == '' { '' } else { b }
+      |                                ^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: unexpected_op,
+  justfile: "
+    a := if '' a '' { '' } else { b }
+
+    foo:
+      echo {{ a }}
+  ",
+  stdout: "",
+  stderr: "
+    error: Expected '!=', '==', or '+', but found identifier
+      |
+    1 | a := if '' a '' { '' } else { b }
+      |            ^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: dump,
+  justfile: "
+    a := if '' == '' { '' } else { '' }
+
+    foo:
+      echo {{ a }}
+  ",
+  args: ("--dump"),
+  stdout: format!("
+    a := if '' == '' {{ '' }} else {{ '' }}{}
+
+    foo:
+        echo {{{{a}}}}
+  ", " ").as_str(),
+}
diff --git a/tests/error_messages.rs b/tests/error_messages.rs
new file mode 100644
index 00000000..e4b2e1d9
--- /dev/null
+++ b/tests/error_messages.rs
@@ -0,0 +1,25 @@
+use crate::common::*;
+
+test! {
+  name: expected_keyword,
+  justfile: "foo := if '' == '' { '' } arlo { '' }",
+  stderr: "
+    error: Expected keyword `else` but found identifier `arlo`
+      |
+    1 | foo := if '' == '' { '' } arlo { '' }
+      |                           ^^^^
+  ",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: unexpected_character,
+  justfile: "!~",
+  stderr: "
+    error: Expected character `=`
+      |
+    1 | !~
+      |  ^
+  ",
+  status: EXIT_FAILURE,
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index 17095d26..a89f5c69 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -5,8 +5,10 @@ mod common;
 
 mod choose;
 mod completions;
+mod conditional;
 mod dotenv;
 mod edit;
+mod error_messages;
 mod examples;
 mod init;
 mod interrupts;