Repository: casey/just
Pull request #1842 (2024-01-13 06:49:23)

Recipe grouping
Implements recipe grouping feature, see https://github.com/casey/just/issues/1776

neunenak: I'm currently using the `-g` flag (for `--groups`) in this draft PR. This issue: https://github.com/casey/just/issues/1648 asks for a global feature, for which it would also be convenient to use the `-g` flag. I'm not sure which item should get the `-g` flag, and if so what would be a better alternate flag letter for either. 
casey: Nice!

You might want to rebase, I just merged a PR which allows overriding the confirm prompt using `[confirm("prompt")]`, so there's some churn in attribute stuff.

I think `[group: foo]`, as you have it, is definitely the nicest syntax. If we have it though, we should also have `[confirm: "prompt"]` too otherwise users will likely get confused. 

casey: I think probably the global flag should get `-g`, since I think it might be more commonly used. The easiest thing to do is probably to not assign short flags to either and wait for someone to complain that one should have a short flag.
neunenak: > Nice!
> 
> You might want to rebase, I just merged a PR which allows overriding the confirm prompt using `[confirm("prompt")]`, so there's some churn in attribute stuff.
> 
> I think `[group: foo]`, as you have it, is definitely the nicest syntax. If we have it though, we should also have `[confirm: "prompt"]` too otherwise users will likely get confused.

Solid, I was debating whether `[attr: argument]` or `[attr(argument)]` was a better syntax - if there's another attr now that uses `()` function-like syntax, that seems like the one to go with then.
neunenak: > I think probably the global flag should get `-g`, since I think it might be more commonly used. The easiest thing to do is probably to not assign short flags to either and wait for someone to complain that one should have a short flag.

Makes sense. This is a feature that I personally want and expect to use reasonably frequently, so I'd want to add a short flag. Maybe `-r`?
neunenak: > Nice!
> 
> You might want to rebase, I just merged a PR which allows overriding the confirm prompt using `[confirm("prompt")]`, so there's some churn in attribute stuff.
> 
> I think `[group: foo]`, as you have it, is definitely the nicest syntax. If we have it though, we should also have `[confirm: "prompt"]` too otherwise users will likely get confused.

Do we want to keep the `[confirm(prompt)]` syntax around and allow both, or get rid of it?
casey: > Makes sense. This is a feature that I personally want and expect to use reasonably frequently, so I'd want to add a short flag. Maybe `-r`?

Oh, hmmm, I see. `--groups` will list groups and organize by group when used with `--list`. So the two uses are `just --groups` and `just --list --groups`. What if we made `--list` organize by group by default? If there are groups in the justfile, I assume that people will want to see them.

Then you wouldn't have to do `just --list --groups`, which is a super long and annoying invocation. And maybe `just --groups` is less common, so it doesn't need a short flag?

> Do we want to keep the `[confirm(prompt)]` syntax around and allow both, or get rid of it?

It made it into a release, so we should keep it. But I think the other reason for keeping it is that we might want to have attributes that take multiple arguments. So `[attr: arg]` is a convenient shorthand when there's a single argument, and `[attr(arg)]` can be use with one or more arguments.
neunenak: > > Makes sense. This is a feature that I personally want and expect to use reasonably frequently, so I'd want to add a short flag. Maybe `-r`?
> 
> Oh, hmmm, I see. `--groups` will list groups and organize by group when used with `--list`. So the two uses are `just --groups` and `just --list --groups`. What if we made `--list` organize by group by default? If there are groups in the justfile, I assume that people will want to see them.
> 
> Then you wouldn't have to do `just --list --groups`, which is a super long and annoying invocation. And maybe `just --groups` is less common, so it doesn't need a short flag?
> 
> > Do we want to keep the `[confirm(prompt)]` syntax around and allow both, or get rid of it?
> 
> It made it into a release, so we should keep it. But I think the other reason for keeping it is that we might want to have attributes that take multiple arguments. So `[attr: arg]` is a convenient shorthand when there's a single argument, and `[attr(arg)]` can be use with one or more arguments.

I like the idea of listing by groups by default. A justfile without any recipes with a `[group]` will have the same listing behavior as now so it won't break existing behavior. I agree that listing the group names themselves is uncommon and doesn't need a short option. And yeah I think the convention where `[attribute: single-arg]` is shorthand for `[attribute(single-arg)]` that doesn't apply to hypothetical multi-argument attributes is fine.
neunenak: Alright, got this working like so:

