Repository: casey/just
Pull request #2444 (2024-10-31 05:03:12)

Add `&&` and `||` operators


Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index e5847f50..00721f15 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -90,7 +90,13 @@ import        : 'import' '?'? string? eol
 
 module        : 'mod' '?'? NAME string? eol
 
-expression    : 'if' condition '{' expression '}' 'else' '{' expression '}'
+expression    : disjunct || expression
+              | disjunct
+
+disjunct      : conjunct && disjunct
+              | conjunct
+
+conjunct      : 'if' condition '{' expression '}' 'else' '{' expression '}'
               | 'assert' '(' condition ',' expression ')'
               | '/' expression
               | value '/' expression
diff --git a/README.md b/README.md
index ca596854..bccf9a13 100644
--- a/README.md
+++ b/README.md
@@ -1290,9 +1290,11 @@ Available recipes:
     test
 ```
 
-### Variables and Substitution
+### Expressions and Substitutions
 
-Variables, strings, concatenation, path joining, substitution using `{{…}}`, and function calls are supported:
+Various operators and function calls are supported in expressions, which may be
+used in assignments, default recipe arguments, and inside recipe body `{{…}}`
+substitutions.
 
 ```just
 tmpdir  := `mktemp -d`
@@ -1310,6 +1312,39 @@ publish:
   rm -rf {{tarball}} {{tardir}}
 ```
 
+#### Concatenation
+
+The `+` operator returns the left-hand argument concatenated with the
+right-hand argument:
+
+```just
+foobar := 'foo' + 'bar'
+```
+
+#### Logical Operators
+
+The logical operators `&&` and `||` can be used to coalesce string
+values<sup>master</sup>, similar to Python's `and` and `or`. These operators
+consider the empty string `''` to be false, and all other strings to be true.
+
+These operators are currently unstable.
+
+The `&&` operator returns the empty string if the left-hand argument is the
+empty string, otherwise it returns the right-hand argument:
+
+```mf
+foo := '' && 'goodbye'      # ''
+bar := 'hello' && 'goodbye' # 'goodbye'
+```
+
+The `||` operator returns the left-hand argument if it is non-empty, otherwise
+it returns the right-hand argument:
+
+```mf
+foo := '' || 'goodbye'      # 'goodbye'
+bar := 'hello' || 'goodbye' # 'hello'
+```
+
 #### Joining Paths
 
 The `/` operator can be used to join two strings with a slash:
@@ -2367,8 +2402,8 @@ Testing server:unit…
 ./test --tests unit server
 ```
 
