Repository: casey/just
Pull request #1387 (2022-10-28 07:28:30)

OS Recipe Attributes
This adds `[linux]`, `[macos]`, `[windows]`, and `[unix]` recipe OS attributes.

By default, without an OS attribute, a recipe is enabled on all operating systems. With an OS attribute, a recipe is only enabled when being run by a version of just build for that OS. Note that this depends on the OS the `just` binary was built for, not the OS that it's running on. So recipes with `[linux]` in a justfile run by a`just` binary built for Linux but running on the Windows Subsystem for Linux be enabled.

This allows writing cross-platform justfiles by writing multiple recipes with the same name which are enabled on different OSes. For example:

```make
[windows]
foo:
  echo bar

[unix]
foo:
  echo baz
```

On Windows, `just foo` will print `bar` and on linux `just foo` will print `baz`.

Recipes with the `[unix]` attribute are enabled on all unices, including Linux and MacOS.

Todo:

- [ ] Document in readme


Patch:
diff --git a/README.md b/README.md
index 767b682c..a03bbce0 100644
--- a/README.md
+++ b/README.md
@@ -1057,9 +1057,7 @@ Done!
 #### System Information
 
 - `arch()` — Instruction set architecture. Possible values are: `"aarch64"`, `"arm"`, `"asmjs"`, `"hexagon"`, `"mips"`, `"msp430"`, `"powerpc"`, `"powerpc64"`, `"s390x"`, `"sparc"`, `"wasm32"`, `"x86"`, `"x86_64"`, and `"xcore"`.
-
 - `os()` — Operating system. Possible values are: `"android"`, `"bitrig"`, `"dragonfly"`, `"emscripten"`, `"freebsd"`, `"haiku"`, `"ios"`, `"linux"`, `"macos"`, `"netbsd"`, `"openbsd"`, `"solaris"`, and `"windows"`.
-
 - `os_family()` — Operating system family; possible values are: `"unix"` and `"windows"`.
 
 For example:
@@ -1143,67 +1141,47 @@ The executable is at: /bin/just
 
 #### String Manipulation
 
-- `capitalize(s)`<sup>1.7.0</sup> - Convert first character of `s` to uppercase and the rest to lowercase.
-
-- `kebabcase(s)`<sup>1.7.0</sup> - Convert `s` to `kebab-case`.
-
-- `lowercamelcase(s)`<sup>1.7.0</sup> - Convert `s` to `lowerCamelCase`.
-
-- `lowercase(s)` - Convert `s` to lowercase.
-
 - `quote(s)` - Replace all single quotes with `'\''` and prepend and append single quotes to `s`. This is sufficient to escape special characters for many shells, including most Bourne shell descendants.
-
 - `replace(s, from, to)` - Replace all occurrences of `from` in `s` to `to`.
-
-- `shoutykebabcase(s)`<sup>1.7.0</sup> - Convert `s` to `SHOUTY-KEBAB-CASE`.
-
-- `shoutysnakecase(s)`<sup>1.7.0</sup> - Convert `s` to `SHOUTY_SNAKE_CASE`.
-
-- `snakecase(s)`<sup>1.7.0</sup> - Convert `s` to `snake_case`.
-
-- `titlecase(s)`<sup>1.7.0</sup> - Convert `s` to `Title Case`.
-
 - `trim(s)` - Remove leading and trailing whitespace from `s`.
-
 - `trim_end(s)` - Remove trailing whitespace from `s`.
-
 - `trim_end_match(s, pat)` - Remove suffix of `s` matching `pat`.
-
 - `trim_end_matches(s, pat)` - Repeatedly remove suffixes of `s` matching `pat`.
-
 - `trim_start(s)` - Remove leading whitespace from `s`.
-
 - `trim_start_match(s, pat)` - Remove prefix of `s` matching `pat`.
-
 - `trim_start_matches(s, pat)` - Repeatedly remove prefixes of `s` matching `pat`.
 
-- `uppercase(s)` - Convert `s` to uppercase.
+#### Case Conversion
 
