From 2db23b172fd152e01d6b89796d275784a7917894 Mon Sep 17 00:00:00 2001 From: Lukas Atkinson Date: Sat, 11 Jan 2025 21:30:46 +0100 Subject: [PATCH] Implement `Val` data model, a list of strings. In support of: * https://github.com/casey/just/issues/2458 * https://github.com/casey/just/issues/1988 A `Val` is a list of strings, but at each point of usage we must decide whether to view it as a singular joined string or as a list of its parts. Previously, Just values were single strings, so in most places we have to invoke `to_joined()` in order to maintain compatibility. In particular, recipes, functions, and operators like `+` or `/` operate solely on strings. This includes logical operators like `&&`, which continue to be defined on strings. That means, the values `[]` and `['']` are currently completely equivalent. So far, this is a purely internal change without externally visible effects. Only the `Bindings`/`Scope`/`Evaluator` had API changes. No new syntax is implemented. However, in expectation of expressions that build lists, a new `evaluate_list_expression() -> Vec` method is introduced that could be used to implement splat or generator expressions. It is already referenced wherever we have lists of arguments, e.g. variadic functions like `shell()` and dependency arguments. But because singular expressions are equivalent to a joined string, this is an unobservable detail for now. For experimenting with lists of strings, variadic recipe parameters like `*args` now produce a multi-part Val, and default to an empty list (not a list with an empty string). Because all operators use `to_joined()`, this is an unobservable implementation detail. However, if any operator becomes list-aware, then this detail should be reverted, or moved behind an unstable setting. For better understanding of the current behavior, I added a bunch of tests. These will help detect regressions if functions or operators become list-aware. No existing tests had to be touched. Next steps: This change is just the foundation for other work, but some ideas are mutually exclusive. Relevant aspects: * list syntax in https://github.com/casey/just/issues/2458 * list aware operators in https://github.com/casey/just/issues/2458 * lossless forwarding of variadics: https://github.com/casey/just/issues/1988 * invoking dependencies multiple times: https://github.com/casey/just/issues/558 The preparatory work like `evaluate_list_expression()` is biased towards implementing a splat operator that would enable #2458 list syntax and #1988 list forwarding, but doesn't commit us to any particular design yet. --- src/binding.rs | 2 +- src/command_ext.rs | 2 +- src/evaluator.rs | 153 ++++++++++++++++++++++++------------- src/justfile.rs | 19 +++-- src/lib.rs | 2 + src/scope.rs | 8 +- src/val.rs | 128 +++++++++++++++++++++++++++++++ tests/functions.rs | 30 ++++++++ tests/logical_operators.rs | 22 ++++++ tests/misc.rs | 19 +++++ tests/parameters.rs | 14 ++++ 11 files changed, 329 insertions(+), 70 deletions(-) create mode 100644 src/val.rs diff --git a/src/binding.rs b/src/binding.rs index 69dcc3b056..610050d934 100644 --- a/src/binding.rs +++ b/src/binding.rs @@ -2,7 +2,7 @@ use super::*; /// A binding of `name` to `value` #[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct Binding<'src, V = String> { +pub(crate) struct Binding<'src, V = Val> { #[serde(skip)] pub(crate) constant: bool, pub(crate) export: bool, diff --git a/src/command_ext.rs b/src/command_ext.rs index 40d9da1600..5fbc7bcf98 100644 --- a/src/command_ext.rs +++ b/src/command_ext.rs @@ -40,7 +40,7 @@ impl CommandExt for Command { for binding in scope.bindings() { if binding.export || (settings.export && !binding.constant) { - self.env(binding.name.lexeme(), &binding.value); + self.env(binding.name.lexeme(), binding.value.to_joined()); } } } diff --git a/src/evaluator.rs b/src/evaluator.rs index 46955980f4..23fc5877e5 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -38,7 +38,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { file_depth: 0, name: assignment.name, private: assignment.private, - value: value.clone(), + value: Val::from_str(value), }); } else { unknown_overrides.push(name.clone()); @@ -65,7 +65,7 @@ impl<'src, 'run> Evaluator<'src, 'run> { Ok(evaluator.scope) } - fn evaluate_assignment(&mut self, assignment: &Assignment<'src>) -> RunResult<'src, &str> { + fn evaluate_assignment(&mut self, assignment: &Assignment<'src>) -> RunResult<'src, &Val> { let name = assignment.name.lexeme(); if !self.scope.bound(name) { @@ -83,40 +83,53 @@ impl<'src, 'run> Evaluator<'src, 'run> { Ok(self.scope.value(name).unwrap()) } + /// A place for adding list operators in the future. + /// + /// List expressions return zero or more strings. + pub(crate) fn evaluate_list_expression( + &mut self, + expression: &Expression<'src>, + ) -> RunResult<'src, Vec> { + // currently, all expression produce a single item + Ok(vec![self.evaluate_expression(expression)?.to_joined()]) + } + pub(crate) fn evaluate_expression( &mut self, expression: &Expression<'src>, - ) -> RunResult<'src, String> { + ) -> RunResult<'src, Val> { match expression { Expression::And { lhs, rhs } => { let lhs = self.evaluate_expression(lhs)?; - if lhs.is_empty() { - return Ok(String::new()); + if lhs.to_joined().is_empty() { + return Ok(Val::new()); } self.evaluate_expression(rhs) } Expression::Assert { condition, error } => { if self.evaluate_condition(condition)? { - Ok(String::new()) + Ok(Val::new()) } else { Err(Error::Assert { - message: self.evaluate_expression(error)?, + message: self.evaluate_expression(error)?.to_joined(), }) } } Expression::Backtick { contents, token } => { if self.context.config.dry_run { - Ok(format!("`{contents}`")) + Ok(format!("`{contents}`").into()) } else { - Ok(self.run_backtick(contents, token)?) + Ok(self.run_backtick(contents, token)?.into()) } } Expression::Call { thunk } => { use Thunk::*; - let result = match thunk { + // All functions are currently of type (...String) -> Result. + // They do not take or return a `Val`. + let result: Result = match thunk { Nullary { function, .. } => function(function::Context::new(self, thunk.name())), Unary { function, arg, .. } => { - let arg = self.evaluate_expression(arg)?; + let arg = self.evaluate_expression(arg)?.to_joined(); function(function::Context::new(self, thunk.name()), &arg) } UnaryOpt { @@ -124,9 +137,9 @@ impl<'src, 'run> Evaluator<'src, 'run> { args: (a, b), .. } => { - let a = self.evaluate_expression(a)?; + let a = self.evaluate_expression(a)?.to_joined(); let b = match b.as_ref() { - Some(b) => Some(self.evaluate_expression(b)?), + Some(b) => Some(self.evaluate_expression(b)?.to_joined()), None => None, }; function(function::Context::new(self, thunk.name()), &a, b.as_deref()) @@ -136,10 +149,10 @@ impl<'src, 'run> Evaluator<'src, 'run> { args: (a, rest), .. } => { - let a = self.evaluate_expression(a)?; + let a = self.evaluate_expression(a)?.to_joined(); let mut rest_evaluated = Vec::new(); for arg in rest { - rest_evaluated.push(self.evaluate_expression(arg)?); + rest_evaluated.extend(self.evaluate_list_expression(arg)?); } function( function::Context::new(self, thunk.name()), @@ -152,8 +165,8 @@ impl<'src, 'run> Evaluator<'src, 'run> { args: [a, b], .. } => { - let a = self.evaluate_expression(a)?; - let b = self.evaluate_expression(b)?; + let a = self.evaluate_expression(a)?.to_joined(); + let b = self.evaluate_expression(b)?.to_joined(); function(function::Context::new(self, thunk.name()), &a, &b) } BinaryPlus { @@ -161,11 +174,11 @@ impl<'src, 'run> Evaluator<'src, 'run> { args: ([a, b], rest), .. } => { - let a = self.evaluate_expression(a)?; - let b = self.evaluate_expression(b)?; + let a = self.evaluate_expression(a)?.to_joined(); + let b = self.evaluate_expression(b)?.to_joined(); let mut rest_evaluated = Vec::new(); for arg in rest { - rest_evaluated.push(self.evaluate_expression(arg)?); + rest_evaluated.extend(self.evaluate_list_expression(arg)?); } function( function::Context::new(self, thunk.name()), @@ -179,19 +192,23 @@ impl<'src, 'run> Evaluator<'src, 'run> { args: [a, b, c], .. } => { - let a = self.evaluate_expression(a)?; - let b = self.evaluate_expression(b)?; - let c = self.evaluate_expression(c)?; + let a = self.evaluate_expression(a)?.to_joined(); + let b = self.evaluate_expression(b)?.to_joined(); + let c = self.evaluate_expression(c)?.to_joined(); function(function::Context::new(self, thunk.name()), &a, &b, &c) } }; - result.map_err(|message| Error::FunctionCall { - function: thunk.name(), - message, - }) + result + .map(Val::from_str) + .map_err(|message| Error::FunctionCall { + function: thunk.name(), + message, + }) } Expression::Concatenation { lhs, rhs } => { - Ok(self.evaluate_expression(lhs)? + &self.evaluate_expression(rhs)?) + let a = self.evaluate_expression(lhs)?.to_joined(); + let b = self.evaluate_expression(rhs)?.to_joined(); + Ok(Val::from_str(a + &b)) } Expression::Conditional { condition, @@ -205,19 +222,26 @@ impl<'src, 'run> Evaluator<'src, 'run> { } } Expression::Group { contents } => self.evaluate_expression(contents), - Expression::Join { lhs: None, rhs } => Ok("/".to_string() + &self.evaluate_expression(rhs)?), + Expression::Join { lhs: None, rhs } => { + let rhs = self.evaluate_expression(rhs)?.to_joined(); + Ok(Val::from_str("/".to_string() + &rhs)) + } Expression::Join { lhs: Some(lhs), rhs, - } => Ok(self.evaluate_expression(lhs)? + "/" + &self.evaluate_expression(rhs)?), + } => { + let lhs = self.evaluate_expression(lhs)?.to_joined(); + let rhs = self.evaluate_expression(rhs)?.to_joined(); + Ok(Val::from_str(lhs + "/" + &rhs)) + } Expression::Or { lhs, rhs } => { let lhs = self.evaluate_expression(lhs)?; - if !lhs.is_empty() { + if !lhs.to_joined().is_empty() { return Ok(lhs); } self.evaluate_expression(rhs) } - Expression::StringLiteral { string_literal } => Ok(string_literal.cooked.clone()), + Expression::StringLiteral { string_literal } => Ok(Val::from_str(&string_literal.cooked)), Expression::Variable { name, .. } => { let variable = name.lexeme(); if let Some(value) = self.scope.value(variable) { @@ -240,14 +264,14 @@ impl<'src, 'run> Evaluator<'src, 'run> { let lhs_value = self.evaluate_expression(&condition.lhs)?; let rhs_value = self.evaluate_expression(&condition.rhs)?; let condition = match condition.operator { - ConditionalOperator::Equality => lhs_value == rhs_value, - ConditionalOperator::Inequality => lhs_value != rhs_value, - ConditionalOperator::RegexMatch => Regex::new(&rhs_value) + ConditionalOperator::Equality => lhs_value.to_joined() == rhs_value.to_joined(), + ConditionalOperator::Inequality => lhs_value.to_joined() != rhs_value.to_joined(), + ConditionalOperator::RegexMatch => Regex::new(&rhs_value.to_joined()) .map_err(|source| Error::RegexCompile { source })? - .is_match(&lhs_value), - ConditionalOperator::RegexMismatch => !Regex::new(&rhs_value) + .is_match(&lhs_value.to_joined()), + ConditionalOperator::RegexMismatch => !Regex::new(&rhs_value.to_joined()) .map_err(|source| Error::RegexCompile { source })? - .is_match(&lhs_value), + .is_match(&lhs_value.to_joined()), }; Ok(condition) } @@ -303,14 +327,20 @@ impl<'src, 'run> Evaluator<'src, 'run> { } } Fragment::Interpolation { expression } => { - evaluated += &self.evaluate_expression(expression)?; + evaluated += &self.evaluate_expression(expression)?.to_joined(); } } } Ok(evaluated) } - pub(crate) fn evaluate_parameters( + /// Bind recipe arguments to their parameters. + /// + /// Returns a `(scope, positional_arguments)` tuple if successful. + /// + /// May evaluate defaults, which can append strings to the positional-arguments. + /// Defaults are evaluated left-to-right, and may reference preceding params. + pub(crate) fn evaluate_recipe_parameters( context: &ExecutionContext<'src, 'run>, is_dependency: bool, arguments: &[String], @@ -322,30 +352,45 @@ impl<'src, 'run> Evaluator<'src, 'run> { let mut rest = arguments; for parameter in parameters { + // Each recipe argument must be a singular string, as if it was provided as a CLI argument. + // This prevents lists from leaking into dependencies unexpectedly. + // The one exception is an explicitly variadic parameter. let value = if rest.is_empty() { - if let Some(ref default) = parameter.default { - let value = evaluator.evaluate_expression(default)?; - positional.push(value.clone()); - value - } else if parameter.kind == ParameterKind::Star { - String::new() - } else { - return Err(Error::Internal { - message: "missing parameter without default".to_owned(), - }); + match (¶meter.default, parameter.kind) { + (Some(default), ParameterKind::Star | ParameterKind::Plus) => { + let value = evaluator.evaluate_expression(default)?; + // auto-splat variadic defaults, in case we want to support expressions like + // `recipe *args=['a', 'b']: ...` + for part in value.to_parts() { + positional.push(part.to_string()); + } + value + } + (Some(default), ParameterKind::Singular) => { + let value = evaluator.evaluate_expression(default)?; + let value = Val::from_str(value.to_joined()); // singularize + positional.push(value.to_string()); + value + } + (None, ParameterKind::Star) => Val::new(), + (None, ParameterKind::Plus | ParameterKind::Singular) => { + return Err(Error::Internal { + message: "missing parameter without default".to_owned(), + }); + } } } else if parameter.kind.is_variadic() { for value in rest { positional.push(value.clone()); } - let value = rest.to_vec().join(" "); + let value = Val::from_parts(rest); rest = &[]; value } else { - let value = rest[0].clone(); - positional.push(value.clone()); + let value = rest[0].as_str(); + positional.push(value.to_string()); rest = &rest[1..]; - value + Val::from_str(value) }; evaluator.scope.bind(Binding { constant: false, diff --git a/src/justfile.rs b/src/justfile.rs index 2ce96ece40..27a28038b3 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -323,7 +323,7 @@ impl<'src> Justfile<'src> { } let (outer, positional) = - Evaluator::evaluate_parameters(context, is_dependency, arguments, &recipe.parameters)?; + Evaluator::evaluate_recipe_parameters(context, is_dependency, arguments, &recipe.parameters)?; let scope = outer.child(); @@ -331,12 +331,12 @@ impl<'src> Justfile<'src> { if !context.config.no_dependencies { for Dependency { recipe, arguments } in recipe.dependencies.iter().take(recipe.priors) { - let arguments = arguments - .iter() - .map(|argument| evaluator.evaluate_expression(argument)) - .collect::>>()?; + let mut evaluated_args = Vec::new(); + for argument in arguments { + evaluated_args.extend(evaluator.evaluate_list_expression(argument)?); + } - Self::run_recipe(&arguments, context, ran, recipe, true)?; + Self::run_recipe(&evaluated_args, context, ran, recipe, true)?; } } @@ -346,13 +346,12 @@ impl<'src> Justfile<'src> { let mut ran = Ran::default(); for Dependency { recipe, arguments } in recipe.subsequents() { - let mut evaluated = Vec::new(); - + let mut evaluated_args = Vec::new(); for argument in arguments { - evaluated.push(evaluator.evaluate_expression(argument)?); + evaluated_args.extend(evaluator.evaluate_list_expression(argument)?); } - Self::run_recipe(&evaluated, context, &mut ran, recipe, true)?; + Self::run_recipe(&evaluated_args, context, &mut ran, recipe, true)?; } } diff --git a/src/lib.rs b/src/lib.rs index 30777e513d..95c3a7ff50 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,6 +94,7 @@ pub(crate) use { unresolved_recipe::UnresolvedRecipe, unstable_feature::UnstableFeature, use_color::UseColor, + val::Val, variables::Variables, verbosity::Verbosity, warning::Warning, @@ -270,6 +271,7 @@ mod unresolved_dependency; mod unresolved_recipe; mod unstable_feature; mod use_color; +mod val; mod variables; mod verbosity; mod warning; diff --git a/src/scope.rs b/src/scope.rs index 78d12ca06b..a1c12233cf 100644 --- a/src/scope.rs +++ b/src/scope.rs @@ -3,7 +3,7 @@ use super::*; #[derive(Debug)] pub(crate) struct Scope<'src: 'run, 'run> { parent: Option<&'run Self>, - bindings: Table<'src, Binding<'src, String>>, + bindings: Table<'src, Binding<'src, Val>>, } impl<'src, 'run> Scope<'src, 'run> { @@ -52,15 +52,15 @@ impl<'src, 'run> Scope<'src, 'run> { self.bindings.contains_key(name) } - pub(crate) fn value(&self, name: &str) -> Option<&str> { + pub(crate) fn value(&self, name: &str) -> Option<&Val> { if let Some(binding) = self.bindings.get(name) { - Some(binding.value.as_ref()) + Some(&binding.value) } else { self.parent?.value(name) } } - pub(crate) fn bindings(&self) -> impl Iterator> { + pub(crate) fn bindings(&self) -> impl Iterator> { self.bindings.values() } diff --git a/src/val.rs b/src/val.rs new file mode 100644 index 0000000000..529bf025a3 --- /dev/null +++ b/src/val.rs @@ -0,0 +1,128 @@ +use std::rc::Rc; + +// Design notes: +// ============= +// +// The "list of strings" data structure can be represented in three different reasonable ways: +// +// 1. As a vec of strings (currently implemented). +// This is simple and easy to debug, but makes zero-copy stuff difficult. +// At least can make copying cheap with an Rc. +// +// 2. As a pre-joined string + vec of indices. +// This allows a `&str` ref to the pre-joined string to be acquired, +// at the cost of bounds checking when the parts are accessed. +// Would probably be the most compact and efficient implementation. +// +// 3. As a value that contains both a pre-joined variant and list variant. +// This trades memory for the ability to borrow refs to both variants. +// +// The current implementation focuses on simplicity, +// but tries to preserve flexibility for future optimizations +// by mostly only dealing with owned data at the API boundaries (no borrowed views). + +/// The internal data type of Just, an immutable list of strings. +/// +/// It can be viewed as a "joined string", or as the individual "parts". +#[derive(Debug, Clone, Default)] +pub(crate) struct Val { + parts: Rc<[Box]>, +} + +impl Val { + /// Create an empty `Val` + pub fn new() -> Self { + Self::default() + } + + /// Construct a `Val` consisting of a single string. + pub fn from_str>(s: S) -> Self { + Self { + parts: Rc::new([s.as_ref().into()]), + } + } + + /// Construct a `Val` consisting of multiple parts. + pub fn from_parts(parts: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + Self { + parts: parts.into_iter().map(|s| s.as_ref().into()).collect(), + } + } + + /// Convert to a single joined string. + pub fn to_joined(&self) -> String { + self.parts.join(" ") + } + + /// Convert to individual parts. + pub fn to_parts(&self) -> Vec> { + self.parts.to_vec() + } +} + +// Also provides a `to_string()` method +impl std::fmt::Display for Val { + /// When formatted as a string, a `Val` is joined by whitespace. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_joined()) + } +} + +// convenience conversion +impl From<&str> for Val { + fn from(value: &str) -> Self { + Self::from_str(value) + } +} + +// convenience conversion +impl From for Val { + fn from(value: String) -> Self { + Self::from_str(value) + } +} + +impl> FromIterator for Val { + fn from_iter>(iter: T) -> Self { + Self::from_parts(iter) + } +} + +#[cfg(test)] +fn bs(s: &str) -> Box { + s.to_owned().into_boxed_str() +} + +#[test] +fn can_construct_vals() { + assert_eq!(Val::from("foo bar").to_parts(), vec![bs("foo bar")]); + assert_eq!(Val::from("x y z".to_string()).to_parts(), vec![bs("x y z")]); + assert_eq!(Val::from_parts(["a b"]).to_parts(), vec![bs("a b")]); + assert_eq!( + Val::from_parts(["a b", "c d"]).to_parts(), + vec![bs("a b"), bs("c d")] + ); +} + +#[test] +fn empty_representations() { + let empty_string = Val::from(""); + let empty_parts = Val::from_parts::<_, &str>([]); + + // the two empty string representations have different parts + assert_eq!(empty_string.to_parts(), vec![bs("")]); + assert_eq!(empty_parts.to_parts(), vec![]); + + // but they have the same joined representation + assert_eq!(empty_string.to_joined(), empty_parts.to_joined()); +} + +#[test] +fn joins_by_space_without_quoting() { + let val = Val::from_parts(["a", "b c", "d"]); + assert_eq!(val.to_joined(), "a b c d"); +} diff --git a/tests/functions.rs b/tests/functions.rs index e4766325e0..a59d9c2e53 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -514,6 +514,36 @@ fn prepend() { ); } +#[test] +fn append_prepend_with_variadic() { + const JUSTFILE: &str = " +@foo *args: + echo 'prepend=[{{prepend('x:', args)}}] append=[{{append(':y', args)}}] args=[{{args}}]' + "; + + // no arguments should be empty string + Test::new() + .justfile(JUSTFILE) + .args(["foo"]) + .stdout("prepend=[] append=[] args=[]\n") + .run(); + + // single empty string should have identical output + Test::new() + .justfile(JUSTFILE) + .args(["foo", ""]) + .stdout("prepend=[] append=[] args=[]\n") + .run(); + + // the prepend/append functions split by whitespace, + // so variadic CLI args should not be respected + Test::new() + .justfile(JUSTFILE) + .args(["foo", "a", "b c", "", "d"]) + .stdout("prepend=[x:a x:b x:c x:d] append=[a:y b:y c:y d:y] args=[a b c d]\n") + .run(); +} + #[test] #[cfg(not(windows))] fn join() { diff --git a/tests/logical_operators.rs b/tests/logical_operators.rs index 5baa0f52f5..2e3b0101ea 100644 --- a/tests/logical_operators.rs +++ b/tests/logical_operators.rs @@ -81,3 +81,25 @@ fn nesting() { evaluate("'' || '' || '' || '' || 'foo'", "foo"); evaluate("'foo' && 'foo' && 'foo' && 'foo' && 'bar'", "bar"); } + +#[test] +fn empty_variadics_are_falsey() { + #[track_caller] + fn eval_variadics<'a>(args: impl AsRef<[&'a str]>, expected: &'a str) { + Test::new() + .justfile("@foo *args:\n echo {{ args && 'true' || 'false' }}") + .env("JUST_UNSTABLE", "1") + .arg("foo") + .args(args) + .stdout(format!("{expected}\n")) + .run(); + } + + // false als long as the joined *args are an empty string + eval_variadics([], "false"); + eval_variadics([""], "false"); + + eval_variadics(["x"], "true"); + eval_variadics([" "], "true"); + eval_variadics(["", ""], "true"); +} diff --git a/tests/misc.rs b/tests/misc.rs index 79b609f314..20b506cfe2 100644 --- a/tests/misc.rs +++ b/tests/misc.rs @@ -2066,3 +2066,22 @@ test! { "#, args: (), } + +#[test] +fn variadics_are_forwarded_as_single_string() { + Test::new() + .justfile( + " + set positional-arguments + @entry *args: (scalar-dep args) (variadic-dep args) + echo entry-len=$# + @scalar-dep depargs: + echo scalar-dep-len=$# + @variadic-dep *depargs: + echo variadic-dep-len=$# + ", + ) + .args(["entry", "a b", "c d"]) + .stdout("scalar-dep-len=1\nvariadic-dep-len=1\nentry-len=2\n") + .run(); +} diff --git a/tests/parameters.rs b/tests/parameters.rs index 8cf93ba13d..eb65bc7dd0 100644 --- a/tests/parameters.rs +++ b/tests/parameters.rs @@ -36,3 +36,17 @@ fn parameter_default_values_may_not_use_later_parameters() { .status(EXIT_FAILURE) .run(); } + +#[test] +fn parameter_variadic_default_may_use_earlier_parameters() { + Test::new() + .justfile( + " + @foo a *b=a: + echo {{ b }} + ", + ) + .args(["foo", "bar"]) + .stdout("bar\n") + .run(); +}