Allow setting custom confirm prompt (#1834)

This commit is contained in:
Marc 2024-01-13 03:44:13 +01:00 committed by GitHub
parent 5bbc89b718
commit 8bd411de45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 187 additions and 47 deletions

View File

@ -104,9 +104,11 @@ string : STRING
sequence : expression ',' sequence sequence : expression ',' sequence
| expression ','? | expression ','?
recipe : attribute? '@'? NAME parameter* variadic? ':' dependency* body? recipe : attributes* '@'? NAME parameter* variadic? ':' dependency* body?
attribute : '[' NAME ']' eol attributes : '[' attribute* ']' eol
attribute : NAME ( '(' string ')' )?
parameter : '$'? NAME parameter : '$'? NAME
| '$'? NAME '=' value | '$'? NAME '=' value

View File

@ -1456,6 +1456,7 @@ Recipes may be annotated with attributes that change their behavior.
| Name | Description | | Name | Description |
|------|-------------| |------|-------------|
| `[confirm]`<sup>1.17.0</sup> | Require confirmation prior to executing recipe. | | `[confirm]`<sup>1.17.0</sup> | Require confirmation prior to executing recipe. |
| `[confirm("prompt")]`<sup>master</sup> | Require confirmation prior to executing recipe with a custom prompt. |
| `[linux]`<sup>1.8.0</sup> | Enable recipe on Linux. | | `[linux]`<sup>1.8.0</sup> | Enable recipe on Linux. |
| `[macos]`<sup>1.8.0</sup> | Enable recipe on MacOS. | | `[macos]`<sup>1.8.0</sup> | Enable recipe on MacOS. |
| `[no-cd]`<sup>1.9.0</sup> | Don't change directory before executing recipe. | | `[no-cd]`<sup>1.9.0</sup> | Don't change directory before executing recipe. |
@ -1544,6 +1545,16 @@ delete all:
rm -rf * rm -rf *
``` ```
#### Custom Confirmation Prompt<sup>master</sup>
The default confirmation prompt can be overridden with `[confirm(PROMPT)]`:
```just
[confirm("Are you sure you want to delete everything?")]
delete-everything:
rm -rf *
```
### Command Evaluation Using Backticks ### Command Evaluation Using Backticks
Backticks can be used to store the result of commands: Backticks can be used to store the result of commands:

View File

@ -3,7 +3,7 @@ use super::*;
/// An alias, e.g. `name := target` /// An alias, e.g. `name := target`
#[derive(Debug, PartialEq, Clone, Serialize)] #[derive(Debug, PartialEq, Clone, Serialize)]
pub(crate) struct Alias<'src, T = Rc<Recipe<'src>>> { pub(crate) struct Alias<'src, T = Rc<Recipe<'src>>> {
pub(crate) attributes: BTreeSet<Attribute>, pub(crate) attributes: BTreeSet<Attribute<'src>>,
pub(crate) name: Name<'src>, pub(crate) name: Name<'src>,
#[serde( #[serde(
bound(serialize = "T: Keyed<'src>"), bound(serialize = "T: Keyed<'src>"),

View File

@ -211,11 +211,11 @@ impl<'src> Analyzer<'src> {
fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src> { fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src> {
let name = alias.name.lexeme(); let name = alias.name.lexeme();
for attr in &alias.attributes { for attribute in &alias.attributes {
if *attr != Attribute::Private { if *attribute != Attribute::Private {
return Err(alias.name.token.error(AliasInvalidAttribute { return Err(alias.name.token.error(AliasInvalidAttribute {
alias: name, alias: name,
attr: *attr, attribute: attribute.clone(),
})); }));
} }
} }

View File

@ -1,12 +1,10 @@
use super::*; use super::*;
#[derive( #[derive(EnumString, PartialEq, Debug, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr)]
EnumString, PartialEq, Debug, Copy, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr,
)]
#[strum(serialize_all = "kebab-case")] #[strum(serialize_all = "kebab-case")]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub(crate) enum Attribute { pub(crate) enum Attribute<'src> {
Confirm, Confirm(Option<StringLiteral<'src>>),
Linux, Linux,
Macos, Macos,
NoCd, NoCd,
@ -17,14 +15,45 @@ pub(crate) enum Attribute {
Windows, Windows,
} }
impl Attribute { impl<'src> Attribute<'src> {
pub(crate) fn from_name(name: Name) -> Option<Attribute> { pub(crate) fn from_name(name: Name) -> Option<Self> {
name.lexeme().parse().ok() name.lexeme().parse().ok()
} }
pub(crate) fn to_str(self) -> &'static str { pub(crate) fn name(&self) -> &'static str {
self.into() self.into()
} }
pub(crate) fn with_argument(
self,
name: Name<'src>,
argument: StringLiteral<'src>,
) -> CompileResult<'src, Self> {
match self {
Self::Confirm(_) => Ok(Self::Confirm(Some(argument))),
_ => Err(name.error(CompileErrorKind::UnexpectedAttributeArgument { attribute: self })),
}
}
fn argument(&self) -> Option<&StringLiteral> {
if let Self::Confirm(prompt) = self {
prompt.as_ref()
} else {
None
}
}
}
impl<'src> Display for Attribute<'src> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
write!(f, "{}", self.name())?;
if let Some(argument) = self.argument() {
write!(f, "({argument})")?;
}
Ok(())
}
} }
#[cfg(test)] #[cfg(test)]
@ -32,7 +61,7 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn to_str() { fn name() {
assert_eq!(Attribute::NoExitMessage.to_str(), "no-exit-message"); assert_eq!(Attribute::NoExitMessage.name(), "no-exit-message");
} }
} }

