Repository: casey/just
Pull request #2108 (2024-05-29 07:52:36)

Allow listing submodules with `--list PATH`


Patch:
diff --git a/README.md b/README.md
index 89506d3e..72e55c75 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 165f7079..1566f3c4 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 7c58fbbc..43407805 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 <SHELL>'
+            cand -l 'List available recipes and their arguments'
+            cand --list 'List available recipes and their arguments'
             cand -s 'Show information about <RECIPE>'
             cand --show 'Show information about <RECIPE>'
             cand --dotenv-filename 'Search for environment file named <DOTENV-FILENAME> 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 7d0b87c8..50556caa 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -48,6 +48,7 @@ complete -c just -l shell-arg -d 'Invoke shell with <SHELL-ARG> as an argument'
 complete -c just -s d -l working-directory -d 'Use <WORKING-DIRECTORY> 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 <SHELL>' -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 <RECIPE>' -r
 complete -c just -l dotenv-filename -d 'Search for environment file named <DOTENV-FILENAME> instead of `.env`' -r
 complete -c just -s E -l dotenv-path -d 'Load <DOTENV-PATH> 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 01648cd6..66210d3a 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 <SHELL>')
+            [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 <RECIPE>')
             [CompletionResult]::new('--show', 'show', [CompletionResultType]::ParameterName, 'Show information about <RECIPE>')
             [CompletionResult]::new('--dotenv-filename', 'dotenv-filename', [CompletionResultType]::ParameterName, 'Search for environment file named <DOTENV-FILENAME> 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 a30496f0..7805d869 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>]:SHELL:(bash elvish fish powershell zsh)' \
+'-l+[List available recipes and their arguments]' \
+'--list=[List available recipes and their arguments]' \
 '-s+[Show information about <RECIPE>]: :(_just_commands)' \
 '--show=[Show information about <RECIPE>]: :(_just_commands)' \
 '(-E --dotenv-path)--dotenv-filename=[Search for environment file named <DOTENV-FILENAME> 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 060131e9..c6095292 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::<String>(cmd::LIST) {
+      Subcommand::List {
+        path: path
+          .clone()
+          .map(|s| (*s).as_str())
+          .collect::<Vec<&str>>()
+          .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 36a22742..e59767be 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<String> },
   #[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 54caeb9d..ed590977 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -160,6 +160,9 @@ pub(crate) enum Error<'src> {
     recipe: &'src str,
     line_number: Option<usize>,
   },
+  UnknownSubmodule {
+    path: ModulePath,
+  },
   UnknownOverrides {
     overrides: Vec<String>,
   },
@@ -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 adbad1bb..4d28a446 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 25e79efc..959ee56a 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 00000000..1f31be8e
--- /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<String>,
+  pub(crate) spaced: bool,
+}
+
+impl TryFrom<&[&str]> for ModulePath {
+  type Error = ();
+
+  fn try_from(path: &[&str]) -> Result<Self, Self::Error> {
+    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::<Vec<String>>()
+    } 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 5e7b7d21..c859376d 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<String>,
@@ -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()


Test patch:
diff --git a/tests/list.rs b/tests/list.rs
index 9d073608..7ee5d46d 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();
+}