From bc5b2e75d32790a981050f75be762a822c1e0e78 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 25 Sep 2026 18:05:13 -0700 Subject: [PATCH] Allow `--usage` with module path to show usage for all recipes in module --- src/arguments.rs | 9 ++-- src/error.rs | 2 +- src/subcommand.rs | 109 +++++++++++++++++++++++++---------------- src/usage.rs | 71 +++++++++++++++++++-------- tests/completions.rs | 2 +- tests/modules.rs | 18 +++++++ tests/usage.rs | 112 +++++++++++++++++++++++++++++++++++++++---- 7 files changed, 247 insertions(+), 76 deletions(-) diff --git a/src/arguments.rs b/src/arguments.rs index 6ceb70f9b6..b70fd5d4d4 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -486,13 +486,14 @@ pub(crate) struct Subcommand { )] pub(crate) summary: bool, #[arg( - add = ArgValueCompleter::new(Completer::complete_recipe), + add = ArgValueCompleter::new(Completer::complete_recipe_or_module), conflicts_with = "arguments", - help = "Print usage information for recipe at ", + help = "Print usage information for recipe at or all recipes in module at , \ + defaulting to all recipes in the root justfile", help_heading = Self::HEADING, long, - num_args = 1.., - value_name = "RECIPE_PATH", + num_args = 0.., + value_name = "PATH", )] pub(crate) usage: Option>, #[arg( diff --git a/src/error.rs b/src/error.rs index 859b9d62eb..9b0b8dc06a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1101,7 +1101,7 @@ impl ColorDisplay for Error<'_> { f, "{}", Usage { - long: false, + mode: usage::Mode::Short, path: recipe.recipe_path(), recipe, } diff --git a/src/subcommand.rs b/src/subcommand.rs index 0e963f5ca1..13f638802d 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -622,27 +622,8 @@ impl Subcommand { } fn list<'src>(config: &Config, root: &Justfile<'src>, path: &Modulepath) -> RunResult<'src> { - let mut module = root; - - for name in &path.components { - if let Some(submodule) = module.modules.get(name) { - module = submodule; - } else if let Some(alias) = module.module_aliases.get(name) { - module = root.submodule(&alias.target).unwrap(); - } else if module.absent_modules.contains(name) { - return Err(Error::ModuleAbsent { - module: module.module_path.join(name), - }); - } else { - return Err(Error::UnknownSubmodule { - path: path.to_string(), - suggestion: module.suggest_submodule(name), - }); - } - } - + let module = Self::resolve_module(root, path, &path.components)?; Self::list_module(config, 0, &config.groups, module)?; - Ok(()) } @@ -1042,38 +1023,66 @@ impl Subcommand { } } - fn usage<'src>(config: &Config, module: &Justfile<'src>, path: &Modulepath) -> RunResult<'src> { - let (alias, recipe) = Self::resolve_path(module, path, "usage")?; + fn usage<'src>(config: &Config, root: &Justfile<'src>, path: &Modulepath) -> RunResult<'src> { + if let Some(module) = root.submodule(path) { + let recipes = module.public_recipes(config); - if let Some(alias) = alias { - println!("{alias}"); - } + if recipes.is_empty() { + if config.verbosity.loud() { + eprintln!("module contains no recipes"); + } + } else { + println!("{}", config.color.stdout().heading().paint("Usage:")); + for (i, recipe) in recipes.into_iter().enumerate() { + if i > 0 { + println!(); + } + + let path = Modulepath { + spaced: true, + ..path.join(recipe.name()) + }; + + println!( + "{}", + Usage { + mode: usage::Mode::Module, + path: &path, + recipe, + } + .color_display(config.color.stdout()), + ); + } + } + } else { + let (alias, recipe) = Self::resolve_path(root, path, "usage")?; - println!( - "{}", - Usage { - long: true, - path, - recipe, + if let Some(alias) = alias { + println!("{alias}"); } - .color_display(config.color.stdout()), - ); + + println!( + "{}", + Usage { + mode: usage::Mode::Recipe, + path, + recipe + } + .color_display(config.color.stdout()), + ); + } Ok(()) } - fn resolve_path<'src, 'run>( + fn resolve_module<'src, 'run>( root: &'run Justfile<'src>, path: &Modulepath, - subcommand: &'static str, - ) -> RunResult<'src, (Option<&'run RecipeAlias<'src>>, &'run Recipe<'src>)> { + components: &[String], + ) -> RunResult<'src, &'run Justfile<'src>> { let mut module = root; - let Some((name, ancestors)) = path.components.split_last() else { - return Err(Error::RecipeRequired { subcommand }); - }; - - for name in ancestors { + for name in components { if let Some(submodule) = module.modules.get(name) { module = submodule; } else if let Some(alias) = module.module_aliases.get(name) { @@ -1090,6 +1099,20 @@ impl Subcommand { } } + Ok(module) + } + + fn resolve_path<'src, 'run>( + root: &'run Justfile<'src>, + path: &Modulepath, + subcommand: &'static str, + ) -> RunResult<'src, (Option<&'run RecipeAlias<'src>>, &'run Recipe<'src>)> { + let Some((name, ancestors)) = path.components.split_last() else { + return Err(Error::RecipeRequired { subcommand }); + }; + + let module = Self::resolve_module(root, path, ancestors)?; + if let Some(alias) = module.recipe_alias(name) { Ok((Some(alias), &alias.target)) } else if let Some(recipe) = module.recipe(name) { @@ -1104,6 +1127,10 @@ impl Subcommand { alias: path.clone(), modules: disabled.modules.clone(), }) + } else if module.absent_modules.contains(name) { + Err(Error::ModuleAbsent { + module: module.module_path.join(name), + }) } else { Err(Error::UnknownRecipe { recipe: name.to_owned(), diff --git a/src/usage.rs b/src/usage.rs index fb48dc1059..379b9d5f5f 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -1,20 +1,48 @@ use super::*; -pub(crate) struct Usage<'a, D> { - pub(crate) long: bool, +#[derive(PartialEq)] +pub(crate) enum Mode { + Module, + Recipe, + Short, +} + +pub(crate) struct Usage<'a> { + pub(crate) mode: Mode, pub(crate) path: &'a Modulepath, - pub(crate) recipe: &'a Recipe<'a, D>, + pub(crate) recipe: &'a Recipe<'a>, } -impl ColorDisplay for Usage<'_, D> { +impl ColorDisplay for Usage<'_> { fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { + let indentation = match self.mode { + Mode::Module => " ", + Mode::Recipe | Mode::Short => "", + }; + + match self.mode { + Mode::Module => { + if let Some(doc) = self.recipe.doc() { + for line in doc.lines() { + writeln!( + f, + "{indentation}{}", + color.doc().paint(&format!("# {line}")) + )?; + } + } + } + Mode::Recipe => { + write!(f, "{} ", color.heading().paint("Usage:"))?; + } + Mode::Short => { + write!(f, "{}\n ", color.heading().paint("usage:"))?; + } + } + write!( f, - "{}{}{} {}", - color - .heading() - .paint(if self.long { "Usage:" } else { "usage:" }), - if self.long { " " } else { "\n " }, + "{indentation}{} {}", color.argument().paint("just"), color.argument().paint(&self.path.to_string()), )?; @@ -45,14 +73,17 @@ impl ColorDisplay for Usage<'_, D> { )?; } - if !self.long { + if self.mode == Mode::Short { return Ok(()); } if arguments { writeln!(f)?; - writeln!(f)?; - writeln!(f, "{}", color.heading().paint("Arguments:"))?; + + if self.mode == Mode::Recipe { + writeln!(f)?; + writeln!(f, "{}", color.heading().paint("Arguments:"))?; + } for (i, parameter) in self .recipe @@ -65,11 +96,9 @@ impl ColorDisplay for Usage<'_, D> { writeln!(f)?; } - write!(f, " ")?; - write!( f, - "{}", + "{indentation} {}", UsageParameter { parameter, long: true, @@ -81,8 +110,12 @@ impl ColorDisplay for Usage<'_, D> { if options { writeln!(f)?; - writeln!(f)?; - writeln!(f, "{}", color.heading().paint("Options:"))?; + + if self.mode == Mode::Recipe { + writeln!(f)?; + writeln!(f, "{}", color.heading().paint("Options:"))?; + } + for (i, parameter) in self .recipe .parameters @@ -94,11 +127,9 @@ impl ColorDisplay for Usage<'_, D> { writeln!(f)?; } - write!(f, " ")?; - write!( f, - "{}", + "{indentation} {}", UsageParameter { parameter, long: true, diff --git a/tests/completions.rs b/tests/completions.rs index d98e3a8c62..b5e643a75e 100644 --- a/tests/completions.rs +++ b/tests/completions.rs @@ -423,7 +423,7 @@ fn usage_recipes() { .shell(false) .env("JUST_COMPLETE", "fish") .args(complete_args(&["--usage", ""])) - .stdout("bar\nfoo\n") + .stdout_regex("bar\nfoo\nbar\nfoo\n.\njustfile\n--.*") .success(); } diff --git a/tests/modules.rs b/tests/modules.rs index d424a4fa18..1cfacf38d7 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -1024,6 +1024,15 @@ fn show_recipe_in_absent_optional_module_is_error() { .failure(); } +#[test] +fn show_absent_optional_module_is_error() { + Test::new() + .justfile("mod? foo") + .args(["--show", "foo"]) + .stderr("error: optional module `foo` is absent\n") + .failure(); +} + #[test] fn list_absent_optional_module_is_error() { Test::new() @@ -1033,6 +1042,15 @@ fn list_absent_optional_module_is_error() { .failure(); } +#[test] +fn usage_absent_optional_module_is_error() { + Test::new() + .justfile("mod? foo") + .args(["--usage", "foo"]) + .stderr("error: optional module `foo` is absent\n") + .failure(); +} + #[test] fn evaluate_absent_optional_module_is_error() { Test::new() diff --git a/tests/usage.rs b/tests/usage.rs index bd6aa07bbd..531b4a7253 100644 --- a/tests/usage.rs +++ b/tests/usage.rs @@ -1,14 +1,5 @@ use super::*; -#[test] -fn search_directory_without_recipe() { - Test::new() - .justfile("foo:") - .args(["--usage", "."]) - .stderr("error: `--usage` requires recipe\n") - .failure(); -} - #[test] fn usage_recipe_in_search_directory() { Test::new() @@ -159,3 +150,106 @@ fn flags_have_no_value_placeholder() { ) .success(); } + +#[test] +fn root_module() { + Test::new() + .justfile( + " + # comment + foo bar: + + [arg('baz', short='b')] + qux baz: + ", + ) + .args(["--usage"]) + .stdout( + " + Usage: + # comment + just foo bar + bar + + just qux [OPTIONS] + -b baz + ", + ) + .success(); +} + +#[test] +fn submodule() { + Test::new() + .justfile("mod foo") + .write("foo.just", "bar:") + .args(["--usage", "foo"]) + .stdout( + " + Usage: + just foo bar + ", + ) + .success(); +} + +#[test] +fn module_alias() { + Test::new() + .justfile( + " + mod foo + + alias f := foo + ", + ) + .write("foo.just", "bar:") + .args(["--usage", "f"]) + .stdout( + " + Usage: + just f bar + ", + ) + .success(); +} + +#[test] +fn multi_line_doc() { + Test::new() + .justfile( + " + [doc(\"foo\\nbar\")] + baz: + ", + ) + .args(["--usage"]) + .stdout( + " + Usage: + # foo + # bar + just baz + ", + ) + .success(); +} + +#[test] +fn empty_module() { + Test::new() + .justfile("mod foo") + .write("foo.just", "") + .args(["--usage", "foo"]) + .stderr("module contains no recipes\n") + .success(); +} + +#[test] +fn empty_module_quiet() { + Test::new() + .justfile("mod foo") + .write("foo.just", "") + .args(["--quiet", "--usage", "foo"]) + .success(); +}