Repository: casey/just (Rust)

Issue:
Recipe listing has wrong indentation when nested modules are used
Consider the following layout:

```
.
├── a.just
├── b.just
└── justfile
```
```just
# a.just

mod b

top-level:
    @echo top
```

```just
# b.just

nested:
    @echo "nested"
```

```just
# justfile

mod a
```

The output for the mid-level `b` module is misaligned, suggesting that `b` is available at the top level:

```console
$ just --unstable --list
Available recipes:
    a:
        top-level
    b:
            nested
```




Discussion:

Patch:
diff --git a/src/lib.rs b/src/lib.rs
index 9894e92f54..74e85dc7cb 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -29,13 +29,14 @@ pub(crate) use {
     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, 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, verbosity::Verbosity, warning::Warning,
+    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,
+    verbosity::Verbosity, warning::Warning,
   },
   std::{
     borrow::Cow,
@@ -168,6 +169,7 @@ mod range_ext;
 mod recipe;
 mod recipe_context;
 mod recipe_resolver;
+mod recipe_signature;
 mod run;
 mod scope;
 mod search;
diff --git a/src/recipe_signature.rs b/src/recipe_signature.rs
new file mode 100644
index 0000000000..d805ba65fe
--- /dev/null
+++ b/src/recipe_signature.rs
@@ -0,0 +1,16 @@
+use super::*;
+
+pub(crate) struct RecipeSignature<'a> {
+  pub(crate) name: &'a str,
+  pub(crate) recipe: &'a Recipe<'a>,
+}
+
+impl<'a> ColorDisplay for RecipeSignature<'a> {
+  fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result {
+    write!(f, "{}", self.name)?;
+    for parameter in &self.recipe.parameters {
+      write!(f, " {}", parameter.color_display(color))?;
+    }
+    Ok(())
+  }
+}
diff --git a/src/subcommand.rs b/src/subcommand.rs
index ddc1ac0877..ce4e756ca0 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -466,8 +466,12 @@ impl Subcommand {
   fn list(config: &Config, level: usize, justfile: &Justfile) {
     const MAX_WIDTH: usize = 50;
 
+    if level == 0 {
+      print!("{}", config.list_heading);
+    }
+
     // Construct a target to alias map.
-    let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
+    let mut recipe_aliases = BTreeMap::<&str, Vec<&str>>::new();
     if !config.no_aliases {
       for alias in justfile.aliases.values() {
         if alias.is_private() {
@@ -483,7 +487,7 @@ impl Subcommand {
       }
     }
 
-    let mut line_widths: BTreeMap<&str, usize> = BTreeMap::new();
+    let mut line_widths = BTreeMap::<&str, usize>::new();
 
     for (name, recipe) in &justfile.recipes {
       if !recipe.is_public() {
@@ -491,26 +495,24 @@ impl Subcommand {
       }
 
       for name in iter::once(name).chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) {
-        let mut line_width = UnicodeWidthStr::width(*name);
-
-        for parameter in &recipe.parameters {
-          line_width += UnicodeWidthStr::width(
-            format!(" {}", parameter.color_display(Color::never())).as_str(),
-          );
-        }
-
-        if line_width <= MAX_WIDTH {
-          line_widths.insert(name, line_width);
-        }
+        line_widths.insert(
+          name,
+          UnicodeWidthStr::width(
+            RecipeSignature { name, recipe }
+              .color_display(Color::never())
+              .to_string()
+              .as_str(),
+          ),
+        );
       }
     }
 
-    let max_line_width = line_widths.values().copied().max().unwrap_or_default();
-    let doc_color = config.color.stdout().doc();
-
-    if level == 0 {
-      print!("{}", config.list_heading);
-    }
+    let max_line_width = line_widths
+      .values()
+      .filter(|line_width| **line_width <= MAX_WIDTH)
+      .copied()
+      .max()
+      .unwrap_or_default();
 
     for recipe in justfile.public_recipes(config.unsorted) {
       let name = recipe.name();
@@ -519,10 +521,11 @@ impl Subcommand {
         .chain(recipe_aliases.get(name).unwrap_or(&Vec::new()))
         .enumerate()
       {
-        print!("{}{name}", config.list_prefix.repeat(level + 1));
-        for parameter in &recipe.parameters {
-          print!(" {}", parameter.color_display(config.color.stdout()));
-        }
+        print!(
+          "{}{}",
+          config.list_prefix.repeat(level + 1),
+          RecipeSignature { name, recipe }.color_display(config.color.stdout())
+        );
 
         let doc = match (i, recipe.doc) {
           (0, Some(doc)) => Some(Cow::Borrowed(doc)),
@@ -534,10 +537,9 @@ impl Subcommand {
           print!(
             " {:padding$}{} {}",
             "",
-            doc_color.paint("#"),
-            doc_color.paint(&doc),
-            padding = max_line_width
-              .saturating_sub(line_widths.get(name).copied().unwrap_or(max_line_width))
+            config.color.stdout().doc().paint("#"),
+            config.color.stdout().doc().paint(&doc),
+            padding = max_line_width.saturating_sub(line_widths[name]),
           );
         }
 
@@ -546,7 +548,7 @@ impl Subcommand {
     }
 
     for (name, module) in &justfile.modules {
-      println!("    {name}:");
+      println!("{}{name}:", config.list_prefix.repeat(level + 1));
       Self::list(config, level + 1, module);
     }
   }


Test patch:
diff --git a/tests/lib.rs b/tests/lib.rs
index 806cdca6e0..a3d18a583d 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -69,6 +69,7 @@ mod interrupts;
 mod invocation_directory;
 mod json;
 mod line_prefixes;
+mod list;
 mod man;
 mod misc;
 mod modules;
diff --git a/tests/list.rs b/tests/list.rs
new file mode 100644
index 0000000000..cf93410e86
--- /dev/null
+++ b/tests/list.rs
@@ -0,0 +1,79 @@
+use super::*;
+
+#[test]
+fn list_displays_recipes_in_submodules() {
+  Test::new()
+    .write("foo.just", "bar:\n @echo FOO")
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .test_round_trip(false)
+    .arg("--unstable")
+    .arg("--list")
+    .stdout(
+      "
+      Available recipes:
+          foo:
+              bar
+    ",
+    )
+    .run();
+}
+
+#[test]
+fn module_recipe_list_alignment_ignores_private_recipes() {
+  Test::new()
+    .write(
+      "foo.just",
+      "
+# foos
+foo:
+ @echo FOO
+
+[private]
+barbarbar:
+ @echo BAR
+
+@_bazbazbaz:
+ @echo BAZ
+      ",
+    )
+    .justfile("mod foo")
+    .test_round_trip(false)
+    .arg("--unstable")
+    .arg("--list")
+    .stdout(
+      "
+        Available recipes:
+            foo:
+                foo # foos
+      ",
+    )
+    .run();
+}
+
+#[test]
+fn nested_modules_are_properly_indented() {
+  Test::new()
+    .write("foo.just", "mod bar")
+    .write("bar.just", "baz:\n @echo FOO")
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .test_round_trip(false)
+    .arg("--unstable")
+    .arg("--list")
+    .stdout(
+      "
+      Available recipes:
+          foo:
+              bar:
+                  baz
+    ",
+    )
+    .run();
+}
diff --git a/tests/modules.rs b/tests/modules.rs
index 4c10b41361..b9089aebcd 100644
--- a/tests/modules.rs
+++ b/tests/modules.rs
@@ -512,28 +512,6 @@ fn missing_optional_modules_do_not_conflict() {
     .run();
 }
 
-#[test]
-fn list_displays_recipes_in_submodules() {
-  Test::new()
-    .write("foo.just", "bar:\n @echo FOO")
-    .justfile(
-      "
-        mod foo
-      ",
-    )
-    .test_round_trip(false)
-    .arg("--unstable")
-    .arg("--list")
-    .stdout(
-      "
-      Available recipes:
-          foo:
-              bar
-    ",
-    )
-    .run();
-}
-
 #[test]
 fn root_dotenv_is_available_to_submodules() {
   Test::new()
@@ -695,37 +673,6 @@ fn module_paths_beginning_with_tilde_are_expanded_to_homdir() {
     .run();
 }
 
-#[test]
-fn module_recipe_list_alignment_ignores_private_recipes() {
-  Test::new()
-    .write(
-      "foo.just",
-      "
-# foos
-foo:
- @echo FOO
-
-[private]
-barbarbar:
- @echo BAR
-
-@_bazbazbaz:
- @echo BAZ
-      ",
-    )
-    .justfile("mod foo")
-    .test_round_trip(false)
-    .arg("--unstable")
-    .arg("--list")
-    .stdout(
-      "Available recipes:
-    foo:
-        foo # foos
-",
-    )
-    .run();
-}
-
 #[test]
 fn recipes_with_same_name_are_both_run() {
   Test::new()