diff --git a/GRAMMAR.md b/GRAMMAR.md index 4c4a25ca..5c23cc2f 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -104,9 +104,11 @@ string : STRING sequence : expression ',' sequence | 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 | '$'? NAME '=' value diff --git a/README.md b/README.md index 0020dabd..b7f58658 100644 --- a/README.md +++ b/README.md @@ -1456,6 +1456,7 @@ Recipes may be annotated with attributes that change their behavior. | Name | Description | |------|-------------| | `[confirm]`1.17.0 | Require confirmation prior to executing recipe. | +| `[confirm("prompt")]`master | Require confirmation prior to executing recipe with a custom prompt. | | `[linux]`1.8.0 | Enable recipe on Linux. | | `[macos]`1.8.0 | Enable recipe on MacOS. | | `[no-cd]`1.9.0 | Don't change directory before executing recipe. | @@ -1544,6 +1545,16 @@ delete all: rm -rf * ``` +#### Custom Confirmation Promptmaster + +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 Backticks can be used to store the result of commands: diff --git a/src/alias.rs b/src/alias.rs index bb9d4400..286cefd0 100644 --- a/src/alias.rs +++ b/src/alias.rs @@ -3,7 +3,7 @@ use super::*; /// An alias, e.g. `name := target` #[derive(Debug, PartialEq, Clone, Serialize)] pub(crate) struct Alias<'src, T = Rc>> { - pub(crate) attributes: BTreeSet, + pub(crate) attributes: BTreeSet>, pub(crate) name: Name<'src>, #[serde( bound(serialize = "T: Keyed<'src>"), diff --git a/src/analyzer.rs b/src/analyzer.rs index eb306421..19f36e1d 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -211,11 +211,11 @@ impl<'src> Analyzer<'src> { fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src> { let name = alias.name.lexeme(); - for attr in &alias.attributes { - if *attr != Attribute::Private { + for attribute in &alias.attributes { + if *attribute != Attribute::Private { return Err(alias.name.token.error(AliasInvalidAttribute { alias: name, - attr: *attr, + attribute: attribute.clone(), })); } } diff --git a/src/attribute.rs b/src/attribute.rs index 0c361e3b..8516ce94 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -1,12 +1,10 @@ use super::*; -#[derive( - EnumString, PartialEq, Debug, Copy, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr, -)] +#[derive(EnumString, PartialEq, Debug, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr)] #[strum(serialize_all = "kebab-case")] #[serde(rename_all = "kebab-case")] -pub(crate) enum Attribute { - Confirm, +pub(crate) enum Attribute<'src> { + Confirm(Option>), Linux, Macos, NoCd, @@ -17,14 +15,45 @@ pub(crate) enum Attribute { Windows, } -impl Attribute { - pub(crate) fn from_name(name: Name) -> Option { +impl<'src> Attribute<'src> { + pub(crate) fn from_name(name: Name) -> Option { name.lexeme().parse().ok() } - pub(crate) fn to_str(self) -> &'static str { + pub(crate) fn name(&self) -> &'static str { 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)] @@ -32,7 +61,7 @@ mod tests { use super::*; #[test] - fn to_str() { - assert_eq!(Attribute::NoExitMessage.to_str(), "no-exit-message"); + fn name() { + assert_eq!(Attribute::NoExitMessage.name(), "no-exit-message"); } } diff --git a/src/compile_error.rs b/src/compile_error.rs index d1af6a0f..1457dff5 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -32,11 +32,13 @@ impl Display for CompileError<'_> { use CompileErrorKind::*; match &*self.kind { - AliasInvalidAttribute { alias, attr } => write!( - f, - "Alias {alias} has an invalid attribute `{}`", - attr.to_str(), - ), + AliasInvalidAttribute { alias, attribute } => { + write!( + f, + "Alias `{alias}` has invalid attribute `{}`", + attribute.name(), + ) + } AliasShadowsRecipe { alias, recipe_line } => write!( f, "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" ), 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}`"), UnexpectedClosingDelimiter { close } => { write!(f, "Unexpected closing delimiter `{}`", close.close()) diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 4c98d5e2..fbdbf2a8 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -4,7 +4,7 @@ use super::*; pub(crate) enum CompileErrorKind<'src> { AliasInvalidAttribute { alias: &'src str, - attr: Attribute, + attribute: Attribute<'src>, }, AliasShadowsRecipe { alias: &'src str, @@ -85,6 +85,9 @@ pub(crate) enum CompileErrorKind<'src> { UndefinedVariable { variable: &'src str, }, + UnexpectedAttributeArgument { + attribute: Attribute<'src>, + }, UnexpectedCharacter { expected: char, }, diff --git a/src/parser.rs b/src/parser.rs index 3f7deda7..ba006ac5 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -435,7 +435,7 @@ impl<'run, 'src> Parser<'run, 'src> { /// Parse an alias, e.g `alias name := target` fn parse_alias( &mut self, - attributes: BTreeSet, + attributes: BTreeSet>, ) -> CompileResult<'src, Alias<'src, Name<'src>>> { self.presume_keyword(Keyword::Alias)?; let name = self.parse_name()?; @@ -672,7 +672,7 @@ impl<'run, 'src> Parser<'run, 'src> { &mut self, doc: Option<&'src str>, quiet: bool, - attributes: BTreeSet, + attributes: BTreeSet>, ) -> CompileResult<'src, UnresolvedRecipe<'src>> { let name = self.parse_name()?; @@ -905,7 +905,7 @@ impl<'run, 'src> Parser<'run, 'src> { } /// Parse recipe attributes - fn parse_attributes(&mut self) -> CompileResult<'src, Option>> { + fn parse_attributes(&mut self) -> CompileResult<'src, Option>>> { let mut attributes = BTreeMap::new(); while self.accepted(BracketL)? { @@ -922,6 +922,15 @@ impl<'run, 'src> Parser<'run, 'src> { 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); if !self.accepted(Comma)? { diff --git a/src/range_ext.rs b/src/range_ext.rs index 2a96064e..bf4995e8 100644 --- a/src/range_ext.rs +++ b/src/range_ext.rs @@ -13,11 +13,13 @@ pub(crate) struct DisplayRange(T); impl Display for DisplayRange<&Range> { fn fmt(&self, f: &mut Formatter) -> fmt::Result { if self.0.start == self.0.end { + write!(f, "0")?; + } else if self.0.start == self.0.end - 1 { write!(f, "{}", self.0.start)?; } else if self.0.end == usize::MAX { write!(f, "{} or more", self.0.start)?; } else { - write!(f, "{} to {}", self.0.start, self.0.end)?; + write!(f, "{} to {}", self.0.start, self.0.end - 1)?; } Ok(()) } @@ -47,15 +49,11 @@ mod tests { #[test] fn exclusive() { - assert!(!(0..0).range_contains(&0)); assert!(!(0..0).range_contains(&0)); assert!(!(1..10).range_contains(&0)); assert!(!(1..10).range_contains(&10)); 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!((10..20).range_contains(&15)); assert!((10..20).range_contains(&15)); } @@ -75,8 +73,13 @@ mod tests { #[test] fn display() { - assert_eq!((1..1).display().to_string(), "1"); - assert_eq!((1..2).display().to_string(), "1 to 2"); + assert!(!(1..1).contains(&1)); + 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"); } } diff --git a/src/recipe.rs b/src/recipe.rs index bc06cf46..3ef2843f 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -22,7 +22,7 @@ fn error_from_signal(recipe: &str, line_number: Option, exit_status: Exit /// A recipe, e.g. `foo: bar baz` #[derive(PartialEq, Debug, Clone, Serialize)] pub(crate) struct Recipe<'src, D = Dependency<'src>> { - pub(crate) attributes: BTreeSet, + pub(crate) attributes: BTreeSet>, pub(crate) body: Vec>, pub(crate) dependencies: Vec, #[serde(skip)] @@ -71,17 +71,22 @@ impl<'src, D> Recipe<'src, D> { } pub(crate) fn confirm(&self) -> RunResult<'src, bool> { - if self.attributes.contains(&Attribute::Confirm) { - eprint!("Run recipe `{}`? ", self.name); - let mut line = String::new(); - std::io::stdin() - .read_line(&mut line) - .map_err(|io_error| Error::GetConfirmation { io_error })?; - let line = line.trim().to_lowercase(); - Ok(line == "y" || line == "yes") - } else { - Ok(true) + 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); + } + let mut line = String::new(); + std::io::stdin() + .read_line(&mut line) + .map_err(|io_error| Error::GetConfirmation { io_error })?; + let line = line.trim().to_lowercase(); + return Ok(line == "y" || line == "yes"); + } } + Ok(true) } pub(crate) fn check_can_be_default_recipe(&self) -> RunResult<'src, ()> { @@ -423,7 +428,7 @@ impl<'src, D: Display> ColorDisplay for Recipe<'src, D> { } for attribute in &self.attributes { - writeln!(f, "[{}]", attribute.to_str())?; + writeln!(f, "[{attribute}]")?; } if self.quiet { diff --git a/src/string_literal.rs b/src/string_literal.rs index 47459cfc..4a2b7744 100644 --- a/src/string_literal.rs +++ b/src/string_literal.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(PartialEq, Debug, Clone)] +#[derive(PartialEq, Debug, Clone, Ord, Eq, PartialOrd)] pub(crate) struct StringLiteral<'src> { pub(crate) kind: StringKind, pub(crate) raw: &'src str, diff --git a/tests/attributes.rs b/tests/attributes.rs index 605e7476..a3a451cc 100644 --- a/tests/attributes.rs +++ b/tests/attributes.rs @@ -72,7 +72,7 @@ fn multiple_attributes_one_line_error_message() { ) .stderr( " - error: Expected ']' or ',', but found identifier + error: Expected ']', ',', or '(', but found identifier ——▶ justfile:1:17 │ 1 │ [macos, windows linux] @@ -106,3 +106,26 @@ fn multiple_attributes_one_line_duplicate_check() { .status(1) .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(); +} diff --git a/tests/confirm.rs b/tests/confirm.rs index 8b37489c..b1a2657d 100644 --- a/tests/confirm.rs +++ b/tests/confirm.rs @@ -103,3 +103,49 @@ fn do_not_confirm_recipe_with_confirm_recipe_dependency() { .status(1) .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(); +} diff --git a/tests/error_messages.rs b/tests/error_messages.rs index 89b4fd1c..5c96035b 100644 --- a/tests/error_messages.rs +++ b/tests/error_messages.rs @@ -4,7 +4,7 @@ test! { name: invalid_alias_attribute, justfile: "[private]\n[linux]\nalias t := test\n\ntest:\n", stderr: " - error: Alias t has an invalid attribute `linux` + error: Alias `t` has invalid attribute `linux` ——▶ justfile:3:7 │ 3 │ alias t := test