Repository: casey/just
Pull request #525 (2019-11-11 06:19:14)

Add `shell` setting
Add a `set SETTING := VALUE` construct.

This construct is intended to be extended as needed with new settings,
but for now we're starting with `set shell := [COMMAND, ARG1, ...]`,
which allows setting the shell to use for recipe and backtick execution
in a justfile.

One of the primary reasons for adding this feature is to have a better
story on windows, where users are forced to scrounge up an `sh` binary
if they want to use `just`. This should allow them to use cmd.exe or
powershell in their justfiles, making just optionally dependency-free.


Patch:
diff --git a/src/analyzer.rs b/src/analyzer.rs
index 8812751e..32ab2cc8 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -2,28 +2,33 @@ use crate::common::*;
 
 use CompilationErrorKind::*;
 
-pub(crate) struct Analyzer<'a> {
-  recipes: Table<'a, Recipe<'a>>,
-  assignments: Table<'a, Assignment<'a>>,
-  aliases: Table<'a, Alias<'a>>,
+pub(crate) struct Analyzer<'src> {
+  recipes: Table<'src, Recipe<'src>>,
+  assignments: Table<'src, Assignment<'src>>,
+  aliases: Table<'src, Alias<'src>>,
+  sets: Table<'src, Set<'src>>,
 }
 
