Repository: casey/just (rust)

Issue:
Show submodule recipes in --choose
From [this comment](https://github.com/casey/just/issues/929#issuecomment-2136874285)

@foresterre reports that submodule recipes aren't appearing in `--choose`

I wasn't actually able to reproduce this. Doing this:

```
$ cat test.just
mod foo
$ cat foo.just
bar:
$ cargo run -- --unstable --justfile test.just --choose
```

Showed `foo::bar` in my chooser.

Pull request description:
Show recipes in submodules with `--show RECIPE::PATH`
Fixes #2110.

Patch:
diff --git a/completions/just.elvish b/completions/just.elvish
index 41524dfe..18382eae 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -33,10 +33,10 @@ 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 -l 'List available recipes'
+            cand --list 'List available recipes'
+            cand -s 'Show recipe at <PATH>'
+            cand --show 'Show recipe at <PATH>'
             cand --dotenv-filename 'Search for environment file named <DOTENV-FILENAME> instead of `.env`'
             cand -E 'Load <DOTENV-PATH> as environment file instead of searching for one'
             cand --dotenv-path 'Load <DOTENV-PATH> as environment file instead of searching for one'
diff --git a/completions/just.fish b/completions/just.fish
index 4a4615d0..7b5a1b45 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -48,8 +48,8 @@ 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 -s l -l list -d 'List available recipes' -r
+complete -c just -s s -l show -d 'Show recipe at <PATH>' -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
 complete -c just -l timestamp-format -d 'Timestamp format string' -r
diff --git a/completions/just.powershell b/completions/just.powershell
index 88098f04..5413bb1b 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -36,10 +36,10 @@ 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('-l', 'l', [CompletionResultType]::ParameterName, 'List available recipes')
+            [CompletionResult]::new('--list', 'list', [CompletionResultType]::ParameterName, 'List available recipes')
+            [CompletionResult]::new('-s', 's', [CompletionResultType]::ParameterName, 'Show recipe at <PATH>')
+            [CompletionResult]::new('--show', 'show', [CompletionResultType]::ParameterName, 'Show recipe at <PATH>')
             [CompletionResult]::new('--dotenv-filename', 'dotenv-filename', [CompletionResultType]::ParameterName, 'Search for environment file named <DOTENV-FILENAME> instead of `.env`')
             [CompletionResult]::new('-E', 'E ', [CompletionResultType]::ParameterName, 'Load <DOTENV-PATH> as environment file instead of searching for one')
             [CompletionResult]::new('--dotenv-path', 'dotenv-path', [CompletionResultType]::ParameterName, 'Load <DOTENV-PATH> as environment file instead of searching for one')
diff --git a/completions/just.zsh b/completions/just.zsh
index b8e0a2c8..1c1fe92d 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -31,10 +31,10 @@ _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)' \
+'()-l+[List available recipes]' \
+'()--list=[List available recipes]' \
+'-s+[Show recipe at <PATH>]: :(_just_commands)' \
+'--show=[Show recipe at <PATH>]: :(_just_commands)' \
 '(-E --dotenv-path)--dotenv-filename=[Search for environment file named <DOTENV-FILENAME> instead of \`.env\`]: : ' \
 '-E+[Load <DOTENV-PATH> as environment file instead of searching for one]: :_files' \
 '--dotenv-path=[Load <DOTENV-PATH> as environment file instead of searching for one]: :_files' \
diff --git a/src/completions.rs b/src/completions.rs
index bc8564ba..23f2902f 100644
--- a/src/completions.rs
+++ b/src/completions.rs
@@ -47,10 +47,10 @@ pub(crate) const ZSH_COMPLETION_REPLACEMENTS: &[(&str, &str)] = &[
     r"'*--set=[Override <VARIABLE> with <VALUE>]: :(_just_variables)' \",
   ),
   (
-    r"'()-s+[Show information about <RECIPE>]:RECIPE: ' \
-'()--show=[Show information about <RECIPE>]:RECIPE: ' \",
-    r"'-s+[Show information about <RECIPE>]: :(_just_commands)' \
-'--show=[Show information about <RECIPE>]: :(_just_commands)' \",
+    r"'()-s+[Show recipe at <PATH>]:PATH: ' \
+'()--show=[Show recipe at <PATH>]:PATH: ' \",
+    r"'-s+[Show recipe at <PATH>]: :(_just_commands)' \
+'--show=[Show recipe at <PATH>]: :(_just_commands)' \",
   ),
   (
     "'*::ARGUMENTS -- Overrides and recipe(s) to run, defaulting to the first recipe in the \
diff --git a/src/config.rs b/src/config.rs
index 9f31eec7..cfe569cb 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -2,6 +2,7 @@ use {
   super::*,
   clap::{
     builder::{styling::AnsiColor, FalseyValueParser, PossibleValuesParser, Styles},
+    parser::ValuesRef,
     value_parser, Arg, ArgAction, ArgGroup, ArgMatches, Command,
   },
 };
@@ -421,7 +422,8 @@ impl Config {
           .num_args(0..)
           .value_name("PATH")
           .action(ArgAction::Set)
-          .help("List available recipes and their arguments"),
+          .conflicts_with(arg::ARGUMENTS)
+          .help("List available recipes"),
       )
       .arg(
         Arg::new(cmd::GROUPS)
@@ -439,10 +441,11 @@ impl Config {
         Arg::new(cmd::SHOW)
           .short('s')
           .long("show")
+          .num_args(1..)
           .action(ArgAction::Set)
-          .value_name("RECIPE")
+          .value_name("PATH")
           .conflicts_with(arg::ARGUMENTS)
-          .help("Show information about <RECIPE>"),
+          .help("Show recipe at <PATH>"),
       )
       .arg(
         Arg::new(cmd::SUMMARY)
@@ -557,6 +560,18 @@ impl Config {
     }
   }
 
+  fn parse_module_path(path: ValuesRef<String>) -> ConfigResult<ModulePath> {
+    path
+      .clone()
+      .map(|s| (*s).as_str())
+      .collect::<Vec<&str>>()
+      .as_slice()
+      .try_into()
+      .map_err(|()| ConfigError::ModulePath {
+        path: path.cloned().collect(),
+      })
+  }
+
   fn search_config(matches: &ArgMatches, positional: &Positional) -> ConfigResult<SearchConfig> {
     if matches.get_flag(arg::GLOBAL_JUSTFILE) {
       return Ok(SearchConfig::GlobalJustfile);
@@ -676,22 +691,16 @@ impl Config {
       Subcommand::Init
     } 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(),
-          })?,
+        path: Self::parse_module_path(path)?,
       }
     } 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) {
-      Subcommand::Show { name }
+    } else if let Some(path) = matches.get_many::<String>(cmd::SHOW) {
+      Subcommand::Show {
+        path: Self::parse_module_path(path)?,
+      }
     } else if matches.get_flag(cmd::EVALUATE) {
       if positional.arguments.len() > 1 {
         return Err(ConfigError::SubcommandArguments {
@@ -1298,36 +1307,42 @@ mod tests {
   test! {
     name: subcommand_list_long,
     args: ["--list"],
-    subcommand: Subcommand::List{ path: ModulePath{ path: Vec::new(), spaced: false } },
+    subcommand: Subcommand::List{ path: ModulePath { path: Vec::new(), spaced: false } },
   }
 
   test! {
     name: subcommand_list_short,
     args: ["-l"],
-    subcommand: Subcommand::List{ path: ModulePath{ path: Vec::new(), spaced: false } },
+    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 } },
+    subcommand: Subcommand::List{ path: ModulePath { path: vec!["bar".into()], spaced: false } },
   }
 
   test! {
     name: subcommand_show_long,
     args: ["--show", "build"],
-    subcommand: Subcommand::Show { name: String::from("build") },
+    subcommand: Subcommand::Show { path: ModulePath { path: vec!["build".into()], spaced: false } },
   }
 
   test! {
     name: subcommand_show_short,
     args: ["-s", "build"],
-    subcommand: Subcommand::Show { name: String::from("build") },
+    subcommand: Subcommand::Show { path: ModulePath { path: vec!["build".into()], spaced: false } },
   }
 
-  error! {
-    name: subcommand_show_no_arg,
-    args: ["--show"],
+  test! {
+    name: subcommand_show_multiple_args,
+    args: ["--show", "foo", "bar"],
+    subcommand: Subcommand::Show {
+      path: ModulePath {
+        path: vec!["foo".into(), "bar".into()],
+        spaced: true,
+      },
+    },
   }
 
   test! {
@@ -1602,20 +1617,6 @@ mod tests {
     },
   }
 
-  error_matches! {
-    name: show_arguments,
-    args: ["--show", "foo", "bar"],
-    error: error,
-    check: {
-      assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
-      assert_eq!(error.context().collect::<Vec<_>>(), vec![
-        (ContextKind::InvalidArg, &ContextValue::String("--show <RECIPE>".into())),
-        (ContextKind::PriorArg, &ContextValue::String("[ARGUMENTS]...".into())),
-        (ContextKind::Usage, &ContextValue::StyledStr("\u{1b}[33mUsage:\u{1b}[0m \u{1b}[32mjust\u{1b}[0m \u{1b}[32m--show\u{1b}[0m\u{1b}[32m \u{1b}[0m\u{1b}[32m<RECIPE>\u{1b}[0m \u{1b}[32m[ARGUMENTS]...\u{1b}[0m".into())),
-      ]);
-    },
-  }
-
   error! {
     name: summary_arguments,
     args: ["--summary", "bar"],
diff --git a/src/config_error.rs b/src/config_error.rs
index e59767be..6935b81c 100644
--- a/src/config_error.rs
+++ b/src/config_error.rs
@@ -11,7 +11,7 @@ pub(crate) enum ConfigError {
   ))]
   Internal { message: String },
   #[snafu(display("Invalid module path `{}`", path.join(" ")))]
-  ListPath { path: Vec<String> },
+  ModulePath { path: Vec<String> },
   #[snafu(display(
     "Path-prefixed recipes may not be used with `--working-directory` or `--justfile`."
   ))]
diff --git a/src/subcommand.rs b/src/subcommand.rs
index c859376d..9cf24e3d 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -40,7 +40,7 @@ pub(crate) enum Subcommand {
     overrides: BTreeMap<String, String>,
   },
   Show {
-    name: String,
+    path: ModulePath,
   },
   Summary,
   Variables,
@@ -91,7 +91,7 @@ impl Subcommand {
       Format => Self::format(config, &search, src, ast)?,
       Groups => Self::groups(config, justfile),
       List { path } => Self::list_module(config, justfile, path)?,
-      Show { ref name } => Self::show(config, name, justfile)?,
+      Show { path } => Self::show(config, justfile, path)?,
       Summary => Self::summary(config, justfile),
       Variables => Self::variables(justfile),
       Changelog | Completions { .. } | Edit | Init | Man | Run { .. } => unreachable!(),
@@ -636,19 +636,32 @@ impl Subcommand {
     }
   }
 
-  fn show<'src>(config: &Config, name: &str, justfile: &Justfile<'src>) -> Result<(), Error<'src>> {
-    if let Some(alias) = justfile.get_alias(name) {
-      let recipe = justfile.get_recipe(alias.target.name.lexeme()).unwrap();
+  fn show<'src>(
+    config: &Config,
+    mut module: &Justfile<'src>,
+    path: &ModulePath,
+  ) -> Result<(), Error<'src>> {
+    for name in &path.path[0..path.path.len() - 1] {
+      module = module
+        .modules
+        .get(name)
+        .ok_or_else(|| Error::UnknownSubmodule { path: path.clone() })?;
+    }
+
+    let name = path.path.last().unwrap();
+
+    if let Some(alias) = module.get_alias(name) {
+      let recipe = module.get_recipe(alias.target.name.lexeme()).unwrap();
       println!("{alias}");
       println!("{}", recipe.color_display(config.color.stdout()));
       Ok(())
-    } else if let Some(recipe) = justfile.get_recipe(name) {
+    } else if let Some(recipe) = module.get_recipe(name) {
       println!("{}", recipe.color_display(config.color.stdout()));
       Ok(())
     } else {
       Err(Error::UnknownRecipes {
         recipes: vec![name.to_owned()],
-        suggestion: justfile.suggest_recipe(name),
+        suggestion: module.suggest_recipe(name),
       })
     }
   }


Test patch:
diff --git a/tests/show.rs b/tests/show.rs
index a822d74a..e9fe5e6a 100644
--- a/tests/show.rs
+++ b/tests/show.rs
@@ -100,3 +100,27 @@ a Z="\t z":
   stderr:   "error: Justfile does not contain recipe `fooooooo`.\n",
   status:   EXIT_FAILURE,
 }
+
+#[test]
+fn show_recipe_at_path() {
+  Test::new()
+    .write("foo.just", "bar:\n @echo MODULE")
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .test_round_trip(false)
+    .args(["--unstable", "--show", "foo::bar"])
+    .stdout("bar:\n    @echo MODULE\n")
+    .run();
+}
+
+#[test]
+fn show_invalid_path() {
+  Test::new()
+    .args(["--show", "$hello"])
+    .stderr("error: Invalid module path `$hello`\n")
+    .status(1)
+    .run();
+}