From da1749a2cea3d196fdf8951a7a57ed4e2afe294d Mon Sep 17 00:00:00 2001 From: Jarrod Moore Date: Fri, 3 Oct 2025 15:57:40 +1000 Subject: [PATCH 1/5] Add lazy variable evaluation with `lazy` keyword Implements lazy evaluation for variable assignments using a new `lazy` keyword. Lazy variables are not evaluated when the justfile loads, but only when they are first accessed during recipe execution. This is useful for expensive operations (like credential fetching or remote API calls) that should only run when actually needed, not on every justfile invocation. Usage: lazy credentials := `aws sts get-session-token` # credentials are only evaluated if this recipe runs deploy: ./deploy.sh {{credentials}} Fixes #953 --- src/assignment.rs | 4 ++ src/binding.rs | 1 + src/evaluator.rs | 9 ++- src/keyword.rs | 1 + src/parser.rs | 31 +++++++++ src/scope.rs | 1 + tests/json.rs | 2 + tests/lazy.rs | 156 ++++++++++++++++++++++++++++++++++++++++++++++ tests/lib.rs | 1 + 9 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 tests/lazy.rs 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..c083949b9b --- /dev/null +++ b/tests/lazy.rs @@ -0,0 +1,156 @@ +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(); +} 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; From de82bdd3524e10494ad1aa7be67e74a83457b11c Mon Sep 17 00:00:00 2001 From: Jarrod Moore Date: Fri, 3 Oct 2025 22:34:08 +1000 Subject: [PATCH 2/5] Add test to ensure variable is only evaluated once --- tests/lazy.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/lazy.rs b/tests/lazy.rs index c083949b9b..c2a3189890 100644 --- a/tests/lazy.rs +++ b/tests/lazy.rs @@ -154,3 +154,26 @@ fn lazy_with_private() { .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(); +} From 4d025973702ee26a3716538306196f8966480657 Mon Sep 17 00:00:00 2001 From: Jarrod Moore Date: Sun, 5 Oct 2025 19:24:58 +1100 Subject: [PATCH 3/5] Update grammar for lazy evaluation keyword --- GRAMMAR.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 2cfd023f689cfa33df37eb004571e520bf1c0b51 Mon Sep 17 00:00:00 2001 From: Jarrod Moore Date: Sun, 5 Oct 2025 19:25:19 +1100 Subject: [PATCH 4/5] Add doco block in README for lazy evaluation feature --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index ee98348101..ef5e818a33 100644 --- a/README.md +++ b/README.md @@ -1490,6 +1490,19 @@ 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 password := `aws ecr get-login-password --region us-west-2` +``` + +This is useful for values that are expensive to compute, or that may not be +needed in every invocation of `just`. + ### Strings `'single'`, `"double"`, and `'''triple'''` quoted string literals are From 3f0e2f760e08f1c68d68242e21b0e3636bd8cd4c Mon Sep 17 00:00:00 2001 From: Jarrod Moore Date: Sun, 5 Oct 2025 19:42:18 +1100 Subject: [PATCH 5/5] Use better example for lazy eval and explicitly explain that the value won't be recomputed each time --- README.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ef5e818a33..3db672d383 100644 --- a/README.md +++ b/README.md @@ -1497,11 +1497,27 @@ variable to only be evaluated when it is used for the first time, you can use th `lazy` keyword: ```just -lazy password := `aws ecr get-login-password --region us-west-2` +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`. +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