justfile:
```
# Comment
[group: alpha]
a:

[group(alpha)]
[group(beta)]
b:

c:

[group:"multi word group"]
d:

[group("alpha")]
e:

[group: "beta"]
[group(alpha)]
f:
```

`just --list` output:
```
Available recipes:
(no group)
    c
[group: alpha]
    a # Comment
    b
    e
    f
[group: beta]
    b
    f
[group: multi word group]
    d
```

neunenak: https://github.com/casey/just/issues/1344 is also probably taken care of by this PR
neunenak: @casey this is ready for review
jermatic: when merge?
casey: I'm super busy with another project, so just is sadly being neglected right now. Honestly it'll probably take at least a month before I can start grinding down my GitHub notification backlog, which is insanely large at this point.
epicserve: @neunenak,

In reading the docs that were added, I'm wondering if you would consider the following output. It's a little more readable to me and is based off of how Django does it.

```
$ just --list

Available recipes:

[no group]
    email-everyone

[lint]
    cpp-lint
    js-lint
    rust-lint

[rust jobs]
    rust-lint

```

Instead of:
```
$ just --list
Available recipes:
(no group)
    email-everyone # Not in a group
[group: lint]
    cpp-lint
    js-lint
    rust-lint
[group: rust jobs]
    rust-lint
```
epicserve: @neunenak,

Thank you for the update! I'm really looking forward to this new feature getting added!
neunenak: > @neunenak,
> 
> Thank you for the update! I'm really looking forward to this new feature getting added!

Thanks! I'm gonna ping @casey about this PR again, hopefully this can get merged pretty quickly. 
casey: I started looking at this, and to be honest I don't really like the refactor into multiple functions. I don't really mind long functions, and generally don't like functions which are defined and only used once. I think I prefer being able to read a long linear function, and not indirect through other functions. Also, it makes the review harder, because it's hard to tell what changed and what didn't. Can you inline it back into a single function in `Subcommand::list`?

