diff --git a/README.md b/README.md
index d6a47f0566..cbfaa26b06 100644
--- a/README.md
+++ b/README.md
@@ -1610,7 +1610,8 @@ Recipes may be annotated with attributes that change their behavior.
| Name | Description |
|------|-------------|
| `[confirm]`1.17.0 | Require confirmation prior to executing recipe. |
-| `[confirm("prompt")]`1.23.0 | Require confirmation prior to executing recipe with a custom prompt. |
+| `[confirm('prompt')]`1.23.0 | Require confirmation prior to executing recipe with a custom prompt. |
+| `[group('NAME"']`master | Put recipe in [recipe group](#recipe-groups) `NAME`.
| `[linux]`1.8.0 | Enable recipe on Linux. |
| `[macos]`1.8.0 | Enable recipe on MacOS. |
| `[no-cd]`1.9.0 | 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 abfa86de3b..f52183d071 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 e1f29d9cb9..726d52ea6f 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 8ff8f0c936..1caf1afad5 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 e19f9fb58d..04f8137aae 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 715f5b5bfb..4a7ddc07c8 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 8516ce9403..e650a9078f 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>),
+ Group(StringLiteral<'src>),
Linux,
Macos,
NoCd,
NoExitMessage,
- Private,
NoQuiet,
+ Private,
Unix,
Windows,
}
-impl<'src> Attribute<'src> {
- pub(crate) fn from_name(name: Name) -> Option {
- name.lexeme().parse().ok()
- }
-
- pub(crate) fn name(&self) -> &'static str {
- self.into()
+impl AttributeDiscriminant {
+ fn argument_range(self) -> RangeInclusive {
+ 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>,
) -> 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::()
+ .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 cc7311512f..e2e5781bad 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 1e9c9566fc..5a9b7a41ba 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 b657b18d75..064c94f4dd 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::(cmd::SHOW).map(Into::into) {
diff --git a/src/justfile.rs b/src/justfile.rs
index 30985bf6c5..0f1bdabf70 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 {
+ 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 74e85dc7cb..115437c9cb 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 2843fd8547..9b907d3450 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 d4ff98037c..a6afe2f20a 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 {
+ 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 5a695bf71e..024bd5b9ea 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -29,6 +29,7 @@ pub(crate) enum Subcommand {
variable: Option,
},
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::