Repository: casey/just
Pull request #778 (2021-03-29 05:32:07)

Add `dotenv-load` setting
The `dotenv-load` setting controls whether or not a `.env` file will be
loaded if present. It currently defaults to true.


Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index c8cfa1d9..01e25305 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -55,9 +55,12 @@ assignment    : NAME ':=' expression eol
 
 export        : 'export' assignment
 
-setting       : 'set' 'export'
+setting       : 'set' 'dotenv-load' boolean?
+              | 'set' 'export' boolean?
               | 'set' 'shell' ':=' '[' string (',' string)* ','? ']'
 
+boolean       : ':=' ('true' | 'false')
+
 expression    : 'if' condition '{' expression '}' else '{' expression '}'
               | value '+' expression
               | value
diff --git a/README.adoc b/README.adoc
index d7e53ba7..63abefd2 100644
--- a/README.adoc
+++ b/README.adoc
@@ -388,29 +388,30 @@ foo:
 [options="header"]
 |=================
 | Name | Value | Description
-| `export` | | Export all variables as environment variables
+| `dotenv-load` | `true` or `false` | Load a `.env` file, if present.
+| `export` | `true` or `false` | Export all variables as environment variables.
 |`shell` | `[COMMAND, ARGS...]` | Set the command used to invoke recipes and evaluate backticks.
 |=================
 
-==== Shell
-
-The `shell` setting controls the command used to invoke recipe lines and backticks. Shebang recipes are unaffected.
+Boolean settings can be written as:
 
-```make
-# use python3 to execute recipe lines and backticks
-set shell := ["python3", "-c"]
+```
+set NAME
+```
 
-# use print to capture result of evaluation
-foos := `print("foo" * 4)`
+Which is equivalent to:
 
-foo:
-  print("Snake snake snake snake.")
-  print("{{foos}}")
 ```
+set NAME := true
+```
+
+==== Dotenv Load
+
+If `dotenv-load` is `true`, a `.env` file will be loaded if present. Defaults to `true`.
 
 ==== Export
 
-The `export` setting causes all Just variables to be exported as environment variables.
+The `export` setting causes all Just variables to be exported as environment variables. Defaults to `false`.
 
 ```make
 set export
