Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add confirm prompt #1834

Merged
merged 36 commits into from
Jan 13, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ec0794c
add optional boxed string for prompt text
CramBL Jan 10, 2024
8b28f7b
add test with confirmation prompt
CramBL Jan 10, 2024
3c266d8
implement confirm prompt
CramBL Jan 10, 2024
a3fd212
remove redundant test
CramBL Jan 11, 2024
4a11619
Use Option<String> instead of boxed string
CramBL Jan 11, 2024
d28ac60
completely override prompt if user defines a prompt
CramBL Jan 11, 2024
fe22811
describe custom confirmation prompt
CramBL Jan 11, 2024
04b6b7d
address comments about confirm prompt documentation
CramBL Jan 12, 2024
d03a45a
add compile error variant for attribute args
CramBL Jan 12, 2024
bc7636e
add expect_args and with_arguments for Attribute enum
CramBL Jan 12, 2024
233b177
revise parsing of attribute args
CramBL Jan 12, 2024
12e63a6
also revert alignment of settings table
CramBL Jan 12, 2024
3c90d6a
make more generic
CramBL Jan 12, 2024
d1c2ed7
add error message to AttributeArgumentCountMismatch compileerrorkid
CramBL Jan 12, 2024
05faf55
remove duplicate assertions
CramBL Jan 12, 2024
dec8ed7
use hacky range_contains of that treats half-open range as open range
CramBL Jan 12, 2024
2734e93
fix broken DisplayRange and add assertions for clarifying
CramBL Jan 12, 2024
6545e54
add confirm test with too many args
CramBL Jan 12, 2024
145bc89
Merge branch 'master' into add-confirm-prompt
CramBL Jan 12, 2024
bc1f5f1
update test
CramBL Jan 12, 2024
4c1bf1c
implement display for RangeInclusive
CramBL Jan 12, 2024
7589a2b
use RangeInclusive instead of Range
CramBL Jan 12, 2024
b86ded7
accept comma-separated arguments
CramBL Jan 12, 2024
5987265
update test
CramBL Jan 12, 2024
fcea9a6
replace unreleased with master
CramBL Jan 12, 2024
2ee15f0
revert to just support a single recipe attribute argument
CramBL Jan 12, 2024
b528c0d
Add to grammar and tweak readme
casey Jan 13, 2024
d6caa19
Tweak
casey Jan 13, 2024
50d47c7
Tweak
casey Jan 13, 2024
e8387bc
Tweak
casey Jan 13, 2024
41c8071
Tweak
casey Jan 13, 2024
76ca7bb
Tweak
casey Jan 13, 2024
bc82faf
Tweak
casey Jan 13, 2024
b8b3317
Tweak
casey Jan 13, 2024
cb43e0f
Tweak
casey Jan 13, 2024
1c7585c
Tweak
casey Jan 13, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1456,6 +1456,7 @@ Recipes may be annotated with attributes that change their behavior.
| Name | Description |
|------|-------------|
| `[confirm]`<sup>1.17.0</sup> | Require confirmation prior to executing recipe. |
| `[confirm("custom prompt")]`<sup>unreleased</sup> | Same as above but with a user-defined prompt |
| `[linux]`<sup>1.8.0</sup> | Enable recipe on Linux. |
| `[macos]`<sup>1.8.0</sup> | Enable recipe on MacOS. |
| `[no-cd]`<sup>1.9.0</sup> | Don't change directory before executing recipe. |
Expand Down Expand Up @@ -1544,6 +1545,16 @@ delete all:
rm -rf *
```

#### Customize recipe confirmation prompt <sup>unreleased</sup>

The default prompt: ``Run recipe `foo`?`` can be overwritten 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:
Expand Down
2 changes: 1 addition & 1 deletion src/analyzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ impl<'src> Analyzer<'src> {
if *attr != Attribute::Private {
return Err(alias.name.token.error(AliasInvalidAttribute {
alias: name,
attr: *attr,
attr: attr.clone(),
}));
}
}
Expand Down
40 changes: 35 additions & 5 deletions src/attribute.rs
Original file line number Diff line number Diff line change
@@ -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,
Confirm(Option<String>),
Linux,
Macos,
NoCd,
Expand All @@ -22,9 +20,41 @@ impl Attribute {
name.lexeme().parse().ok()
}

pub(crate) fn to_str(self) -> &'static str {
pub(crate) fn to_str(&self) -> &'static str {
self.into()
}

/// Returns a range from the min to max expected arguments of a given attribute
pub(crate) fn expect_args(&self) -> Range<usize> {
use Attribute::*;

match self {
Confirm(_) => 1..2,
CramBL marked this conversation as resolved.
Show resolved Hide resolved
_ => 0..0,
}
}

pub(crate) fn with_arguments(
self,
arguments: Vec<StringLiteral<'_>>,
) -> Result<Attribute, CompileErrorKind<'_>> {
use Attribute::*;

if !self.expect_args().range_contains(&arguments.len()) {
return Err(CompileErrorKind::AttributeArgumentCountMismatch {
attribute: self.to_str(),
found: arguments.len(),
expected: self.expect_args(),
});
}

match self {
Confirm(_) => Ok(Attribute::Confirm(
arguments.first().map(|s| s.cooked.clone()),
)),
_ => unreachable!("Missing implementation for attribute that accepts arguments"),
}
}
}

#[cfg(test)]
Expand Down
10 changes: 10 additions & 0 deletions src/compile_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ impl Display for CompileError<'_> {
self.token.line.ordinal(),
recipe_line.ordinal(),
),
AttributeArgumentCountMismatch {
attribute,
found,
expected,
} => write!(
f,
"Attribute `{attribute}` called with {found} {} but takes {}",
Count("argument", *found),
expected.display(),
),
BacktickShebang => write!(f, "Backticks may not start with `#!`"),
CircularRecipeDependency { recipe, ref circle } => {
if circle.len() == 2 {
Expand Down
5 changes: 5 additions & 0 deletions src/compile_error_kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ pub(crate) enum CompileErrorKind<'src> {
alias: &'src str,
recipe_line: usize,
},
AttributeArgumentCountMismatch {
attribute: &'src str,
found: usize,
expected: Range<usize>,
},
BacktickShebang,
CircularRecipeDependency {
recipe: &'src str,
Expand Down
25 changes: 25 additions & 0 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -922,8 +922,33 @@ impl<'run, 'src> Parser<'run, 'src> {
first: *line,
}));
}

