diff --git a/src/error.rs b/src/error.rs index cd22311e7b..94a110993a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -172,6 +172,9 @@ pub(crate) enum Error<'src> { UnknownSubmodule { path: String, }, + UnknownSubmoduleGroup { + path: String, + }, UnknownOverrides { overrides: Vec, }, @@ -456,6 +459,9 @@ impl<'src> ColorDisplay for Error<'src> { UnknownSubmodule { path } => { write!(f, "Justfile does not contain submodule `{path}`")?; } + UnknownSubmoduleGroup { path } => { + write!(f, "Justfile does not contain submodule nor group `{path}`")?; + } UnknownOverrides { overrides } => { let count = Count("Variable", overrides.len()); let overrides = List::and_ticked(overrides); diff --git a/src/justfile.rs b/src/justfile.rs index b31084ef18..639ad8d821 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -401,6 +401,38 @@ impl<'src> Justfile<'src> { pub(crate) fn groups(&self) -> &[String] { &self.groups } + pub(crate) fn public_group_map( + &self, + config: &Config, + ) -> BTreeMap, Vec> { + let mut groups = BTreeMap::, Vec>::new(); + let aliases = self.aliases(config); + for recipe in self.public_recipes(config) { + let recipe_groups = recipe.groups(); + let entry = ListEntry::from_recipe( + recipe, + self.name().to_string(), + aliases.get(recipe.name()).unwrap_or(&Vec::new()).clone(), + ); + if recipe_groups.is_empty() { + groups.entry(None).or_default().push(entry); + } else { + for group in recipe_groups { + groups.entry(Some(group)).or_default().push(entry.clone()); + } + } + } + groups + } + + pub(crate) fn find_public_group(&self, group: &str, config: &Config) -> Vec<&Recipe> { + self + .public_recipes(config) + .iter() + .filter(|recipe| recipe.groups().contains(group)) + .copied() + .collect() + } pub(crate) fn public_groups(&self, config: &Config) -> Vec { let mut groups = Vec::new(); @@ -431,6 +463,20 @@ impl<'src> Justfile<'src> { groups.into_iter().map(|(_, _, group)| group).collect() } + + pub(crate) fn aliases(&self, config: &Config) -> BTreeMap<&str, Vec<&str>> { + if config.no_aliases { + return BTreeMap::new(); + } + let mut aliases = BTreeMap::<&str, Vec<&str>>::new(); + for alias in self.aliases.values().filter(|alias| !alias.is_private()) { + aliases + .entry(alias.target.name.lexeme()) + .or_default() + .push(alias.name.lexeme()); + } + aliases + } } impl<'src> ColorDisplay for Justfile<'src> { diff --git a/src/lib.rs b/src/lib.rs index d141b10a2b..b48c70ea8d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,19 +18,19 @@ pub(crate) use { fragment::Fragment, function::Function, interpreter::Interpreter, interrupt_guard::InterruptGuard, interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, keyed::Keyed, keyword::Keyword, lexer::Lexer, line::Line, list::List, - load_dotenv::load_dotenv, loader::Loader, module_path::ModulePath, name::Name, - namepath::Namepath, ordinal::Ordinal, output::output, output_error::OutputError, + list_entry::ListEntry, load_dotenv::load_dotenv, loader::Loader, module_path::ModulePath, + name::Name, namepath::Namepath, ordinal::Ordinal, output::output, output_error::OutputError, parameter::Parameter, parameter_kind::ParameterKind, parser::Parser, platform::Platform, platform_interface::PlatformInterface, position::Position, positional::Positional, ran::Ran, range_ext::RangeExt, recipe::Recipe, recipe_resolver::RecipeResolver, - recipe_signature::RecipeSignature, scope::Scope, search::Search, search_config::SearchConfig, - search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang, - show_whitespace::ShowWhitespace, source::Source, string_delimiter::StringDelimiter, - string_kind::StringKind, string_literal::StringLiteral, subcommand::Subcommand, - suggestion::Suggestion, table::Table, thunk::Thunk, token::Token, token_kind::TokenKind, - unresolved_dependency::UnresolvedDependency, unresolved_recipe::UnresolvedRecipe, - unstable_feature::UnstableFeature, use_color::UseColor, variables::Variables, - verbosity::Verbosity, warning::Warning, + recipe_signature::RecipeSignature, recipe_signature::SignatureWidths, scope::Scope, + search::Search, search_config::SearchConfig, search_error::SearchError, set::Set, + setting::Setting, settings::Settings, shebang::Shebang, show_whitespace::ShowWhitespace, + source::Source, string_delimiter::StringDelimiter, string_kind::StringKind, + string_literal::StringLiteral, subcommand::Subcommand, suggestion::Suggestion, table::Table, + thunk::Thunk, token::Token, token_kind::TokenKind, unresolved_dependency::UnresolvedDependency, + unresolved_recipe::UnresolvedRecipe, unstable_feature::UnstableFeature, use_color::UseColor, + variables::Variables, verbosity::Verbosity, warning::Warning, }, camino::Utf8Path, clap::ValueEnum, @@ -151,6 +151,7 @@ mod keyword; mod lexer; mod line; mod list; +mod list_entry; mod load_dotenv; mod loader; mod module_path; diff --git a/src/list_entry.rs b/src/list_entry.rs new file mode 100644 index 0000000000..c7a069642a --- /dev/null +++ b/src/list_entry.rs @@ -0,0 +1,22 @@ +use super::*; + +#[derive(Debug, Clone)] +pub(crate) struct ListEntry<'src, 'outer> { + pub(crate) prefix: String, + pub(crate) recipe: &'outer Recipe<'src>, + pub(crate) aliases: Vec<&'src str>, +} + +impl<'src, 'outer> ListEntry<'src, 'outer> { + pub(crate) fn from_recipe( + recipe: &'outer Recipe<'src>, + prefix: String, + aliases: Vec<&'src str>, + ) -> Self { + Self { + prefix, + recipe, + aliases, + } + } +} diff --git a/src/recipe_signature.rs b/src/recipe_signature.rs index d805ba65fe..ccfd3418db 100644 --- a/src/recipe_signature.rs +++ b/src/recipe_signature.rs @@ -14,3 +14,58 @@ impl<'a> ColorDisplay for RecipeSignature<'a> { Ok(()) } } + +#[derive(Debug)] +pub(crate) struct SignatureWidths<'a> { + pub(crate) widths: BTreeMap<&'a str, usize>, + pub(crate) threshold: usize, + pub(crate) max_width: usize, +} + +impl<'a> SignatureWidths<'a> { + pub fn empty() -> Self { + Self { + widths: BTreeMap::new(), + threshold: 50, + max_width: 0, + } + } + + pub fn add_string_custom_width(&mut self, string: &'a str, width: usize) { + self.widths.insert(string, width); + self.max_width = self.max_width.max(width).min(self.threshold); + } + + pub fn add_entries<'outer>(&mut self, entries: &Vec>) { + for entry in entries { + self.add_entry(entry); + } + } + + pub fn add_entry<'file>(&mut self, entry: &ListEntry<'a, 'file>) { + if !entry.recipe.is_public() { + return; + } + + for name in iter::once(entry.recipe.name()).chain(entry.aliases.iter().copied()) { + let format = if entry.prefix.is_empty() { + Cow::Borrowed(name) + } else { + Cow::Owned(format!("{}{}", entry.prefix, name)) + }; + let width = UnicodeWidthStr::width( + RecipeSignature { + name: &format, + recipe: entry.recipe, + } + .color_display(Color::never()) + .to_string() + .as_str(), + ); + self.widths.insert(name, width); + if width <= self.threshold { + self.max_width = self.max_width.max(width); + } + } + } +} diff --git a/src/subcommand.rs b/src/subcommand.rs index 3f2c1ce90c..8b252b3978 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -429,13 +429,26 @@ impl Subcommand { } fn list(config: &Config, mut module: &Justfile, path: &ModulePath) -> RunResult<'static> { + let mut result = Ok(()); for name in &path.path { - module = module + let submodule = module .modules .get(name) .ok_or_else(|| Error::UnknownSubmodule { path: path.to_string(), - })?; + }); + match submodule { + Ok(submodule) => module = submodule, + Err(err) => { + result = Err(err); + break; + } + } + } + + // default is to check submodules, otherwise we check groups + if result.is_err() { + return Self::list_group_recursive(config, module, path, 0, config.list_prefix.as_str()); } Self::list_module(config, module, 0); @@ -443,76 +456,153 @@ impl Subcommand { Ok(()) } - fn list_module(config: &Config, module: &Justfile, depth: usize) { - fn format_doc( - config: &Config, - name: &str, - doc: Option<&str>, - max_signature_width: usize, - signature_widths: &BTreeMap<&str, usize>, - ) { - if let Some(doc) = doc { - if !doc.is_empty() && doc.lines().count() <= 1 { - print!( - "{:padding$}{} {}", - "", - config.color.stdout().doc().paint("#"), - config.color.stdout().doc().paint(doc), - padding = max_signature_width.saturating_sub(signature_widths[name]) + 1, - ); + fn format_entries<'src>( + config: &Config, + entries: &Vec>, + signature_widths: &SignatureWidths<'src>, + list_prefix: &str, + include_prefix: bool, + ) { + for entry in entries { + for (i, name) in iter::once(entry.recipe.name()) + .chain(entry.aliases.iter().copied()) + .enumerate() + { + let doc = if i == 0 { + entry.recipe.doc().map(Cow::Borrowed) + } else { + Some(Cow::Owned(format!("alias for `{}`", entry.recipe.name))) + }; + + if let Some(doc) = &doc { + if doc.lines().count() > 1 { + for line in doc.lines() { + println!( + "{list_prefix}{} {}", + config.color.stdout().doc().paint("#"), + config.color.stdout().doc().paint(line), + ); + } + } } + + print!( + "{list_prefix}{}{}", + { + if include_prefix { + &entry.prefix + } else { + "" + } + }, + RecipeSignature { + name, + recipe: entry.recipe + } + .color_display(config.color.stdout()) + ); + + Self::format_doc(config, name, doc.as_deref(), signature_widths); } - println!(); } + } - let aliases = if config.no_aliases { - BTreeMap::new() - } else { - let mut aliases = BTreeMap::<&str, Vec<&str>>::new(); - for alias in module.aliases.values().filter(|alias| !alias.is_private()) { - aliases - .entry(alias.target.name.lexeme()) - .or_default() - .push(alias.name.lexeme()); + fn list_group_recursive( + config: &Config, + module: &Justfile, + path: &ModulePath, + depth: usize, + prefix: &str, + ) -> RunResult<'static> { + let mut entries = Vec::new(); + let mut signature_widths = SignatureWidths::empty(); + let mut queue = Vec::new(); + queue.push((String::new(), module)); + while let Some((prefix, module)) = queue.pop() { + if config.list_submodules { + let name = module.name(); + queue.append( + &mut iter::repeat(if name.is_empty() { + String::new() + } else { + format!("{prefix}{name}::") + }) + .zip(module.modules(config).into_iter()) + .collect(), + ); } - aliases - }; + let target_name = path.path.first().unwrap().to_string(); + let group = module.find_public_group(&target_name, config); + let aliases = module.aliases(config); + for recipe in &group { + let name = module.name(); + let entry = ListEntry::from_recipe( + recipe, + if name.is_empty() { + String::new() + } else { + format!("{prefix}{}::", module.name()) + }, + aliases.get(recipe.name()).unwrap_or(&Vec::new()).clone(), + ); + signature_widths.add_entry(&entry); + entries.push(entry); + } + } - let signature_widths = { - let mut signature_widths: BTreeMap<&str, usize> = BTreeMap::new(); + if entries.is_empty() { + return Err(Error::UnknownSubmoduleGroup { + path: path.to_string(), + }); + } - for (name, recipe) in &module.recipes { - if !recipe.is_public() { - continue; - } + let list_prefix = prefix; - 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(), - ), - ); - } - } - if !config.list_submodules { - for (name, _) in &module.modules { - signature_widths.insert(name, UnicodeWidthStr::width(format!("{name} ...").as_str())); - } + if depth == 0 { + print!("{}", config.list_heading); + } + + Self::format_entries(config, &entries, &signature_widths, list_prefix, true); + + Ok(()) + } + + fn format_doc( + config: &Config, + name: &str, + doc: Option<&str>, + signature_widths: &SignatureWidths, + ) { + if let Some(doc) = doc { + if !doc.is_empty() && doc.lines().count() <= 1 { + print!( + "{:padding$}{} {}", + "", + config.color.stdout().doc().paint("#"), + config.color.stdout().doc().paint(doc), + padding = signature_widths + .max_width + .saturating_sub(signature_widths.widths[name]) + + 1, + ); } + } + println!(); + } - signature_widths - }; + fn list_module(config: &Config, module: &Justfile, depth: usize) { + let mut signature_widths = SignatureWidths::empty(); + let recipe_groups = module.public_group_map(config); - let max_signature_width = signature_widths + recipe_groups .values() - .copied() - .filter(|width| *width <= 50) - .max() - .unwrap_or(0); + .for_each(|entries| signature_widths.add_entries(entries)); + if !config.list_submodules { + for (name, _) in &module.modules { + signature_widths + .add_string_custom_width(name, UnicodeWidthStr::width(format!("{name} ...").as_str())); + } + } let list_prefix = config.list_prefix.repeat(depth + 1); @@ -520,21 +610,6 @@ impl Subcommand { print!("{}", config.list_heading); } - let recipe_groups = { - let mut groups = BTreeMap::, Vec<&Recipe>>::new(); - for recipe in module.public_recipes(config) { - 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 - }; - let submodule_groups = { let mut groups = BTreeMap::, Vec<&Justfile>>::new(); for submodule in module.modules(config) { @@ -580,44 +655,8 @@ impl Subcommand { } } - if let Some(recipes) = recipe_groups.get(&group) { - for recipe in recipes { - for (i, name) in iter::once(&recipe.name()) - .chain(aliases.get(recipe.name()).unwrap_or(&Vec::new())) - .enumerate() - { - let doc = if i == 0 { - recipe.doc().map(Cow::Borrowed) - } else { - Some(Cow::Owned(format!("alias for `{}`", recipe.name))) - }; - - if let Some(doc) = &doc { - if doc.lines().count() > 1 { - for line in doc.lines() { - println!( - "{list_prefix}{} {}", - config.color.stdout().doc().paint("#"), - config.color.stdout().doc().paint(line), - ); - } - } - } - - print!( - "{list_prefix}{}", - RecipeSignature { name, recipe }.color_display(config.color.stdout()) - ); - - format_doc( - config, - name, - doc.as_deref(), - max_signature_width, - &signature_widths, - ); - } - } + if let Some(entries) = recipe_groups.get(&group) { + Self::format_entries(config, entries, &signature_widths, &list_prefix, false); } if let Some(submodules) = submodule_groups.get(&group) { @@ -631,11 +670,10 @@ impl Subcommand { Self::list_module(config, submodule, depth + 1); } else { print!("{list_prefix}{} ...", submodule.name()); - format_doc( + Self::format_doc( config, submodule.name(), submodule.doc.as_deref(), - max_signature_width, &signature_widths, ); } diff --git a/src/submodule.rs b/src/submodule.rs new file mode 100644 index 0000000000..af4f04c59b --- /dev/null +++ b/src/submodule.rs @@ -0,0 +1,2 @@ +struct Submodule<'src> { + doc: <'src>, diff --git a/tests/list.rs b/tests/list.rs index db7b669e44..b9683bbc0a 100644 --- a/tests/list.rs +++ b/tests/list.rs @@ -215,7 +215,7 @@ fn list_invalid_path() { fn list_unknown_submodule() { Test::new() .args(["--list", "hello"]) - .stderr("error: Justfile does not contain submodule `hello`\n") + .stderr("error: Justfile does not contain submodule nor group `hello`\n") .status(1) .run(); } @@ -378,6 +378,10 @@ fn module_doc_aligned() { # comment mod very_long_name_for_module \"bar.just\" # comment + # another lifechanging experience + recipe2: + @echo fooled + # will change your world recipe: @echo Hi @@ -388,6 +392,7 @@ fn module_doc_aligned() { " Available recipes: recipe # will change your world + recipe2 # another lifechanging experience foo ... # Module foo very_long_name_for_module ... # comment ", @@ -438,3 +443,148 @@ fn no_space_before_submodules_not_following_groups() { ) .run(); } +#[test] +fn group_list() { + Test::new() + .justfile( + " + [group('group_name')] + recipe: + @echo Hi + + recipe2: + @echo Hi + ", + ) + .test_round_trip(false) + .args(["--list", "group_name"]) + .stdout( + " + Available recipes: + recipe + ", + ) + .run(); +} + +#[test] +fn group_list_recursive() { + Test::new() + .write( + "foo.just", + " +[group('group_name')] +rec_recipe: + @echo recursion fun + ", + ) + .justfile( + " + mod foo + + [group('group_name')] + recipe: + @echo Hi + + recipe2: + @echo Hi + ", + ) + .test_round_trip(false) + .args(["--list-submodules", "--list", "group_name"]) + .stdout( + " + Available recipes: + recipe + foo::rec_recipe + ", + ) + .run(); +} + +#[test] +fn group_list_recursive_with_comments() { + Test::new() + .write( + "foo.just", + " +# this is a module comment +[group('group_name')] +rec_recipe: + @echo recursion fun + ", + ) + .justfile( + " + mod foo + + # comment + [group('group_name')] + recipe: + @echo Hi + + recipe2: + @echo Hi + ", + ) + .test_round_trip(false) + .args(["--list-submodules", "--list", "group_name"]) + .stdout( + " + Available recipes: + recipe # comment + foo::rec_recipe # this is a module comment + ", + ) + .run(); +} +#[test] +fn group_list_recursive_nested() { + Test::new() + .write( + "bar.just", + " +# this is a module comment +[group('group_name')] +rec_recipe: + @echo recursion fun + +mod baz + ", + ) + .write( + "foo.just", + " +mod bar + ", + ) + .write( + "baz.just", + " +[group('group_name')] +other: + @echo recursion fun + ", + ) + .justfile( + " + mod foo + + # comment + [group('group_name')] + recipe: + @echo Hi + ", + ) + .test_round_trip(false) + .args(["--list-submodules", "--list", "group_name"]) + .stdout( + " + Available recipes: + recipe # comment + foo::bar::rec_recipe # this is a module comment + foo::bar::baz::other + ", + ) + .run(); +}