-Default values may be arbitrary expressions, but concatenations or path joins
-must be parenthesized:
+Default values may be arbitrary expressions, but expressions containing the
+`+`, `&&`, `||`, or `/` operators must be parenthesized:
 
 ```just
 arch := "wasm"
diff --git a/src/analyzer.rs b/src/analyzer.rs
index 9a26ef04..0929294c 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -36,12 +36,15 @@ impl<'run, 'src> Analyzer<'run, 'src> {
   ) -> CompileResult<'src, Justfile<'src>> {
     let mut definitions = HashMap::new();
     let mut imports = HashSet::new();
+    let mut unstable_features = BTreeSet::new();
 
     let mut stack = Vec::new();
     let ast = asts.get(root).unwrap();
     stack.push(ast);
 
     while let Some(ast) = stack.pop() {
+      unstable_features.extend(&ast.unstable_features);
+
       for item in &ast.items {
         match item {
           Item::Alias(alias) => {
@@ -166,8 +169,6 @@ impl<'run, 'src> Analyzer<'run, 'src> {
       aliases.insert(Self::resolve_alias(&recipes, alias)?);
     }
 
-    let mut unstable_features = BTreeSet::new();
-
     for recipe in recipes.values() {
       for attribute in &recipe.attributes {
         if let Attribute::Script(_) = attribute {
diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs
index 53863fc9..511464d4 100644
--- a/src/assignment_resolver.rs
+++ b/src/assignment_resolver.rs
@@ -31,7 +31,29 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
     self.stack.push(name);
 
     if let Some(assignment) = self.assignments.get(name) {
-      self.resolve_expression(&assignment.value)?;
+      for variable in assignment.value.variables() {
+        let name = variable.lexeme();
+
+        if self.evaluated.contains(name) || constants().contains_key(name) {
+          continue;
+        }
+
+        if self.stack.contains(&name) {
+          self.stack.push(name);
+          return Err(
+            self.assignments[name]
+              .name
+              .error(CircularVariableDependency {
+                variable: name,
+                circle: self.stack.clone(),
+              }),
+          );
+        } else if self.assignments.contains_key(name) {
+          self.resolve_assignment(name)?;
+        } else {
+          return Err(variable.error(UndefinedVariable { variable: name }));
+        }
+      }
       self.evaluated.insert(name);
     } else {
       let message = format!("attempted to resolve unknown assignment `{name}`");
@@ -51,112 +73,6 @@ impl<'src: 'run, 'run> AssignmentResolver<'src, 'run> {
 
     Ok(())
   }
-
-  fn resolve_expression(&mut self, expression: &Expression<'src>) -> CompileResult<'src> {
-    match expression {
-      Expression::Assert {
-        condition: Condition {
-          lhs,
-          rhs,
-          operator: _,
-        },
-        error,
-      } => {
-        self.resolve_expression(lhs)?;
-        self.resolve_expression(rhs)?;
-        self.resolve_expression(error)
-      }
-      Expression::Call { thunk } => match thunk {
-        Thunk::Nullary { .. } => Ok(()),
-        Thunk::Unary { arg, .. } => self.resolve_expression(arg),
-        Thunk::UnaryOpt { args: (a, b), .. } => {
-          self.resolve_expression(a)?;
-          if let Some(b) = b.as_ref() {
-            self.resolve_expression(b)?;
-          }
-          Ok(())
-        }
-        Thunk::UnaryPlus {
-          args: (a, rest), ..
-        } => {
-          self.resolve_expression(a)?;
-          for arg in rest {
-            self.resolve_expression(arg)?;
-          }
-          Ok(())
-        }
-        Thunk::Binary { args: [a, b], .. } => {
-          self.resolve_expression(a)?;
-          self.resolve_expression(b)
-        }
-        Thunk::BinaryPlus {
-          args: ([a, b], rest),
-          ..
-        } => {
-          self.resolve_expression(a)?;
-          self.resolve_expression(b)?;
-          for arg in rest {
-            self.resolve_expression(arg)?;
-          }
-          Ok(())
-        }
-        Thunk::Ternary {
-          args: [a, b, c], ..
-        } => {
-          self.resolve_expression(a)?;
-          self.resolve_expression(b)?;
-          self.resolve_expression(c)
-        }
-      },
-      Expression::Concatenation { lhs, rhs } => {
-        self.resolve_expression(lhs)?;
-        self.resolve_expression(rhs)
-      }
-      Expression::Conditional {
-        condition: Condition {
-          lhs,
-          rhs,
-          operator: _,
-        },
-        then,
-        otherwise,
-        ..
-      } => {
-        self.resolve_expression(lhs)?;
-        self.resolve_expression(rhs)?;
-        self.resolve_expression(then)?;
-        self.resolve_expression(otherwise)
-      }
-      Expression::Group { contents } => self.resolve_expression(contents),
-      Expression::Join { lhs, rhs } => {
-        if let Some(lhs) = lhs {
-          self.resolve_expression(lhs)?;
-        }
-        self.resolve_expression(rhs)
-      }
-      Expression::StringLiteral { .. } | Expression::Backtick { .. } => Ok(()),
-      Expression::Variable { name } => {
-        let variable = name.lexeme();
-        if self.evaluated.contains(variable) || constants().contains_key(variable) {
-          Ok(())
-        } else if self.stack.contains(&variable) {
-          self.stack.push(variable);
-          Err(
-            self.assignments[variable]
-              .name
-              .error(CircularVariableDependency {
-                variable,
-                circle: self.stack.clone(),
-              }),
-          )
-        } else if self.assignments.contains_key(variable) {
-          self.resolve_assignment(variable)
-        } else {
-          Err(name.token.error(UndefinedVariable { variable }))
-        }
-      }
-    }
-  }
 }
 
 #[cfg(test)]
diff --git a/src/ast.rs b/src/ast.rs
index f9dd10c9..1ad7a8aa 100644
--- a/src/ast.rs
+++ b/src/ast.rs
@@ -6,6 +6,7 @@ use super::*;
 #[derive(Debug, Clone)]
 pub(crate) struct Ast<'src> {
   pub(crate) items: Vec<Item<'src>>,
+  pub(crate) unstable_features: BTreeSet<UnstableFeature>,
   pub(crate) warnings: Vec<Warning>,
   pub(crate) working_directory: PathBuf,
 }
diff --git a/src/evaluator.rs b/src/evaluator.rs
index 4ed00036..d48cece8 100644
--- a/src/evaluator.rs
+++ b/src/evaluator.rs
@@ -84,24 +84,31 @@ impl<'src, 'run> Evaluator<'src, 'run> {
     expression: &Expression<'src>,
   ) -> RunResult<'src, String> {
     match expression {
-      Expression::Variable { name, .. } => {
-        let variable = name.lexeme();
-        if let Some(value) = self.scope.value(variable) {
-          Ok(value.to_owned())
-        } else if let Some(assignment) = self
-          .assignments
-          .and_then(|assignments| assignments.get(variable))
-        {
-          Ok(self.evaluate_assignment(assignment)?.to_owned())
+      Expression::And { lhs, rhs } => {
+        let lhs = self.evaluate_expression(lhs)?;
+        if lhs.is_empty() {
+          return Ok(String::new());
+        }
+        self.evaluate_expression(rhs)
+      }
+      Expression::Assert { condition, error } => {
+        if self.evaluate_condition(condition)? {
+          Ok(String::new())
         } else {
-          Err(Error::Internal {
-            message: format!("attempted to evaluate undefined variable `{variable}`"),
+          Err(Error::Assert {
+            message: self.evaluate_expression(error)?,
           })
         }
       }
+      Expression::Backtick { contents, token } => {
+        if self.context.config.dry_run {
+          Ok(format!("`{contents}`"))
+        } else {
+          Ok(self.run_backtick(contents, token)?)
+        }
+      }
       Expression::Call { thunk } => {
         use Thunk::*;
-
         let result = match thunk {
           Nullary { function, .. } => function(function::Context::new(self, thunk.name())),
           Unary { function, arg, .. } => {
@@ -118,7 +125,6 @@ impl<'src, 'run> Evaluator<'src, 'run> {
               Some(b) => Some(self.evaluate_expression(b)?),
               None => None,
             };
-
             function(function::Context::new(self, thunk.name()), &a, b.as_deref())
           }
           UnaryPlus {
@@ -175,20 +181,11 @@ impl<'src, 'run> Evaluator<'src, 'run> {
             function(function::Context::new(self, thunk.name()), &a, &b, &c)
           }
         };
-
         result.map_err(|message| Error::FunctionCall {
           function: thunk.name(),
           message,
         })
       }
-      Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.clone()),
-      Expression::Backtick { contents, token } => {
-        if self.context.config.dry_run {
-          Ok(format!("`{contents}`"))
-        } else {
-          Ok(self.run_backtick(contents, token)?)
-        }
-      }
       Expression::Concatenation { lhs, rhs } => {
         Ok(self.evaluate_expression(lhs)? + &self.evaluate_expression(rhs)?)
       }
@@ -209,12 +206,26 @@ impl<'src, 'run> Evaluator<'src, 'run> {
         lhs: Some(lhs),
         rhs,
       } => Ok(self.evaluate_expression(lhs)? + "/" + &self.evaluate_expression(rhs)?),
-      Expression::Assert { condition, error } => {
-        if self.evaluate_condition(condition)? {
-          Ok(String::new())
+      Expression::Or { lhs, rhs } => {
+        let lhs = self.evaluate_expression(lhs)?;
+        if !lhs.is_empty() {
+          return Ok(lhs);
+        }
+        self.evaluate_expression(rhs)
+      }
+      Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.clone()),
+      Expression::Variable { name, .. } => {
+        let variable = name.lexeme();
+        if let Some(value) = self.scope.value(variable) {
+          Ok(value.to_owned())
+        } else if let Some(assignment) = self
+          .assignments
+          .and_then(|assignments| assignments.get(variable))
+        {
+          Ok(self.evaluate_assignment(assignment)?.to_owned())
         } else {
-          Err(Error::Assert {
-            message: self.evaluate_expression(error)?,
+          Err(Error::Internal {
+            message: format!("attempted to evaluate undefined variable `{variable}`"),
           })
         }
       }
diff --git a/src/expression.rs b/src/expression.rs
index c1e4b5e0..3d5c3392 100644
--- a/src/expression.rs
+++ b/src/expression.rs
@@ -8,6 +8,11 @@ use super::*;
 /// The parser parses both values and expressions into `Expression`s.
 #[derive(PartialEq, Debug, Clone)]
 pub(crate) enum Expression<'src> {
+  /// `lhs && rhs`
+  And {
+    lhs: Box<Expression<'src>>,
+    rhs: Box<Expression<'src>>,
+  },
   /// `assert(condition, error)`
   Assert {
     condition: Condition<'src>,
@@ -38,6 +43,11 @@ pub(crate) enum Expression<'src> {
     lhs: Option<Box<Expression<'src>>>,
     rhs: Box<Expression<'src>>,
   },
+  /// `lhs || rhs`
+  Or {
+    lhs: Box<Expression<'src>>,
+    rhs: Box<Expression<'src>>,
+  },
   /// `"string_literal"` or `'string_literal'`
   StringLiteral { string_literal: StringLiteral<'src> },
   /// `variable`
@@ -53,23 +63,25 @@ impl<'src> Expression<'src> {
 impl<'src> Display for Expression<'src> {
   fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     match self {
+      Self::And { lhs, rhs } => write!(f, "{lhs} && {rhs}"),
       Self::Assert { condition, error } => write!(f, "assert({condition}, {error})"),
       Self::Backtick { token, .. } => write!(f, "{}", token.lexeme()),
-      Self::Join { lhs: None, rhs } => write!(f, "/ {rhs}"),
-      Self::Join {
-        lhs: Some(lhs),
-        rhs,
-      } => write!(f, "{lhs} / {rhs}"),
+      Self::Call { thunk } => write!(f, "{thunk}"),
       Self::Concatenation { lhs, rhs } => write!(f, "{lhs} + {rhs}"),
       Self::Conditional {
         condition,
         then,
         otherwise,
       } => write!(f, "if {condition} {{ {then} }} else {{ {otherwise} }}"),
+      Self::Group { contents } => write!(f, "({contents})"),
+      Self::Join { lhs: None, rhs } => write!(f, "/ {rhs}"),
+      Self::Join {
+        lhs: Some(lhs),
+        rhs,
+      } => write!(f, "{lhs} / {rhs}"),
+      Self::Or { lhs, rhs } => write!(f, "{lhs} || {rhs}"),
       Self::StringLiteral { string_literal } => write!(f, "{string_literal}"),
       Self::Variable { name } => write!(f, "{}", name.lexeme()),
-      Self::Call { thunk } => write!(f, "{thunk}"),
-      Self::Group { contents } => write!(f, "({contents})"),
     }
   }
 }
@@ -80,6 +92,13 @@ impl<'src> Serialize for Expression<'src> {
     S: Serializer,
   {
     match self {
+      Self::And { lhs, rhs } => {
+        let mut seq = serializer.serialize_seq(None)?;
+        seq.serialize_element("and")?;
+        seq.serialize_element(lhs)?;
+        seq.serialize_element(rhs)?;
+        seq.end()
+      }
       Self::Assert { condition, error } => {
         let mut seq: <S as Serializer>::SerializeSeq = serializer.serialize_seq(None)?;
         seq.serialize_element("assert")?;
@@ -101,13 +120,6 @@ impl<'src> Serialize for Expression<'src> {
         seq.serialize_element(rhs)?;
         seq.end()
       }
-      Self::Join { lhs, rhs } => {
-        let mut seq = serializer.serialize_seq(None)?;
-        seq.serialize_element("join")?;
-        seq.serialize_element(lhs)?;
-        seq.serialize_element(rhs)?;
-        seq.end()
-      }
       Self::Conditional {
         condition,
         then,
@@ -121,6 +133,20 @@ impl<'src> Serialize for Expression<'src> {
         seq.end()
       }
       Self::Group { contents } => contents.serialize(serializer),
+      Self::Join { lhs, rhs } => {
+        let mut seq = serializer.serialize_seq(None)?;
+        seq.serialize_element("join")?;
+        seq.serialize_element(lhs)?;
+        seq.serialize_element(rhs)?;
+        seq.end()
+      }
+      Self::Or { lhs, rhs } => {
+        let mut seq = serializer.serialize_seq(None)?;
+        seq.serialize_element("or")?;
+        seq.serialize_element(lhs)?;
+        seq.serialize_element(rhs)?;
+        seq.end()
+      }
       Self::StringLiteral { string_literal } => string_literal.serialize(serializer),
       Self::Variable { name } => {
         let mut seq = serializer.serialize_seq(None)?;
diff --git a/src/lexer.rs b/src/lexer.rs
index 4d28a446..2c56db9d 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -496,6 +496,7 @@ impl<'src> Lexer<'src> {
       ']' => self.lex_delimiter(BracketR),
       '`' | '"' | '\'' => self.lex_string(),
       '{' => self.lex_delimiter(BraceL),
