Repository: casey/just (rust)

Issue:
completions and modules don't work together
The completions script uses `just --summary` to fetch a list of recipes. A recipe `bar` inside module `foo` is listed as `foo::bar`, however module recipes can't be invoked like this ("Justfile does not contain recipe `foo::bar`").

I'm not sure the best solution although I do personally find `foo::bar` easier syntax to read as `foo bar` in my mind looks like a recipe `foo` that takes a parameter `bar`. 

I wonder whether `foo::bar` should be added as additional valid syntax to run a module's recipe?

Pull request description:
Recipes can be invoked with path syntax
Fixes #1807.

Patch:
diff --git a/README.md b/README.md
index f2cd1191..835dfbd8 100644
--- a/README.md
+++ b/README.md
@@ -2669,6 +2669,13 @@ $ just --unstable bar b
 B
 ```
 
+Or with path syntax:
+
+```sh
+$ just --unstable bar::b
+B
+```
+
 If a module is named `foo`, just will search for the module file in `foo.just`,
 `foo/mod.just`, `foo/justfile`, and `foo/.justfile`. In the latter two cases,
 the module file may have any capitalization.
diff --git a/src/justfile.rs b/src/justfile.rs
index 6105e154..8bec2956 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -2,7 +2,7 @@ use {super::*, serde::Serialize};
 
 #[derive(Debug)]
 struct Invocation<'src: 'run, 'run> {
-  arguments: &'run [&'run str],
+  arguments: Vec<&'run str>,
   recipe: &'run Recipe<'src>,
   settings: &'run Settings<'src>,
   scope: &'run Scope<'src, 'run>,
@@ -209,7 +209,7 @@ impl<'src> Justfile<'src> {
       _ => {}
     }
 
-    let argvec: Vec<&str> = if !arguments.is_empty() {
+    let mut remaining: Vec<&str> = if !arguments.is_empty() {
       arguments.iter().map(String::as_str).collect()
     } else if let Some(recipe) = &self.default {
       recipe.check_can_be_default_recipe()?;
@@ -220,15 +220,29 @@ impl<'src> Justfile<'src> {
       return Err(Error::NoDefaultRecipe);
     };
 
-    let arguments = argvec.as_slice();
-
     let mut missing = Vec::new();
     let mut invocations = Vec::new();
-    let mut remaining = arguments;
     let mut scopes = BTreeMap::new();
     let arena: Arena<Scope> = Arena::new();
 
-    while let Some((first, mut rest)) = remaining.split_first() {
+    while let Some(first) = remaining.first().copied() {
+      if first.contains("::") {
+        if first.starts_with(':') || first.ends_with(':') || first.contains(":::") {
+          missing.push(first.to_string());
+          remaining = remaining[1..].to_vec();
+          continue;
+        }
+
+        remaining = first
+          .split("::")
+          .chain(remaining[1..].iter().copied())
+          .collect();
+
+        continue;
+      }
+
+      let rest = &remaining[1..];
+
       if let Some((invocation, consumed)) = self.invocation(
         0,
         &mut Vec::new(),
@@ -241,12 +255,12 @@ impl<'src> Justfile<'src> {
         first,
         rest,
       )? {
-        rest = &rest[consumed..];
+        remaining = rest[consumed..].to_vec();
         invocations.push(invocation);
       } else {
-        missing.push((*first).to_owned());
+        missing.push(first.to_string());
+        remaining = rest.to_vec();
       }
-      remaining = rest;
     }
 
     if !missing.is_empty() {
@@ -273,7 +287,7 @@ impl<'src> Justfile<'src> {
       Self::run_recipe(
         &context,
         invocation.recipe,
-        invocation.arguments,
+        &invocation.arguments,
         &dotenv,
         search,
         &mut ran,
@@ -306,7 +320,7 @@ impl<'src> Justfile<'src> {
     search: &'run Search,
     parent: &'run Scope<'src, 'run>,
     first: &'run str,
-    rest: &'run [&'run str],
+    rest: &[&'run str],
   ) -> RunResult<'src, Option<(Invocation<'src, 'run>, usize)>> {
     if let Some(module) = self.modules.get(first) {
       path.push(first);
@@ -327,7 +341,7 @@ impl<'src> Justfile<'src> {
             Invocation {
               settings: &module.settings,
               recipe,
-              arguments: &[],
+              arguments: Vec::new(),
               scope,
             },
             depth,
@@ -352,7 +366,7 @@ impl<'src> Justfile<'src> {
       if recipe.parameters.is_empty() {
         Ok(Some((
           Invocation {
-            arguments: &[],
+            arguments: Vec::new(),
             recipe,
             scope: parent,
             settings: &self.settings,
@@ -373,7 +387,7 @@ impl<'src> Justfile<'src> {
         }
         Ok(Some((
           Invocation {
-            arguments: &rest[..argument_count],
+            arguments: rest[..argument_count].to_vec(),
             recipe,
             scope: parent,
             settings: &self.settings,


Test patch:
diff --git a/tests/modules.rs b/tests/modules.rs
index 533fd67a..f2ef01a4 100644
--- a/tests/modules.rs
+++ b/tests/modules.rs
@@ -52,6 +52,74 @@ fn module_recipes_can_be_run_as_subcommands() {
     .run();
 }
 
+#[test]
+fn module_recipes_can_be_run_with_path_syntax() {
+  Test::new()
+    .write("foo.just", "foo:\n @echo FOO")
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .test_round_trip(false)
+    .arg("--unstable")
+    .arg("foo::foo")
+    .stdout("FOO\n")
+    .run();
+}
+
+#[test]
+fn nested_module_recipes_can_be_run_with_path_syntax() {
+  Test::new()
+    .write("foo.just", "mod bar")
+    .write("bar.just", "baz:\n @echo BAZ")
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .test_round_trip(false)
+    .arg("--unstable")
+    .arg("foo::bar::baz")
+    .stdout("BAZ\n")
+    .run();
+}
+
+#[test]
+fn invalid_path_syntax() {
+  Test::new()
+    .test_round_trip(false)
+    .arg(":foo::foo")
+    .stderr("error: Justfile does not contain recipe `:foo::foo`.\n")
+    .status(EXIT_FAILURE)
+    .run();
+
+  Test::new()
+    .test_round_trip(false)
+    .arg("foo::foo:")
+    .stderr("error: Justfile does not contain recipe `foo::foo:`.\n")
+    .status(EXIT_FAILURE)
+    .run();
+
+  Test::new()
+    .test_round_trip(false)
+    .arg("foo:::foo")
+    .stderr("error: Justfile does not contain recipe `foo:::foo`.\n")
+    .status(EXIT_FAILURE)
+    .run();
+}
+
+#[test]
+fn missing_recipe_after_invalid_path() {
+  Test::new()
+    .test_round_trip(false)
+    .arg(":foo::foo")
+    .arg("bar")
+    .stderr("error: Justfile does not contain recipes `:foo::foo` or `bar`.\n")
+    .status(EXIT_FAILURE)
+    .run();
+}
+
 #[test]
 fn assignments_are_evaluated_in_modules() {
   Test::new()