diff --git a/GRAMMAR.md b/GRAMMAR.md index e29e999c67..c6011d56eb 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -59,7 +59,7 @@ alias : 'alias' NAME ':=' target eol target : NAME ('::' NAME)* -assignment : NAME ':=' expression eol +assignment : 'lazy'? NAME ':=' expression eol export : 'export' assignment diff --git a/README.md b/README.md index ee98348101..3db672d383 100644 --- a/README.md +++ b/README.md @@ -1490,6 +1490,35 @@ braces: echo 'I {{ "{{" }}LOVE}} curly braces!' ``` +#### Lazy Evaluation + +By default, variables are evaluated when they are defined. If you would like a +variable to only be evaluated when it is used for the first time, you can use the +`lazy` keyword: + +```just +lazy aws_account_id := `aws sts get-caller-identity --query Account --output text` +``` + +Once a lazy variable has been evaluated, its value is the same for the rest of +the invocation of `just`, even if it is used multiple times: + +```just +lazy timestamp := `date +%s` + +foo: + # The value is computed here + echo The time is {{timestamp}} + sleep 1 + # The same value is used here + echo The time is still {{timestamp}} +``` + +This is useful for values that are expensive to compute, or that may not be +needed in every invocation of `just`. It also saves you from having expensive +values being recomputed even for simple invocations of `just` that don't +actually use them, like `just --list`. + ### Strings `'single'`, `"double"`, and `'''triple'''` quoted string literals are diff --git a/src/assignment.rs b/src/assignment.rs index c41ecbab0d..2e7927b051 100644 --- a/src/assignment.rs +++ b/src/assignment.rs @@ -9,6 +9,10 @@ impl Display for Assignment<'_> { writeln!(f, "[private]")?; } + if self.lazy { + write!(f, "lazy ")?; + } + if self.export { write!(f, "export ")?; } diff --git a/src/binding.rs b/src/binding.rs index 69dcc3b056..1680b6a5c3 100644 --- a/src/binding.rs +++ b/src/binding.rs @@ -8,6 +8,7 @@ pub(crate) struct Binding<'src, V = String> { pub(crate) export: bool, #[serde(skip)] pub(crate) file_depth: u32, + pub(crate) lazy: bool, pub(crate) name: Name<'src>, pub(crate) private: bool, pub(crate) value: V, diff --git a/src/evaluator.rs b/src/evaluator.rs index e3a7166bab..ef9465b723 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -37,6 +37,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { file_depth: 0, name: assignment.name, private: assignment.private, + lazy: false, value: value.clone(), }); } else { @@ -58,7 +59,9 @@ impl<'src, 'run> Evaluator<'src, 'run> { }; for assignment in module.assignments.values() { - evaluator.evaluate_assignment(assignment)?; + if !assignment.lazy { + evaluator.evaluate_assignment(assignment)?; + } } Ok(evaluator.scope) @@ -75,6 +78,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { file_depth: 0, name: assignment.name, private: assignment.private, + lazy: false, value, }); } @@ -363,6 +367,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { file_depth: 0, name: parameter.name, private: false, + lazy: false, value, }); } @@ -376,7 +381,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { scope: &'run Scope<'src, 'run>, ) -> Self { Self { - assignments: None, + assignments: Some(&context.module.assignments), context: *context, is_dependency, scope: scope.child(), diff --git a/src/keyword.rs b/src/keyword.rs index 7cae3606fa..e511cdb15e 100644 --- a/src/keyword.rs +++ b/src/keyword.rs @@ -19,6 +19,7 @@ pub(crate) enum Keyword { If, IgnoreComments, Import, + Lazy, Mod, NoExitMessage, PositionalArguments, diff --git a/src/parser.rs b/src/parser.rs index 6ddd161c2e..e02222f7d3 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -346,6 +346,12 @@ impl<'run, 'src> Parser<'run, 'src> { self.parse_assignment(true, take_attributes())?, )); } + Some(Keyword::Lazy) if self.next_are(&[Identifier, Identifier, ColonEquals]) => { + self.presume_keyword(Keyword::Lazy)?; + items.push(Item::Assignment( + self.parse_assignment_lazy(take_attributes())?, + )); + } Some(Keyword::Unexport) if self.next_are(&[Identifier, Identifier, Eof]) || self.next_are(&[Identifier, Identifier, Eol]) => @@ -526,6 +532,31 @@ impl<'run, 'src> Parser<'run, 'src> { file_depth: self.file_depth, name, private: private || name.lexeme().starts_with('_'), + lazy: false, + value, + }) + } + + fn parse_assignment_lazy( + &mut self, + attributes: AttributeSet<'src>, + ) -> CompileResult<'src, Assignment<'src>> { + let name = self.parse_name()?; + self.presume(ColonEquals)?; + let value = self.parse_expression()?; + self.expect_eol()?; + + let private = attributes.contains(AttributeDiscriminant::Private); + + attributes.ensure_valid_attributes("Assignment", *name, &[AttributeDiscriminant::Private])?; + + Ok(Assignment { + constant: false, + export: false, + file_depth: self.file_depth, + name, + private: private || name.lexeme().starts_with('_'), + lazy: true, value, }) } diff --git a/src/scope.rs b/src/scope.rs index 1aac919cfa..832d7f53b8 100644 --- a/src/scope.rs +++ b/src/scope.rs @@ -37,6 +37,7 @@ impl<'src, 'run> Scope<'src, 'run> { }, }, private: false, + lazy: false, value: (*value).into(), }); } diff --git a/tests/json.rs b/tests/json.rs index 5850727bf6..8aad952ee5 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -12,6 +12,8 @@ struct Alias<'a> { #[serde(deny_unknown_fields)] struct Assignment<'a> { export: bool, + #[serde(default)] + lazy: bool, name: &'a str, private: bool, value: &'a str, diff --git a/tests/lazy.rs b/tests/lazy.rs new file mode 100644 index 0000000000..c2a3189890 --- /dev/null +++ b/tests/lazy.rs @@ -0,0 +1,179 @@ +use super::*; + +#[test] +fn lazy_variable_not_evaluated_if_unused() { + Test::new() + .justfile( + " + lazy expensive := `exit 1` + + works: + @echo 'Success' + ", + ) + .stdout("Success\n") + .run(); +} + +#[test] +fn lazy_variable_evaluated_when_used() { + Test::new() + .justfile( + " + lazy greeting := `echo 'Hello'` + + test: + @echo {{greeting}} + ", + ) + .stdout("Hello\n") + .run(); +} + +#[test] +fn lazy_variable_with_backtick_error() { + Test::new() + .justfile( + " + lazy bad := `exit 1` + + test: + @echo {{bad}} + ", + ) + .stderr( + " + error: Backtick failed with exit code 1 + ——▶ justfile:1:13 + │ + 1 │ lazy bad := `exit 1` + │ ^^^^^^^^ + ", + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn lazy_variable_used_multiple_times() { + Test::new() + .justfile( + " + lazy value := `echo 'test'` + + test: + @echo {{value}} + @echo {{value}} + ", + ) + .stdout("test\ntest\n") + .run(); +} + +#[test] +fn lazy_and_export_are_separate() { + Test::new() + .justfile( + " + lazy foo := `echo 'lazy'` + export bar := 'exported' + + test: + @echo {{foo}} $bar + ", + ) + .stdout("lazy exported\n") + .run(); +} + +#[test] +fn lazy_variable_dump() { + Test::new() + .justfile( + " + lazy greeting := `echo 'Hello'` + normal := 'value' + ", + ) + .args(["--dump"]) + .stdout( + " + lazy greeting := `echo 'Hello'` + normal := 'value' + ", + ) + .run(); +} + +#[test] +fn lazy_keyword_lexeme() { + Test::new() + .justfile( + " + lazy := 'not a keyword here' + + test: + @echo {{lazy}} + ", + ) + .stdout("not a keyword here\n") + .run(); +} + +#[test] +fn lazy_variable_in_dependency() { + Test::new() + .justfile( + " + lazy value := `echo 'computed'` + + dep: + @echo {{value}} + + main: dep + @echo 'done' + ", + ) + .args(["main"]) + .stdout("computed\ndone\n") + .run(); +} + +#[test] +fn lazy_with_private() { + Test::new() + .justfile( + " + [private] + lazy _secret := `echo 'hidden'` + + test: + @echo {{_secret}} + ", + ) + .stdout("hidden\n") + .run(); +} + +#[test] +fn lazy_variable_evaluated_once() { + Test::new() + .justfile( + " + lazy value := `date +%s%N` + + test: + #!/usr/bin/env bash + first={{value}} + second={{value}} + if [ \"$first\" = \"$second\" ]; then + echo \"PASS: $first\" + else + echo \"FAIL: first=$first second=$second\" + exit 1 + fi + ", + ) + .stdout_regex("^PASS: \\d+\\n$") + .run(); +} diff --git a/tests/lib.rs b/tests/lib.rs index 450ac29525..91cb1c69d8 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -84,6 +84,7 @@ mod imports; mod init; mod invocation_directory; mod json; +mod lazy; mod line_prefixes; mod list; mod logical_operators;