+      '|' => self.lex_digraph('|', '|', BarBar),
       '}' => self.lex_delimiter(BraceR),
       _ if Self::is_identifier_start(start) => self.lex_identifier(),
       _ => {
@@ -948,6 +949,7 @@ mod tests {
       Asterisk => "*",
       At => "@",
       BangEquals => "!=",
+      BarBar => "||",
       BraceL => "{",
       BraceR => "}",
       BracketL => "[",
diff --git a/src/node.rs b/src/node.rs
index 3ccf862d..f8788c4d 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -88,6 +88,7 @@ impl<'src> Node<'src> for Assignment<'src> {
 impl<'src> Node<'src> for Expression<'src> {
   fn tree(&self) -> Tree<'src> {
     match self {
+      Self::And { lhs, rhs } => Tree::atom("&&").push(lhs.tree()).push(rhs.tree()),
       Self::Assert {
         condition: Condition { lhs, rhs, operator },
         error,
@@ -96,25 +97,10 @@ impl<'src> Node<'src> for Expression<'src> {
         .push(operator.to_string())
         .push(rhs.tree())
         .push(error.tree()),
-      Self::Concatenation { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()),
-      Self::Conditional {
-        condition: Condition { lhs, rhs, operator },
-        then,
-        otherwise,
-      } => {
-        let mut tree = Tree::atom(Keyword::If.lexeme());
-        tree.push_mut(lhs.tree());
-        tree.push_mut(operator.to_string());
-        tree.push_mut(rhs.tree());
-        tree.push_mut(then.tree());
-        tree.push_mut(otherwise.tree());
-        tree
-      }
+      Self::Backtick { contents, .. } => Tree::atom("backtick").push(Tree::string(contents)),
       Self::Call { thunk } => {
         use Thunk::*;
-
         let mut tree = Tree::atom("call");
-
         match thunk {
           Nullary { name, .. } => tree.push_mut(name.lexeme()),
           Unary { name, arg, .. } => {
@@ -171,20 +157,33 @@ impl<'src> Node<'src> for Expression<'src> {
             tree.push_mut(c.tree());
           }
         }
-
         tree
       }
-      Self::Variable { name } => Tree::atom(name.lexeme()),
-      Self::StringLiteral {
-        string_literal: StringLiteral { cooked, .. },
-      } => Tree::string(cooked),
-      Self::Backtick { contents, .. } => Tree::atom("backtick").push(Tree::string(contents)),
+      Self::Concatenation { lhs, rhs } => Tree::atom("+").push(lhs.tree()).push(rhs.tree()),
+      Self::Conditional {
+        condition: Condition { lhs, rhs, operator },
+        then,
+        otherwise,
+      } => {
+        let mut tree = Tree::atom(Keyword::If.lexeme());
+        tree.push_mut(lhs.tree());
+        tree.push_mut(operator.to_string());
+        tree.push_mut(rhs.tree());
+        tree.push_mut(then.tree());
+        tree.push_mut(otherwise.tree());
+        tree
+      }
       Self::Group { contents } => Tree::List(vec![contents.tree()]),
       Self::Join { lhs: None, rhs } => Tree::atom("/").push(rhs.tree()),
       Self::Join {
         lhs: Some(lhs),
         rhs,
       } => Tree::atom("/").push(lhs.tree()).push(rhs.tree()),
+      Self::Or { lhs, rhs } => Tree::atom("||").push(lhs.tree()).push(rhs.tree()),
+      Self::StringLiteral {
+        string_literal: StringLiteral { cooked, .. },
+      } => Tree::string(cooked),
+      Self::Variable { name } => Tree::atom(name.lexeme()),
     }
   }
 }
diff --git a/src/parser.rs b/src/parser.rs
index 99544acd..e03ab783 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -31,6 +31,7 @@ pub(crate) struct Parser<'run, 'src> {
   next_token: usize,
   recursion_depth: usize,
   tokens: &'run [Token<'src>],
+  unstable_features: BTreeSet<UnstableFeature>,
   working_directory: &'run Path,
 }
 
