From af05657abd1523a3719c6bc6dc2a01c79fed4fbc Mon Sep 17 00:00:00 2001 From: Alexander Demin Date: Thu, 26 Mar 2026 09:03:14 +0000 Subject: [PATCH 1/3] Support expressions in confirm attribute --- README.md | 23 ++++++++- src/attribute.rs | 46 +++++++++++++++--- src/justfile.rs | 12 ++--- src/parser.rs | 81 +++++++++++++++++++------------- src/recipe.rs | 10 ++-- tests/confirm.rs | 120 ++++++++++++++++++++++++++++++++++++++++------- 6 files changed, 225 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index d2c9361a4a..36fdb7ee77 100644 --- a/README.md +++ b/README.md @@ -2225,7 +2225,7 @@ change their behavior. | `[arg(ARG, pattern="PATTERN")]`1.45.0 | recipe | Require values of argument `ARG` to match regular expression `PATTERN`. | | `[arg(ARG, short="S")]`1.46.0 | recipe | Require values of argument `ARG` to be passed as short `-S` option. | | `[arg(ARG, value="VALUE")]`1.46.0 | recipe | Makes option `ARG` a flag which does not take a value. | -| `[confirm(PROMPT)]`1.23.0 | recipe | Require confirmation prior to executing recipe with a custom prompt. | +| `[confirm(EXPRESSION)]`1.23.0 | recipe | Require confirmation prior to executing recipe with a custom prompt expression. | | `[confirm]`1.17.0 | recipe | Require confirmation prior to executing recipe. | | `[default]`1.43.0 | recipe | Use recipe as module's default recipe. | | `[doc(DOC)]`1.27.0 | module, recipe | Set recipe or module's [documentation comment](#documentation-comments) to `DOC`. | @@ -2340,7 +2340,7 @@ delete-all: #### Custom Confirmation Prompt The default confirmation prompt can be overridden with -`[confirm(PROMPT)]`1.23.0: +`[confirm(EXPRESSION)]`1.23.0: ```just [confirm("Are you sure you want to delete everything?")] @@ -2348,6 +2348,25 @@ delete-everything: rm -rf * ``` +The confirmation prompt accepts any expression, so you can use variables, +concatenation, and function calls: + +```just +target := "production" + +[confirm("Deploy to " + target + "?")] +deploy: + echo 'Deploying...' +``` + +Recipe parameters can also be used in confirmation prompts: + +```just +[confirm("Deploy to " + env + "?")] +deploy env: + echo 'Deploying to {{env}}...' +``` + #### Metadata Metadata in the form of lists of strings may be attached to recipes with the diff --git a/src/attribute.rs b/src/attribute.rs index 112761096c..cda7d74cee 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -1,9 +1,7 @@ use super::*; #[allow(clippy::large_enum_variant)] -#[derive( - EnumDiscriminants, PartialEq, Debug, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr, -)] +#[derive(EnumDiscriminants, PartialEq, Debug, Clone, Serialize, IntoStaticStr)] #[strum(serialize_all = "kebab-case")] #[serde(rename_all = "kebab-case")] #[strum_discriminants(name(AttributeDiscriminant))] @@ -20,7 +18,7 @@ pub(crate) enum Attribute<'src> { short: Option>, value: Option>, }, - Confirm(Option>), + Confirm(Option>), Default, Doc(Option>), Dragonfly, @@ -184,7 +182,12 @@ impl<'src> Attribute<'src> { value, } } - AttributeDiscriminant::Confirm => Self::Confirm(arguments.into_iter().next()), + AttributeDiscriminant::Confirm => Self::Confirm( + arguments + .into_iter() + .next() + .map(|string_literal| Expression::StringLiteral { string_literal }), + ), AttributeDiscriminant::Default => Self::Default, AttributeDiscriminant::Doc => Self::Doc(arguments.into_iter().next()), AttributeDiscriminant::Dragonfly => Self::Dragonfly, @@ -320,8 +323,8 @@ impl Display for Attribute<'_> { | Self::Script(None) | Self::Unix | Self::Windows => {} - Self::Confirm(Some(argument)) - | Self::Doc(Some(argument)) + Self::Confirm(Some(argument)) => write!(f, "({argument})")?, + Self::Doc(Some(argument)) | Self::Extension(argument) | Self::Group(argument) | Self::WorkingDirectory(argument) => write!(f, "({argument})")?, @@ -343,6 +346,35 @@ impl Display for Attribute<'_> { } } +impl Eq for Attribute<'_> {} + +impl PartialOrd for Attribute<'_> { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Attribute<'_> { + fn cmp(&self, other: &Self) -> Ordering { + let disc_cmp = self.discriminant().cmp(&other.discriminant()); + + if disc_cmp != Ordering::Equal { + return disc_cmp; + } + + // For repeatable attributes, compare inner values to distinguish them + // in BTreeSet. Non-repeatable attributes (including Confirm) appear at + // most once, so Equal is correct. + match (self, other) { + (Self::Arg { name: a, .. }, Self::Arg { name: b, .. }) => a.cmp(b), + (Self::Env(k1, v1), Self::Env(k2, v2)) => (k1, v1).cmp(&(k2, v2)), + (Self::Group(a), Self::Group(b)) => a.cmp(b), + (Self::Metadata(a), Self::Metadata(b)) => a.cmp(b), + _ => Ordering::Equal, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/justfile.rs b/src/justfile.rs index bec689a957..4b363b9ec0 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -350,12 +350,6 @@ impl<'src> Justfile<'src> { return Ok(()); } - if !config.yes && !recipe.confirm()? { - return Err(Error::NotConfirmed { - recipe: recipe.name(), - }); - } - let (module, scope) = scopes .get(recipe.module_path()) .expect("failed to retrieve scope for module"); @@ -380,6 +374,12 @@ impl<'src> Justfile<'src> { let mut evaluator = Evaluator::new(&context, BTreeMap::new(), true, &scope); + if !config.yes && !recipe.confirm(&mut evaluator)? { + return Err(Error::NotConfirmed { + recipe: recipe.name(), + }); + } + Self::run_dependencies( config, &context, diff --git a/src/parser.rs b/src/parser.rs index 64a9d5022f..cac200339a 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1437,45 +1437,62 @@ impl<'run, 'src> Parser<'run, 'src> { loop { let name = self.parse_name()?; - let mut arguments = Vec::new(); - let mut keyword_arguments = BTreeMap::new(); - - if self.accepted(Colon)? { - arguments.push(self.parse_string_literal()?); - } else if self.accepted(ParenL)? { - loop { - if self.next_is(Identifier) && !self.next_is_shell_expanded_string() { - let key = self.parse_name()?; - - let value = self - .accepted(Equals)? - .then(|| self.parse_string_literal()) - .transpose()?; - - keyword_arguments.insert(key.lexeme(), (key, value)); + let attribute = if name.lexeme() == "confirm" { + let expression = if self.accepted(Colon)? { + Some(self.parse_expression()?) + } else if self.accepted(ParenL)? { + let expr = if self.next_is(ParenR) { + None } else { - let literal = self.parse_string_literal()?; - - if !keyword_arguments.is_empty() { - return Err( - literal - .token - .error(CompileErrorKind::AttributePositionalFollowsKeyword), - ); + Some(self.parse_expression()?) + }; + self.expect(ParenR)?; + expr + } else { + None + }; + Attribute::Confirm(expression) + } else { + let mut arguments = Vec::new(); + let mut keyword_arguments = BTreeMap::new(); + + if self.accepted(Colon)? { + arguments.push(self.parse_string_literal()?); + } else if self.accepted(ParenL)? { + loop { + if self.next_is(Identifier) && !self.next_is_shell_expanded_string() { + let key = self.parse_name()?; + + let value = self + .accepted(Equals)? + .then(|| self.parse_string_literal()) + .transpose()?; + + keyword_arguments.insert(key.lexeme(), (key, value)); + } else { + let literal = self.parse_string_literal()?; + + if !keyword_arguments.is_empty() { + return Err( + literal + .token + .error(CompileErrorKind::AttributePositionalFollowsKeyword), + ); + } + + arguments.push(literal); } - arguments.push(literal); + if !self.accepted(Comma)? || self.next_is(ParenR) { + break; + } } - if !self.accepted(Comma)? || self.next_is(ParenR) { - break; - } + self.expect(ParenR)?; } - self.expect(ParenR)?; - } - - let attribute = Attribute::new(name, arguments, keyword_arguments)?; + Attribute::new(name, arguments, keyword_arguments)? + }; let first = if attribute.repeatable() { None diff --git a/src/recipe.rs b/src/recipe.rs index b7cca58a39..6e9fb2bc4f 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -104,10 +104,14 @@ impl<'src, D> Recipe<'src, D> { self.name.line } - pub(crate) fn confirm(&self) -> RunResult<'src, bool> { + pub(crate) fn confirm( + &self, + evaluator: &mut Evaluator<'src, '_>, + ) -> RunResult<'src, bool> { if let Some(Attribute::Confirm(prompt)) = self.attributes.get(AttributeDiscriminant::Confirm) { - if let Some(prompt) = prompt { - eprint!("{} ", prompt.cooked); + if let Some(expression) = prompt { + let message = evaluator.evaluate_expression(expression)?; + eprint!("{message} "); } else { eprint!("Run recipe `{}`? ", self.name); } diff --git a/tests/confirm.rs b/tests/confirm.rs index db7332be31..89799ad3ef 100644 --- a/tests/confirm.rs +++ b/tests/confirm.rs @@ -118,37 +118,123 @@ fn confirm_recipe_with_prompt() { } #[test] -fn confirm_recipe_with_prompt_too_many_args() { +fn confirm_attribute_is_formatted_correctly() { + Test::new() + .justfile( + " + [confirm('prompt')] + foo: + ", + ) + .arg("--dump") + .stdout("[confirm('prompt')]\nfoo:\n") + .success(); +} + +#[test] +fn confirm_with_variable() { Test::new() .justfile( r#" - [confirm("PROMPT","EXTRA")] - requires_confirmation: - echo confirmed + target := "production" + + [confirm(target)] + deploy: + echo deployed "#, ) - .stderr( + .stdin("y") + .stderr("production echo deployed\n") + .stdout("deployed\n") + .success(); +} + +#[test] +fn confirm_with_concatenation() { + Test::new() + .justfile( r#" - error: Attribute `confirm` got 2 arguments but takes at most 1 argument - ——▶ justfile:1:2 - │ - 1 │ [confirm("PROMPT","EXTRA")] - │ ^^^^^^^ + target := "production" + + [confirm("Deploy to " + target + "?")] + deploy: + echo deployed "#, ) - .failure(); + .stdin("y") + .stderr("Deploy to production? echo deployed\n") + .stdout("deployed\n") + .success(); } #[test] -fn confirm_attribute_is_formatted_correctly() { +fn confirm_with_expression_and_yes_flag() { Test::new() .justfile( - " - [confirm('prompt')] - foo: - ", + r#" + target := "production" + + [confirm("Deploy to " + target + "?")] + deploy: + echo deployed + "#, + ) + .arg("--yes") + .stderr("echo deployed\n") + .stdout("deployed\n") + .success(); +} + +#[test] +fn confirm_with_recipe_parameter() { + Test::new() + .justfile( + r#" + [confirm("Deploy to " + target + "?")] + deploy target: + echo "deployed to {{target}}" + "#, + ) + .args(["deploy", "staging"]) + .stdin("y") + .stderr("Deploy to staging? echo \"deployed to staging\"\n") + .stdout("deployed to staging\n") + .success(); +} + +#[test] +fn confirm_with_function_call() { + Test::new() + .justfile( + r#" + target := "production" + + [confirm("Deploy to " + uppercase(target) + "?")] + deploy: + echo deployed + "#, + ) + .stdin("y") + .stderr("Deploy to PRODUCTION? echo deployed\n") + .stdout("deployed\n") + .success(); +} + +#[test] +fn confirm_expression_dump() { + Test::new() + .justfile( + r#" + target := "production" + + [confirm("Deploy to " + target + "?")] + deploy: + echo deployed + "#, ) .arg("--dump") - .stdout("[confirm('prompt')]\nfoo:\n") + .stdout( + "target := \"production\"\n\n[confirm(\"Deploy to \" + target + \"?\")]\ndeploy:\n echo deployed\n", + ) .success(); } From 0e86ef3f4572d97ad7dcb3e1b35c465fc5bb1f47 Mon Sep 17 00:00:00 2001 From: Alexander Demin Date: Thu, 26 Mar 2026 09:07:59 +0000 Subject: [PATCH 2/3] Fix linting --- src/attribute.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index cda7d74cee..bc6f07ef0c 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -366,9 +366,9 @@ impl Ord for Attribute<'_> { // in BTreeSet. Non-repeatable attributes (including Confirm) appear at // most once, so Equal is correct. match (self, other) { - (Self::Arg { name: a, .. }, Self::Arg { name: b, .. }) => a.cmp(b), (Self::Env(k1, v1), Self::Env(k2, v2)) => (k1, v1).cmp(&(k2, v2)), - (Self::Group(a), Self::Group(b)) => a.cmp(b), + (Self::Arg { name: a, .. }, Self::Arg { name: b, .. }) + | (Self::Group(a), Self::Group(b)) => a.cmp(b), (Self::Metadata(a), Self::Metadata(b)) => a.cmp(b), _ => Ordering::Equal, } From 55b60a0d6fa2123007257abb85dec6d28ebf487f Mon Sep 17 00:00:00 2001 From: Alexander Demin Date: Thu, 26 Mar 2026 09:11:47 +0000 Subject: [PATCH 3/3] Fix formatting --- src/attribute.rs | 5 +++-- src/recipe.rs | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/attribute.rs b/src/attribute.rs index bc6f07ef0c..42d14e9ed5 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -367,8 +367,9 @@ impl Ord for Attribute<'_> { // most once, so Equal is correct. match (self, other) { (Self::Env(k1, v1), Self::Env(k2, v2)) => (k1, v1).cmp(&(k2, v2)), - (Self::Arg { name: a, .. }, Self::Arg { name: b, .. }) - | (Self::Group(a), Self::Group(b)) => a.cmp(b), + (Self::Arg { name: a, .. }, Self::Arg { name: b, .. }) | (Self::Group(a), Self::Group(b)) => { + a.cmp(b) + } (Self::Metadata(a), Self::Metadata(b)) => a.cmp(b), _ => Ordering::Equal, } diff --git a/src/recipe.rs b/src/recipe.rs index 6e9fb2bc4f..a9d6bee93d 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -104,10 +104,7 @@ impl<'src, D> Recipe<'src, D> { self.name.line } - pub(crate) fn confirm( - &self, - evaluator: &mut Evaluator<'src, '_>, - ) -> RunResult<'src, bool> { + pub(crate) fn confirm(&self, evaluator: &mut Evaluator<'src, '_>) -> RunResult<'src, bool> { if let Some(Attribute::Confirm(prompt)) = self.attributes.get(AttributeDiscriminant::Confirm) { if let Some(expression) = prompt { let message = evaluator.evaluate_expression(expression)?;