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..42d14e9ed5 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,36 @@ 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::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::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..a9d6bee93d 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -104,10 +104,11 @@ 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();
}