@@ -51,6 +52,7 @@ impl<'run, 'src> Parser<'run, 'src> {
       next_token: 0,
       recursion_depth: 0,
       tokens,
+      unstable_features: BTreeSet::new(),
       working_directory,
     }
     .parse_ast()
@@ -442,18 +444,19 @@ impl<'run, 'src> Parser<'run, 'src> {
       }
     }
 
-    if self.next_token == self.tokens.len() {
-      Ok(Ast {
-        items,
-        warnings: Vec::new(),
-        working_directory: self.working_directory.into(),
-      })
-    } else {
-      Err(self.internal_error(format!(
+    if self.next_token != self.tokens.len() {
+      return Err(self.internal_error(format!(
         "Parse completed with {} unparsed tokens",
         self.tokens.len() - self.next_token,
-      ))?)
+      ))?);
     }
+
+    Ok(Ast {
+      items,
+      unstable_features: self.unstable_features,
+      warnings: Vec::new(),
+      working_directory: self.working_directory.into(),
+    })
   }
 
   /// Parse an alias, e.g `alias name := target`
@@ -517,31 +520,63 @@ impl<'run, 'src> Parser<'run, 'src> {
 
     self.recursion_depth += 1;
 
-    let expression = if self.accepted_keyword(Keyword::If)? {
-      self.parse_conditional()?
+    let disjunct = self.parse_disjunct()?;
+
+    let expression = if self.accepted(BarBar)? {
+      self
+        .unstable_features
+        .insert(UnstableFeature::LogicalOperators);
+      let lhs = disjunct.into();
+      let rhs = self.parse_expression()?.into();
+      Expression::Or { lhs, rhs }
+    } else {
+      disjunct
+    };
+
+    self.recursion_depth -= 1;
+
+    Ok(expression)
+  }
+
+  fn parse_disjunct(&mut self) -> CompileResult<'src, Expression<'src>> {
+    let conjunct = self.parse_conjunct()?;
+
+    let disjunct = if self.accepted(AmpersandAmpersand)? {
+      self
+        .unstable_features
+        .insert(UnstableFeature::LogicalOperators);
+      let lhs = conjunct.into();
+      let rhs = self.parse_disjunct()?.into();
+      Expression::And { lhs, rhs }
+    } else {
+      conjunct
+    };
+
+    Ok(disjunct)
+  }
+
+  fn parse_conjunct(&mut self) -> CompileResult<'src, Expression<'src>> {
+    if self.accepted_keyword(Keyword::If)? {
+      self.parse_conditional()
     } else if self.accepted(Slash)? {
       let lhs = None;
-      let rhs = self.parse_expression()?.into();
-      Expression::Join { lhs, rhs }
+      let rhs = self.parse_conjunct()?.into();
+      Ok(Expression::Join { lhs, rhs })
     } else {
       let value = self.parse_value()?;
 
       if self.accepted(Slash)? {
         let lhs = Some(Box::new(value));
-        let rhs = self.parse_expression()?.into();
-        Expression::Join { lhs, rhs }
+        let rhs = self.parse_conjunct()?.into();
+        Ok(Expression::Join { lhs, rhs })
       } else if self.accepted(Plus)? {
         let lhs = value.into();
-        let rhs = self.parse_expression()?.into();
-        Expression::Concatenation { lhs, rhs }
+        let rhs = self.parse_conjunct()?.into();
+        Ok(Expression::Concatenation { lhs, rhs })
       } else {
-        value
+        Ok(value)
       }
-    };
-
-    self.recursion_depth -= 1;
-
-    Ok(expression)
+    }
   }
 
   /// Parse a conditional, e.g. `if a == b { "foo" } else { "bar" }`