Patch:
diff --git a/README.md b/README.md
index d6a47f05..cbfaa26b 100644
--- a/README.md
+++ b/README.md
@@ -1610,7 +1610,8 @@ 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("prompt")]`<sup>1.23.0</sup> | Require confirmation prior to executing recipe with a custom prompt. |
+| `[confirm('prompt')]`<sup>1.23.0</sup> | Require confirmation prior to executing recipe with a custom prompt. |
+| `[group('NAME"']`<sup>master</sup> | Put recipe in [recipe group](#recipe-groups) `NAME`.
 | `[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. |
@@ -1709,6 +1710,72 @@ delete-everything:
   rm -rf *
 ```
 
+### Recipe Groups
+
+Recipes can be annotated with a group name:
+
+```just
+[group('lint')]
+js-lint:
+    echo 'Running JS linter…'
+
+[group('rust recipes')]
+[group('lint')]
+rust-lint:
+    echo 'Runninng Rust linter…'
+
+[group('lint')]
+cpp-lint:
+  echo 'Running C++ linter…'
+
+# not in any group
+email-everyone:
+    echo 'Sending mass email…'
+```
+
+Recipes are listed by group:
+
+```
+$ just --list
+Available recipes:
+    (no group)
+    email-everyone # not in any group
+
+    [lint]
+    cpp-lint
+    js-lint
+    rust-lint
+
+    [rust recipes]
+    rust-lint
+```
+
+`just --list --unsorted` prints recipes in their justfile order within each group:
+
+```
+$ just --list --unsorted
+Available recipes:
+    (no group)
+    email-everyone # not in any group
+
+    [lint]
+    js-lint
+    rust-lint
+    cpp-lint
+
+    [rust recipes]
+    rust-lint
+```
+
+Groups can be listed with `--groups`:
+
+```
+$ just --groups
+Recipe groups:
+  lint
+  rust recipes
+```
+
 ### Command Evaluation Using Backticks
 
 Backticks can be used to store the result of commands:
diff --git a/completions/just.bash b/completions/just.bash
index abfa86de..f52183d0 100644
--- a/completions/just.bash
+++ b/completions/just.bash
@@ -30,7 +30,7 @@ _just() {
 
     case "${cmd}" in
         "$1")
-            opts="-n -f -q -u -v -d -c -e -l -s -E -g -h -V --check --chooser --color --command-color --yes --dry-run --dump-format --highlight --list-heading --list-prefix --no-aliases --no-deps --no-dotenv --no-highlight --justfile --quiet --set --shell --shell-arg --shell-command --clear-shell-args --unsorted --unstable --verbose --working-directory --changelog --choose --command --completions --dump --edit --evaluate --fmt --init --list --man --show --summary --variables --dotenv-filename --dotenv-path --global-justfile --help --version [ARGUMENTS]..."
+            opts="-n -f -q -u -v -d -c -e -l -s -E -g -h -V --check --chooser --color --command-color --yes --dry-run --dump-format --highlight --list-heading --list-prefix --no-aliases --no-deps --no-dotenv --no-highlight --justfile --quiet --set --shell --shell-arg --shell-command --clear-shell-args --unsorted --unstable --verbose --working-directory --changelog --choose --command --completions --dump --edit --evaluate --fmt --init --list --groups --man --show --summary --variables --dotenv-filename --dotenv-path --global-justfile --help --version [ARGUMENTS]..."
                 if [[ ${cur} == -* ]] ; then
                     COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                     return 0
diff --git a/completions/just.elvish b/completions/just.elvish
index e1f29d9c..726d52ea 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -66,6 +66,7 @@ set edit:completion:arg-completer[just] = {|@words|
             cand --init 'Initialize new justfile in project root'
             cand -l 'List available recipes and their arguments'
             cand --list 'List available recipes and their arguments'
+            cand --groups 'List recipe groups'
             cand --man 'Print man page'
             cand --summary 'List names of available recipes'
             cand --variables 'List names of variables'
diff --git a/completions/just.fish b/completions/just.fish
index 8ff8f0c9..1caf1afa 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -73,6 +73,7 @@ complete -c just -l evaluate -d 'Evaluate and print all variables. If a variable
 complete -c just -l fmt -d 'Format and overwrite justfile'
 complete -c just -l init -d 'Initialize new justfile in project root'
 complete -c just -s l -l list -d 'List available recipes and their arguments'
+complete -c just -l groups -d 'List recipe groups'
 complete -c just -l man -d 'Print man page'
 complete -c just -l summary -d 'List names of available recipes'
 complete -c just -l variables -d 'List names of variables'
diff --git a/completions/just.powershell b/completions/just.powershell
index e19f9fb5..04f8137a 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -69,6 +69,7 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
             [CompletionResult]::new('--init', 'init', [CompletionResultType]::ParameterName, 'Initialize new justfile in project root')
             [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'List available recipes and their arguments')
             [CompletionResult]::new('--list', 'list', [CompletionResultType]::ParameterName, 'List available recipes and their arguments')
+            [CompletionResult]::new('--groups', 'groups', [CompletionResultType]::ParameterName, 'List recipe groups')
             [CompletionResult]::new('--man', 'man', [CompletionResultType]::ParameterName, 'Print man page')
             [CompletionResult]::new('--summary', 'summary', [CompletionResultType]::ParameterName, 'List names of available recipes')
             [CompletionResult]::new('--variables', 'variables', [CompletionResultType]::ParameterName, 'List names of variables')
diff --git a/completions/just.zsh b/completions/just.zsh
index 715f5b5b..4a7ddc07 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -64,6 +64,7 @@ _just() {
 '--init[Initialize new justfile in project root]' \
 '-l[List available recipes and their arguments]' \
 '--list[List available recipes and their arguments]' \
+'--groups[List recipe groups]' \
 '--man[Print man page]' \
 '--summary[List names of available recipes]' \
 '--variables[List names of variables]' \
diff --git a/src/attribute.rs b/src/attribute.rs
index 8516ce94..e650a907 100644
--- a/src/attribute.rs
+++ b/src/attribute.rs
@@ -1,45 +1,105 @@
 use super::*;
 
-#[derive(EnumString, PartialEq, Debug, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr)]
+#[derive(
+  EnumDiscriminants, PartialEq, Debug, Clone, Serialize, Ord, PartialOrd, Eq, IntoStaticStr,
+)]
 #[strum(serialize_all = "kebab-case")]
 #[serde(rename_all = "kebab-case")]
+#[strum_discriminants(name(AttributeDiscriminant))]
+#[strum_discriminants(derive(EnumString))]
+#[strum_discriminants(strum(serialize_all = "kebab-case"))]
 pub(crate) enum Attribute<'src> {
   Confirm(Option<StringLiteral<'src>>),
+  Group(StringLiteral<'src>),
   Linux,
   Macos,
   NoCd,
   NoExitMessage,
-  Private,
   NoQuiet,
+  Private,
   Unix,
   Windows,
 }
 
-impl<'src> Attribute<'src> {
-  pub(crate) fn from_name(name: Name) -> Option<Self> {
-    name.lexeme().parse().ok()
-  }
-
-  pub(crate) fn name(&self) -> &'static str {
-    self.into()
+impl AttributeDiscriminant {
+  fn argument_range(self) -> RangeInclusive<usize> {
+    match self {
+      Self::Confirm => 0..=1,
+      Self::Group => 1..=1,
+      Self::Linux
+      | Self::Macos
+      | Self::NoCd
+      | Self::NoExitMessage
+      | Self::NoQuiet
+      | Self::Private
+      | Self::Unix
+      | Self::Windows => 0..=0,
+    }
   }
+}
 
-  pub(crate) fn with_argument(
-    self,
+impl<'src> Attribute<'src> {
+  pub(crate) fn new(
     name: Name<'src>,
-    argument: StringLiteral<'src>,
+    argument: Option<StringLiteral<'src>>,
   ) -> CompileResult<'src, Self> {
-    match self {
-      Self::Confirm(_) => Ok(Self::Confirm(Some(argument))),
-      _ => Err(name.error(CompileErrorKind::UnexpectedAttributeArgument { attribute: self })),
+    use AttributeDiscriminant::*;
+
+    let discriminant = name
+      .lexeme()
+      .parse::<AttributeDiscriminant>()
+      .ok()
+      .ok_or_else(|| {
+        name.error(CompileErrorKind::UnknownAttribute {
+          attribute: name.lexeme(),
+        })
+      })?;
+
+    let found = argument.as_ref().iter().count();
+
+    let range = discriminant.argument_range();
+
+    if !range.contains(&found) {
+      return Err(
+        name.error(CompileErrorKind::AttributeArgumentCountMismatch {
+          attribute: name.lexeme(),
+          found,
+          min: *range.start(),
+          max: *range.end(),
+        }),
+      );
     }
+
+    Ok(match discriminant {
+      Confirm => Self::Confirm(argument),
+      Group => Self::Group(argument.unwrap()),
+      Linux => Self::Linux,
+      Macos => Self::Macos,
+      NoCd => Self::NoCd,
+      NoExitMessage => Self::NoExitMessage,
+      NoQuiet => Self::NoQuiet,
+      Private => Self::Private,
+      Unix => Self::Unix,
+      Windows => Self::Windows,
+    })
+  }
+
+  pub(crate) fn name(&self) -> &'static str {
+    self.into()
   }
 
   fn argument(&self) -> Option<&StringLiteral> {
-    if let Self::Confirm(prompt) = self {
-      prompt.as_ref()
-    } else {
-      None
+    match self {
+      Self::Confirm(prompt) => prompt.as_ref(),
+      Self::Group(name) => Some(name),
+      Self::Linux
+      | Self::Macos
+      | Self::NoCd
+      | Self::NoExitMessage
+      | Self::NoQuiet
+      | Self::Private
+      | Self::Unix
+      | Self::Windows => None,
     }
   }
 }
diff --git a/src/compile_error.rs b/src/compile_error.rs
index cc731151..e2e5781b 100644
--- a/src/compile_error.rs
+++ b/src/compile_error.rs
@@ -45,6 +45,27 @@ impl Display for CompileError<'_> {
         self.token.line.ordinal(),
         recipe_line.ordinal(),
       ),
+      AttributeArgumentCountMismatch {
+        attribute,
+        found,
+        min,
+        max,
+      } => {
+        write!(
+          f,
+          "Attribute `{attribute}` got {found} {} but takes ",
+          Count("argument", *found),
+        )?;
+
+        if min == max {
+          let expected = min;
+          write!(f, "{expected} {}", Count("argument", *expected))
+        } else if found < min {
+          write!(f, "at least {min} {}", Count("argument", *min))
+        } else {
+          write!(f, "at most {max} {}", Count("argument", *max))
+        }
+      }
       BacktickShebang => write!(f, "Backticks may not start with `#!`"),
       CircularRecipeDependency { recipe, ref circle } => {
         if circle.len() == 2 {
@@ -212,13 +233,6 @@ impl Display for CompileError<'_> {
         "Non-default parameter `{parameter}` follows default parameter"
       ),
       UndefinedVariable { variable } => write!(f, "Variable `{variable}` not defined"),
-      UnexpectedAttributeArgument { attribute } => {
-        write!(
-          f,
-          "Attribute `{}` specified with argument but takes no arguments",
-          attribute.name(),
-        )
-      }
       UnexpectedCharacter { expected } => write!(f, "Expected character `{expected}`"),
       UnexpectedClosingDelimiter { close } => {
         write!(f, "Unexpected closing delimiter `{}`", close.close())
diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs
index 1e9c9566..5a9b7a41 100644
--- a/src/compile_error_kind.rs
+++ b/src/compile_error_kind.rs
@@ -10,6 +10,12 @@ pub(crate) enum CompileErrorKind<'src> {
     alias: &'src str,
     recipe_line: usize,
   },
+  AttributeArgumentCountMismatch {
+    attribute: &'src str,
+    found: usize,
+    min: usize,
+    max: usize,
+  },
   BacktickShebang,
   CircularRecipeDependency {
     recipe: &'src str,
@@ -88,9 +94,6 @@ pub(crate) enum CompileErrorKind<'src> {
   UndefinedVariable {
     variable: &'src str,
   },
-  UnexpectedAttributeArgument {
-    attribute: Attribute<'src>,
-  },
   UnexpectedCharacter {
     expected: char,
   },
diff --git a/src/config.rs b/src/config.rs
index b657b18d..064c94f4 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -54,6 +54,7 @@ mod cmd {
   pub(crate) const EDIT: &str = "EDIT";
   pub(crate) const EVALUATE: &str = "EVALUATE";
   pub(crate) const FORMAT: &str = "FORMAT";
+  pub(crate) const GROUPS: &str = "GROUPS";
   pub(crate) const INIT: &str = "INIT";
   pub(crate) const LIST: &str = "LIST";
   pub(crate) const MAN: &str = "MAN";
@@ -417,6 +418,12 @@ impl Config {
           .action(ArgAction::SetTrue)
           .help("List available recipes and their arguments"),
       )
+      .arg(
+        Arg::new(cmd::GROUPS)
+        .long("groups")
+        .action(ArgAction::SetTrue)
+        .help("List recipe groups")
+        )
       .arg(
         Arg::new(cmd::MAN)
           .long("man")
@@ -649,6 +656,8 @@ impl Config {
       Subcommand::Init
     } else if matches.get_flag(cmd::LIST) {
       Subcommand::List
+    } else if matches.get_flag(cmd::GROUPS) {
+      Subcommand::Groups
     } else if matches.get_flag(cmd::MAN) {
       Subcommand::Man
     } else if let Some(name) = matches.get_one::<String>(cmd::SHOW).map(Into::into) {
diff --git a/src/justfile.rs b/src/justfile.rs
index 30985bf6..0f1bdabf 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -488,6 +488,16 @@ impl<'src> Justfile<'src> {
 
     recipes
   }
+
+  pub(crate) fn public_groups(&self) -> BTreeSet<String> {
+    self
+      .recipes
+      .values()
+      .map(AsRef::as_ref)
+      .filter(|recipe| recipe.is_public())
+      .flat_map(Recipe::groups)
+      .collect()
+  }
 }
 
 impl<'src> ColorDisplay for Justfile<'src> {
diff --git a/src/lib.rs b/src/lib.rs
index 74e85dc7..115437c9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -71,7 +71,7 @@ pub(crate) use {
       Serialize, Serializer,
     },
     snafu::{ResultExt, Snafu},
-    strum::{Display, EnumString, IntoStaticStr},
+    strum::{Display, EnumDiscriminants, EnumString, IntoStaticStr},
     typed_arena::Arena,
     unicode_width::{UnicodeWidthChar, UnicodeWidthStr},
   },
diff --git a/src/parser.rs b/src/parser.rs
index 2843fd85..9b907d34 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -976,11 +976,17 @@ impl<'run, 'src> Parser<'run, 'src> {
     while self.accepted(BracketL)? {
       loop {
         let name = self.parse_name()?;
-        let attribute = Attribute::from_name(name).ok_or_else(|| {
-          name.error(CompileErrorKind::UnknownAttribute {
-            attribute: name.lexeme(),
-          })
-        })?;
+
+        let argument = if self.accepted(ParenL)? {
+          let argument = self.parse_string_literal()?;
+          self.expect(ParenR)?;
+          Some(argument)
+        } else {
+          None
+        };
+
+        let attribute = Attribute::new(name, argument)?;
+
         if let Some(line) = attributes.get(&attribute) {
           return Err(name.error(CompileErrorKind::DuplicateAttribute {
             attribute: name.lexeme(),
@@ -988,14 +994,6 @@ impl<'run, 'src> Parser<'run, 'src> {
           }));
         }
 
-        let attribute = if self.accepted(ParenL)? {
-          let argument = self.parse_string_literal()?;
-          self.expect(ParenR)?;
-          attribute.with_argument(name, argument)?
-        } else {
-          attribute
-        };
-
         attributes.insert(attribute, name.line);
 
         if !self.accepted(Comma)? {
diff --git a/src/recipe.rs b/src/recipe.rs
index d4ff9803..a6afe2f2 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -436,6 +436,20 @@ impl<'src, D> Recipe<'src, D> {
       }),
     }
   }
+
+  pub(crate) fn groups(&self) -> BTreeSet<String> {
+    self
+      .attributes
+      .iter()
+      .filter_map(|attribute| {
+        if let Attribute::Group(group) = attribute {
+          Some(group.cooked.clone())
+        } else {
+          None
+        }
+      })
+      .collect()
+  }
 }
 
 impl<'src, D: Display> ColorDisplay for Recipe<'src, D> {
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 5a695bf7..024bd5b9 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -29,6 +29,7 @@ pub(crate) enum Subcommand {
     variable: Option<String>,
   },
   Format,
+  Groups,
   Init,
   List,
   Man,
@@ -86,6 +87,7 @@ impl Subcommand {
       }
       Dump => Self::dump(config, ast, justfile)?,
       Format => Self::format(config, &search, src, ast)?,
+      Groups => Self::groups(config, justfile),
       List => Self::list(config, 0, justfile),
       Show { ref name } => Self::show(config, name, justfile)?,
       Summary => Self::summary(config, justfile),
@@ -96,6 +98,13 @@ impl Subcommand {
     Ok(())
   }
 
+  fn groups(config: &Config, justfile: &Justfile) {
+    println!("Recipe groups:");
+    for group in justfile.public_groups() {
+      println!("{}{group}", config.list_prefix);
+    }
+  }
+
   fn run<'src>(
     config: &Config,
     loader: &'src Loader,
@@ -469,90 +478,125 @@ impl Subcommand {
   }
 
   fn list(config: &Config, level: usize, justfile: &Justfile) {
-    const MAX_WIDTH: usize = 50;
+    let aliases = if config.no_aliases {
+      BTreeMap::new()
+    } else {
+      let mut aliases = BTreeMap::<&str, Vec<&str>>::new();
+      for alias in justfile
+        .aliases
+        .values()
+        .filter(|alias| !alias.is_private())
+      {
+        aliases
+          .entry(alias.target.name.lexeme())
+          .or_default()
+          .push(alias.name.lexeme());
+      }
+      aliases
+    };
 
-    if level == 0 {
-      print!("{}", config.list_heading);
-    }
+    let signature_widths = {
+      let mut signature_widths: BTreeMap<&str, usize> = BTreeMap::new();
 
-    // Construct a target to alias map.
-    let mut recipe_aliases = BTreeMap::<&str, Vec<&str>>::new();
-    if !config.no_aliases {
-      for alias in justfile.aliases.values() {
-        if alias.is_private() {
+      for (name, recipe) in &justfile.recipes {
+        if !recipe.is_public() {
           continue;
         }
 
-        if recipe_aliases.contains_key(alias.target.name.lexeme()) {
-          let aliases = recipe_aliases.get_mut(alias.target.name.lexeme()).unwrap();
-          aliases.push(alias.name.lexeme());
-        } else {
-          recipe_aliases.insert(alias.target.name.lexeme(), vec![alias.name.lexeme()]);
+        for name in iter::once(name).chain(aliases.get(name).unwrap_or(&Vec::new())) {
+          signature_widths.insert(
+            name,
+            UnicodeWidthStr::width(
+              RecipeSignature { name, recipe }
+                .color_display(Color::never())
+                .to_string()
+                .as_str(),
+            ),
+          );
         }
       }
-    }
-
-    let mut line_widths = BTreeMap::<&str, usize>::new();
-
-    for (name, recipe) in &justfile.recipes {
-      if !recipe.is_public() {
-        continue;
-      }
 
-      for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) {
-        line_widths.insert(
-          name,
-          UnicodeWidthStr::width(
-            RecipeSignature { name, recipe }
-              .color_display(Color::never())
-              .to_string()
-              .as_str(),
-          ),
-        );
-      }
-    }
+      signature_widths
+    };
 
-    let max_line_width = line_widths
+    let max_signature_width = signature_widths
       .values()
-      .filter(|line_width| **line_width <= MAX_WIDTH)
       .copied()
+      .filter(|width| *width <= 50)
       .max()
-      .unwrap_or_default();
+      .unwrap_or(0);
 
-    for recipe in justfile.public_recipes(config.unsorted) {
-      let name = recipe.name();
+    if level == 0 {
+      print!("{}", config.list_heading);
+    }
 
-      for (i, name) in iter::once(&name)
-        .chain(recipe_aliases.get(name).unwrap_or(&Vec::new()))
-        .enumerate()
-      {
-        print!(
-          "{}{}",
-          config.list_prefix.repeat(level + 1),
-          RecipeSignature { name, recipe }.color_display(config.color.stdout())
-        );
-
-        let doc = match (i, recipe.doc) {
-          (0, Some(doc)) => Some(Cow::Borrowed(doc)),
-          (0, None) => None,
-          _ => Some(Cow::Owned(format!("alias for `{}`", recipe.name))),
-        };
+    let groups = {
+      let mut groups = BTreeMap::<Option<String>, Vec<&Recipe>>::new();
+      for recipe in justfile.public_recipes(config.unsorted) {
+        let recipe_groups = recipe.groups();
+        if recipe_groups.is_empty() {
+          groups.entry(None).or_default().push(recipe);
+        } else {
+          for group in recipe_groups {
+            groups.entry(Some(group)).or_default().push(recipe);
+          }
+        }
+      }
+      groups
+    };
+
+    for (i, (group, recipes)) in groups.iter().enumerate() {
+      if i > 0 {
+        println!();
+      }
 
-        if let Some(doc) = doc {
+      let no_groups = groups.contains_key(&None) && groups.len() == 1;
+
+      if !no_groups {
+        print!("{}", config.list_prefix.repeat(level + 1));
+        if let Some(group_name) = group {
+          println!("[{group_name}]");
+        } else {
+          println!("(no group)");
+        }
+      }
+
+      for recipe in recipes {
+        for (i, name) in iter::once(&recipe.name())
+          .chain(aliases.get(recipe.name()).unwrap_or(&Vec::new()))
+          .enumerate()
+        {
           print!(
-            " {:padding$}{} {}",
-            "",
-            config.color.stdout().doc().paint("#"),
-            config.color.stdout().doc().paint(&doc),
-            padding = max_line_width.saturating_sub(line_widths[name]),
+            "{}{}",
+            config.list_prefix.repeat(level + 1),
+            RecipeSignature { name, recipe }.color_display(config.color.stdout())
           );
+
+          let doc = if i == 0 {
+            recipe.doc.map(Cow::Borrowed)
+          } else {
+            Some(Cow::Owned(format!("alias for `{}`", recipe.name)))
+          };
+
+          if let Some(doc) = doc {
+            print!(
+              "{:padding$}{} {}",
+              "",
+              config.color.stdout().doc().paint("#"),
+              config.color.stdout().doc().paint(&doc),
+              padding = max_signature_width.saturating_sub(signature_widths[name]) + 1,
+            );
+          }
+          println!();
         }
+      }
+    }
 
+    for (i, (name, module)) in justfile.modules.iter().enumerate() {
+      if i + groups.len() > 0 {
         println!();
       }
-    }
 
-    for (name, module) in &justfile.modules {
       println!("{}{name}:", config.list_prefix.repeat(level + 1));
       Self::list(config, level + 1, module);
     }


Test patch:
diff --git a/tests/attributes.rs b/tests/attributes.rs
index a3a451cc..055ec9e6 100644
--- a/tests/attributes.rs
+++ b/tests/attributes.rs
@@ -119,7 +119,7 @@ fn unexpected_attribute_argument() {
     )
     .stderr(
       "
-        error: Attribute `private` specified with argument but takes no arguments
+        error: Attribute `private` got 1 argument but takes 0 arguments
          ——▶ justfile:1:2
           │
         1 │ [private('foo')]
diff --git a/tests/groups.rs b/tests/groups.rs
new file mode 100644
index 00000000..630d780f
--- /dev/null
+++ b/tests/groups.rs
@@ -0,0 +1,174 @@
+use super::*;
+
+#[test]
+fn list_with_groups() {
+  Test::new()
+    .justfile(
+      "
+        [group('alpha')]
+        a:
+        # Doc comment
+        [group('alpha')]
+        [group('beta')]
+        b:
+        c:
+        [group('multi word group')]
+        d:
+        [group('alpha')]
+        e:
+        [group('beta')]
+        [group('alpha')]
+        f:
+      ",
+    )
+    .arg("--list")
+    .stdout(
+      "
+        Available recipes:
+            (no group)
+            c
+
+            [alpha]
+            a
+            b # Doc comment
+            e
+            f
+
+            [beta]
+            b # Doc comment
+            f
+
+            [multi word group]
+            d
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn list_with_groups_unsorted() {
+  Test::new()
+    .justfile(
+      "
+        [group('beta')]
+        [group('alpha')]
+        f:
+
+        [group('alpha')]
+        e:
+
+        [group('multi word group')]
+        d:
+
+        c:
+
+        # Doc comment
+        [group('alpha')]
+        [group('beta')]
+        b:
+
+        [group('alpha')]
+        a:
+
+      ",
+    )
+    .args(["--list", "--unsorted"])
+    .stdout(
+      "
+        Available recipes:
+            (no group)
+            c
+
+            [alpha]
+            f
+            e
+            b # Doc comment
+            a
+
+            [beta]
+            f
+            b # Doc comment
+
+            [multi word group]
+            d
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn list_groups() {
+  Test::new()
+    .justfile(
+      "
+        [group('B')]
+        bar:
+
+        [group('A')]
+        [group('B')]
+        foo:
+
+      ",
+    )
+    .args(["--groups"])
+    .stdout(
+      "
+      Recipe groups:
+          A
+          B
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn list_groups_with_custom_prefix() {
+  Test::new()
+    .justfile(
+      "
+        [group('B')]
+        foo:
+
+        [group('A')]
+        [group('B')]
+        bar:
+      ",
+    )
+    .args(["--groups", "--list-prefix", "..."])
+    .stdout(
+      "
+      Recipe groups:
+      ...A
+      ...B
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn list_with_groups_in_modules() {
+  Test::new()
+    .justfile(
+      "
+        [group('FOO')]
+        foo:
+
+        mod bar
+      ",
+    )
+    .write("bar.just", "[group('BAZ')]\nbaz:")
+    .test_round_trip(false)
+    .args(["--unstable", "--list"])
+    .stdout(
+      "
+        Available recipes:
+            [FOO]
+            foo
+
+            bar:
+                [BAZ]
+                baz
+      ",
+    )
+    .run();
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index a3d18a58..00cbc956 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -61,6 +61,7 @@ mod fmt;
 mod functions;
 #[cfg(unix)]
 mod global;
+mod groups;
 mod ignore_comments;
 mod imports;
 mod init;
diff --git a/tests/list.rs b/tests/list.rs
index cf93410e..af87d543 100644
--- a/tests/list.rs
+++ b/tests/list.rs
@@ -22,6 +22,34 @@ fn list_displays_recipes_in_submodules() {
     .run();
 }
 
+#[test]
+fn modules_are_space_separated_in_output() {
+  Test::new()
+    .write("foo.just", "foo:")
+    .write("bar.just", "bar:")
+    .justfile(
+      "
+        mod foo
+
+        mod bar
+      ",
+    )
+    .test_round_trip(false)
+    .arg("--unstable")
+    .arg("--list")
+    .stdout(
+      "
+      Available recipes:
+          bar:
+              bar
+
+          foo:
+              foo
+    ",
+    )
+    .run();
+}
+
 #[test]
 fn module_recipe_list_alignment_ignores_private_recipes() {
   Test::new()