View File

@ -32,11 +32,13 @@ impl Display for CompileError<'_> {
use CompileErrorKind::*; use CompileErrorKind::*;
match &*self.kind { match &*self.kind {
AliasInvalidAttribute { alias, attr } => write!( AliasInvalidAttribute { alias, attribute } => {
write!(
f, f,
"Alias {alias} has an invalid attribute `{}`", "Alias `{alias}` has invalid attribute `{}`",
attr.to_str(), attribute.name(),
), )
}
AliasShadowsRecipe { alias, recipe_line } => write!( AliasShadowsRecipe { alias, recipe_line } => write!(
f, f,
"Alias `{alias}` defined on line {} shadows recipe `{alias}` defined on line {}", "Alias `{alias}` defined on line {} shadows recipe `{alias}` defined on line {}",
@ -209,6 +211,13 @@ impl Display for CompileError<'_> {
"Non-default parameter `{parameter}` follows default parameter" "Non-default parameter `{parameter}` follows default parameter"
), ),
UndefinedVariable { variable } => write!(f, "Variable `{variable}` not defined"), UndefinedVariable { variable } => write!(f, "Variable `{variable}` not defined"),
UnexpectedAttributeArgument { attribute } => {
write!(
f,
"Attribute `{}` specified with argument but takes no arguments",
attribute.name(),
)
}
UnexpectedCharacter { expected } => write!(f, "Expected character `{expected}`"), UnexpectedCharacter { expected } => write!(f, "Expected character `{expected}`"),
UnexpectedClosingDelimiter { close } => { UnexpectedClosingDelimiter { close } => {
write!(f, "Unexpected closing delimiter `{}`", close.close()) write!(f, "Unexpected closing delimiter `{}`", close.close())

View File

@ -4,7 +4,7 @@ use super::*;
pub(crate) enum CompileErrorKind<'src> { pub(crate) enum CompileErrorKind<'src> {
AliasInvalidAttribute { AliasInvalidAttribute {
alias: &'src str, alias: &'src str,
attr: Attribute, attribute: Attribute<'src>,
}, },
AliasShadowsRecipe { AliasShadowsRecipe {
alias: &'src str, alias: &'src str,
@ -85,6 +85,9 @@ pub(crate) enum CompileErrorKind<'src> {
UndefinedVariable { UndefinedVariable {
variable: &'src str, variable: &'src str,
}, },
UnexpectedAttributeArgument {
attribute: Attribute<'src>,
},
UnexpectedCharacter { UnexpectedCharacter {
expected: char, expected: char,
}, },

View File

@ -435,7 +435,7 @@ impl<'run, 'src> Parser<'run, 'src> {
/// Parse an alias, e.g `alias name := target` /// Parse an alias, e.g `alias name := target`
fn parse_alias( fn parse_alias(
&mut self, &mut self,
attributes: BTreeSet<Attribute>, attributes: BTreeSet<Attribute<'src>>,
) -> CompileResult<'src, Alias<'src, Name<'src>>> { ) -> CompileResult<'src, Alias<'src, Name<'src>>> {
self.presume_keyword(Keyword::Alias)?; self.presume_keyword(Keyword::Alias)?;
let name = self.parse_name()?; let name = self.parse_name()?;
@ -672,7 +672,7 @@ impl<'run, 'src> Parser<'run, 'src> {
&mut self, &mut self,
doc: Option<&'src str>, doc: Option<&'src str>,
quiet: bool, quiet: bool,
attributes: BTreeSet<Attribute>, attributes: BTreeSet<Attribute<'src>>,
) -> CompileResult<'src, UnresolvedRecipe<'src>> { ) -> CompileResult<'src, UnresolvedRecipe<'src>> {
let name = self.parse_name()?; let name = self.parse_name()?;
@ -905,7 +905,7 @@ impl<'run, 'src> Parser<'run, 'src> {
} }
/// Parse recipe attributes /// Parse recipe attributes
fn parse_attributes(&mut self) -> CompileResult<'src, Option<BTreeSet<Attribute>>> { fn parse_attributes(&mut self) -> CompileResult<'src, Option<BTreeSet<Attribute<'src>>>> {
let mut attributes = BTreeMap::new(); let mut attributes = BTreeMap::new();
while self.accepted(BracketL)? { while self.accepted(BracketL)? {
@ -922,6 +922,15 @@ impl<'run, 'src> Parser<'run, 'src> {
first: *line, first: *line,
})); }));
} }
let attribute = if self.accepted(ParenL)? {
let argument = self.parse_string_literal()?;
self.expect(ParenR)?;
attribute.with_argument(name, argument)?
} else {
attribute
};
attributes.insert(attribute, name.line); attributes.insert(attribute, name.line);
if !self.accepted(Comma)? { if !self.accepted(Comma)? {

View File

@ -13,11 +13,13 @@ pub(crate) struct DisplayRange<T>(T);
impl Display for DisplayRange<&Range<usize>> { impl Display for DisplayRange<&Range<usize>> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
if self.0.start == self.0.end { if self.0.start == self.0.end {
write!(f, "0")?;
} else if self.0.start == self.0.end - 1 {
write!(f, "{}", self.0.start)?; write!(f, "{}", self.0.start)?;
} else if self.0.end == usize::MAX { } else if self.0.end == usize::MAX {
write!(f, "{} or more", self.0.start)?; write!(f, "{} or more", self.0.start)?;
} else { } else {
write!(f, "{} to {}", self.0.start, self.0.end)?; write!(f, "{} to {}", self.0.start, self.0.end - 1)?;
} }
Ok(()) Ok(())
} }
@ -47,15 +49,11 @@ mod tests {
#[test] #[test]
fn exclusive() { fn exclusive() {
assert!(!(0..0).range_contains(&0));
assert!(!(0..0).range_contains(&0)); assert!(!(0..0).range_contains(&0));
assert!(!(1..10).range_contains(&0)); assert!(!(1..10).range_contains(&0));
assert!(!(1..10).range_contains(&10)); assert!(!(1..10).range_contains(&10));
assert!(!(1..10).range_contains(&0)); assert!(!(1..10).range_contains(&0));
assert!(!(1..10).range_contains(&10));
assert!((0..1).range_contains(&0)); assert!((0..1).range_contains(&0));
assert!((0..1).range_contains(&0));
assert!((10..20).range_contains(&15));
assert!((10..20).range_contains(&15)); assert!((10..20).range_contains(&15));
} }
@ -75,8 +73,13 @@ mod tests {
#[test] #[test]
fn display() { fn display() {
assert_eq!((1..1).display().to_string(), "1"); assert!(!(1..1).contains(&1));
assert_eq!((1..2).display().to_string(), "1 to 2"); assert!((1..1).is_empty());
assert!((5..5).is_empty());
assert_eq!((1..1).display().to_string(), "0");
assert_eq!((1..2).display().to_string(), "1");
assert_eq!((5..6).display().to_string(), "5");
assert_eq!((5..10).display().to_string(), "5 to 9");
assert_eq!((1..usize::MAX).display().to_string(), "1 or more"); assert_eq!((1..usize::MAX).display().to_string(), "1 or more");
} }
} }

View File

@ -22,7 +22,7 @@ fn error_from_signal(recipe: &str, line_number: Option<usize>, exit_status: Exit
/// A recipe, e.g. `foo: bar baz` /// A recipe, e.g. `foo: bar baz`
#[derive(PartialEq, Debug, Clone, Serialize)] #[derive(PartialEq, Debug, Clone, Serialize)]
pub(crate) struct Recipe<'src, D = Dependency<'src>> { pub(crate) struct Recipe<'src, D = Dependency<'src>> {
pub(crate) attributes: BTreeSet<Attribute>, pub(crate) attributes: BTreeSet<Attribute<'src>>,
pub(crate) body: Vec<Line<'src>>, pub(crate) body: Vec<Line<'src>>,
pub(crate) dependencies: Vec<D>, pub(crate) dependencies: Vec<D>,
#[serde(skip)] #[serde(skip)]
@ -71,18 +71,23 @@ impl<'src, D> Recipe<'src, D> {
} }
pub(crate) fn confirm(&self) -> RunResult<'src, bool> { pub(crate) fn confirm(&self) -> RunResult<'src, bool> {
if self.attributes.contains(&Attribute::Confirm) { for attribute in &self.attributes {
if let Attribute::Confirm(prompt) = attribute {
if let Some(prompt) = prompt {
eprint!("{} ", prompt.cooked);
} else {
eprint!("Run recipe `{}`? ", self.name); eprint!("Run recipe `{}`? ", self.name);
}
let mut line = String::new(); let mut line = String::new();
std::io::stdin() std::io::stdin()
.read_line(&mut line) .read_line(&mut line)
.map_err(|io_error| Error::GetConfirmation { io_error })?; .map_err(|io_error| Error::GetConfirmation { io_error })?;
let line = line.trim().to_lowercase(); let line = line.trim().to_lowercase();
Ok(line == "y" || line == "yes") return Ok(line == "y" || line == "yes");
} else {
Ok(true)
} }
} }
Ok(true)
}
pub(crate) fn check_can_be_default_recipe(&self) -> RunResult<'src, ()> { pub(crate) fn check_can_be_default_recipe(&self) -> RunResult<'src, ()> {
let min_arguments = self.min_arguments(); let min_arguments = self.min_arguments();
@ -423,7 +428,7 @@ impl<'src, D: Display> ColorDisplay for Recipe<'src, D> {
} }
for attribute in &self.attributes { for attribute in &self.attributes {
writeln!(f, "[{}]", attribute.to_str())?; writeln!(f, "[{attribute}]")?;
} }
if self.quiet { if self.quiet {

View File

@ -1,6 +1,6 @@
use super::*; use super::*;
#[derive(PartialEq, Debug, Clone)] #[derive(PartialEq, Debug, Clone, Ord, Eq, PartialOrd)]
pub(crate) struct StringLiteral<'src> { pub(crate) struct StringLiteral<'src> {
pub(crate) kind: StringKind, pub(crate) kind: StringKind,
pub(crate) raw: &'src str, pub(crate) raw: &'src str,

View File

@ -72,7 +72,7 @@ fn multiple_attributes_one_line_error_message() {
) )
.stderr( .stderr(
" "
error: Expected ']' or ',', but found identifier error: Expected ']', ',', or '(', but found identifier
justfile:1:17 justfile:1:17
1 [macos, windows linux] 1 [macos, windows linux]
@ -106,3 +106,26 @@ fn multiple_attributes_one_line_duplicate_check() {
.status(1) .status(1)
.run(); .run();
} }
#[test]
fn unexpected_attribute_argument() {
Test::new()
.justfile(
"
[private('foo')]
foo:
exit 1
",
)
.stderr(
"
error: Attribute `private` specified with argument but takes no arguments
justfile:1:2
1 [private('foo')]
^^^^^^^
",
)
.status(1)
.run();
}

View File

@ -103,3 +103,49 @@ fn do_not_confirm_recipe_with_confirm_recipe_dependency() {
.status(1) .status(1)
.run(); .run();
} }
#[test]
fn confirm_recipe_with_prompt() {
Test::new()
.justfile(
"
[confirm(\"This is dangerous - are you sure you want to run it?\")]
requires_confirmation:
echo confirmed
",
)
.stderr("This is dangerous - are you sure you want to run it? echo confirmed\n")
.stdout("confirmed\n")
.stdin("y")
.run();
}
#[test]
fn confirm_recipe_with_prompt_too_many_args() {
Test::new()
.justfile(
"
[confirm(\"This is dangerous - are you sure you want to run it?\",\"this second argument is not supported\")]
requires_confirmation:
echo confirmed
",
)
.stderr("error: Expected ')', but found ','\n ——▶ justfile:1:64\n\n1 │ [confirm(\"This is dangerous - are you sure you want to run it?\",\"this second argument is not supported\")]\n │ ^\n")
.stdout("")
.status(1)
.run();
}
#[test]
fn confirm_attribute_is_formatted_correctly() {
Test::new()
.justfile(
"
[confirm('prompt')]
foo:
",
)
.arg("--dump")
.stdout("[confirm('prompt')]\nfoo:\n")
.run();
}

View File

@ -4,7 +4,7 @@ test! {
name: invalid_alias_attribute, name: invalid_alias_attribute,
justfile: "[private]\n[linux]\nalias t := test\n\ntest:\n", justfile: "[private]\n[linux]\nalias t := test\n\ntest:\n",
stderr: " stderr: "
error: Alias t has an invalid attribute `linux` error: Alias `t` has invalid attribute `linux`
justfile:3:7 justfile:3:7
3 alias t := test 3 alias t := test