diff --git a/src/summary.rs b/src/summary.rs
index ee3a8d11..76483d63 100644
--- a/src/summary.rs
+++ b/src/summary.rs
@@ -183,6 +183,10 @@ impl Assignment {
 
 #[derive(Eq, PartialEq, Hash, Ord, PartialOrd, Debug, Clone)]
 pub enum Expression {
+  And {
+    lhs: Box<Expression>,
+    rhs: Box<Expression>,
+  },
   Assert {
     condition: Condition,
     error: Box<Expression>,
@@ -209,6 +213,10 @@ pub enum Expression {
     lhs: Option<Box<Expression>>,
     rhs: Box<Expression>,
   },
+  Or {
+    lhs: Box<Expression>,
+    rhs: Box<Expression>,
+  },
   String {
     text: String,
   },
@@ -221,6 +229,10 @@ impl Expression {
   fn new(expression: &full::Expression) -> Self {
     use full::Expression::*;
     match expression {
+      And { lhs, rhs } => Self::And {
+        lhs: Self::new(lhs).into(),
+        rhs: Self::new(rhs).into(),
+      },
       Assert {
         condition: full::Condition { lhs, rhs, operator },
         error,
@@ -250,11 +262,9 @@ impl Expression {
           ..
         } => {
           let mut arguments = Vec::new();
-
           if let Some(b) = opt_b.as_ref() {
             arguments.push(Self::new(b));
           }
-
           arguments.push(Self::new(a));
           Self::Call {
             name: name.lexeme().to_owned(),
@@ -308,10 +318,6 @@ impl Expression {
         lhs: Self::new(lhs).into(),
         rhs: Self::new(rhs).into(),
       },
-      Join { lhs, rhs } => Self::Join {
-        lhs: lhs.as_ref().map(|lhs| Self::new(lhs).into()),
-        rhs: Self::new(rhs).into(),
-      },
       Conditional {
         condition: full::Condition { lhs, rhs, operator },
         otherwise,
@@ -323,13 +329,21 @@ impl Expression {
         rhs: Self::new(rhs).into(),
         then: Self::new(then).into(),
       },
+      Group { contents } => Self::new(contents),
+      Join { lhs, rhs } => Self::Join {
+        lhs: lhs.as_ref().map(|lhs| Self::new(lhs).into()),
+        rhs: Self::new(rhs).into(),
+      },
+      Or { lhs, rhs } => Self::Or {
+        lhs: Self::new(lhs).into(),
+        rhs: Self::new(rhs).into(),
+      },
       StringLiteral { string_literal } => Self::String {
         text: string_literal.cooked.clone(),
       },
       Variable { name, .. } => Self::Variable {
         name: name.lexeme().to_owned(),
       },
-      Group { contents } => Self::new(contents),
     }
   }
 }
diff --git a/src/token_kind.rs b/src/token_kind.rs
index 0db15d2d..850afa96 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -7,6 +7,7 @@ pub(crate) enum TokenKind {
   At,
   Backtick,
   BangEquals,
+  BarBar,
   BraceL,
   BraceR,
   BracketL,
@@ -50,6 +51,7 @@ impl Display for TokenKind {
         At => "'@'",
         Backtick => "backtick",
         BangEquals => "'!='",
+        BarBar => "'||'",
         BraceL => "'{'",
         BraceR => "'}'",
         BracketL => "'['",
diff --git a/src/unstable_feature.rs b/src/unstable_feature.rs
index 07d99540..70e26fab 100644
--- a/src/unstable_feature.rs
+++ b/src/unstable_feature.rs
@@ -3,6 +3,7 @@ use super::*;
 #[derive(Copy, Clone, Debug, PartialEq, Ord, Eq, PartialOrd)]
 pub(crate) enum UnstableFeature {
   FormatSubcommand,
+  LogicalOperators,
   ScriptAttribute,
   ScriptInterpreterSetting,
 }
@@ -11,6 +12,10 @@ impl Display for UnstableFeature {
   fn fmt(&self, f: &mut Formatter) -> fmt::Result {
     match self {
       Self::FormatSubcommand => write!(f, "The `--fmt` command is currently unstable."),
+      Self::LogicalOperators => write!(
+        f,
+        "The logical operators `&&` and `||` are currently unstable."
+      ),
       Self::ScriptAttribute => write!(f, "The `[script]` attribute is currently unstable."),
       Self::ScriptInterpreterSetting => {
         write!(f, "The `script-interpreter` setting is currently unstable.")
diff --git a/src/variables.rs b/src/variables.rs
index 57979563..9de1c98c 100644
--- a/src/variables.rs
+++ b/src/variables.rs
@@ -16,7 +16,24 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
   fn next(&mut self) -> Option<Token<'src>> {
     loop {
       match self.stack.pop()? {
-        Expression::StringLiteral { .. } | Expression::Backtick { .. } => {}
+        Expression::And { lhs, rhs } | Expression::Or { lhs, rhs } => {
+          self.stack.push(lhs);
+          self.stack.push(rhs);
+        }
+        Expression::Assert {
+          condition:
+            Condition {
+              lhs,
+              rhs,
+              operator: _,
+            },
+          error,
+        } => {
+          self.stack.push(error);
+          self.stack.push(rhs);
+          self.stack.push(lhs);
+        }
+        Expression::Backtick { .. } | Expression::StringLiteral { .. } => {}
         Expression::Call { thunk } => match thunk {
           Thunk::Nullary { .. } => {}
           Thunk::Unary { arg, .. } => self.stack.push(arg),
@@ -56,6 +73,10 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
             }
           }
         },
+        Expression::Concatenation { lhs, rhs } => {
+          self.stack.push(rhs);
+          self.stack.push(lhs);
+        }
         Expression::Conditional {
           condition:
             Condition {
@@ -71,10 +92,8 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
           self.stack.push(rhs);
           self.stack.push(lhs);
         }
-        Expression::Variable { name, .. } => return Some(name.token),
-        Expression::Concatenation { lhs, rhs } => {
-          self.stack.push(rhs);
-          self.stack.push(lhs);
+        Expression::Group { contents } => {
+          self.stack.push(contents);
         }
         Expression::Join { lhs, rhs } => {
           self.stack.push(rhs);
@@ -82,22 +101,7 @@ impl<'expression, 'src> Iterator for Variables<'expression, 'src> {
             self.stack.push(lhs);
           }
         }
-        Expression::Group { contents } => {
-          self.stack.push(contents);
-        }
-        Expression::Assert {
-          condition:
-            Condition {
-              lhs,
-              rhs,
-              operator: _,
-            },
-          error,
-        } => {
-          self.stack.push(error);
-          self.stack.push(rhs);
-          self.stack.push(lhs);
-        }
+        Expression::Variable { name, .. } => return Some(name.token),
       }
     }
   }


Test patch:
diff --git a/tests/assert_success.rs b/tests/assert_success.rs
index bcb364f8..f9202b7f 100644
--- a/tests/assert_success.rs
+++ b/tests/assert_success.rs
@@ -1,3 +1,4 @@
+#[track_caller]
 pub(crate) fn assert_success(output: &std::process::Output) {
   if !output.status.success() {
     eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
diff --git a/tests/conditional.rs b/tests/conditional.rs
index c1739240..4eab2f4d 100644
--- a/tests/conditional.rs
+++ b/tests/conditional.rs
@@ -136,7 +136,7 @@ test! {
   ",
   stdout: "",
   stderr: "
-    error: Expected '!=', '==', '=~', '+', or '/', but found identifier
+    error: Expected '&&', '!=', '||', '==', '=~', '+', or '/', but found identifier
      ——▶ justfile:1:12
       │
     1 │ a := if '' a '' { '' } else { b }
diff --git a/tests/ignore_comments.rs b/tests/ignore_comments.rs
index c3028a57..3f068227 100644
--- a/tests/ignore_comments.rs
+++ b/tests/ignore_comments.rs
@@ -125,7 +125,7 @@ fn comments_still_must_be_parsable_when_ignored() {
     )
     .stderr(
       "
-        error: Expected '}}', '(', '+', or '/', but found identifier
+        error: Expected '&&', '||', '}}', '(', '+', or '/', but found identifier
          ——▶ justfile:4:12
           │
         4 │   # {{ foo bar }}
diff --git a/tests/lib.rs b/tests/lib.rs
index ec71c665..7c85460b 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -73,6 +73,7 @@ mod invocation_directory;
 mod json;
 mod line_prefixes;
 mod list;
+mod logical_operators;
 mod man;
 mod misc;
 mod modules;
diff --git a/tests/logical_operators.rs b/tests/logical_operators.rs
new file mode 100644
index 00000000..5baa0f52
--- /dev/null
+++ b/tests/logical_operators.rs
@@ -0,0 +1,83 @@
+use super::*;
+
+#[track_caller]
+fn evaluate(expression: &str, expected: &str) {
+  Test::new()
+    .justfile(format!("x := {expression}"))
+    .env("JUST_UNSTABLE", "1")
+    .args(["--evaluate", "x"])
+    .stdout(expected)
+    .run();
+}
+
+#[test]
+fn logical_operators_are_unstable() {
+  Test::new()
+    .justfile("x := 'foo' && 'bar'")
+    .args(["--evaluate", "x"])
+    .stderr_regex(r"error: The logical operators `&&` and `\|\|` are currently unstable. .*")
+    .status(EXIT_FAILURE)
+    .run();
+
+  Test::new()
+    .justfile("x := 'foo' || 'bar'")
+    .args(["--evaluate", "x"])
+    .stderr_regex(r"error: The logical operators `&&` and `\|\|` are currently unstable. .*")
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn and_returns_empty_string_if_lhs_is_empty() {
+  evaluate("'' && 'hello'", "");
+}
+
+#[test]
+fn and_returns_rhs_if_lhs_is_non_empty() {
+  evaluate("'hello' && 'goodbye'", "goodbye");
+}
+
+#[test]
+fn and_has_lower_precedence_than_plus() {
+  evaluate("'' && 'goodbye' + 'foo'", "");
+
+  evaluate("'foo' + 'hello' && 'goodbye'", "goodbye");
+
+  evaluate("'foo' + '' && 'goodbye'", "goodbye");
+
+  evaluate("'foo' + 'hello' && 'goodbye' + 'bar'", "goodbyebar");
+}
+
+#[test]
+fn or_returns_rhs_if_lhs_is_empty() {
+  evaluate("'' || 'hello'", "hello");
+}
+
+#[test]
+fn or_returns_lhs_if_lhs_is_non_empty() {
+  evaluate("'hello' || 'goodbye'", "hello");
+}
+
+#[test]
+fn or_has_lower_precedence_than_plus() {
+  evaluate("'' || 'goodbye' + 'foo'", "goodbyefoo");
+
+  evaluate("'foo' + 'hello' || 'goodbye'", "foohello");
+
+  evaluate("'foo' + '' || 'goodbye'", "foo");
+
+  evaluate("'foo' + 'hello' || 'goodbye' + 'bar'", "foohello");
+}
+
+#[test]
+fn and_has_higher_precedence_than_or() {
+  evaluate("('' && 'foo') || 'bar'", "bar");
+  evaluate("'' && 'foo' || 'bar'", "bar");
+  evaluate("'a' && 'b' || 'c'", "b");
+}
+
+#[test]
+fn nesting() {
+  evaluate("'' || '' || '' || '' || 'foo'", "foo");
+  evaluate("'foo' && 'foo' && 'foo' && 'foo' && 'bar'", "bar");
+}
diff --git a/tests/shell_expansion.rs b/tests/shell_expansion.rs
index 67fdc07d..954e2a33 100644
--- a/tests/shell_expansion.rs
+++ b/tests/shell_expansion.rs
@@ -25,7 +25,7 @@ fn shell_expanded_strings_must_not_have_whitespace() {
     .status(1)
     .stderr(
       "
-        error: Expected comment, end of file, end of line, '(', '+', or '/', but found string
+        error: Expected '&&', '||', comment, end of file, end of line, '(', '+', or '/', but found string
          ——▶ justfile:1:8
           │
         1 │ x := x '$JUST_TEST_VARIABLE'