-impl<'a> Analyzer<'a> {
-  pub(crate) fn analyze(module: Module<'a>) -> CompilationResult<'a, Justfile> {
+impl<'src> Analyzer<'src> {
+  pub(crate) fn analyze(module: Module<'src>) -> CompilationResult<'src, Justfile> {
     let analyzer = Analyzer::new();
 
     analyzer.justfile(module)
   }
 
-  pub(crate) fn new() -> Analyzer<'a> {
+  pub(crate) fn new() -> Analyzer<'src> {
     Analyzer {
       recipes: empty(),
       assignments: empty(),
       aliases: empty(),
+      sets: empty(),
     }
   }
 
-  pub(crate) fn justfile(mut self, module: Module<'a>) -> CompilationResult<'a, Justfile<'a>> {
+  pub(crate) fn justfile(
+    mut self,
+    module: Module<'src>,
+  ) -> CompilationResult<'src, Justfile<'src>> {
     for item in module.items {
       match item {
         Item::Alias(alias) => {
@@ -38,6 +43,10 @@ impl<'a> Analyzer<'a> {
           self.analyze_recipe(&recipe)?;
           self.recipes.insert(recipe);
         }
+        Item::Set(set) => {
+          self.analyze_set(&set)?;
+          self.sets.insert(set);
+        }
       }
     }
 
@@ -70,15 +79,27 @@ impl<'a> Analyzer<'a> {
 
     AliasResolver::resolve_aliases(&aliases, &recipes)?;
 
+    let mut settings = Settings::new();
+
+    for (_, set) in self.sets.into_iter() {
+      match set.value {
+        Setting::Shell(shell) => {
+          assert!(settings.shell.is_none());
+          settings.shell = Some(shell);
+        }
+      }
+    }
+
     Ok(Justfile {
       warnings: module.warnings,
-      recipes,
-      assignments,
       aliases,
+      assignments,
+      recipes,
+      settings,
     })
   }
 
-  fn analyze_recipe(&self, recipe: &Recipe<'a>) -> CompilationResult<'a, ()> {
+  fn analyze_recipe(&self, recipe: &Recipe<'src>) -> CompilationResult<'src, ()> {
     if let Some(original) = self.recipes.get(recipe.name.lexeme()) {
       return Err(recipe.name.token().error(DuplicateRecipe {
         recipe: original.name(),
@@ -141,7 +162,7 @@ impl<'a> Analyzer<'a> {
     Ok(())
   }
 
-  fn analyze_assignment(&self, assignment: &Assignment<'a>) -> CompilationResult<'a, ()> {
+  fn analyze_assignment(&self, assignment: &Assignment<'src>) -> CompilationResult<'src, ()> {
     if self.assignments.contains_key(assignment.name.lexeme()) {
       return Err(assignment.name.token().error(DuplicateVariable {
         variable: assignment.name.lexeme(),
@@ -150,7 +171,7 @@ impl<'a> Analyzer<'a> {
     Ok(())
   }
 
-  fn analyze_alias(&self, alias: &Alias<'a>) -> CompilationResult<'a, ()> {
+  fn analyze_alias(&self, alias: &Alias<'src>) -> CompilationResult<'src, ()> {
     let name = alias.name.lexeme();
 
     if let Some(original) = self.aliases.get(name) {
@@ -162,6 +183,17 @@ impl<'a> Analyzer<'a> {
 
     Ok(())
   }
+
+  fn analyze_set(&self, set: &Set<'src>) -> CompilationResult<'src, ()> {
+    if let Some(original) = self.sets.get(set.name.lexeme()) {
+      return Err(set.name.error(DuplicateSet {
+        setting: original.name.lexeme(),
+        first: original.name.line,
+      }));
+    }
+
+    Ok(())
+  }
 }
 
 #[cfg(test)]
diff --git a/src/assignment_evaluator.rs b/src/assignment_evaluator.rs
index 57c0d84f..77b0b1d1 100644
--- a/src/assignment_evaluator.rs
+++ b/src/assignment_evaluator.rs
@@ -8,6 +8,7 @@ pub(crate) struct AssignmentEvaluator<'a: 'b, 'b> {
   pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>,
   pub(crate) working_directory: &'b Path,
   pub(crate) overrides: &'b BTreeMap<String, String>,
+  pub(crate) settings: &'b Settings<'b>,
 }
 
 impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
@@ -17,10 +18,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
     dotenv: &'b BTreeMap<String, String>,
     assignments: &BTreeMap<&'a str, Assignment<'a>>,
     overrides: &BTreeMap<String, String>,
+    settings: &'b Settings<'b>,
   ) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> {
     let mut evaluator = AssignmentEvaluator {
       evaluated: empty(),
       scope: &empty(),
+      settings,
       overrides,
       config,
       assignments,
@@ -134,11 +137,11 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
     raw: &str,
     token: &Token<'a>,
   ) -> RunResult<'a, String> {
-    let mut cmd = Command::new(&self.config.shell);
+    let mut cmd = self.settings.shell_command(&self.config.shell);
 
-    cmd.current_dir(self.working_directory);
+    cmd.arg(raw);
 
-    cmd.arg("-cu").arg(raw);
+    cmd.current_dir(self.working_directory);
 
     cmd.export_environment_variables(self.scope, dotenv)?;
 
diff --git a/src/common.rs b/src/common.rs
index 125120a3..02a61b71 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -32,7 +32,7 @@ pub(crate) use snafu::{ResultExt, Snafu};
 pub(crate) use unicode_width::UnicodeWidthChar;
 
 // modules
-pub(crate) use crate::{config_error, keyword, search_error};
+pub(crate) use crate::{config_error, keyword, search_error, setting};
 
 // functions
 pub(crate) use crate::{
@@ -60,10 +60,11 @@ pub(crate) use crate::{
   parameter::Parameter, parser::Parser, platform::Platform, position::Position,
   positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
   recipe_resolver::RecipeResolver, runtime_error::RuntimeError, search::Search,
-  search_config::SearchConfig, search_error::SearchError, shebang::Shebang,
-  show_whitespace::ShowWhitespace, state::State, string_literal::StringLiteral,
-  subcommand::Subcommand, table::Table, token::Token, token_kind::TokenKind, use_color::UseColor,
-  variables::Variables, verbosity::Verbosity, warning::Warning,
+  search_config::SearchConfig, search_error::SearchError, set::Set, setting::Setting,
+  settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace, state::State,
+  string_literal::StringLiteral, subcommand::Subcommand, table::Table, token::Token,
+  token_kind::TokenKind, use_color::UseColor, variables::Variables, verbosity::Verbosity,
+  warning::Warning,
 };
 
 // type aliases
diff --git a/src/compilation_error.rs b/src/compilation_error.rs
index 80eafb41..c1df6863 100644
--- a/src/compilation_error.rs
+++ b/src/compilation_error.rs
@@ -109,6 +109,15 @@ impl Display for CompilationError<'_> {
           self.line.ordinal()
         )?;
       }
+      DuplicateSet { setting, first } => {
+        writeln!(
+          f,
+          "Setting `{}` first set on line {} is redefined on line {}",
+          setting,
+          first.ordinal(),
+          self.line.ordinal(),
+        )?;
+      }
       DependencyHasParameters { recipe, dependency } => {
         writeln!(
           f,
@@ -184,6 +193,9 @@ impl Display for CompilationError<'_> {
       UnknownFunction { function } => {
         writeln!(f, "Call to unknown function `{}`", function)?;
       }
+      UnknownSetting { setting } => {
+        writeln!(f, "Unknown setting `{}`", setting)?;
+      }
       UnknownStartOfToken => {
         writeln!(f, "Unknown start of token:")?;
       }
diff --git a/src/compilation_error_kind.rs b/src/compilation_error_kind.rs
index 5a1b378e..a89dd378 100644
--- a/src/compilation_error_kind.rs
+++ b/src/compilation_error_kind.rs
@@ -37,6 +37,10 @@ pub(crate) enum CompilationErrorKind<'a> {
   DuplicateVariable {
     variable: &'a str,
   },
+  DuplicateSet {
+    setting: &'a str,
+    first: usize,
+  },
   ExtraLeadingWhitespace,
   FunctionArgumentCountMismatch {
     function: &'a str,
@@ -84,6 +88,9 @@ pub(crate) enum CompilationErrorKind<'a> {
     function: &'a str,
   },
   UnknownStartOfToken,
+  UnknownSetting {
+    setting: &'a str,
+  },
   UnpairedCarriageReturn,
   UnterminatedInterpolation,
   UnterminatedString,
diff --git a/src/item.rs b/src/item.rs
index 1309a1b7..cb078a7c 100644
--- a/src/item.rs
+++ b/src/item.rs
@@ -6,4 +6,5 @@ pub(crate) enum Item<'src> {
   Alias(Alias<'src>),
   Assignment(Assignment<'src>),
   Recipe(Recipe<'src>),
+  Set(Set<'src>),
 }
diff --git a/src/justfile.rs b/src/justfile.rs
index 8c173394..1402ee21 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -1,14 +1,15 @@
 use crate::common::*;
 
 #[derive(Debug, PartialEq)]
-pub(crate) struct Justfile<'a> {
-  pub(crate) recipes: Table<'a, Recipe<'a>>,
-  pub(crate) assignments: Table<'a, Assignment<'a>>,
-  pub(crate) aliases: Table<'a, Alias<'a>>,
-  pub(crate) warnings: Vec<Warning<'a>>,
+pub(crate) struct Justfile<'src> {
+  pub(crate) recipes: Table<'src, Recipe<'src>>,
+  pub(crate) assignments: Table<'src, Assignment<'src>>,
+  pub(crate) aliases: Table<'src, Alias<'src>>,
+  pub(crate) settings: Settings<'src>,
+  pub(crate) warnings: Vec<Warning<'src>>,
 }
 
-impl<'a> Justfile<'a> {
+impl<'src> Justfile<'src> {
   pub(crate) fn first(&self) -> Option<&Recipe> {
     let mut first: Option<&Recipe> = None;
     for recipe in self.recipes.values() {
@@ -27,7 +28,7 @@ impl<'a> Justfile<'a> {
     self.recipes.len()
   }
 
-  pub(crate) fn suggest(&self, name: &str) -> Option<&'a str> {
+  pub(crate) fn suggest(&self, name: &str) -> Option<&'src str> {
     let mut suggestions = self
       .recipes
       .keys()
@@ -43,12 +44,12 @@ impl<'a> Justfile<'a> {
   }
 
   pub(crate) fn run(
-    &'a self,
-    config: &'a Config,
-    working_directory: &'a Path,
-    overrides: &'a BTreeMap<String, String>,
-    arguments: &'a Vec<String>,
-  ) -> RunResult<'a, ()> {
+    &'src self,
+    config: &'src Config,
+    working_directory: &'src Path,
+    overrides: &'src BTreeMap<String, String>,
+    arguments: &'src Vec<String>,
+  ) -> RunResult<'src, ()> {
     let argvec: Vec<&str> = if !arguments.is_empty() {
       arguments.iter().map(|argument| argument.as_str()).collect()
     } else if let Some(recipe) = self.first() {
@@ -86,6 +87,7 @@ impl<'a> Justfile<'a> {
       &dotenv,
       &self.assignments,
       overrides,
+      &self.settings,
     )?;
 
     if let Subcommand::Evaluate { .. } = config.subcommand {
@@ -142,6 +144,7 @@ impl<'a> Justfile<'a> {
     }
 
     let context = RecipeContext {
+      settings: &self.settings,
       config,
       scope,
       working_directory,
@@ -159,7 +162,7 @@ impl<'a> Justfile<'a> {
     self.aliases.get(name)
   }
 
-  pub(crate) fn get_recipe(&self, name: &str) -> Option<&Recipe<'a>> {
+  pub(crate) fn get_recipe(&self, name: &str) -> Option<&Recipe<'src>> {
     if let Some(recipe) = self.recipes.get(name) {
       Some(recipe)
     } else if let Some(alias) = self.aliases.get(name) {
@@ -171,11 +174,11 @@ impl<'a> Justfile<'a> {
 
   fn run_recipe<'b>(
     &self,
-    context: &'b RecipeContext<'a>,
-    recipe: &Recipe<'a>,
-    arguments: &[&'a str],
+    context: &'b RecipeContext<'src>,
+    recipe: &Recipe<'src>,
+    arguments: &[&'src str],
     dotenv: &BTreeMap<String, String>,
-    ran: &mut BTreeSet<&'a str>,
+    ran: &mut BTreeSet<&'src str>,
     overrides: &BTreeMap<String, String>,
   ) -> RunResult<()> {
     for dependency_name in &recipe.dependencies {
@@ -190,7 +193,7 @@ impl<'a> Justfile<'a> {
   }
 }
 
-impl<'a> Display for Justfile<'a> {
+impl<'src> Display for Justfile<'src> {
   fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
     let mut items = self.recipes.len() + self.assignments.len() + self.aliases.len();
     for (name, assignment) in &self.assignments {
diff --git a/src/keyword.rs b/src/keyword.rs
index 0fa4ba0d..8bf8371b 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -1,2 +1,5 @@
 pub(crate) const ALIAS: &str = "alias";
 pub(crate) const EXPORT: &str = "export";
+pub(crate) const SET: &str = "set";
+
+pub(crate) const SHELL: &str = "shell";
diff --git a/src/lexer.rs b/src/lexer.rs
index 3427cf2f..d8091400 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -387,6 +387,8 @@ impl<'a> Lexer<'a> {
   fn lex_normal(&mut self, start: char) -> CompilationResult<'a, ()> {
     match start {
       '@' => self.lex_single(At),
+      '[' => self.lex_single(BracketL),
+      ']' => self.lex_single(BracketR),
       '=' => self.lex_single(Equals),
       ',' => self.lex_single(Comma),
       ':' => self.lex_colon(),
@@ -759,6 +761,8 @@ mod tests {
     match kind {
       // Fixed lexemes
       At => "@",
+      BracketL => "[",
+      BracketR => "]",
       Colon => ":",
       ColonEquals => ":=",
       Comma => ",",
@@ -1604,6 +1608,12 @@ mod tests {
     ),
   }
 
+  test! {
+    name:   brackets,
+    text:   "][",
+    tokens: (BracketR, BracketL),
+  }
+
   error! {
     name:  tokenize_space_then_tab,
     input: "a:
diff --git a/src/lib.rs b/src/lib.rs
index 85fbeb08..5d41582e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -72,6 +72,9 @@ mod runtime_error;
 mod search;
 mod search_config;
 mod search_error;
+mod set;
+mod setting;
+mod settings;
 mod shebang;
 mod show_whitespace;
 mod state;
diff --git a/src/node.rs b/src/node.rs
index 9a7aff06..609c6cc8 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -21,6 +21,7 @@ impl<'src> Node<'src> for Item<'src> {
       Item::Alias(alias) => alias.tree(),
       Item::Assignment(assignment) => assignment.tree(),
       Item::Recipe(recipe) => recipe.tree(),
+      Item::Set(set) => set.tree(),
     }
   }
 }
@@ -141,6 +142,26 @@ 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);
+
+    set.push_mut(self.name.lexeme());
+
+    use Setting::*;
+    match &self.value {
+      Shell(setting::Shell { command, arguments }) => {
+        set.push_mut(Tree::string(&command.cooked));
+        for argument in arguments {
+          set.push_mut(Tree::string(&argument.cooked));
+        }
+      }
+    }
+
+    set
+  }
+}
+
 impl<'src> Node<'src> for Warning<'src> {
   fn tree(&self) -> Tree<'src> {
     match self {
diff --git a/src/parser.rs b/src/parser.rs
index b45e6d91..6a7aa8f2 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -135,6 +135,17 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     }
   }
 
+  /// Return an error if the next token is not one of kinds `kinds`.
+  fn expect_any(&mut self, expected: &[TokenKind]) -> CompilationResult<'src, Token<'src>> {
+    for expected in expected.iter().cloned() {
+      if let Some(token) = self.accept(expected)? {
+        return Ok(token);
+      }
+    }
+
+    Err(self.unexpected_token(expected)?)
+  }
+
   /// Return an unexpected token error if the next token is not an EOL
   fn expect_eol(&mut self) -> CompilationResult<'src, ()> {
     self.accept(Comment)?;
@@ -269,6 +280,13 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
               items.push(Item::Recipe(self.parse_recipe(doc, false)?));
             }
           }
+          keyword::SET => {
+            if self.next_are(&[Identifier, Identifier, ColonEquals]) {
+              items.push(Item::Set(self.parse_set()?));
+            } else {
+              items.push(Item::Recipe(self.parse_recipe(doc, false)?));
+            }
+          }
           _ => {
             if self.next_are(&[Identifier, Equals]) {
               warnings.push(Warning::DeprecatedEquals {
@@ -380,7 +398,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
   /// Parse a string literal, e.g. `"FOO"`
   fn parse_string_literal(&mut self) -> CompilationResult<'src, StringLiteral<'src>> {
-    let token = self.presume_any(&[StringRaw, StringCooked])?;
+    let token = self.expect_any(&[StringRaw, StringCooked])?;
 
     let raw = &token.lexeme()[1..token.lexeme().len() - 1];
 
@@ -580,12 +598,56 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
     Ok(lines)
   }
+
+  /// Parse a setting
+  fn parse_set(&mut self) -> CompilationResult<'src, Set<'src>> {
+    self.presume_name(keyword::SET)?;
+    let name = Name::from_identifier(self.presume(Identifier)?);
+    self.presume(ColonEquals)?;
+    match name.lexeme() {
+      keyword::SHELL => {
+        self.expect(BracketL)?;
+
+        let command = self.parse_string_literal()?;
+
+        let mut arguments = Vec::new();
+
+        let mut comma = false;
+
+        if self.accepted(Comma)? {
+          comma = true;
+          while !self.next_is(BracketR) {
+            arguments.push(self.parse_string_literal().expected(&[BracketR])?);
+
+            if !self.accepted(Comma)? {
+              comma = false;
+              break;
+            }
+            comma = true;
+          }
+        }
+
+        self
+          .expect(BracketR)
+          .expected(if comma { &[] } else { &[Comma] })?;
+
+        Ok(Set {
+          value: Setting::Shell(setting::Shell { command, arguments }),
+          name,
+        })
+      }
+      _ => Err(name.error(CompilationErrorKind::UnknownSetting {
+        setting: name.lexeme(),
+      })),
+    }
+  }
 }
 
 #[cfg(test)]
 mod tests {
   use super::*;
 
+  use pretty_assertions::assert_eq;
   use testing::unindent;
   use CompilationErrorKind::*;
 
@@ -1368,6 +1430,42 @@ mod tests {
     tree: (justfile (recipe a (body ("foo"))) (recipe b)),
   }
 
+  test! {
+    name: set_shell_no_arguments,
+    text: "set shell := ['tclsh']",
+    tree: (justfile (set shell "tclsh")),
+  }
+
+  test! {
+    name: set_shell_no_arguments_cooked,
+    text: "set shell := [\"tclsh\"]",
+    tree: (justfile (set shell "tclsh")),
+  }
+
+  test! {
+    name: set_shell_no_arguments_trailing_comma,
+    text: "set shell := ['tclsh',]",
+    tree: (justfile (set shell "tclsh")),
+  }
+
+  test! {
+    name: set_shell_with_one_argument,
+    text: "set shell := ['bash', '-cu']",
+    tree: (justfile (set shell "bash" "-cu")),
+  }
+
+  test! {
+    name: set_shell_with_one_argument_trailing_comma,
+    text: "set shell := ['bash', '-cu',]",
+    tree: (justfile (set shell "bash" "-cu")),
+  }
+
+  test! {
+    name: set_shell_with_two_arguments,
+    text: "set shell := ['bash', '-cu', '-l']",
+    tree: (justfile (set shell "bash" "-cu" "-l")),
+  }
+
   error! {
     name: alias_syntax_multiple_rhs,
     input: "alias foo = bar baz",
@@ -1529,4 +1627,93 @@ mod tests {
     width:  1,
     kind:   ParameterFollowsVariadicParameter{parameter: "e"},
   }
+
+  error! {
+    name:   set_shell_empty,
+    input:  "set shell := []",
+    offset: 14,
+    line:   0,
+    column: 14,
+    width:  1,
+    kind:   UnexpectedToken {
+      expected: vec![StringCooked, StringRaw],
+      found: BracketR,
+    },
+  }
+
+  error! {
+    name:   set_shell_non_literal_first,
+    input:  "set shell := ['bar' + 'baz']",
+    offset: 20,
+    line:   0,
+    column: 20,
+    width:  1,
+    kind:   UnexpectedToken {
+      expected: vec![BracketR, Comma],
+      found: Plus,
+    },
+  }
+
+  error! {
+    name:   set_shell_non_literal_second,
+    input:  "set shell := ['biz', 'bar' + 'baz']",
+    offset: 27,
+    line:   0,
+    column: 27,
+    width:  1,
+    kind:   UnexpectedToken {
+      expected: vec![BracketR, Comma],
+      found: Plus,
+    },
+  }
+
+  error! {
+    name:   set_shell_bad_comma,
+    input:  "set shell := ['bash',",
+    offset: 21,
+    line:   0,
+    column: 21,
+    width:  0,
+    kind:   UnexpectedToken {
+      expected: vec![BracketR, StringCooked, StringRaw],
+      found: Eof,
+    },
+  }
+
+  error! {
+    name:   set_shell_bad,
+    input:  "set shell := ['bash'",
+    offset: 20,
+    line:   0,
+    column: 20,
+    width:  0,
+    kind:   UnexpectedToken {
+      expected: vec![BracketR, Comma],
+      found: Eof,
+    },
+  }
+
+  error! {
+    name:   set_unknown,
+    input:  "set shall := []",
+    offset: 4,
+    line:   0,
+    column: 4,
+    width:  5,
+    kind:   UnknownSetting {
+      setting: "shall",
+    },
+  }
+
+  error! {
+    name:   set_shell_non_string,
+    input:  "set shall := []",
+    offset: 4,
+    line:   0,
+    column: 4,
+    width:  5,
+    kind:   UnknownSetting {
+      setting: "shall",
+    },
+  }
 }
diff --git a/src/recipe.rs b/src/recipe.rs
index dfdd4f8f..5c8d4259 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -1,6 +1,6 @@
 use crate::common::*;
 
-use std::process::{Command, ExitStatus, Stdio};
+use std::process::{ExitStatus, Stdio};
 
 /// Return a `RuntimeError::Signal` if the process was terminated by a signal,
 /// otherwise return an `RuntimeError::UnknownFailure`
@@ -90,6 +90,7 @@ impl<'a> Recipe<'a> {
       evaluated: empty(),
       working_directory: context.working_directory,
       scope: &context.scope,
+      settings: &context.settings,
       overrides,
       config,
       dotenv,
@@ -274,11 +275,11 @@ impl<'a> Recipe<'a> {
           continue;
         }
 
-        let mut cmd = Command::new(&config.shell);
+        let mut cmd = context.settings.shell_command(&config.shell);
 
         cmd.current_dir(context.working_directory);
 
-        cmd.arg("-cu").arg(command);
+        cmd.arg(command);
 
         if config.quiet {
           cmd.stderr(Stdio::null());
diff --git a/src/recipe_context.rs b/src/recipe_context.rs
index a05fa086..7c45b2c4 100644
--- a/src/recipe_context.rs
+++ b/src/recipe_context.rs
@@ -4,4 +4,5 @@ pub(crate) struct RecipeContext<'a> {
   pub(crate) config: &'a Config,
   pub(crate) scope: BTreeMap<&'a str, (bool, String)>,
   pub(crate) working_directory: &'a Path,
+  pub(crate) settings: &'a Settings<'a>,
 }
diff --git a/src/set.rs b/src/set.rs
new file mode 100644
index 00000000..4f45df9a
--- /dev/null
+++ b/src/set.rs
@@ -0,0 +1,13 @@
+use crate::common::*;
+
+#[derive(Debug)]
+pub(crate) struct Set<'src> {
+  pub(crate) name: Name<'src>,
+  pub(crate) value: Setting<'src>,
+}
+
+impl<'src> Keyed<'src> for Set<'src> {
+  fn key(&self) -> &'src str {
+    self.name.lexeme()
+  }
+}
diff --git a/src/setting.rs b/src/setting.rs
new file mode 100644
index 00000000..abb9851f
--- /dev/null
+++ b/src/setting.rs
@@ -0,0 +1,12 @@
+use crate::common::*;
+
+#[derive(Debug)]
+pub(crate) enum Setting<'src> {
+  Shell(Shell<'src>),
+}
+
+#[derive(Debug, PartialEq)]
+pub(crate) struct Shell<'src> {
+  pub(crate) command: StringLiteral<'src>,
+  pub(crate) arguments: Vec<StringLiteral<'src>>,
+}
diff --git a/src/settings.rs b/src/settings.rs
new file mode 100644
index 00000000..7ebd56c9
--- /dev/null
+++ b/src/settings.rs
@@ -0,0 +1,30 @@
+use crate::common::*;
+
+#[derive(Debug, PartialEq)]
+pub(crate) struct Settings<'src> {
+  pub(crate) shell: Option<setting::Shell<'src>>,
+}
+
+impl<'src> Settings<'src> {
+  pub(crate) fn new() -> Settings<'src> {
+    Settings { shell: None }
+  }
+
+  pub(crate) fn shell_command(&self, default_shell: &str) -> Command {
+    if let Some(shell) = &self.shell {
+      let mut cmd = Command::new(shell.command.cooked.as_ref());
+
+      for argument in &shell.arguments {
+        cmd.arg(argument.cooked.as_ref());
+      }
+
+      cmd
+    } else {
+      let mut cmd = Command::new(default_shell);
+
+      cmd.arg("-cu");
+
+      cmd
+    }
+  }
+}
diff --git a/src/token_kind.rs b/src/token_kind.rs
index 0c4211e0..52802f43 100644
--- a/src/token_kind.rs
+++ b/src/token_kind.rs
@@ -4,6 +4,8 @@ use crate::common::*;
 pub(crate) enum TokenKind {
   At,
   Backtick,
+  BracketL,
+  BracketR,
   Colon,
   ColonEquals,
   Comma,
@@ -34,6 +36,8 @@ impl Display for TokenKind {
       match *self {
         At => "'@'",
         Backtick => "backtick",
+        BracketL => "'['",
+        BracketR => "']'",
         Colon => "':'",
         ColonEquals => "':='",
         Comma => "','",


Test patch:
diff --git a/tests/integration.rs b/tests/integration.rs
index 7df99f44..d32ea429 100644
--- a/tests/integration.rs
+++ b/tests/integration.rs
@@ -350,6 +350,22 @@ _y:
   stdout:   "a b c d\n",
 }
 
+test! {
+  name: set_shell,
+  justfile: "
+    set shell := ['echo', '-n']
+
+    x := `bar`
+
+    foo:
+      echo {{x}}
+      echo foo
+  ",
+  args: (),
+  stdout: "echo barecho foo",
+  stderr: "echo bar\necho foo\n",
+}
+
 test! {
   name:     select,
   justfile: "b:
diff --git a/tests/shell.rs b/tests/shell.rs
index f3cf8f5c..179387d1 100644
--- a/tests/shell.rs
+++ b/tests/shell.rs
@@ -14,9 +14,9 @@ recipe default=`DEFAULT`:
 ";
 
 /// Test that --shell correctly sets the shell
-#[cfg(unix)]
 #[test]
-fn shell() {
+#[cfg_attr(windows, ignore)]
+fn flag() {
   let tmp = tmptree! {
     justfile: JUSTFILE,
     shell: "#!/usr/bin/env bash\necho \"$@\"",
@@ -24,8 +24,11 @@ fn shell() {
 
   let shell = tmp.path().join("shell");
 
-  let permissions = std::os::unix::fs::PermissionsExt::from_mode(0o700);
-  std::fs::set_permissions(&shell, permissions).unwrap();
+  #[cfg(not(windows))]
+  {
+    let permissions = std::os::unix::fs::PermissionsExt::from_mode(0o700);
+    std::fs::set_permissions(&shell, permissions).unwrap();
+  }
 
   let output = Command::new(executable_path("just"))
     .current_dir(tmp.path())
@@ -35,6 +38,63 @@ fn shell() {
     .unwrap();
 
   let stdout = "-cu -cu EXPRESSION\n-cu -cu DEFAULT\n-cu RECIPE\n";
+  assert_stdout(&output, stdout);
+}
+
+const JUSTFILE_CMD: &str = r#"
+
+set shell := ["cmd.exe", "/C"]
+
+x := `Echo`
+
+recipe:
+  REM foo
+  Echo "{{x}}"
+"#;
+
+/// Test that we can use `set shell` to use cmd.exe on windows
+#[test]
+#[cfg_attr(unix, ignore)]
+fn cmd() {
+  let tmp = tmptree! {
+    justfile: JUSTFILE_CMD,
+  };
+
+  let output = Command::new(executable_path("just"))
+    .current_dir(tmp.path())
+    .output()
+    .unwrap();
+
+  let stdout = "\\\"ECHO is on.\\\"\r\n";
+
+  assert_stdout(&output, stdout);
+}
+
+const JUSTFILE_POWERSHELL: &str = r#"
+
+set shell := ["powershell.exe", "-c"]
+
+x := `Write-Host "Hello, world!"`
+
+recipe:
+  For ($i=0; $i -le 10; $i++) { Write-Host $i }
+  Write-Host "{{x}}"
+"#;
+
+/// Test that we can use `set shell` to use cmd.exe on windows
+#[test]
+#[cfg_attr(unix, ignore)]
+fn powershell() {
+  let tmp = tmptree! {
+    justfile: JUSTFILE_POWERSHELL,
+  };
+
+  let output = Command::new(executable_path("just"))
+    .current_dir(tmp.path())
+    .output()
+    .unwrap();
+
+  let stdout = "0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\nHello, world!\n";
 
   assert_stdout(&output, stdout);
 }