diff --git a/GRAMMAR.md b/GRAMMAR.md index 51a431f..6a0c40a 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -60,7 +60,9 @@ assignment : NAME ':=' expression eol export : 'export' assignment setting : 'set' 'allow-duplicate-recipes' boolean? + | 'set' 'dotenv-filename' ':=' string | 'set' 'dotenv-load' boolean? + | 'set' 'dotenv-path' ':=' string | 'set' 'export' boolean? | 'set' 'fallback' boolean? | 'set' 'ignore-comments' boolean? diff --git a/README.md b/README.md index f385d55..65e7aa4 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Yay, all your tests passed! - Wherever possible, errors are resolved statically. Unknown recipes and circular dependencies are reported before anything runs. -- `just` [loads `.env` files](#dotenv-integration), making it easy to populate environment variables. +- `just` [loads `.env` files](#dotenv-settings), making it easy to populate environment variables. - Recipes can be [listed from the command line](#listing-available-recipes). @@ -669,7 +669,9 @@ foo: | Name | Value | Default | Description | | ------------------------- | ------------------ | ------- |---------------------------------------------------------------------------------------------- | | `allow-duplicate-recipes` | boolean | `false` | Allow recipes appearing later in a `justfile` to override earlier recipes with the same name. | +| `dotenv-filename` | string | - | Load a `.env` file with a custom name, if present. | | `dotenv-load` | boolean | `false` | Load a `.env` file, if present. | +| `dotenv-path` | string | - | Load a `.env` file from a custom path, if present. Overrides `dotenv-filename`. | | `export` | boolean | `false` | Export all variables as environment variables. | | `fallback` | boolean | `false` | Search `justfile` in parent directory if the first recipe on the command line is not found. | | `ignore-comments` | boolean | `false` | Ignore recipe lines beginning with `#`. | @@ -710,9 +712,41 @@ $ just foo bar ``` -#### Dotenv Load +#### Dotenv Settings -If `dotenv-load` is `true`, a `.env` file will be loaded if present. Defaults to `false`. +If `dotenv-load`, `dotenv-filename` or `dotenv-path` is set, `just` will load environment variables from a file. + +If `dotenv-path` is set, `just` will look for a file at the given path. + +Otherwise, `just` looks for a file named `.env` by default, unless `dotenv-filename` set, in which case the value of `dotenv-filename` is used. This file can be located in the same directory as your `justfile` or in a parent directory. + +The loaded variables are environment variables, not `just` variables, and so must be accessed using `$VARIABLE_NAME` in recipes and backticks. + +For example, if your `.env` file contains: + +```sh +# a comment, will be ignored +DATABASE_ADDRESS=localhost:6379 +SERVER_PORT=1337 +``` + +And your `justfile` contains: + +```just +set dotenv-load + +serve: + @echo "Starting server with database $DATABASE_ADDRESS on port $SERVER_PORT…" + ./server --database $DATABASE_ADDRESS --port $SERVER_PORT +``` + +`just serve` will output: + +```sh +$ just serve +Starting server with database localhost:6379 on port 1337… +./server --database $DATABASE_ADDRESS --port $SERVER_PORT +``` #### Export @@ -878,36 +912,6 @@ Available recipes: test # test stuff ``` -### Dotenv Integration - -If [`dotenv-load`](#dotenv-load) is set, `just` will load environment variables from a file named `.env`. This file can be located in the same directory as your `justfile` or in a parent directory. These variables are environment variables, not `just` variables, and so must be accessed using `$VARIABLE_NAME` in recipes and backticks. - -For example, if your `.env` file contains: - -```sh -# a comment, will be ignored -DATABASE_ADDRESS=localhost:6379 -SERVER_PORT=1337 -``` - -And your `justfile` contains: - -```just -set dotenv-load - -serve: - @echo "Starting server with database $DATABASE_ADDRESS on port $SERVER_PORT…" - ./server --database $DATABASE_ADDRESS --port $SERVER_PORT -``` - -`just serve` will output: - -```sh -$ just serve -Starting server with database localhost:6379 on port 1337… -./server --database $DATABASE_ADDRESS --port $SERVER_PORT -``` - ### Variables and Substitution Variables, strings, concatenation, path joining, and substitution using `{{…}}` are supported: @@ -1528,9 +1532,6 @@ print_home_folder: $ just HOME is '/home/myuser' ``` -#### Loading Environment Variables from a `.env` File - -`just` will load environment variables from a `.env` file if [dotenv-load](#dotenv-load) is set. The variables in the file will be available as environment variables to the recipes. See [dotenv-integration](#dotenv-integration) for more information. #### Setting `just` Variables from Environment Variables diff --git a/justfile b/justfile index 52b4ee1..f25e719 100755 --- a/justfile +++ b/justfile @@ -6,10 +6,6 @@ alias t := test alias c := check -bt := '0' - -export RUST_BACKTRACE := bt - log := "warn" export JUST_LOG := log diff --git a/src/keyword.rs b/src/keyword.rs index bff1b6c..db2ba95 100644 --- a/src/keyword.rs +++ b/src/keyword.rs @@ -5,7 +5,9 @@ use super::*; pub(crate) enum Keyword { Alias, AllowDuplicateRecipes, + DotenvFilename, DotenvLoad, + DotenvPath, Else, Export, Fallback, diff --git a/src/load_dotenv.rs b/src/load_dotenv.rs index b1fbe86..1ccb4b9 100644 --- a/src/load_dotenv.rs +++ b/src/load_dotenv.rs @@ -7,25 +7,28 @@ pub(crate) fn load_dotenv( settings: &Settings, working_directory: &Path, ) -> RunResult<'static, BTreeMap> { - if !settings.dotenv_load.unwrap_or(false) - && config.dotenv_filename.is_none() - && config.dotenv_path.is_none() - { + let dotenv_filename = config + .dotenv_filename + .as_ref() + .or(settings.dotenv_filename.as_ref()); + + let dotenv_path = config + .dotenv_path + .as_ref() + .or(settings.dotenv_path.as_ref()); + + if !settings.dotenv_load.unwrap_or(false) && dotenv_filename.is_none() && dotenv_path.is_none() { return Ok(BTreeMap::new()); } - if let Some(path) = &config.dotenv_path { + if let Some(path) = dotenv_path { return load_from_file(path); } - let filename = config - .dotenv_filename - .as_deref() - .unwrap_or(DEFAULT_DOTENV_FILENAME) - .to_owned(); + let filename = dotenv_filename.map_or(DEFAULT_DOTENV_FILENAME, |s| s.as_str()); for directory in working_directory.ancestors() { - let path = directory.join(filename.as_str()); + let path = directory.join(filename); if path.is_file() { return load_from_file(&path); } diff --git a/src/node.rs b/src/node.rs index e231c24..924bcf3 100644 --- a/src/node.rs +++ b/src/node.rs @@ -249,7 +249,7 @@ impl<'src> Node<'src> for Set<'src> { set.push_mut(Tree::string(&argument.cooked)); } } - Setting::Tempdir(value) => { + Setting::DotenvFilename(value) | Setting::DotenvPath(value) | Setting::Tempdir(value) => { set.push_mut(Tree::string(value)); } } diff --git a/src/parser.rs b/src/parser.rs index 04c4a9d..81ae3c1 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -780,21 +780,23 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { self.presume_keyword(Keyword::Set)?; let name = Name::from_identifier(self.presume(Identifier)?); let lexeme = name.lexeme(); + let Some(keyword) = Keyword::from_lexeme(lexeme) else { + return Err(name.error(CompileErrorKind::UnknownSetting { + setting: name.lexeme(), + })); + }; - let set_bool: Option = match Keyword::from_lexeme(lexeme) { - Some(kw) => match kw { - Keyword::AllowDuplicateRecipes => { - Some(Setting::AllowDuplicateRecipes(self.parse_set_bool()?)) - } - Keyword::DotenvLoad => Some(Setting::DotenvLoad(self.parse_set_bool()?)), - Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)), - Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)), - Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)), - Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)), - Keyword::WindowsPowershell => Some(Setting::WindowsPowerShell(self.parse_set_bool()?)), - _ => None, - }, - None => None, + let set_bool = match keyword { + Keyword::AllowDuplicateRecipes => { + Some(Setting::AllowDuplicateRecipes(self.parse_set_bool()?)) + } + Keyword::DotenvLoad => Some(Setting::DotenvLoad(self.parse_set_bool()?)), + Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)), + Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)), + Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)), + Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)), + Keyword::WindowsPowershell => Some(Setting::WindowsPowerShell(self.parse_set_bool()?)), + _ => None, }; if let Some(value) = set_bool { @@ -803,26 +805,22 @@ impl<'tokens, 'src> Parser<'tokens, 'src> { self.expect(ColonEquals)?; - if name.lexeme() == Keyword::Shell.lexeme() { - Ok(Set { - value: Setting::Shell(self.parse_shell()?), - name, - }) - } else if name.lexeme() == Keyword::WindowsShell.lexeme() { - Ok(Set { - value: Setting::WindowsShell(self.parse_shell()?), - name, - }) - } else if name.lexeme() == Keyword::Tempdir.lexeme() { - Ok(Set { - value: Setting::Tempdir(self.parse_string_literal()?.cooked), - name, - }) - } else { - Err(name.error(CompileErrorKind::UnknownSetting { - setting: name.lexeme(), - })) + let set_value = match keyword { + Keyword::DotenvFilename => Some(Setting::DotenvFilename(self.parse_string_literal()?.cooked)), + Keyword::DotenvPath => Some(Setting::DotenvPath(self.parse_string_literal()?.cooked)), + Keyword::Shell => Some(Setting::Shell(self.parse_shell()?)), + Keyword::Tempdir => Some(Setting::Tempdir(self.parse_string_literal()?.cooked)), + Keyword::WindowsShell => Some(Setting::WindowsShell(self.parse_shell()?)), + _ => None, + }; + + if let Some(value) = set_value { + return Ok(Set { name, value }); } + + Err(name.error(CompileErrorKind::UnknownSetting { + setting: name.lexeme(), + })) } /// Parse a shell setting value diff --git a/src/setting.rs b/src/setting.rs index 7278760..9b2a9fe 100644 --- a/src/setting.rs +++ b/src/setting.rs @@ -3,7 +3,9 @@ use super::*; #[derive(Debug, Clone)] pub(crate) enum Setting<'src> { AllowDuplicateRecipes(bool), + DotenvFilename(String), DotenvLoad(bool), + DotenvPath(String), Export(bool), Fallback(bool), IgnoreComments(bool), @@ -25,8 +27,8 @@ impl<'src> Display for Setting<'src> { | Setting::PositionalArguments(value) | Setting::WindowsPowerShell(value) => write!(f, "{value}"), Setting::Shell(shell) | Setting::WindowsShell(shell) => write!(f, "{shell}"), - Setting::Tempdir(tempdir) => { - write!(f, "{tempdir:?}") + Setting::DotenvFilename(value) | Setting::DotenvPath(value) | Setting::Tempdir(value) => { + write!(f, "{value:?}") } } } diff --git a/src/settings.rs b/src/settings.rs index 508cfb0..26765e0 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,7 +9,9 @@ pub(crate) const WINDOWS_POWERSHELL_ARGS: &[&str] = &["-NoLogo", "-Command"]; #[allow(clippy::struct_excessive_bools)] pub(crate) struct Settings<'src> { pub(crate) allow_duplicate_recipes: bool, + pub(crate) dotenv_filename: Option, pub(crate) dotenv_load: Option, + pub(crate) dotenv_path: Option, pub(crate) export: bool, pub(crate) fallback: bool, pub(crate) ignore_comments: bool, @@ -29,9 +31,15 @@ impl<'src> Settings<'src> { Setting::AllowDuplicateRecipes(allow_duplicate_recipes) => { settings.allow_duplicate_recipes = allow_duplicate_recipes; } + Setting::DotenvFilename(filename) => { + settings.dotenv_filename = Some(filename); + } Setting::DotenvLoad(dotenv_load) => { settings.dotenv_load = Some(dotenv_load); } + Setting::DotenvPath(path) => { + settings.dotenv_path = Some(PathBuf::from(path)); + } Setting::Export(export) => { settings.export = export; } diff --git a/tests/dotenv.rs b/tests/dotenv.rs index a1182f6..3814e89 100644 --- a/tests/dotenv.rs +++ b/tests/dotenv.rs @@ -161,3 +161,87 @@ fn path_flag_overwrites_no_load() { .status(EXIT_SUCCESS) .run(); } + +#[test] +fn can_set_dotenv_filename_from_justfile() { + Test::new() + .justfile( + r#" + set dotenv-filename := ".env.special" + + foo: + @echo $NAME + "#, + ) + .tree(tree! { + ".env.special": "NAME=bar" + }) + .stdout("bar\n") + .status(EXIT_SUCCESS) + .run(); +} + +#[test] +fn can_set_dotenv_path_from_justfile() { + Test::new() + .justfile( + r#" + set dotenv-path:= "subdir/.env" + + foo: + @echo $NAME + "#, + ) + .tree(tree! { + subdir: { + ".env": "NAME=bar" + } + }) + .stdout("bar\n") + .status(EXIT_SUCCESS) + .run(); +} + +#[test] +fn program_argument_has_priority_for_dotenv_filename() { + Test::new() + .justfile( + r#" + set dotenv-filename := ".env.special" + + foo: + @echo $NAME + "#, + ) + .tree(tree! { + ".env.special": "NAME=bar", + ".env.superspecial": "NAME=baz" + }) + .args(["--dotenv-filename", ".env.superspecial"]) + .stdout("baz\n") + .status(EXIT_SUCCESS) + .run(); +} + +#[test] +fn program_argument_has_priority_for_dotenv_path() { + Test::new() + .justfile( + r#" + set dotenv-path:= "subdir/.env" + + foo: + @echo $NAME + "#, + ) + .tree(tree! { + subdir: { + ".env": "NAME=bar", + ".env.special": "NAME=baz" + } + }) + .args(["--dotenv-path", "subdir/.env.special"]) + .stdout("baz\n") + .status(EXIT_SUCCESS) + .run(); +} diff --git a/tests/json.rs b/tests/json.rs index 4e4afb1..39e04b6 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -42,7 +42,9 @@ fn alias() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "positional_arguments": false, @@ -74,7 +76,9 @@ fn assignment() { "recipes": {}, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -120,7 +124,9 @@ fn body() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -177,7 +183,9 @@ fn dependencies() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -271,7 +279,9 @@ fn dependency_argument() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -329,7 +339,9 @@ fn duplicate_recipes() { }, "settings": { "allow_duplicate_recipes": true, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -368,7 +380,9 @@ fn doc_comment() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -394,7 +408,9 @@ fn empty_justfile() { "recipes": {}, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -535,7 +551,9 @@ fn parameters() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -612,7 +630,9 @@ fn priors() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -651,7 +671,9 @@ fn private() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -690,7 +712,9 @@ fn quiet() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -710,6 +734,8 @@ fn settings() { test( " set dotenv-load + set dotenv-filename := \"filename\" + set dotenv-path := \"path\" set export set fallback set positional-arguments @@ -738,7 +764,9 @@ fn settings() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": "filename", "dotenv_load": true, + "dotenv_path": "path", "export": true, "fallback": true, "ignore_comments": true, @@ -783,7 +811,9 @@ fn shebang() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -822,7 +852,9 @@ fn simple() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "ignore_comments": false, @@ -864,7 +896,9 @@ fn attribute() { }, "settings": { "allow_duplicate_recipes": false, + "dotenv_filename": null, "dotenv_load": null, + "dotenv_path": null, "export": false, "fallback": false, "positional_arguments": false, diff --git a/tests/misc.rs b/tests/misc.rs index 2c64d5d..7d8ff0f 100644 --- a/tests/misc.rs +++ b/tests/misc.rs @@ -71,10 +71,24 @@ test! { set foo ", stderr: " - error: Expected ':=', but found end of line + error: Unknown setting `foo` | 1 | set foo - | ^ + | ^^^ + ", + status: EXIT_FAILURE, +} + +test! { + name: bad_setting_with_keyword_name, + justfile: " + set if := 'foo' + ", + stderr: " + error: Unknown setting `if` + | + 1 | set if := 'foo' + | ^^ ", status: EXIT_FAILURE, }