let arguments = if self.accepted(ParenL)? {
let mut arguments = Vec::new();

while self.next_is(StringToken) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we only have one attribute that accepts a string literal, and if we're accepting multiple arguments, we would want to accept commas between them, so we can make arguments be an Option.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comma-separated arguments are now accepted, do should comma+space also be accepted?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, no sorry, what I was getting at is that we don't have any multi-argument attributes, so we shouldn't bother either with parsing multiple arguments, with or without commas. We can just parse ( STRING ).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, but now it is implemented, do you want it removed? It's like 2 lines and all the scaffolding is there now if there's a need/want for features that take advantage of recipe attributes with arguments. Off the top of my head it could be stuff like [linux("arm64", "amd64")] that enables recipes based on OS + Architecture, or adding kernel version for requirements etc.

But I respect if you invoke YAGNI, then I'll revert it.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree we'll probably add it later, but that could be a while, so I'd assume just remove it. It also makes Attribute::with_arguments simpler, since it can take an Option instead of a Vec.

arguments.push(self.parse_string_literal()?);
}
self.expect(ParenR)?;
Some(arguments)
} else {
None
};

let attribute = if let Some(arguments) = arguments {
match attribute.with_arguments(arguments) {
Ok(attribute) => attribute,
Err(kind) => {
return Err(name.error(kind));
},
}
} else {
attribute
};

attributes.insert(attribute, name.line);


if !self.accepted(Comma)? {
break;
}
Expand Down
16 changes: 9 additions & 7 deletions src/range_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ pub(crate) struct DisplayRange<T>(T);
impl Display for DisplayRange<&Range<usize>> {
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)?;
// the range is exclusive from above so it is "start to end-1"
write!(f, "{} to {}", self.0.start, self.0.end - 1)?;
}
Ok(())
}
Expand Down Expand Up @@ -47,15 +50,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));
}

Expand All @@ -75,8 +74,11 @@ 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).len() == 0);
assert!((5..5).len() == 0);
assert_eq!((1..1).display().to_string(), "0");
assert_eq!((1..2).display().to_string(), "1");
assert_eq!((1..usize::MAX).display().to_string(), "1 or more");
}
}
27 changes: 17 additions & 10 deletions src/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,24 @@ 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)
// Iterate through the attributes and check if any of them are a confirm
// If a `confirm` attribute is found, print a prompt and wait for user input
// else return true
for attribute in &self.attributes {
if let Attribute::Confirm(prompt) = attribute {
let prompt: String = prompt
.as_ref()
.map_or_else(|| format!("Run recipe `{}`?", self.name), std::borrow::ToOwned::to_owned);
eprint!("{prompt} ");
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, ()> {
Expand Down
32 changes: 32 additions & 0 deletions tests/confirm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,35 @@ 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: Attribute `confirm` called with 2 arguments but takes 1\n ——▶ justfile:1:2\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();
}