+- `capitalize(s)`<sup>1.7.0</sup> - Convert first character of `s` to uppercase and the rest to lowercase.
+- `kebabcase(s)`<sup>1.7.0</sup> - Convert `s` to `kebab-case`.
+- `lowercamelcase(s)`<sup>1.7.0</sup> - Convert `s` to `lowerCamelCase`.
+- `lowercase(s)` - Convert `s` to lowercase.
+- `shoutykebabcase(s)`<sup>1.7.0</sup> - Convert `s` to `SHOUTY-KEBAB-CASE`.
+- `shoutysnakecase(s)`<sup>1.7.0</sup> - Convert `s` to `SHOUTY_SNAKE_CASE`.
+- `snakecase(s)`<sup>1.7.0</sup> - Convert `s` to `snake_case`.
+- `titlecase(s)`<sup>1.7.0</sup> - Convert `s` to `Title Case`.
 - `uppercamelcase(s)`<sup>1.7.0</sup> - Convert `s` to `UpperCamelCase`.
+- `uppercase(s)` - Convert `s` to uppercase.
+
 
 #### Path Manipulation
 
 ##### Fallible
 
 - `absolute_path(path)` - Absolute path to relative `path` in the working directory. `absolute_path("./bar.txt")` in directory `/foo` is `/foo/bar.txt`.
-
 - `extension(path)` - Extension of `path`. `extension("/foo/bar.txt")` is `txt`.
-
 - `file_name(path)` - File name of `path` with any leading directory components removed. `file_name("/foo/bar.txt")` is `bar.txt`.
-
 - `file_stem(path)` - File name of `path` without extension. `file_stem("/foo/bar.txt")` is `bar`.
-
 - `parent_directory(path)` - Parent directory of `path`. `parent_directory("/foo/bar.txt")` is `/foo`.
-
 - `without_extension(path)` - `path` without extension. `without_extension("/foo/bar.txt")` is `/foo/bar`.
 
 These functions can fail, for example if a path does not have an extension, which will halt execution.
 
 ##### Infallible
 
