From ed0dc20318ab4b8e31b8d4fb95361d880da105b7 Mon Sep 17 00:00:00 2001 From: Greg Shuflin Date: Sat, 25 May 2024 00:32:25 -0700 Subject: [PATCH] Add recipe groups (#1842) --- README.md | 69 ++++++++++++- completions/just.bash | 2 +- completions/just.elvish | 1 + completions/just.fish | 1 + completions/just.powershell | 1 + completions/just.zsh | 1 + src/attribute.rs | 98 +++++++++++++++---- src/compile_error.rs | 28 ++++-- src/compile_error_kind.rs | 9 +- src/config.rs | 9 ++ src/justfile.rs | 10 ++ src/lib.rs | 2 +- src/parser.rs | 24 +++-- src/recipe.rs | 14 +++ src/subcommand.rs | 186 ++++++++++++++++++++++-------------- tests/attributes.rs | 2 +- tests/groups.rs | 174 +++++++++++++++++++++++++++++++++ tests/lib.rs | 1 + tests/list.rs | 28 ++++++ 19 files changed, 543 insertions(+), 117 deletions(-) create mode 100644 tests/groups.rs diff --git a/README.md b/README.md index d6a47f0..cbfaa26 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 abfa86d..f52183d 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 e1f29d9..726d52e 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 8ff8f0c..1caf1af 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 e19f9fb..04f8137 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 715f5b5..4a7ddc0 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 8516ce9..e650a90 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 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, + } + } +} + impl<'src> Attribute<'src> { - pub(crate) fn from_name(name: Name) -> Option { - name.lexeme().parse().ok() + pub(crate) fn new( + name: Name<'src>, + argument: Option>, + ) -> CompileResult<'src, 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() } - pub(crate) fn with_argument( - self, - name: Name<'src>, - argument: StringLiteral<'src>, - ) -> CompileResult<'src, Self> { - match self { - Self::Confirm(_) => Ok(Self::Confirm(Some(argument))), - _ => Err(name.error(CompileErrorKind::UnexpectedAttributeArgument { attribute: self })), - } - } - 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 cc73115..e2e5781 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 1e9c956..5a9b7a4 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 b657b18..064c94f 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 30985bf..0f1bdab 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 74e85dc..115437c 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 2843fd8..9b907d3 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 d4ff980..a6afe2f 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 5a695bf..024bd5b 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 + }; + + let signature_widths = { + let mut signature_widths: BTreeMap<&str, usize> = BTreeMap::new(); + + for (name, recipe) in &justfile.recipes { + if !recipe.is_public() { + continue; + } + + 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(), + ), + ); + } + } + + signature_widths + }; + + let max_signature_width = signature_widths + .values() + .copied() + .filter(|width| *width <= 50) + .max() + .unwrap_or(0); if level == 0 { print!("{}", config.list_heading); } - // 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() { - 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()); + let groups = { + let mut groups = BTreeMap::, 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 { - recipe_aliases.insert(alias.target.name.lexeme(), vec![alias.name.lexeme()]); + for group in recipe_groups { + groups.entry(Some(group)).or_default().push(recipe); + } } } - } - - 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(), - ), - ); - } - } - - let max_line_width = line_widths - .values() - .filter(|line_width| **line_width <= MAX_WIDTH) - .copied() - .max() - .unwrap_or_default(); - - for recipe in justfile.public_recipes(config.unsorted) { - let name = recipe.name(); - - 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))), - }; - - if let Some(doc) = doc { - print!( - " {:padding$}{} {}", - "", - config.color.stdout().doc().paint("#"), - config.color.stdout().doc().paint(&doc), - padding = max_line_width.saturating_sub(line_widths[name]), - ); - } + groups + }; + for (i, (group, recipes)) in groups.iter().enumerate() { + if i > 0 { println!(); } + + 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!( + "{}{}", + 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 (name, module) in &justfile.modules { + for (i, (name, module)) in justfile.modules.iter().enumerate() { + if i + groups.len() > 0 { + println!(); + } + println!("{}{name}:", config.list_prefix.repeat(level + 1)); Self::list(config, level + 1, module); } diff --git a/tests/attributes.rs b/tests/attributes.rs index a3a451c..055ec9e 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 0000000..630d780 --- /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 a3d18a5..00cbc95 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 cf93410..af87d54 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()