@@ -428,6 +429,22 @@ hello
 goodbye
 ```
 
+==== Shell
+
+The `shell` setting controls the command used to invoke recipe lines and backticks. Shebang recipes are unaffected.
+
+```make
+# use python3 to execute recipe lines and backticks
+set shell := ["python3", "-c"]
+
+# use print to capture result of evaluation
+foos := `print("foo" * 4)`
+
+foo:
+  print("Snake snake snake snake.")
+  print("{{foos}}")
+```
+
 === Documentation Comments
 
 Comments immediately preceding a recipe will appear in `just --list`:
diff --git a/src/analyzer.rs b/src/analyzer.rs
index bb392e3e..2d7f8c8a 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -65,13 +65,16 @@ impl<'src> Analyzer<'src> {
 
     for (_, set) in self.sets {
       match set.value {
+        Setting::DotenvLoad(dotenv_load) => {
+          settings.dotenv_load = dotenv_load;
+        },
+        Setting::Export(export) => {
+          settings.export = export;
+        },
         Setting::Shell(shell) => {
           assert!(settings.shell.is_none());
           settings.shell = Some(shell);
         },
-        Setting::Export => {
-          settings.export = true;
-        },
       }
     }
 
diff --git a/src/compilation_error.rs b/src/compilation_error.rs
index 7947a4e5..cf0b4e06 100644
--- a/src/compilation_error.rs
+++ b/src/compilation_error.rs
@@ -15,7 +15,7 @@ impl Display for CompilationError<'_> {
 
     write!(f, "{}", message.prefix())?;
 
-    match self.kind {
+    match &self.kind {
       AliasShadowsRecipe { alias, recipe_line } => {
         writeln!(
           f,
@@ -116,22 +116,23 @@ impl Display for CompilationError<'_> {
           "Dependency `{}` got {} {} but takes ",
           dependency,
           found,
-          Count("argument", found),
+          Count("argument", *found),
         )?;
 
         if min == max {
           let expected = min;
-          writeln!(f, "{} {}", expected, Count("argument", expected))?;
+          writeln!(f, "{} {}", expected, Count("argument", *expected))?;
         } else if found < min {
-          writeln!(f, "at least {} {}", min, Count("argument", min))?;
+          writeln!(f, "at least {} {}", min, Count("argument", *min))?;
         } else {
-          writeln!(f, "at most {} {}", max, Count("argument", max))?;
+          writeln!(f, "at most {} {}", max, Count("argument", *max))?;
         }
       },
       ExpectedKeyword { expected, found } => writeln!(
         f,
-        "Expected keyword `{}` but found identifier `{}`",
-        expected, found
+        "Expected keyword {} but found identifier `{}`",
+        List::or_ticked(expected),
+        found
       )?,
       ParameterShadowsVariable { parameter } => {
         writeln!(
@@ -171,7 +172,7 @@ impl Display for CompilationError<'_> {
           "Function `{}` called with {} {} but takes {}",
           function,
           found,
-          Count("argument", found),
+          Count("argument", *found),
           expected
         )?;
       },
diff --git a/src/compilation_error_kind.rs b/src/compilation_error_kind.rs
index 4e2b2cbc..d01a6321 100644
--- a/src/compilation_error_kind.rs
+++ b/src/compilation_error_kind.rs
@@ -40,7 +40,7 @@ pub(crate) enum CompilationErrorKind<'src> {
     first:   usize,
   },
   ExpectedKeyword {
-    expected: Keyword,
+    expected: Vec<Keyword>,
     found:    &'src str,
   },
   ExtraLeadingWhitespace,
diff --git a/src/justfile.rs b/src/justfile.rs
index d5371f4b..7b1fcbee 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -91,7 +91,7 @@ impl<'src> Justfile<'src> {
     }
 
     let dotenv = if config.load_dotenv {
-      load_dotenv(&search.working_directory)?
+      load_dotenv(&search.working_directory, &self.settings)?
     } else {
       BTreeMap::new()
     };
diff --git a/src/keyword.rs b/src/keyword.rs
index 4d75305f..a3f63143 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -6,6 +6,9 @@ pub(crate) enum Keyword {
   Alias,
   Else,
   Export,
+  DotenvLoad,
+  True,
+  False,
   If,
   Set,
   Shell,
diff --git a/src/load_dotenv.rs b/src/load_dotenv.rs
index 22cb1ea4..d0c1b3dd 100644
--- a/src/load_dotenv.rs
+++ b/src/load_dotenv.rs
@@ -2,10 +2,16 @@ use crate::common::*;
 
 pub(crate) fn load_dotenv(
   working_directory: &Path,
+  settings: &Settings,
 ) -> RunResult<'static, BTreeMap<String, String>> {
   // `dotenv::from_path_iter` should eventually be un-deprecated, see:
   // https://github.com/dotenv-rs/dotenv/issues/13
   #![allow(deprecated)]
+
+  if !settings.dotenv_load {
+    return Ok(BTreeMap::new());
+  }
+
   for directory in working_directory.ancestors() {
     let path = directory.join(".env");
 
diff --git a/src/node.rs b/src/node.rs
index 8b9512ec..4e3baaa8 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -187,17 +187,17 @@ impl<'src> Node<'src> for Set<'src> {
   fn tree(&self) -> Tree<'src> {
     let mut set = Tree::atom(Keyword::Set.lexeme());
 
-    set.push_mut(self.name.lexeme());
+    set.push_mut(self.name.lexeme().replace('-', "_"));
 
     use Setting::*;
     match &self.value {
+      DotenvLoad(value) | Export(value) => set.push_mut(value.to_string()),
       Shell(setting::Shell { command, arguments }) => {
         set.push_mut(Tree::string(&command.cooked));
         for argument in arguments {
           set.push_mut(Tree::string(&argument.cooked));
         }
       },
-      Export => {},
     }
 
     set
diff --git a/src/parser.rs b/src/parser.rs
index f6e503d3..765eb8ac 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -179,7 +179,10 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     if expected == found {
       Ok(())
     } else {
-      Err(identifier.error(CompilationErrorKind::ExpectedKeyword { expected, found }))
+      Err(identifier.error(CompilationErrorKind::ExpectedKeyword {
+        expected: vec![expected],
+        found,
+      }))
     }
   }
 
@@ -347,6 +350,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
           Some(Keyword::Set) =>
             if self.next_are(&[Identifier, Identifier, ColonEquals])
               || self.next_are(&[Identifier, Identifier, Eol])
+              || self.next_are(&[Identifier, Identifier, Eof])
             {
               items.push(Item::Set(self.parse_set()?));
             } else {
@@ -678,19 +682,50 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     Ok(lines)
   }
 
+  /// Parse a boolean setting value
+  fn parse_set_bool(&mut self) -> CompilationResult<'src, bool> {
+    if !self.accepted(ColonEquals)? {
+      return Ok(true);
+    }
+
+    let identifier = self.expect(Identifier)?;
+
+    let value = if Keyword::True == identifier.lexeme() {
+      true
+    } else if Keyword::False == identifier.lexeme() {
+      false
+    } else {
+      return Err(identifier.error(CompilationErrorKind::ExpectedKeyword {
+        expected: vec![Keyword::True, Keyword::False],
+        found:    identifier.lexeme(),
+      }));
+    };
+
+    Ok(value)
+  }
+
   /// Parse a setting
   fn parse_set(&mut self) -> CompilationResult<'src, Set<'src>> {
     self.presume_keyword(Keyword::Set)?;
     let name = Name::from_identifier(self.presume(Identifier)?);
+    let lexeme = name.lexeme();
 
-    if name.lexeme() == Keyword::Export.lexeme() {
+    if Keyword::DotenvLoad == lexeme {
+      let value = self.parse_set_bool()?;
+      return Ok(Set {
+        value: Setting::DotenvLoad(value),
+        name,
+      });
+    } else if Keyword::Export == lexeme {
+      let value = self.parse_set_bool()?;
       return Ok(Set {
-        value: Setting::Export,
+        value: Setting::Export(value),
         name,
       });
     }
 
-    self.presume(ColonEquals)?;
+    self.expect(ColonEquals)?;
+
     if name.lexeme() == Keyword::Shell.lexeme() {
       self.expect(BracketL)?;
 
@@ -1541,6 +1576,42 @@ mod tests {
     tree: (justfile (recipe a (body ("foo"))) (recipe b)),
   }
 
+  test! {
+    name: set_export_implicit,
+    text: "set export",
+    tree: (justfile (set export true)),
+  }
+
+  test! {
+    name: set_export_true,
+    text: "set export := true",
+    tree: (justfile (set export true)),
+  }
+
+  test! {
+    name: set_export_false,
+    text: "set export := false",
+    tree: (justfile (set export false)),
+  }
+
+  test! {
+    name: set_dotenv_load_implicit,
+    text: "set dotenv-load",
+    tree: (justfile (set dotenv_load true)),
+  }
+
+  test! {
+    name: set_dotenv_load_true,
+    text: "set dotenv-load := true",
+    tree: (justfile (set dotenv_load true)),
+  }
+
+  test! {
+    name: set_dotenv_load_false,
+    text: "set dotenv-load := false",
+    tree: (justfile (set dotenv_load false)),
+  }
+
   test! {
     name: set_shell_no_arguments,
     text: "set shell := ['tclsh']",
diff --git a/src/setting.rs b/src/setting.rs
index 5f95d873..082baf19 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -3,7 +3,8 @@ use crate::common::*;
 #[derive(Debug)]
 pub(crate) enum Setting<'src> {
   Shell(Shell<'src>),
-  Export,
+  Export(bool),
+  DotenvLoad(bool),
 }
 
 #[derive(Debug, PartialEq)]
diff --git a/src/settings.rs b/src/settings.rs
index 388a673f..2037d338 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -2,15 +2,17 @@ use crate::common::*;
 
 #[derive(Debug, PartialEq)]
 pub(crate) struct Settings<'src> {
-  pub(crate) shell:  Option<setting::Shell<'src>>,
-  pub(crate) export: bool,
+  pub(crate) dotenv_load: bool,
+  pub(crate) export:      bool,
+  pub(crate) shell:       Option<setting::Shell<'src>>,
 }
 
 impl<'src> Settings<'src> {
   pub(crate) fn new() -> Settings<'src> {
     Settings {
-      shell:  None,
-      export: false,
+      dotenv_load: true,
+      export:      false,
+      shell:       None,
     }
   }
 
diff --git a/src/token.rs b/src/token.rs
index 9b42f182..a0ef4deb 100644
--- a/src/token.rs
+++ b/src/token.rs
@@ -60,7 +60,7 @@ impl<'src> Token<'src> {
           space_column,
           color.prefix(),
           "",
-          space_width,
+          space_width.max(1),
           color.suffix()
         )?;
       },
diff --git a/src/tree.rs b/src/tree.rs
index 0d929601..1e86cb2d 100644
--- a/src/tree.rs
+++ b/src/tree.rs
@@ -3,8 +3,8 @@ use crate::common::*;
 use std::mem;
 
 /// Construct a `Tree` from a symbolic expression literal. This macro, and the
-/// Tree type, are only used in the Parser unit tests, as a concise notation
-/// representing the expected results of parsing a given string.
+/// Tree type, are only used in the Parser unit tests, providing a concise
+/// notation for representing the expected results of parsing a given string.
 macro_rules! tree {
   {
     ($($child:tt)*)


Test patch:
diff --git a/tests/dotenv.rs b/tests/dotenv.rs
index 288d3dd1..50c5b1bc 100644
--- a/tests/dotenv.rs
+++ b/tests/dotenv.rs
@@ -26,3 +26,39 @@ fn dotenv() {
   let stdout = str::from_utf8(&output.stdout).unwrap();
   assert_eq!(stdout, "KEY=SUB\n");
 }
+
+test! {
+  name:     set_false,
+  justfile: r#"
+    set dotenv-load := false
+
+    foo:
+      if [ -n "${DOTENV_KEY+1}" ]; then echo defined; else echo undefined; fi
+  "#,
+  stdout:   "undefined\n",
+  stderr:   "if [ -n \"${DOTENV_KEY+1}\" ]; then echo defined; else echo undefined; fi\n",
+}
+
+test! {
+  name:     set_implicit,
+  justfile: r#"
+    set dotenv-load
+
+    foo:
+      echo $DOTENV_KEY
+  "#,
+  stdout:   "dotenv-value\n",
+  stderr:   "echo $DOTENV_KEY\n",
+}
+
+test! {
+  name:     set_true,
+  justfile: r#"
+    set dotenv-load := true
+
+    foo:
+      echo $DOTENV_KEY
+  "#,
+  stdout:   "dotenv-value\n",
+  stderr:   "echo $DOTENV_KEY\n",
+}
diff --git a/tests/export.rs b/tests/export.rs
index f9ad42e5..9852f973 100644
--- a/tests/export.rs
+++ b/tests/export.rs
@@ -81,7 +81,7 @@ recipe:
 }
 
 test! {
-  name: setting,
+  name: setting_implicit,
   justfile: "
     set export
 
@@ -97,6 +97,37 @@ test! {
   stderr: "echo $A\necho $B\necho $C\n",
 }
 
+test! {
+  name: setting_true,
+  justfile: "
+    set export := true
+
+    A := 'hello'
+
+    foo B C=`echo $A`:
+      echo $A
+      echo $B
+      echo $C
+  ",
+  args: ("foo", "goodbye"),
+  stdout: "hello\ngoodbye\nhello\n",
+  stderr: "echo $A\necho $B\necho $C\n",
+}
+
+test! {
+  name: setting_false,
+  justfile: r#"
+    set export := false
+
+    A := 'hello'
+
+    foo:
+      if [ -n "${A+1}" ]; then echo defined; else echo undefined; fi
+  "#,
+  stdout: "undefined\n",
+  stderr: "if [ -n \"${A+1}\" ]; then echo defined; else echo undefined; fi\n",
+}
+
 test! {
   name: setting_shebang,
   justfile: "
diff --git a/tests/misc.rs b/tests/misc.rs
index 2e0db25f..18d8236d 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -65,6 +65,20 @@ test! {
   stderr: "echo bar\n",
 }
 
+test! {
+  name: bad_setting,
+  justfile: "
+    set foo
+  ",
+  stderr: "
+  error: Expected ':=', but found end of line
+    |
+  1 | set foo
+    |        ^
+  ",
+  status: EXIT_FAILURE,
+}
+
 test! {
   name: alias_with_dependencies,
   justfile: "foo:\n  echo foo\nbar: foo\nalias b := bar",
@@ -2214,82 +2228,6 @@ test! {
   ",
 }
 
-test! {
-  name: shell_args,
-  justfile: "
-    default:
-      echo A${foo}A
-  ",
-  args: ("--shell-arg", "-c"),
-  stdout: "AA\n",
-  stderr: "echo A${foo}A\n",
-  shell: false,
-}
-
-test! {
-  name: shell_override,
-  justfile: "
-    set shell := ['foo-bar-baz']
-
-    default:
-      echo hello
-  ",
-  args: ("--shell", "bash"),
-  stdout: "hello\n",
-  stderr: "echo hello\n",
-  shell: false,
-}
-
-test! {
-  name: shell_arg_override,
-  justfile: "
-    set shell := ['foo-bar-baz']
-
-    default:
-      echo hello
-  ",
-  args: ("--shell-arg", "-cu"),
-  stdout: "hello\n",
-  stderr: "echo hello\n",
-  shell: false,
-}
-
-#[cfg(unix)]
-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",
-  shell: false,
-}
-
-#[cfg(windows)]
-test! {
-  name: set_shell,
-  justfile: "
-    set shell := ['echo', '-n']
-
-    x := `bar`
-
-    foo:
-      echo {{x}}
-      echo foo
-  ",
-  args: (),
-  stdout: "-n echo -n bar\r\r\n-n echo foo\r\n",
-  stderr: "echo -n bar\r\necho foo\n",
-  shell: false,
-}
-
 test! {
   name: dependency_argument_string,
   justfile: "
diff --git a/tests/shell.rs b/tests/shell.rs
index 179387d1..2e526d25 100644
--- a/tests/shell.rs
+++ b/tests/shell.rs
@@ -98,3 +98,79 @@ fn powershell() {
 
   assert_stdout(&output, stdout);
 }
+
+test! {
+  name: shell_args,
+  justfile: "
+    default:
+      echo A${foo}A
+  ",
+  args: ("--shell-arg", "-c"),
+  stdout: "AA\n",
+  stderr: "echo A${foo}A\n",
+  shell: false,
+}
+
+test! {
+  name: shell_override,
+  justfile: "
+    set shell := ['foo-bar-baz']
+
+    default:
+      echo hello
+  ",
+  args: ("--shell", "bash"),
+  stdout: "hello\n",
+  stderr: "echo hello\n",
+  shell: false,
+}
+
+test! {
+  name: shell_arg_override,
+  justfile: "
+    set shell := ['foo-bar-baz']
+
+    default:
+      echo hello
+  ",
+  args: ("--shell-arg", "-cu"),
+  stdout: "hello\n",
+  stderr: "echo hello\n",
+  shell: false,
+}
+
+#[cfg(unix)]
+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",
+  shell: false,
+}
+
+#[cfg(windows)]
+test! {
+  name: set_shell,
+  justfile: "
+    set shell := ['echo', '-n']
+
+    x := `bar`
+
+    foo:
+      echo {{x}}
+      echo foo
+  ",
+  args: (),
+  stdout: "-n echo -n bar\r\r\n-n echo foo\r\n",
+  stderr: "echo -n bar\r\necho foo\n",
+  shell: false,
+}