From 9d2c6b88587a89238a48c5d5aa11e140f5863426 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Wed, 29 May 2024 01:08:29 -0700 Subject: [PATCH] Allow listing submodule recipes with `--list PATH` (#2108) --- README.md | 18 +++++++ completions/just.bash | 8 +++ completions/just.elvish | 4 +- completions/just.fish | 2 +- completions/just.powershell | 4 +- completions/just.zsh | 4 +- src/config.rs | 43 +++++++++------- src/config_error.rs | 5 +- src/error.rs | 6 +++ src/lexer.rs | 4 +- src/lib.rs | 18 ++++--- src/module_path.rs | 100 ++++++++++++++++++++++++++++++++++++ src/subcommand.rs | 23 ++++++++- tests/list.rs | 77 +++++++++++++++++++++++++++ 14 files changed, 277 insertions(+), 39 deletions(-) create mode 100644 src/module_path.rs diff --git a/README.md b/README.md index 89506d3..72e55c7 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,24 @@ Available recipes: lint ``` +Recipes in submodules can be listed with `just --list PATH`, where `PATH` is a +space- or `::`-separated module path: + +``` +$ cat justfile +mod foo +$ cat foo.just +mod bar +$ cat bar.just +baz: +$ just --unstable foo bar +Available recipes: + baz +$ just --unstable foo::bar +Available recipes: + baz +``` + `just --summary` is more concise: ```sh diff --git a/completions/just.bash b/completions/just.bash index 165f707..1566f3c 100644 --- a/completions/just.bash +++ b/completions/just.bash @@ -116,6 +116,14 @@ _just() { COMPREPLY=($(compgen -W "bash elvish fish powershell zsh" -- "${cur}")) return 0 ;; + --list) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + -l) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --show) COMPREPLY=($(compgen -f "${cur}")) return 0 diff --git a/completions/just.elvish b/completions/just.elvish index 7c58fbb..4340780 100644 --- a/completions/just.elvish +++ b/completions/just.elvish @@ -33,6 +33,8 @@ set edit:completion:arg-completer[just] = {|@words| cand -c 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set' cand --command 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set' cand --completions 'Print shell completion script for ' + cand -l 'List available recipes and their arguments' + cand --list 'List available recipes and their arguments' cand -s 'Show information about ' cand --show 'Show information about ' cand --dotenv-filename 'Search for environment file named instead of `.env`' @@ -64,8 +66,6 @@ set edit:completion:arg-completer[just] = {|@words| cand --evaluate 'Evaluate and print all variables. If a variable name is given as an argument, only print that variable''s value.' cand --fmt 'Format and overwrite justfile' 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' diff --git a/completions/just.fish b/completions/just.fish index 7d0b87c..50556ca 100644 --- a/completions/just.fish +++ b/completions/just.fish @@ -48,6 +48,7 @@ complete -c just -l shell-arg -d 'Invoke shell with as an argument' complete -c just -s d -l working-directory -d 'Use as working directory. --justfile must also be set' -r -F complete -c just -s c -l command -d 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set' -r complete -c just -l completions -d 'Print shell completion script for ' -r -f -a "{bash '',elvish '',fish '',powershell '',zsh ''}" +complete -c just -s l -l list -d 'List available recipes and their arguments' -r complete -c just -s s -l show -d 'Show information about ' -r complete -c just -l dotenv-filename -d 'Search for environment file named instead of `.env`' -r complete -c just -s E -l dotenv-path -d 'Load as environment file instead of searching for one' -r -F @@ -72,7 +73,6 @@ complete -c just -s e -l edit -d 'Edit justfile with editor given by $VISUAL or complete -c just -l evaluate -d 'Evaluate and print all variables. If a variable name is given as an argument, only print that variable\'s value.' 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' diff --git a/completions/just.powershell b/completions/just.powershell index 01648cd..66210d3 100644 --- a/completions/just.powershell +++ b/completions/just.powershell @@ -36,6 +36,8 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock { [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set') [CompletionResult]::new('--command', 'command', [CompletionResultType]::ParameterName, 'Run an arbitrary command with the working directory, `.env`, overrides, and exports set') [CompletionResult]::new('--completions', 'completions', [CompletionResultType]::ParameterName, 'Print shell completion script for ') + [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('-s', 's', [CompletionResultType]::ParameterName, 'Show information about ') [CompletionResult]::new('--show', 'show', [CompletionResultType]::ParameterName, 'Show information about ') [CompletionResult]::new('--dotenv-filename', 'dotenv-filename', [CompletionResultType]::ParameterName, 'Search for environment file named instead of `.env`') @@ -67,8 +69,6 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock { [CompletionResult]::new('--evaluate', 'evaluate', [CompletionResultType]::ParameterName, 'Evaluate and print all variables. If a variable name is given as an argument, only print that variable''s value.') [CompletionResult]::new('--fmt', 'fmt', [CompletionResultType]::ParameterName, 'Format and overwrite justfile') [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') diff --git a/completions/just.zsh b/completions/just.zsh index a30496f..7805d86 100644 --- a/completions/just.zsh +++ b/completions/just.zsh @@ -31,6 +31,8 @@ _just() { '*-c+[Run an arbitrary command with the working directory, \`.env\`, overrides, and exports set]: : ' \ '*--command=[Run an arbitrary command with the working directory, \`.env\`, overrides, and exports set]: : ' \ '*--completions=[Print shell completion script for ]:SHELL:(bash elvish fish powershell zsh)' \ +'-l+[List available recipes and their arguments]' \ +'--list=[List available recipes and their arguments]' \ '-s+[Show information about ]: :(_just_commands)' \ '--show=[Show information about ]: :(_just_commands)' \ '(-E --dotenv-path)--dotenv-filename=[Search for environment file named instead of \`.env\`]: : ' \ @@ -62,8 +64,6 @@ _just() { '--evaluate[Evaluate and print all variables. If a variable name is given as an argument, only print that variable'\''s value.]' \ '--fmt[Format and overwrite justfile]' \ '--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]' \ diff --git a/src/config.rs b/src/config.rs index 060131e..c609529 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,9 +80,8 @@ mod cmd { VARIABLES, ]; - pub(crate) const ARGLESS: &[&str] = &[ - CHANGELOG, DUMP, EDIT, FORMAT, INIT, LIST, MAN, SUMMARY, VARIABLES, - ]; + pub(crate) const ARGLESS: &[&str] = + &[CHANGELOG, DUMP, EDIT, FORMAT, INIT, MAN, SUMMARY, VARIABLES]; } mod arg { @@ -417,7 +416,9 @@ impl Config { Arg::new(cmd::LIST) .short('l') .long("list") - .action(ArgAction::SetTrue) + .num_args(0..) + .value_name("PATH") + .action(ArgAction::Set) .help("List available recipes and their arguments"), ) .arg( @@ -663,8 +664,18 @@ impl Config { Subcommand::Format } else if matches.get_flag(cmd::INIT) { Subcommand::Init - } else if matches.get_flag(cmd::LIST) { - Subcommand::List + } else if let Some(path) = matches.get_many::(cmd::LIST) { + Subcommand::List { + path: path + .clone() + .map(|s| (*s).as_str()) + .collect::>() + .as_slice() + .try_into() + .map_err(|()| ConfigError::ListPath { + path: path.cloned().collect(), + })?, + } } else if matches.get_flag(cmd::GROUPS) { Subcommand::Groups } else if matches.get_flag(cmd::MAN) { @@ -1273,13 +1284,19 @@ mod tests { test! { name: subcommand_list_long, args: ["--list"], - subcommand: Subcommand::List, + subcommand: Subcommand::List{ path: ModulePath{ path: Vec::new(), spaced: false } }, } test! { name: subcommand_list_short, args: ["-l"], - subcommand: Subcommand::List, + subcommand: Subcommand::List{ path: ModulePath{ path: Vec::new(), spaced: false } }, + } + + test! { + name: subcommand_list_arguments, + args: ["--list", "bar"], + subcommand: Subcommand::List{ path: ModulePath{ path: vec!["bar".into()], spaced: false } }, } test! { @@ -1511,16 +1528,6 @@ mod tests { }, } - error! { - name: list_arguments, - args: ["--list", "bar"], - error: ConfigError::SubcommandArguments { subcommand, arguments }, - check: { - assert_eq!(subcommand, cmd::LIST); - assert_eq!(arguments, &["bar"]); - }, - } - error! { name: dump_arguments, args: ["--dump", "bar"], diff --git a/src/config_error.rs b/src/config_error.rs index 36a2274..e59767b 100644 --- a/src/config_error.rs +++ b/src/config_error.rs @@ -6,11 +6,12 @@ pub(crate) enum ConfigError { #[snafu(display("Failed to get current directory: {}", source))] CurrentDir { source: io::Error }, #[snafu(display( - "Internal config error, this may indicate a bug in just: {} \ + "Internal config error, this may indicate a bug in just: {message} \ consider filing an issue: https://github.com/casey/just/issues/new", - message ))] Internal { message: String }, + #[snafu(display("Invalid module path `{}`", path.join(" ")))] + ListPath { path: Vec }, #[snafu(display( "Path-prefixed recipes may not be used with `--working-directory` or `--justfile`." ))] diff --git a/src/error.rs b/src/error.rs index 54caeb9..ed59097 100644 --- a/src/error.rs +++ b/src/error.rs @@ -160,6 +160,9 @@ pub(crate) enum Error<'src> { recipe: &'src str, line_number: Option, }, + UnknownSubmodule { + path: ModulePath, + }, UnknownOverrides { overrides: Vec, }, @@ -432,6 +435,9 @@ impl<'src> ColorDisplay for Error<'src> { write!(f, "Recipe `{recipe}` failed for an unknown reason")?; } } + UnknownSubmodule { path } => { + write!(f, "Justfile does not contain submodule `{path}`")?; + } UnknownOverrides { overrides } => { let count = Count("Variable", overrides.len()); let overrides = List::and_ticked(overrides); diff --git a/src/lexer.rs b/src/lexer.rs index adbad1b..4d28a44 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -273,12 +273,12 @@ impl<'src> Lexer<'src> { } /// True if `c` can be the first character of an identifier - fn is_identifier_start(c: char) -> bool { + pub(crate) fn is_identifier_start(c: char) -> bool { matches!(c, 'a'..='z' | 'A'..='Z' | '_') } /// True if `c` can be a continuation character of an identifier - fn is_identifier_continue(c: char) -> bool { + pub(crate) fn is_identifier_continue(c: char) -> bool { Self::is_identifier_start(c) || matches!(c, '0'..='9' | '-') } diff --git a/src/lib.rs b/src/lib.rs index 25e79ef..959ee56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,14 +25,15 @@ pub(crate) use { evaluator::Evaluator, expression::Expression, fragment::Fragment, function::Function, 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, 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_context::RecipeContext, recipe_resolver::RecipeResolver, - recipe_signature::RecipeSignature, scope::Scope, search::Search, search_config::SearchConfig, - search_error::SearchError, set::Set, setting::Setting, settings::Settings, shebang::Shebang, - shell::Shell, show_whitespace::ShowWhitespace, source::Source, string_kind::StringKind, + 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_context::RecipeContext, + recipe_resolver::RecipeResolver, recipe_signature::RecipeSignature, scope::Scope, + search::Search, search_config::SearchConfig, search_error::SearchError, set::Set, + setting::Setting, settings::Settings, shebang::Shebang, shell::Shell, + show_whitespace::ShowWhitespace, source::Source, 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, use_color::UseColor, variables::Variables, @@ -153,6 +154,7 @@ mod line; mod list; mod load_dotenv; mod loader; +mod module_path; mod name; mod namepath; mod ordinal; diff --git a/src/module_path.rs b/src/module_path.rs new file mode 100644 index 0000000..1f31be8 --- /dev/null +++ b/src/module_path.rs @@ -0,0 +1,100 @@ +use super::*; + +#[derive(Debug, PartialEq, Clone)] +pub(crate) struct ModulePath { + pub(crate) path: Vec, + pub(crate) spaced: bool, +} + +impl TryFrom<&[&str]> for ModulePath { + type Error = (); + + fn try_from(path: &[&str]) -> Result { + let spaced = path.len() > 1; + + let path = if path.len() == 1 { + let first = path[0]; + + if first.starts_with(':') || first.ends_with(':') || first.contains(":::") { + return Err(()); + } + + first + .split("::") + .map(str::to_string) + .collect::>() + } else { + path.iter().map(|s| (*s).to_string()).collect() + }; + + for name in &path { + if name.is_empty() { + return Err(()); + } + + for (i, c) in name.chars().enumerate() { + if i == 0 { + if !Lexer::is_identifier_start(c) { + return Err(()); + } + } else if !Lexer::is_identifier_continue(c) { + return Err(()); + } + } + } + + Ok(Self { path, spaced }) + } +} + +impl Display for ModulePath { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + for (i, name) in self.path.iter().enumerate() { + if i > 0 { + if self.spaced { + write!(f, " ")?; + } else { + write!(f, "::")?; + } + } + write!(f, "{name}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn try_from_ok() { + #[track_caller] + fn case(path: &[&str], expected: &[&str], display: &str) { + let actual = ModulePath::try_from(path).unwrap(); + assert_eq!(actual.path, expected); + assert_eq!(actual.to_string(), display); + } + + case(&[], &[], ""); + case(&["foo"], &["foo"], "foo"); + case(&["foo0"], &["foo0"], "foo0"); + case(&["foo", "bar"], &["foo", "bar"], "foo bar"); + case(&["foo::bar"], &["foo", "bar"], "foo::bar"); + } + + #[test] + fn try_from_err() { + #[track_caller] + fn case(path: &[&str]) { + assert!(ModulePath::try_from(path).is_err()); + } + + case(&[":foo"]); + case(&["foo:"]); + case(&["foo:::bar"]); + case(&["0foo"]); + case(&["f$oo"]); + case(&[""]); + } +} diff --git a/src/subcommand.rs b/src/subcommand.rs index 5e7b7d2..c859376 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -31,7 +31,9 @@ pub(crate) enum Subcommand { Format, Groups, Init, - List, + List { + path: ModulePath, + }, Man, Run { arguments: Vec, @@ -88,7 +90,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), + List { path } => Self::list_module(config, justfile, path)?, Show { ref name } => Self::show(config, name, justfile)?, Summary => Self::summary(config, justfile), Variables => Self::variables(justfile), @@ -477,6 +479,23 @@ impl Subcommand { Ok(()) } + fn list_module( + config: &Config, + mut module: &Justfile, + path: &ModulePath, + ) -> Result<(), Error<'static>> { + for name in &path.path { + module = module + .modules + .get(name) + .ok_or_else(|| Error::UnknownSubmodule { path: path.clone() })?; + } + + Self::list(config, 0, module); + + Ok(()) + } + fn list(config: &Config, level: usize, justfile: &Justfile) { let aliases = if config.no_aliases { BTreeMap::new() diff --git a/tests/list.rs b/tests/list.rs index 9d07360..7ee5d46 100644 --- a/tests/list.rs +++ b/tests/list.rs @@ -254,3 +254,80 @@ fn unsorted_list_order() { ) .run(); } + +#[test] +fn list_submodule() { + Test::new() + .write("foo.just", "bar:") + .justfile( + " + mod foo + ", + ) + .test_round_trip(false) + .args(["--unstable", "--list", "foo"]) + .stdout( + " + Available recipes: + bar + ", + ) + .run(); +} + +#[test] +fn list_nested_submodule() { + Test::new() + .write("foo.just", "mod bar") + .write("bar.just", "baz:") + .justfile( + " + mod foo + ", + ) + .test_round_trip(false) + .args(["--unstable", "--list", "foo", "bar"]) + .stdout( + " + Available recipes: + baz + ", + ) + .run(); + + Test::new() + .write("foo.just", "mod bar") + .write("bar.just", "baz:") + .justfile( + " + mod foo + ", + ) + .test_round_trip(false) + .args(["--unstable", "--list", "foo::bar"]) + .stdout( + " + Available recipes: + baz + ", + ) + .run(); +} + +#[test] +fn list_invalid_path() { + Test::new() + .args(["--unstable", "--list", "$hello"]) + .stderr("error: Invalid module path `$hello`\n") + .status(1) + .run(); +} + +#[test] +fn list_unknown_submodule() { + Test::new() + .args(["--unstable", "--list", "hello"]) + .stderr("error: Justfile does not contain submodule `hello`\n") + .status(1) + .run(); +}