diff --git a/README.md b/README.md
index 3874b7105f..199dd52706 100644
--- a/README.md
+++ b/README.md
@@ -1542,24 +1542,58 @@ exported `just` variables cannot be used. However, this allows shell expanded
strings to be used in places like settings and import paths, which cannot
depend on `just` variables and `.env` files.
-### Ignoring Errors
+### Sigils
-Normally, if a command returns a non-zero exit status, execution will stop. To
-continue execution after a command, even if it fails, prefix the command with
-`-`:
+Commands in linewise recipes may be prefixed with any combination of the sigils
+`-`, `@`, and `?`.
+
+The `@` sigil toggles command echoing:
```just
foo:
- -cat foo
- echo 'Done!'
+ @echo "This line won't be echoed!"
+ echo "This line will be echoed!"
+
+@bar:
+ @echo "This line will be echoed!"
+ echo "This line won't be echoed!"
+```
+
+The `-` sigil cause recipe execution to continue even if the command returns a
+nonzero exit status:
+
+```just
+# execution will continue, even if bar doesn't exist
+foo:
+ -rmdir bar
+ mkdir bar
+ echo 'so much good stuff' > bar/stuff.txt
+```
+
+The `?` sigilmaster causes the current recipe to stop executing if
+the command returns a nonzero exit status, but execution of other recipes will
+continue.
+
+If the `guards` settings is unset or false, `?` sigils are ignored.
+
+```just
+set guards
+
+@foo: bar
+ echo FOO
+
+@bar:
+ ?[[ -f baz ]]
+ echo BAR
```
```console
$ just foo
-cat foo
-cat: foo: No such file or directory
-echo 'Done!'
-Done!
+FOO
+$ touch baz
+$ just foo
+BAR
+FOO
```
### Functions
diff --git a/src/keyword.rs b/src/keyword.rs
index ff06c39d27..e146f62a45 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -15,6 +15,7 @@ pub(crate) enum Keyword {
Export,
Fallback,
False,
+ Guards,
If,
IgnoreComments,
Import,
diff --git a/src/lexer.rs b/src/lexer.rs
index afef81c21e..93bad7e096 100644
--- a/src/lexer.rs
+++ b/src/lexer.rs
@@ -259,7 +259,7 @@ impl<'src> Lexer<'src> {
/// True if `text` could be an identifier
pub(crate) fn is_identifier(text: &str) -> bool {
- if !text.chars().next().map_or(false, Self::is_identifier_start) {
+ if !text.chars().next().is_some_and(Self::is_identifier_start) {
return false;
}
diff --git a/src/lib.rs b/src/lib.rs
index 30777e513d..5f765a2f86 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -80,6 +80,7 @@ pub(crate) use {
settings::Settings,
shebang::Shebang,
show_whitespace::ShowWhitespace,
+ sigil::Sigil,
source::Source,
string_delimiter::StringDelimiter,
string_kind::StringKind,
@@ -255,6 +256,7 @@ mod setting;
mod settings;
mod shebang;
mod show_whitespace;
+mod sigil;
mod source;
mod string_delimiter;
mod string_kind;
diff --git a/src/line.rs b/src/line.rs
index 8a58604676..5ad8532984 100644
--- a/src/line.rs
+++ b/src/line.rs
@@ -18,6 +18,27 @@ impl Line<'_> {
}
}
+ pub(crate) fn sigils(&self, settings: &Settings) -> BTreeSet {
+ let mut sigils = BTreeSet::new();
+
+ if let Some(first) = self.first() {
+ for c in first.chars() {
+ let sigil = match c {
+ '-' => Sigil::Infallible,
+ '?' if settings.guards => Sigil::Guard,
+ '@' => Sigil::Quiet,
+ _ => break,
+ };
+
+ if !sigils.insert(sigil) {
+ break;
+ }
+ }
+ }
+
+ sigils
+ }
+
pub(crate) fn is_comment(&self) -> bool {
self.first().is_some_and(|text| text.starts_with('#'))
}
@@ -33,18 +54,6 @@ impl Line<'_> {
self.fragments.is_empty()
}
- pub(crate) fn is_infallible(&self) -> bool {
- self
- .first()
- .is_some_and(|text| text.starts_with('-') || text.starts_with("@-"))
- }
-
- pub(crate) fn is_quiet(&self) -> bool {
- self
- .first()
- .is_some_and(|text| text.starts_with('@') || text.starts_with("-@"))
- }
-
pub(crate) fn is_shebang(&self) -> bool {
self.first().is_some_and(|text| text.starts_with("#!"))
}
diff --git a/src/node.rs b/src/node.rs
index 6bfb042af8..99dca5df34 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -291,6 +291,7 @@ impl<'src> Node<'src> for Set<'src> {
| Setting::DotenvRequired(value)
| Setting::Export(value)
| Setting::Fallback(value)
+ | Setting::Guards(value)
| Setting::PositionalArguments(value)
| Setting::Quiet(value)
| Setting::Unstable(value)
diff --git a/src/parser.rs b/src/parser.rs
index 2258c13b3b..82e969cf83 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -917,7 +917,7 @@ impl<'run, 'src> Parser<'run, 'src> {
let body = self.parse_body()?;
- let shebang = body.first().map_or(false, Line::is_shebang);
+ let shebang = body.first().is_some_and(Line::is_shebang);
let script = attributes.contains(AttributeDiscriminant::Script);
if shebang && script {
@@ -1016,7 +1016,7 @@ impl<'run, 'src> Parser<'run, 'src> {
}
}
- while lines.last().map_or(false, Line::is_empty) {
+ while lines.last().is_some_and(Line::is_empty) {
lines.pop();
}
@@ -1067,6 +1067,7 @@ impl<'run, 'src> Parser<'run, 'src> {
Keyword::DotenvRequired => Some(Setting::DotenvRequired(self.parse_set_bool()?)),
Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)),
Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)),
+ Keyword::Guards => Some(Setting::Guards(self.parse_set_bool()?)),
Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)),
Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)),
Keyword::Quiet => Some(Setting::Quiet(self.parse_set_bool()?)),
diff --git a/src/recipe.rs b/src/recipe.rs
index d53a44bb40..6c93c2583d 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -195,20 +195,19 @@ impl<'src, D> Recipe<'src, D> {
mut evaluator: Evaluator<'src, 'run>,
) -> RunResult<'src, ()> {
let config = &context.config;
+ let settings = &context.module.settings;
let mut lines = self.body.iter().peekable();
let mut line_number = self.line_number() + 1;
loop {
- if lines.peek().is_none() {
+ let Some(line) = lines.peek() else {
return Ok(());
- }
+ };
+
let mut evaluated = String::new();
let mut continued = false;
- let quiet_line = lines.peek().map_or(false, |line| line.is_quiet());
- let infallible_line = lines.peek().map_or(false, |line| line.is_infallible());
-
- let comment_line = context.module.settings.ignore_comments
- && lines.peek().map_or(false, |line| line.is_comment());
+ let comment_line = settings.ignore_comments && line.is_comment();
+ let sigils = line.sigils(settings);
loop {
if lines.peek().is_none() {
@@ -233,9 +232,7 @@ impl<'src, D> Recipe<'src, D> {
let mut command = evaluated.as_str();
- let sigils = usize::from(infallible_line) + usize::from(quiet_line);
-
- command = &command[sigils..];
+ command = &command[sigils.len()..];
if command.is_empty() {
continue;
@@ -243,7 +240,7 @@ impl<'src, D> Recipe<'src, D> {
if config.dry_run
|| config.verbosity.loquacious()
- || !((quiet_line ^ self.quiet)
+ || !((sigils.contains(&Sigil::Quiet) ^ self.quiet)
|| (context.module.settings.quiet && !self.no_quiet())
|| config.verbosity.quiet())
{
@@ -299,13 +296,17 @@ impl<'src, D> Recipe<'src, D> {
match InterruptHandler::guard(|| cmd.status()) {
Ok(exit_status) => {
if let Some(code) = exit_status.code() {
- if code != 0 && !infallible_line {
- return Err(Error::Code {
- recipe: self.name(),
- line_number: Some(line_number),
- code,
- print_message: self.print_exit_message(),
- });
+ if code != 0 {
+ if sigils.contains(&Sigil::Guard) {
+ return Ok(());
+ } else if !sigils.contains(&Sigil::Infallible) {
+ return Err(Error::Code {
+ recipe: self.name(),
+ line_number: Some(line_number),
+ code,
+ print_message: self.print_exit_message(),
+ });
+ }
}
} else {
return Err(error_from_signal(
diff --git a/src/setting.rs b/src/setting.rs
index ec45f30144..a2862d989f 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -10,6 +10,7 @@ pub(crate) enum Setting<'src> {
DotenvRequired(bool),
Export(bool),
Fallback(bool),
+ Guards(bool),
IgnoreComments(bool),
PositionalArguments(bool),
Quiet(bool),
@@ -31,6 +32,7 @@ impl Display for Setting<'_> {
| Self::DotenvRequired(value)
| Self::Export(value)
| Self::Fallback(value)
+ | Self::Guards(value)
| Self::IgnoreComments(value)
| Self::PositionalArguments(value)
| Self::Quiet(value)
diff --git a/src/settings.rs b/src/settings.rs
index 4d0f9ea249..4cfc56ad9c 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -15,6 +15,7 @@ pub(crate) struct Settings<'src> {
pub(crate) dotenv_required: bool,
pub(crate) export: bool,
pub(crate) fallback: bool,
+ pub(crate) guards: bool,
pub(crate) ignore_comments: bool,
pub(crate) positional_arguments: bool,
pub(crate) quiet: bool,
@@ -58,6 +59,9 @@ impl<'src> Settings<'src> {
Setting::Fallback(fallback) => {
settings.fallback = fallback;
}
+ Setting::Guards(guards) => {
+ settings.guards = guards;
+ }
Setting::IgnoreComments(ignore_comments) => {
settings.ignore_comments = ignore_comments;
}
diff --git a/src/sigil.rs b/src/sigil.rs
new file mode 100644
index 0000000000..80f7202f0c
--- /dev/null
+++ b/src/sigil.rs
@@ -0,0 +1,6 @@
+#[derive(Eq, Ord, PartialEq, PartialOrd)]
+pub(crate) enum Sigil {
+ Guard,
+ Infallible,
+ Quiet,
+}
diff --git a/tests/guards.rs b/tests/guards.rs
new file mode 100644
index 0000000000..923026a48e
--- /dev/null
+++ b/tests/guards.rs
@@ -0,0 +1,32 @@
+use super::*;
+
+#[test]
+fn guard_lines_halt_executation() {
+ Test::new()
+ .justfile(
+ "
+ set guards
+
+ @foo:
+ ?[[ 'foo' == 'bar' ]]
+ echo baz
+ ",
+ )
+ .run();
+}
+
+#[test]
+fn guard_lines_have_no_effect_if_successful() {
+ Test::new()
+ .justfile(
+ "
+ set guards
+
+ @foo:
+ ?[[ 'foo' == 'foo' ]]
+ echo baz
+ ",
+ )
+ .stdout("baz\n")
+ .run();
+}
diff --git a/tests/json.rs b/tests/json.rs
index 4e740e52e7..2d849baabf 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -83,6 +83,7 @@ struct Settings<'a> {
dotenv_required: bool,
export: bool,
fallback: bool,
+ guards: bool,
ignore_comments: bool,
positional_arguments: bool,
quiet: bool,
diff --git a/tests/lib.rs b/tests/lib.rs
index a86a3da769..c4f973286d 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -71,6 +71,7 @@ mod functions;
#[cfg(unix)]
mod global;
mod groups;
+mod guards;
mod ignore_comments;
mod imports;
mod init;