Repository: casey/just (rust)

Issue:
Supress the "error: Recipe `...` failed on line ... with exit code ..." without suppressing the exit code
If you're wrapping some binary CLI with `just`, e.g.

```sh
$ just some-command --some --flags
```

It will often return a non-zero exit code (e.g. 2) while you trying to browse through some arguments (especially if there's incomplete subcommands that have further nested subcommands etc). It's pretty annoying to have the `error: Recipe `...` failed on line ... with exit code ...` message pop up every time, making it feel somewhat alien.

Suppressing the error is not the right solution though, because it should remain to be a non-zero exit code! (especially if called from some scripts)

Is there a way to make `just` even more transparent and a bit more quiet? I.e., don't suppress exit codes but don't print anything extra to the output, at all (unless there's some fatal errors in the justfile itself).

// Apologies in advance if I'm missing something obvious here but I couldn't find anything except `@` and `-`.

Pull request description:
Suppress command exit code error messages
When a recipe wraps cli tool and the tool exits with a non-zero code,
just adds its own extra exit error message along with the messages
from the tool. Introduce `no-exit-message` attribute to suppress this
additional message.

Closes #1334

Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index 98b0fbd6..c72f1c10 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -94,7 +94,9 @@ string        : STRING
 sequence      : expression ',' sequence
               | expression ','?
 
-recipe        : '@'? NAME parameter* variadic? ':' dependency* body?
+recipe        : attribute? '@'? NAME parameter* variadic? ':' dependency* body?
+
+attribute     : '[' NAME ']' eol
 
 parameter     : '$'? NAME
               | '$'? NAME '=' value
diff --git a/README.md b/README.md
index 098c454d..8ff70a2d 100644
--- a/README.md
+++ b/README.md
@@ -1987,6 +1987,35 @@ echo 'Bar!'
 Bar!
 ```
 
+`just` normally prints error messages when a recipe line fails. These error
+messages can be suppressed using the `[no-exit-message]` attribute. You may find
+this especially useful with a recipe that recipe wraps a tool:
+
+```make
+git *args:
+    @git {{args}}
+```
+
+```sh
+$ just git status
+fatal: not a git repository (or any of the parent directories): .git
+error: Recipe `git` failed on line 2 with exit code 128
+```
+
+Add the attribute to suppress the exit error message when the tool exits with a
+non-zero code:
+
+```make
+[no-exit-message]
+git *args:
+    @git {{args}}
+```
+
+```sh
+$ just git status
+fatal: not a git repository (or any of the parent directories): .git
+```
+
 ### Selecting Recipes to Run With an Interactive Chooser
 
 The `--choose` subcommand makes `just` invoke a chooser to select which recipes to run. Choosers should read lines containing recipe names from standard input and print one or more of those names separated by spaces to standard output.
diff --git a/src/attribute.rs b/src/attribute.rs
new file mode 100644
index 00000000..1256b541
--- /dev/null
+++ b/src/attribute.rs
@@ -0,0 +1,13 @@
+use super::*;
+
+#[derive(EnumString)]
+#[strum(serialize_all = "kebab_case")]
+pub(crate) enum Attribute {
+  NoExitMessage,
+}
+
+impl Attribute {
+  pub(crate) fn from_name(name: Name) -> Option<Attribute> {
+    name.lexeme().parse().ok()
+  }
+}
diff --git a/src/compile_error.rs b/src/compile_error.rs
index 07568780..1709985d 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -247,6 +247,9 @@ impl Display for CompileError<'_> {
       UnknownAliasTarget { alias, target } => {
         write!(f, "Alias `{}` has an unknown target `{}`", alias, target)?;
       }
+      UnknownAttribute { attribute } => {
+        write!(f, "Unknown attribute `{}`", attribute)?;
+      }
       UnknownDependency { recipe, unknown } => {
         write!(
           f,
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index 29f508e7..3d63f4a2 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -98,6 +98,9 @@ pub(crate) enum CompileErrorKind<'src> {
     alias: &'src str,
     target: &'src str,
   },
+  UnknownAttribute {
+    attribute: &'src str,
+  },
   UnknownDependency {
     recipe: &'src str,
     unknown: &'src str,
diff --git a/src/error.rs b/src/error.rs
index e28a231c..682f00d5 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -35,6 +35,7 @@ pub(crate) enum Error<'src> {
     recipe: &'src str,
     line_number: Option<usize>,
     code: i32,
+    suppress_message: bool,
   },
   CommandInvoke {
     binary: OsString,
@@ -167,6 +168,16 @@ impl<'src> Error<'src> {
       message: message.into(),
     }
   }
+
+  pub(crate) fn suppress_message(&self) -> bool {
+    matches!(
+      self,
+      Error::Code {
+        suppress_message: true,
+        ..
+      }
+    )
+  }
 }
 
 impl<'src> From<CompileError<'src>> for Error<'src> {
@@ -323,6 +334,7 @@ impl<'src> ColorDisplay for Error<'src> {
         recipe,
         line_number,
         code,
+        ..
       } => {
         if let Some(n) = line_number {
           write!(
diff --git a/src/justfile.rs b/src/justfile.rs
index ae90df8e..77c02b9c 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -472,11 +472,13 @@ mod tests {
       recipe,
       line_number,
       code,
+      suppress_message,
     },
     check: {
       assert_eq!(recipe, "a");
       assert_eq!(code, 200);
       assert_eq!(line_number, None);
+      assert!(!suppress_message);
     }
   }
 
@@ -491,11 +493,13 @@ mod tests {
       recipe,
       line_number,
       code,
+      suppress_message,
     },
     check: {
       assert_eq!(recipe, "fail");
       assert_eq!(code, 100);
       assert_eq!(line_number, Some(2));
+      assert!(!suppress_message);
     }
   }
 
@@ -510,11 +514,13 @@ mod tests {
       recipe,
       line_number,
       code,
+      suppress_message,
     },
     check: {
       assert_eq!(recipe, "a");
       assert_eq!(code, 150);
       assert_eq!(line_number, Some(2));
+      assert!(!suppress_message);
     }
   }
 
@@ -664,13 +670,15 @@ mod tests {
     "#,
     args: ["--quiet", "wut"],
     error: Code {
-      line_number,
       recipe,
+      line_number,
+      suppress_message,
       ..
     },
     check: {
       assert_eq!(recipe, "wut");
       assert_eq!(line_number, Some(7));
+      assert!(!suppress_message);
     }
   }
 
diff --git a/src/lib.rs b/src/lib.rs
index 41ab113e..b4b28357 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -16,19 +16,19 @@
 pub(crate) use {
   crate::{
     alias::Alias, analyzer::Analyzer, assignment::Assignment,
-    assignment_resolver::AssignmentResolver, ast::Ast, binding::Binding, color::Color,
-    color_display::ColorDisplay, command_ext::CommandExt, compile_error::CompileError,
-    compile_error_kind::CompileErrorKind, conditional_operator::ConditionalOperator,
-    config::Config, config_error::ConfigError, count::Count, delimiter::Delimiter,
-    dependency::Dependency, dump_format::DumpFormat, enclosure::Enclosure, error::Error,
-    evaluator::Evaluator, expression::Expression, fragment::Fragment, function::Function,
-    function_context::FunctionContext, interrupt_guard::InterruptGuard,
-    interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, keyed::Keyed,
-    keyword::Keyword, lexer::Lexer, line::Line, list::List, load_dotenv::load_dotenv,
-    loader::Loader, name::Name, ordinal::Ordinal, output::output, output_error::OutputError,
-    parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform,
-    platform_interface::PlatformInterface, position::Position, positional::Positional,
-    range_ext::RangeExt, recipe::Recipe, recipe_context::RecipeContext,
+    assignment_resolver::AssignmentResolver, ast::Ast, attribute::Attribute, binding::Binding,
+    color::Color, color_display::ColorDisplay, command_ext::CommandExt,
+    compile_error::CompileError, compile_error_kind::CompileErrorKind,
+    conditional_operator::ConditionalOperator, config::Config, config_error::ConfigError,
+    count::Count, delimiter::Delimiter, dependency::Dependency, dump_format::DumpFormat,
+    enclosure::Enclosure, error::Error, evaluator::Evaluator, expression::Expression,
+    fragment::Fragment, function::Function, function_context::FunctionContext,
+    interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item,
+    justfile::Justfile, keyed::Keyed, keyword::Keyword, lexer::Lexer, line::Line, list::List,
+    load_dotenv::load_dotenv, loader::Loader, name::Name, ordinal::Ordinal, output::output,
+    output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind, parser::Parser,
+    platform::Platform, platform_interface::PlatformInterface, position::Position,
+    positional::Positional, range_ext::RangeExt, recipe::Recipe, recipe_context::RecipeContext,
     recipe_resolver::RecipeResolver, scope::Scope, search::Search, search_config::SearchConfig,
     search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang,
     shell::Shell, show_whitespace::ShowWhitespace, string_kind::StringKind,
@@ -116,6 +116,7 @@ mod analyzer;
 mod assignment;
 mod assignment_resolver;
 mod ast;
+mod attribute;
 mod binding;
 mod color;
 mod color_display;
diff --git a/src/parser.rs b/src/parser.rs
index 44c06c0f..0f8f81e9 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -345,13 +345,20 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
               items.push(Item::Assignment(self.parse_assignment(false)?));
             } else {
               let doc = pop_doc_comment(&mut items, eol_since_last_comment);
-              items.push(Item::Recipe(self.parse_recipe(doc, false)?));
+              items.push(Item::Recipe(self.parse_recipe(doc, false, false)?));
             }
           }
         }
       } else if self.accepted(At)? {
         let doc = pop_doc_comment(&mut items, eol_since_last_comment);
-        items.push(Item::Recipe(self.parse_recipe(doc, true)?));
+        items.push(Item::Recipe(self.parse_recipe(doc, true, false)?));
+      } else if self.accepted(BracketL)? {
+        let Attribute::NoExitMessage = self.parse_attribute_name()?;
+        self.expect(BracketR)?;
+        self.expect_eol()?;
+        let quiet = self.accepted(At)?;
+        let doc = pop_doc_comment(&mut items, eol_since_last_comment);
+        items.push(Item::Recipe(self.parse_recipe(doc, quiet, true)?));
       } else {
         return Err(self.unexpected_token()?);
       }
@@ -595,6 +602,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     &mut self,
     doc: Option<&'src str>,
     quiet: bool,
+    suppress_exit_error_messages: bool,
   ) -> CompileResult<'src, UnresolvedRecipe<'src>> {
     let name = self.parse_name()?;
 
@@ -658,6 +666,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
       parameters: positional.into_iter().chain(variadic).collect(),
       private: name.lexeme().starts_with('_'),
       shebang: body.first().map_or(false, Line::is_shebang),
+      suppress_exit_error_messages,
       priors,
       body,
       dependencies,
@@ -816,6 +825,16 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
 
     Ok(Shell { arguments, command })
   }
+
+  /// Parse a recipe attribute name
+  fn parse_attribute_name(&mut self) -> CompileResult<'src, Attribute> {
+    let name = self.parse_name()?;
+    Attribute::from_name(name).ok_or_else(|| {
+      name.error(CompileErrorKind::UnknownAttribute {
+        attribute: name.lexeme(),
+      })
+    })
+  }
 }
 
 #[cfg(test)]
@@ -1966,7 +1985,7 @@ mod tests {
     column: 0,
     width:  1,
     kind: UnexpectedToken {
-      expected: vec![At, Comment, Eof, Eol, Identifier],
+      expected: vec![At, BracketL, Comment, Eof, Eol, Identifier],
       found: BraceL,
     },
   }
@@ -2144,6 +2163,29 @@ mod tests {
     },
   }
 
+  error! {
+    name:   empty_attribute,
+    input:  "[]\nsome_recipe:\n @exit 3",
+    offset: 1,
+    line:   0,
+    column: 1,
+    width:  1,
+    kind:   UnexpectedToken {
+      expected: vec![Identifier],
+      found: BracketR,
+    },
+  }
+
+  error! {
+    name:   unknown_attribute,
+    input:  "[unknown]\nsome_recipe:\n @exit 3",
+    offset: 1,
+    line:   0,
+    column: 1,
+    width:  7,
+    kind:   UnknownAttribute { attribute: "unknown" },
+  }
+
   error! {
     name:   set_unknown,
     input:  "set shall := []",
diff --git a/src/recipe.rs b/src/recipe.rs
index 698a49db..aa698395 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -30,6 +30,7 @@ pub(crate) struct Recipe<'src, D = Dependency<'src>> {
   pub(crate) private: bool,
   pub(crate) quiet: bool,
   pub(crate) shebang: bool,
+  pub(crate) suppress_exit_error_messages: bool,
 }
 
 impl<'src, D> Recipe<'src, D> {
@@ -191,10 +192,12 @@ impl<'src, D> Recipe<'src, D> {
         Ok(exit_status) => {
           if let Some(code) = exit_status.code() {
             if code != 0 && !infallible_command {
+              let suppress_message = self.suppress_exit_error_messages;
               return Err(Error::Code {
                 recipe: self.name(),
                 line_number: Some(line_number),
                 code,
+                suppress_message,
               });
             }
           } else {
@@ -322,10 +325,12 @@ impl<'src, D> Recipe<'src, D> {
           if code == 0 {
             Ok(())
           } else {
+            let suppress_message = self.suppress_exit_error_messages;
             Err(Error::Code {
               recipe: self.name(),
               line_number: None,
               code,
+              suppress_message,
             })
           }
         },
diff --git a/src/run.rs b/src/run.rs
index 69477cb6..9989d806 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -29,7 +29,7 @@ pub fn run() -> Result<(), i32> {
   config
     .and_then(|config| config.run(&loader))
     .map_err(|error| {
-      if !verbosity.quiet() {
+      if !verbosity.quiet() && !error.suppress_message() {
         eprintln!("{}", error.color_display(color.stderr()));
       }
       error.code().unwrap_or(EXIT_FAILURE)
diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs
index 81f689ba..faa1717b 100644
--- a/src/unresolved_recipe.rs
+++ b/src/unresolved_recipe.rs
@@ -53,6 +53,7 @@ impl<'src> UnresolvedRecipe<'src> {
       quiet: self.quiet,
       shebang: self.shebang,
       priors: self.priors,
+      suppress_exit_error_messages: self.suppress_exit_error_messages,
       dependencies,
     })
   }


Test patch:
diff --git a/tests/byte_order_mark.rs b/tests/byte_order_mark.rs
index 906f49d9..58184a71 100644
--- a/tests/byte_order_mark.rs
+++ b/tests/byte_order_mark.rs
@@ -26,7 +26,7 @@ fn non_leading_byte_order_mark_produces_error() {
     )
     .stderr(
       "
-      error: Expected \'@\', comment, end of file, end of line, or identifier, but found byte order mark
+      error: Expected \'@\', \'[\', comment, end of file, end of line, or identifier, but found byte order mark
         |
       3 | \u{feff}
         | ^
@@ -41,7 +41,7 @@ fn dont_mention_byte_order_mark_in_errors() {
     .justfile("{")
     .stderr(
       "
-      error: Expected '@', comment, end of file, end of line, or identifier, but found '{'
+      error: Expected '@', '[', comment, end of file, end of line, or identifier, but found '{'
         |
       1 | {
         | ^
diff --git a/tests/json.rs b/tests/json.rs
index d70ea6ea..399cc1f9 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -36,6 +36,7 @@ fn alias() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -111,6 +112,7 @@ fn body() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -154,6 +156,7 @@ fn dependencies() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "foo": {
           "body": [],
@@ -165,6 +168,7 @@ fn dependencies() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -238,6 +242,7 @@ fn dependency_argument() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "foo": {
           "body": [],
@@ -256,6 +261,7 @@ fn dependency_argument() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -311,6 +317,7 @@ fn duplicate_recipes() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -348,6 +355,7 @@ fn doc_comment() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -417,6 +425,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "b": {
           "body": [],
@@ -435,6 +444,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "c": {
           "body": [],
@@ -453,6 +463,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "d": {
           "body": [],
@@ -471,6 +482,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "e": {
           "body": [],
@@ -489,6 +501,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "f": {
           "body": [],
@@ -507,6 +520,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
       },
       "settings": {
@@ -548,6 +562,7 @@ fn priors() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         },
         "b": {
           "body": [],
@@ -566,6 +581,7 @@ fn priors() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
           "parameters": [],
           "priors": 1,
         },
@@ -578,6 +594,7 @@ fn priors() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
           "parameters": [],
           "priors": 0,
         },
@@ -617,6 +634,7 @@ fn private() {
           "private": true,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -654,6 +672,7 @@ fn quiet() {
           "private": false,
           "quiet": true,
           "shebang": false,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -711,6 +730,7 @@ fn settings() {
           "private": false,
           "quiet": false,
           "shebang": true,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -754,6 +774,7 @@ fn shebang() {
           "private": false,
           "quiet": false,
           "shebang": true,
+          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -791,6 +812,48 @@ fn simple() {
           "private": false,
           "quiet": false,
           "shebang": false,
+          "suppress_exit_error_messages": false,
+        }
+      },
+      "settings": {
+        "allow_duplicate_recipes": false,
+        "dotenv_load": null,
+        "export": false,
+        "fallback": false,
+        "ignore_comments": false,
+        "positional_arguments": false,
+        "shell": null,
+        "windows_powershell": false,
+        "windows_shell": null,
+      },
+      "warnings": [],
+    }),
+  );
+}
+
+#[test]
+fn attribute() {
+  test(
+    "
+      [no-exit-message]
+      foo:
+    ",
+    json!({
+      "aliases": {},
+      "assignments": {},
+      "first": "foo",
+      "recipes": {
+        "foo": {
+          "body": [],
+          "dependencies": [],
+          "doc": null,
+          "name": "foo",
+          "parameters": [],
+          "priors": 0,
+          "private": false,
+          "quiet": false,
+          "shebang": false,
+          "suppress_exit_error_messages": true,
         }
       },
       "settings": {
diff --git a/tests/lib.rs b/tests/lib.rs
index 3e6f9d0a..3eb692b6 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -59,6 +59,7 @@ mod json;
 mod line_prefixes;
 mod misc;
 mod multibyte_char;
+mod no_exit_message;
 mod parser;
 mod positional_arguments;
 mod quiet;
diff --git a/tests/no_exit_message.rs b/tests/no_exit_message.rs
new file mode 100644
index 00000000..0752302d
--- /dev/null
+++ b/tests/no_exit_message.rs
@@ -0,0 +1,122 @@
+use libc::EXIT_FAILURE;
+
+test! {
+  name: recipe_exit_message_suppressed,
+  justfile: r#"
+# This is a doc comment
+[no-exit-message]
+hello:
+  @echo "Hello, World!"
+  @exit 100
+"#,
+  stdout:   "Hello, World!\n",
+  stderr:   "",
+  status:   100,
+}
+
+test! {
+  name: silent_recipe_exit_message_suppressed,
+  justfile: r#"
+# This is a doc comment
+[no-exit-message]
+@hello:
+  echo "Hello, World!"
+  exit 100
+"#,
+  stdout:   "Hello, World!\n",
+  stderr:   "",
+  status:   100,
+}
+
+test! {
+  name: recipe_has_doc_comment,
+  justfile: r#"
+# This is a doc comment
+[no-exit-message]
+hello:
+  @exit 100
+"#,
+  args: ("--list"),
+  stdout: "
+    Available recipes:
+        hello # This is a doc comment
+  ",
+}
+
+test! {
+  name: unknown_attribute,
+  justfile: r#"
+# This is a doc comment
+[unknown-attribute]
+hello:
+  @exit 100
+"#,
+  stderr: r#"
+error: Unknown attribute `unknown-attribute`
+  |
+2 | [unknown-attribute]
+  |  ^^^^^^^^^^^^^^^^^
+"#,
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: empty_attribute,
+  justfile: r#"
+# This is a doc comment
+[]
+hello:
+  @exit 100
+"#,
+  stderr: r#"
+error: Expected identifier, but found ']'
+  |
+2 | []
+  |  ^
+"#,
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: unattached_attribute_before_comment,
+  justfile: r#"
+[no-exit-message]
+# This is a doc comment
+hello:
+  @exit 100
+"#,
+  stderr: r#"
+error: Expected '@' or identifier, but found comment
+  |
+2 | # This is a doc comment
+  | ^^^^^^^^^^^^^^^^^^^^^^^
+"#,
+
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: unattached_attribute_before_empty_line,
+  justfile: r#"
+[no-exit-message]
+
+hello:
+  @exit 100
+"#,
+  stderr: "error: Expected '@' or identifier, but found end of line\n  |\n2 | \n  | ^\n",
+  status: EXIT_FAILURE,
+}
+
+test! {
+  name: shebang_exit_message_suppressed,
+  justfile: r#"
+[no-exit-message]
+hello:
+  #!/usr/bin/env bash
+  echo 'Hello, World!'
+  exit 100
+"#,
+  stdout: "Hello, World!\n",
+  stderr: "",
+  status: 100,
+}