Add recipe groups (#1842)

This commit is contained in:
Greg Shuflin 2024-05-25 00:32:25 -07:00 committed by GitHub
parent 1654d14867
commit ed0dc20318
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 543 additions and 117 deletions

View File

@ -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:

View File

@ -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

View File

@ -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'

View File

@ -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'

View File

@ -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')

View File

@ -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]' \

View File

@ -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 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,
}
}
}
impl<'src> Attribute<'src> {
pub(crate) fn from_name(name: Name) -> Option<Self> {
name.lexeme().parse().ok()
pub(crate) fn new(
name: Name<'src>,
argument: Option<StringLiteral<'src>>,
) -> CompileResult<'src, 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()
}
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,
}
}
}

View File

@ -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())

View File

@ -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,
},

View File

@ -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) {

View File

@ -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> {

View File

@ -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},
},

View File

@ -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)? {

View File

@ -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> {

View File

@ -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
};
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::<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 {
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);
}

View File

@ -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')]

174
tests/groups.rs Normal file
View File

@ -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();
}

View File

@ -61,6 +61,7 @@ mod fmt;
mod functions;
#[cfg(unix)]
mod global;
mod groups;
mod ignore_comments;
mod imports;
mod init;

View File

@ -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()