Repository: casey/just
Pull request #2568 (2025-01-12 15:47:09)

Adds `no-exit-message` Setting and `[exit-message]` attribute
This adds both the `no-exit-message` setting and the `[exit-message]` attribute. Current behaviour is `[exit-message]` overrides both `set no-exit-message` and `[no-exit-message]`.

Idea from #2566. 

I would like feedback on whether I need to add anything to the documents and whether any test cases are missing. 

ArchieAtkinson: @casey added requested changes

Patch:
diff --git a/src/attribute.rs b/src/attribute.rs
index 4ec813f0..fb2fef4f 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -11,6 +11,7 @@ use super::*;
 pub(crate) enum Attribute<'src> {
   Confirm(Option<StringLiteral<'src>>),
   Doc(Option<StringLiteral<'src>>),
+  ExitMessage,
   Extension(StringLiteral<'src>),
   Group(StringLiteral<'src>),
   Linux,
@@ -32,7 +33,8 @@ impl AttributeDiscriminant {
     match self {
       Self::Confirm | Self::Doc => 0..=1,
       Self::Group | Self::Extension | Self::WorkingDirectory => 1..=1,
-      Self::Linux
+      Self::ExitMessage
+      | Self::Linux
       | Self::Macos
       | Self::NoCd
       | Self::NoExitMessage
@@ -78,6 +80,7 @@ impl<'src> Attribute<'src> {
     Ok(match discriminant {
       AttributeDiscriminant::Confirm => Self::Confirm(arguments.into_iter().next()),
       AttributeDiscriminant::Doc => Self::Doc(arguments.into_iter().next()),
+      AttributeDiscriminant::ExitMessage => Self::ExitMessage,
       AttributeDiscriminant::Extension => Self::Extension(arguments.into_iter().next().unwrap()),
       AttributeDiscriminant::Group => Self::Group(arguments.into_iter().next().unwrap()),
       AttributeDiscriminant::Linux => Self::Linux,
@@ -129,6 +132,7 @@ impl Display for Attribute<'_> {
       Self::Script(Some(shell)) => write!(f, "({shell})")?,
       Self::Confirm(None)
       | Self::Doc(None)
+      | Self::ExitMessage
       | Self::Linux
       | Self::Macos
       | Self::NoCd
diff --git a/src/compile_error.rs b/src/compile_error.rs
index ce53c12f..b09a1e5e 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -121,6 +121,10 @@ impl Display for CompileError<'_> {
       DuplicateUnexport { variable } => {
         write!(f, "Variable `{variable}` is unexported multiple times")
       }
+      ExitMessageAndNoExitMessageAttribute { recipe } => write!(
+        f,
+        "Recipe `{recipe}` has both `[exit-message]` and `[no-exit-message]` attributes"
+      ),
       ExpectedKeyword { expected, found } => {
         let expected = List::or_ticked(expected);
         if found.kind == TokenKind::Identifier {
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index 09e2eb33..ca6f6fa7 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -47,6 +47,9 @@ pub(crate) enum CompileErrorKind<'src> {
   DuplicateUnexport {
     variable: &'src str,
   },
+  ExitMessageAndNoExitMessageAttribute {
+    recipe: &'src str,
+  },
   ExpectedKeyword {
     expected: Vec<Keyword>,
     found: Token<'src>,
diff --git a/src/keyword.rs b/src/keyword.rs
index ff06c39d..41c9aaa7 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -19,6 +19,7 @@ pub(crate) enum Keyword {
   IgnoreComments,
   Import,
   Mod,
+  NoExitMessage,
   PositionalArguments,
   Quiet,
   ScriptInterpreter,
diff --git a/src/node.rs b/src/node.rs
index 6bfb042a..c39dc074 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::NoExitMessage(value)
       | Setting::PositionalArguments(value)
       | Setting::Quiet(value)
       | Setting::Unstable(value)
diff --git a/src/parser.rs b/src/parser.rs
index 348032a0..42e9f26c 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -951,9 +951,9 @@ impl<'run, 'src> Parser<'run, 'src> {
       }));
     }
 
-    let working_directory = attributes.contains(AttributeDiscriminant::WorkingDirectory);
-
-    if working_directory && attributes.contains(AttributeDiscriminant::NoCd) {
+    if attributes.contains(AttributeDiscriminant::WorkingDirectory)
+      && attributes.contains(AttributeDiscriminant::NoCd)
+    {
       return Err(
         name.error(CompileErrorKind::NoCdAndWorkingDirectoryAttribute {
           recipe: name.lexeme(),
@@ -961,6 +961,16 @@ impl<'run, 'src> Parser<'run, 'src> {
       );
     }
 
+    if attributes.contains(AttributeDiscriminant::ExitMessage)
+      && attributes.contains(AttributeDiscriminant::NoExitMessage)
+    {
+      return Err(
+        name.error(CompileErrorKind::ExitMessageAndNoExitMessageAttribute {
+          recipe: name.lexeme(),
+        }),
+      );
+    }
+
     let private =
       name.lexeme().starts_with('_') || attributes.contains(AttributeDiscriminant::Private);
 
@@ -1093,6 +1103,7 @@ impl<'run, 'src> Parser<'run, 'src> {
       Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)),
       Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)),
       Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)),
+      Keyword::NoExitMessage => Some(Setting::NoExitMessage(self.parse_set_bool()?)),
       Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)),
       Keyword::Quiet => Some(Setting::Quiet(self.parse_set_bool()?)),
       Keyword::Unstable => Some(Setting::Unstable(self.parse_set_bool()?)),
diff --git a/src/recipe.rs b/src/recipe.rs
index 11365377..1f5cb981 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -131,10 +131,16 @@ impl<'src, D> Recipe<'src, D> {
       || (cfg!(windows) && windows)
   }
 
-  fn print_exit_message(&self) -> bool {
-    !self
-      .attributes
-      .contains(AttributeDiscriminant::NoExitMessage)
+  fn print_exit_message(&self, settings: &Settings) -> bool {
+    if self.attributes.contains(AttributeDiscriminant::ExitMessage) {
+      true
+    } else if settings.no_exit_message {
+      false
+    } else {
+      !self
+        .attributes
+        .contains(AttributeDiscriminant::NoExitMessage)
+    }
   }
 
   fn working_directory<'a>(&'a self, context: &'a ExecutionContext) -> Option<PathBuf> {
@@ -304,7 +310,7 @@ impl<'src, D> Recipe<'src, D> {
                 recipe: self.name(),
                 line_number: Some(line_number),
                 code,
-                print_message: self.print_exit_message(),
+                print_message: self.print_exit_message(&context.module.settings),
               });
             }
           } else {
@@ -449,7 +455,7 @@ impl<'src, D> Recipe<'src, D> {
               recipe: self.name(),
               line_number: None,
               code,
-              print_message: self.print_exit_message(),
+              print_message: self.print_exit_message(&context.module.settings),
             })
           }
         },
diff --git a/src/setting.rs b/src/setting.rs
index ec45f301..f187f418 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -11,6 +11,7 @@ pub(crate) enum Setting<'src> {
   Export(bool),
   Fallback(bool),
   IgnoreComments(bool),
+  NoExitMessage(bool),
   PositionalArguments(bool),
   Quiet(bool),
   ScriptInterpreter(Interpreter<'src>),
@@ -32,6 +33,7 @@ impl Display for Setting<'_> {
       | Self::Export(value)
       | Self::Fallback(value)
       | Self::IgnoreComments(value)
+      | Self::NoExitMessage(value)
       | Self::PositionalArguments(value)
       | Self::Quiet(value)
       | Self::Unstable(value)
diff --git a/src/settings.rs b/src/settings.rs
index 4d0f9ea2..6ab33353 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -16,6 +16,7 @@ pub(crate) struct Settings<'src> {
   pub(crate) export: bool,
   pub(crate) fallback: bool,
   pub(crate) ignore_comments: bool,
+  pub(crate) no_exit_message: bool,
   pub(crate) positional_arguments: bool,
   pub(crate) quiet: bool,
   #[serde(skip)]
@@ -61,6 +62,9 @@ impl<'src> Settings<'src> {
         Setting::IgnoreComments(ignore_comments) => {
           settings.ignore_comments = ignore_comments;
         }
+        Setting::NoExitMessage(no_exit_message) => {
+          settings.no_exit_message = no_exit_message;
+        }
         Setting::PositionalArguments(positional_arguments) => {
           settings.positional_arguments = positional_arguments;
         }


Test patch:
diff --git a/tests/json.rs b/tests/json.rs
index 4e740e52..d7a1a1c3 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -84,6 +84,7 @@ struct Settings<'a> {
   export: bool,
   fallback: bool,
   ignore_comments: bool,
+  no_exit_message: bool,
   positional_arguments: bool,
   quiet: bool,
   shell: Option<Interpreter<'a>>,
diff --git a/tests/no_exit_message.rs b/tests/no_exit_message.rs
index 95771982..c55dbc82 100644
--- a/tests/no_exit_message.rs
+++ b/tests/no_exit_message.rs
@@ -1,131 +1,277 @@
 use super::*;
 
-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]
+fn recipe_exit_message_suppressed() {
+  Test::new()
+    .justfile(
+      "
+      # This is a doc comment
+      [no-exit-message]
+      hello:
+        @echo 'Hello, World!'
+        @exit 100
+      ",
+    )
+    .stdout("Hello, World!\n")
+    .status(100)
+    .run();
 }
 
-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]
+fn silent_recipe_exit_message_suppressed() {
+  Test::new()
+    .justfile(
+      "
+      # This is a doc comment
+      [no-exit-message]
+      @hello:
+        echo 'Hello, World!'
+        exit 100
+      ",
+    )
+    .stdout("Hello, World!\n")
+    .status(100)
+    .run();
 }
 
-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]
+fn recipe_has_doc_comment() {
+  Test::new()
+    .justfile(
+      "
+    # This is a doc comment
+    [no-exit-message]
+    hello:
+      @exit 100
+        ",
+    )
+    .arg("--list")
+    .stdout(
+      "
+      Available recipes:
+          hello # This is a doc comment
+      ",
+    )
+    .run();
 }
 
-test! {
-  name: unknown_attribute,
-  justfile: r"
-# This is a doc comment
-[unknown-attribute]
-hello:
-  @exit 100
-",
-  stderr: r"
-error: Unknown attribute `unknown-attribute`
- ——▶ justfile:2:2
-  │
-2 │ [unknown-attribute]
-  │  ^^^^^^^^^^^^^^^^^
-",
-  status: EXIT_FAILURE,
+#[test]
+fn unknown_attribute() {
+  Test::new()
+    .justfile(
+      "
+      # This is a doc comment
+      [unknown-attribute]
+      hello:
+        @exit 100
+    ",
+    )
+    .stderr(
+      "
+      error: Unknown attribute `unknown-attribute`
+       ——▶ justfile:2:2
+        │
+      2 │ [unknown-attribute]
+        │  ^^^^^^^^^^^^^^^^^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
 }
 
-test! {
-  name: empty_attribute,
-  justfile: r"
-# This is a doc comment
-[]
-hello:
-  @exit 100
-",
-  stderr: r"
-error: Expected identifier, but found ']'
- ——▶ justfile:2:2
-  │
-2 │ []
-  │  ^
-",
-  status: EXIT_FAILURE,
+#[test]
+fn empty_attribute() {
+  Test::new()
+    .justfile(
+      "
+      # This is a doc comment
+      []
+      hello:
+        @exit 100
+    ",
+    )
+    .stderr(
+      "
+      error: Expected identifier, but found ']'
+       ——▶ justfile:2:2
+        │
+      2 │ []
+        │  ^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
 }
 
-test! {
-  name: extraneous_attribute_before_comment,
-  justfile: r"
-[no-exit-message]
-# This is a doc comment
-hello:
-  @exit 100
-",
-  stderr: r"
-error: Extraneous attribute
- ——▶ justfile:1:1
-  │
-1 │ [no-exit-message]
-  │ ^
-",
-
-  status: EXIT_FAILURE,
+#[test]
+fn extraneous_attribute_before_comment() {
+  Test::new()
+    .justfile(
+      "
+      [no-exit-message]
+      # This is a doc comment
+      hello:
+        @exit 100
+    ",
+    )
+    .stderr(
+      "
+      error: Extraneous attribute
+       ——▶ justfile:1:1
+        │
+      1 │ [no-exit-message]
+        │ ^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
 }
 
-test! {
-  name: extraneous_attribute_before_empty_line,
-  justfile: r"
-[no-exit-message]
-
-hello:
-  @exit 100
-",
-  stderr: "
-    error: Extraneous attribute
-     ——▶ justfile:1:1
-      │
-    1 │ [no-exit-message]
-      │ ^
-  ",
-  status: EXIT_FAILURE,
+#[test]
+fn extraneous_attribute_before_empty_line() {
+  Test::new()
+    .justfile(
+      "
+      [no-exit-message]
+
+      hello:
+        @exit 100
+    ",
+    )
+    .stderr(
+      "
+      error: Extraneous attribute
+       ——▶ justfile:1:1
+        │
+      1 │ [no-exit-message]
+        │ ^
+    ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn shebang_exit_message_suppressed() {
+  Test::new()
+    .justfile(
+      "
+      [no-exit-message]
+      hello:
+        #!/usr/bin/env bash
+        echo 'Hello, World!'
+        exit 100
+    ",
+    )
+    .stdout("Hello, World!\n")
+    .status(100)
+    .run();
+}
+
+#[test]
+fn no_exit_message() {
+  Test::new()
+    .justfile(
+      "
+      [no-exit-message]
+      @hello:
+        echo 'Hello, World!'
+        exit 100
+    ",
+    )
+    .stdout("Hello, World!\n")
+    .status(100)
+    .run();
+}
+
+#[test]
+fn exit_message() {
+  Test::new()
+    .justfile(
+      "
+      [exit-message]
+      @hello:
+        echo 'Hello, World!'
+        exit 100
+    ",
+    )
+    .stdout("Hello, World!\n")
+    .status(100)
+    .stderr("error: Recipe `hello` failed on line 4 with exit code 100\n")
+    .run();
+}
+
+#[test]
+fn recipe_exit_message_setting_suppressed() {
+  Test::new()
+    .justfile(
+      "
+      set no-exit-message
+
+      # This is a doc comment
+      hello:
+        @echo 'Hello, World!'
+        @exit 100
+    ",
+    )
+    .stdout("Hello, World!\n")
+    .status(100)
+    .run();
+}
+
+#[test]
+fn shebang_exit_message_setting_suppressed() {
+  Test::new()
+    .justfile(
+      "
+      set no-exit-message
+
+      hello:
+        #!/usr/bin/env bash
+        echo 'Hello, World!'
+        exit 100
+    ",
+    )
+    .stdout("Hello, World!\n")
+    .status(100)
+    .run();
+}
+
+#[test]
+fn exit_message_override_no_exit_setting() {
+  Test::new()
+    .justfile(
+      "
+      set no-exit-message
+
+      [exit-message]
+      fail:
+        @exit 100
+    ",
+    )
+    .status(100)
+    .stderr("error: Recipe `fail` failed on line 5 with exit code 100\n")
+    .run();
 }
 
-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,
+#[test]
+fn exit_message_and_no_exit_message_compile_forbidden() {
+  Test::new()
+    .justfile(
+      "
+      [exit-message, no-exit-message]
+      bar:
+    ",
+    )
+    .stderr(
+      "
+        error: Recipe `bar` has both `[exit-message]` and `[no-exit-message]` attributes
+         ——▶ justfile:2:1
+          │
+        2 │ bar:
+          │ ^^^
+      ",
+    )
+    .status(EXIT_FAILURE)
+    .run();
 }