-- `join(a, b…)` - *This function uses `/` on Unix and `\` on Windows, which can be lead to unwanted behavior. The `/` operator, e.g., `a / b`, which always uses `/`, should be considered as a replacement unless `\`s are specifically desired on Windows.* Join path `a` with path `b`. `join("foo/bar", "baz")` is `foo/bar/baz`. Accepts two or more arguments.
-
 - `clean(path)` - Simplify `path` by removing extra path separators, intermediate `.` components, and `..` where possible. `clean("foo//bar")` is `foo/bar`, `clean("foo/..")` is `.`, `clean("foo/./bar")` is `foo/bar`.
+- `join(a, b…)` - *This function uses `/` on Unix and `\` on Windows, which can be lead to unwanted behavior. The `/` operator, e.g., `a / b`, which always uses `/`, should be considered as a replacement unless `\`s are specifically desired on Windows.* Join path `a` with path `b`. `join("foo/bar", "baz")` is `foo/bar/baz`. Accepts two or more arguments.
 
 #### Filesystem Access
 
@@ -1219,6 +1197,42 @@ These functions can fail, for example if a path does not have an extension, whic
 - `sha256_file(path)` - Return the SHA-256 hash of the file at `path` as a hexadecimal string.
 - `uuid()` - Return a randomly generated UUID.
 
+### Recipe Attributes
+
+Recipes may be annotated with attributes that change their behavior.
+
+| Name                | Description                                       |
+| ------------------- | ------------------------------------------------- |
+| `[no-exit-message]` | Don't print an error message when a recipe fails. |
+| `[linux]`           | Enable recipe on Linux.                           |
+| `[macos]`           | Enable recipe on MacOS.                           |
+| `[unix]`            | Enable recipe on Unixes.                          |
+| `[windows]`         | Enable recipe on Windows.                         |
+
+#### Enabling and Disabling Recipes
+
+The `[linux]`, `[macos]`, `[unix]`, and `[windows]` attributes are
+configuration attributes. By default, recipes are always enabled. A recipe with
+one or more configuration attributes will only be enabled when one or more of
+those configurations is active.
+
+This can be used to write `justfile`s that behave differently depending on
+which operating system they run on. The `run` recipe in this `justfile` will
+compile and run `main.c`, using a different C compiler and using the correct
+output binary name for that compiler depending on the operating system:
+
+```make
+[unix]
+run:
+  cc main.c
+  ./a.out
+
+[windows]
+run:
+  cl main.c
+  main.exe
+```
+
 ### Command Evaluation Using Backticks
 
 Backticks can be used to store the result of commands:
diff --git a/src/analyzer.rs b/src/analyzer.rs
index a57c0e5d..237b6742 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -30,8 +30,10 @@ impl<'src> Analyzer<'src> {
         }
         Item::Comment(_) => (),
         Item::Recipe(recipe) => {
-          Self::analyze_recipe(&recipe)?;
-          recipes.push(recipe);
+          if recipe.enabled() {
+            Self::analyze_recipe(&recipe)?;
+            recipes.push(recipe);
+          }
         }
         Item::Set(set) => {
           self.analyze_set(&set)?;
diff --git a/src/attribute.rs b/src/attribute.rs
index 1256b541..375f2bb6 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -1,13 +1,34 @@
 use super::*;
 
-#[derive(EnumString)]
-#[strum(serialize_all = "kebab_case")]
+#[derive(
+  EnumString, PartialEq, Debug, Copy, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr,
+)]
+#[strum(serialize_all = "kebab-case")]
+#[serde(rename_all = "kebab-case")]
 pub(crate) enum Attribute {
+  Linux,
+  Macos,
   NoExitMessage,
+  Unix,
+  Windows,
 }
 
 impl Attribute {
   pub(crate) fn from_name(name: Name) -> Option<Attribute> {
     name.lexeme().parse().ok()
   }
+
+  pub(crate) fn to_str(self) -> &'static str {
+    self.into()
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn to_str() {
+    assert_eq!(Attribute::NoExitMessage.to_str(), "no-exit-message");
+  }
 }
diff --git a/src/compile_error.rs b/src/compile_error.rs
index 1709985d..abdb9340 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -96,6 +96,15 @@ impl Display for CompileError<'_> {
           self.token.line.ordinal(),
         )?;
       }
+      DuplicateAttribute { attribute, first } => {
+        write!(
+          f,
+          "Recipe attribute `{}` first used on line {} is duplicated on line {}",
+          attribute,
+          first.ordinal(),
+          self.token.line.ordinal(),
+        )?;
+      }
       DuplicateParameter { recipe, parameter } => {
         write!(
           f,
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index 3d63f4a2..a6cc5f00 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -25,6 +25,10 @@ pub(crate) enum CompileErrorKind<'src> {
     alias: &'src str,
     first: usize,
   },
+  DuplicateAttribute {
+    attribute: &'src str,
+    first: usize,
+  },
   DuplicateParameter {
     recipe: &'src str,
     parameter: &'src str,
diff --git a/src/error.rs b/src/error.rs
index 682f00d5..886cedf0 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -35,7 +35,7 @@ pub(crate) enum Error<'src> {
     recipe: &'src str,
     line_number: Option<usize>,
     code: i32,
-    suppress_message: bool,
+    print_message: bool,
   },
   CommandInvoke {
     binary: OsString,
@@ -169,11 +169,11 @@ impl<'src> Error<'src> {
     }
   }
 
-  pub(crate) fn suppress_message(&self) -> bool {
-    matches!(
+  pub(crate) fn print_message(&self) -> bool {
+    !matches!(
       self,
       Error::Code {
-        suppress_message: true,
+        print_message: false,
         ..
       }
     )
diff --git a/src/justfile.rs b/src/justfile.rs
index 77c02b9c..15d4c5b0 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -472,13 +472,13 @@ mod tests {
       recipe,
       line_number,
       code,
-      suppress_message,
+      print_message,
     },
     check: {
       assert_eq!(recipe, "a");
       assert_eq!(code, 200);
       assert_eq!(line_number, None);
-      assert!(!suppress_message);
+      assert!(print_message);
     }
   }
 
@@ -493,13 +493,13 @@ mod tests {
       recipe,
       line_number,
       code,
-      suppress_message,
+      print_message,
     },
     check: {
       assert_eq!(recipe, "fail");
       assert_eq!(code, 100);
       assert_eq!(line_number, Some(2));
-      assert!(!suppress_message);
+      assert!(print_message);
     }
   }
 
@@ -514,13 +514,13 @@ mod tests {
       recipe,
       line_number,
       code,
-      suppress_message,
+      print_message,
     },
     check: {
       assert_eq!(recipe, "a");
       assert_eq!(code, 150);
       assert_eq!(line_number, Some(2));
-      assert!(!suppress_message);
+      assert!(print_message);
     }
   }
 
@@ -672,13 +672,13 @@ mod tests {
     error: Code {
       recipe,
       line_number,
-      suppress_message,
+      print_message,
       ..
     },
     check: {
       assert_eq!(recipe, "wut");
       assert_eq!(line_number, Some(7));
-      assert!(!suppress_message);
+      assert!(print_message);
     }
   }
 
diff --git a/src/parser.rs b/src/parser.rs
index 291ed3d6..f7c7e2f7 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -345,20 +345,25 @@ 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, false)?));
+              items.push(Item::Recipe(self.parse_recipe(
+                doc,
+                false,
+                BTreeSet::new(),
+              )?));
             }
           }
         }
       } 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, false)?));
-      } else if self.accepted(BracketL)? {
-        let Attribute::NoExitMessage = self.parse_attribute_name()?;
-        self.expect(BracketR)?;
-        self.expect_eol()?;
+        items.push(Item::Recipe(self.parse_recipe(
+          doc,
+          true,
+          BTreeSet::new(),
+        )?));
+      } else if let Some(attributes) = self.parse_attributes()? {
         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)?));
+        items.push(Item::Recipe(self.parse_recipe(doc, quiet, attributes)?));
       } else {
         return Err(self.unexpected_token()?);
       }
@@ -602,7 +607,7 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
     &mut self,
     doc: Option<&'src str>,
     quiet: bool,
-    suppress_exit_error_messages: bool,
+    attributes: BTreeSet<Attribute>,
   ) -> CompileResult<'src, UnresolvedRecipe<'src>> {
     let name = self.parse_name()?;
 
@@ -666,7 +671,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,
+      attributes,
       priors,
       body,
       dependencies,
@@ -831,14 +836,33 @@ 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(),
-      })
-    })
+  /// Parse recipe attributes
+  fn parse_attributes(&mut self) -> CompileResult<'src, Option<BTreeSet<Attribute>>> {
+    let mut attributes = BTreeMap::new();
+
+    while self.accepted(BracketL)? {
+      let name = self.parse_name()?;
+      let attribute = Attribute::from_name(name).ok_or_else(|| {
+        name.error(CompileErrorKind::UnknownAttribute {
+          attribute: name.lexeme(),
+        })
+      })?;
+      if let Some(line) = attributes.get(&attribute) {
+        return Err(name.error(CompileErrorKind::DuplicateAttribute {
+          attribute: name.lexeme(),
+          first: *line,
+        }));
+      }
+      attributes.insert(attribute, name.line);
+      self.expect(BracketR)?;
+      self.expect_eol()?;
+    }
+
+    if attributes.is_empty() {
+      Ok(None)
+    } else {
+      Ok(Some(attributes.into_keys().collect()))
+    }
   }
 }
 
diff --git a/src/recipe.rs b/src/recipe.rs
index 10baf485..7da2a614 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -21,6 +21,7 @@ fn error_from_signal(recipe: &str, line_number: Option<usize>, 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<Attribute>,
   pub(crate) body: Vec<Line<'src>>,
   pub(crate) dependencies: Vec<D>,
   pub(crate) doc: Option<&'src str>,
@@ -30,7 +31,6 @@ 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> {
@@ -66,6 +66,24 @@ impl<'src, D> Recipe<'src, D> {
     !self.private
   }
 
+  pub(crate) fn enabled(&self) -> bool {
+    let windows = self.attributes.contains(&Attribute::Windows);
+    let linux = self.attributes.contains(&Attribute::Linux);
+    let macos = self.attributes.contains(&Attribute::Macos);
+    let unix = self.attributes.contains(&Attribute::Unix);
+
+    (!windows && !linux && !macos && !unix)
+      || (cfg!(target_os = "windows") && windows)
+      || (cfg!(target_os = "linux") && (linux || unix))
+      || (cfg!(target_os = "macos") && (macos || unix))
+      || (cfg!(windows) && windows)
+      || (cfg!(unix) && unix)
+  }
+
+  fn print_exit_message(&self) -> bool {
+    !self.attributes.contains(&Attribute::NoExitMessage)
+  }
+
   pub(crate) fn run<'run>(
     &self,
     context: &RecipeContext<'src, 'run>,
@@ -192,12 +210,11 @@ 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,
+                print_message: self.print_exit_message(),
               });
             }
           } else {
@@ -327,12 +344,11 @@ 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,
+              print_message: self.print_exit_message(),
             })
           }
         },
@@ -353,6 +369,10 @@ impl<'src, D: Display> ColorDisplay for Recipe<'src, D> {
       writeln!(f, "# {}", doc)?;
     }
 
+    for attribute in &self.attributes {
+      writeln!(f, "[{}]", attribute.to_str())?;
+    }
+
     if self.quiet {
       write!(f, "@{}", self.name)?;
     } else {
diff --git a/src/run.rs b/src/run.rs
index 9989d806..950fa0bd 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() && !error.suppress_message() {
+      if !verbosity.quiet() && error.print_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 faa1717b..0a49443d 100644
--- a/src/unresolved_recipe.rs
+++ b/src/unresolved_recipe.rs
@@ -53,7 +53,7 @@ impl<'src> UnresolvedRecipe<'src> {
       quiet: self.quiet,
       shebang: self.shebang,
       priors: self.priors,
-      suppress_exit_error_messages: self.suppress_exit_error_messages,
+      attributes: self.attributes,
       dependencies,
     })
   }


Test patch:
diff --git a/tests/attributes.rs b/tests/attributes.rs
new file mode 100644
index 00000000..993e9eeb
--- /dev/null
+++ b/tests/attributes.rs
@@ -0,0 +1,43 @@
+use super::*;
+
+#[test]
+fn all() {
+  Test::new()
+    .justfile(
+      "
+      [macos]
+      [windows]
+      [linux]
+      [unix]
+      [no-exit-message]
+      foo:
+        exit 1
+    ",
+    )
+    .stderr("exit 1\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn duplicate_attributes_are_disallowed() {
+  Test::new()
+    .justfile(
+      "
+      [no-exit-message]
+      [no-exit-message]
+      foo:
+        echo bar
+    ",
+    )
+    .stderr(
+      "
+      error: Recipe attribute `no-exit-message` first used on line 1 is duplicated on line 2
+        |
+      2 | [no-exit-message]
+        |  ^^^^^^^^^^^^^^^
+      ",
+    )
+    .status(1)
+    .run();
+}
diff --git a/tests/json.rs b/tests/json.rs
index 72c0acc2..9f5eef6b 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -27,6 +27,7 @@ fn alias() {
       "assignments": {},
       "recipes": {
         "foo": {
+          "attributes": [],
           "body": [],
           "dependencies": [],
           "doc": null,
@@ -36,7 +37,6 @@ fn alias() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -102,6 +102,7 @@ fn body() {
       "first": "foo",
       "recipes": {
         "foo": {
+          "attributes": [],
           "body": [
             ["bar"],
             ["abc", ["xyz"], "def"],
@@ -114,7 +115,6 @@ fn body() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
         }
       },
       "settings": {
@@ -147,6 +147,7 @@ fn dependencies() {
       "first": "foo",
       "recipes": {
         "bar": {
+          "attributes": [],
           "doc": null,
           "name": "bar",
           "body": [],
@@ -159,7 +160,6 @@ fn dependencies() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
         },
         "foo": {
           "body": [],
@@ -171,7 +171,7 @@ fn dependencies() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -246,7 +246,7 @@ fn dependency_argument() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         },
         "foo": {
           "body": [],
@@ -265,7 +265,7 @@ fn dependency_argument() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -322,7 +322,7 @@ fn duplicate_recipes() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -361,7 +361,7 @@ fn doc_comment() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -424,6 +424,7 @@ fn parameters() {
       "assignments": {},
       "recipes": {
         "a": {
+          "attributes": [],
           "body": [],
           "dependencies": [],
           "doc": null,
@@ -433,7 +434,6 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
         },
         "b": {
           "body": [],
@@ -452,7 +452,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         },
         "c": {
           "body": [],
@@ -471,7 +471,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         },
         "d": {
           "body": [],
@@ -490,7 +490,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         },
         "e": {
           "body": [],
@@ -509,7 +509,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         },
         "f": {
           "body": [],
@@ -528,7 +528,7 @@ fn parameters() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         },
       },
       "settings": {
@@ -571,7 +571,7 @@ fn priors() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         },
         "b": {
           "body": [],
@@ -590,7 +590,7 @@ fn priors() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
           "parameters": [],
           "priors": 1,
         },
@@ -603,7 +603,7 @@ fn priors() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
           "parameters": [],
           "priors": 0,
         },
@@ -644,7 +644,7 @@ fn private() {
           "private": true,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -683,7 +683,7 @@ fn quiet() {
           "private": false,
           "quiet": true,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -741,7 +741,7 @@ fn settings() {
           "private": false,
           "quiet": false,
           "shebang": true,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -786,7 +786,7 @@ fn shebang() {
           "private": false,
           "quiet": false,
           "shebang": true,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -825,7 +825,7 @@ fn simple() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": false,
+          "attributes": [],
         }
       },
       "settings": {
@@ -858,6 +858,7 @@ fn attribute() {
       "first": "foo",
       "recipes": {
         "foo": {
+          "attributes": ["no-exit-message"],
           "body": [],
           "dependencies": [],
           "doc": null,
@@ -867,7 +868,6 @@ fn attribute() {
           "private": false,
           "quiet": false,
           "shebang": false,
-          "suppress_exit_error_messages": true,
         }
       },
       "settings": {
diff --git a/tests/lib.rs b/tests/lib.rs
index 3eb692b6..7a999fe2 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -33,6 +33,7 @@ mod test;
 mod allow_duplicate_recipes;
 mod assert_stdout;
 mod assert_success;
+mod attributes;
 mod byte_order_mark;
 mod changelog;
 mod choose;
@@ -60,6 +61,7 @@ mod line_prefixes;
 mod misc;
 mod multibyte_char;
 mod no_exit_message;
+mod os_attributes;
 mod parser;
 mod positional_arguments;
 mod quiet;
diff --git a/tests/no_exit_message.rs b/tests/no_exit_message.rs
index 0752302d..e3abb4af 100644
--- a/tests/no_exit_message.rs
+++ b/tests/no_exit_message.rs
@@ -1,4 +1,4 @@
-use libc::EXIT_FAILURE;
+use super::*;
 
 test! {
   name: recipe_exit_message_suppressed,
@@ -86,7 +86,7 @@ hello:
   @exit 100
 "#,
   stderr: r#"
-error: Expected '@' or identifier, but found comment
+error: Expected '@', '[', or identifier, but found comment
   |
 2 | # This is a doc comment
   | ^^^^^^^^^^^^^^^^^^^^^^^
@@ -103,7 +103,7 @@ test! {
 hello:
   @exit 100
 "#,
-  stderr: "error: Expected '@' or identifier, but found end of line\n  |\n2 | \n  | ^\n",
+  stderr: "error: Expected '@', '[', or identifier, but found end of line\n  |\n2 | \n  | ^\n",
   status: EXIT_FAILURE,
 }
 
diff --git a/tests/os_attributes.rs b/tests/os_attributes.rs
new file mode 100644
index 00000000..81729850
--- /dev/null
+++ b/tests/os_attributes.rs
@@ -0,0 +1,103 @@
+use super::*;
+
+#[test]
+fn os_family() {
+  Test::new()
+    .justfile(
+      "
+      [unix]
+      foo:
+        echo bar
+
+      [windows]
+      foo:
+        echo baz
+    ",
+    )
+    .stdout(if cfg!(unix) {
+      "bar\n"
+    } else if cfg!(windows) {
+      "baz\n"
+    } else {
+      panic!("unexpected os family")
+    })
+    .stderr(if cfg!(unix) {
+      "echo bar\n"
+    } else if cfg!(windows) {
+      "echo baz\n"
+    } else {
+      panic!("unexpected os family")
+    })
+    .run();
+}
+
+#[test]
+fn os() {
+  Test::new()
+    .justfile(
+      "
+      [macos]
+      foo:
+        echo bar
+
+      [windows]
+      foo:
+        echo baz
+
+      [linux]
+      foo:
+        echo quxx
+    ",
+    )
+    .stdout(if cfg!(target_os = "macos") {
+      "bar\n"
+    } else if cfg!(windows) {
+      "baz\n"
+    } else if cfg!(target_os = "linux") {
+      "quxx\n"
+    } else {
+      panic!("unexpected os family")
+    })
+    .stderr(if cfg!(target_os = "macos") {
+      "echo bar\n"
+    } else if cfg!(windows) {
+      "echo baz\n"
+    } else if cfg!(target_os = "linux") {
+      "echo quxx\n"
+    } else {
+      panic!("unexpected os family")
+    })
+    .run();
+}
+
+#[test]
+fn all() {
+  Test::new()
+    .justfile(
+      "
+      [macos]
+      [windows]
+      [linux]
+      [unix]
+      foo:
+        echo bar
+    ",
+    )
+    .stdout("bar\n")
+    .stderr("echo bar\n")
+    .run();
+}
+
+#[test]
+fn none() {
+  Test::new()
+    .justfile(
+      "
+      foo:
+        echo bar
+    ",
+    )
+    .stdout("bar\n")
+    .stderr("echo bar\n")
+    .run();
+}