Repository: casey/just
Pull request #2154 (2024-06-14 02:03:53)

Improve argument parsing for submodules


Patch:
diff --git a/src/argument_parser.rs b/src/argument_parser.rs
new file mode 100644
index 00000000..85daccce
--- /dev/null
+++ b/src/argument_parser.rs
@@ -0,0 +1,403 @@
+use super::*;
+
+#[allow(clippy::doc_markdown)]
+/// The argument parser is responsible for grouping positional arguments into
+/// argument groups, which consist of a path to a recipe and its arguments.
+///
+/// Argument parsing is substantially complicated by the fact that recipe paths
+/// can be given on the command line as multiple arguments, i.e., "foo" "bar"
+/// baz", or as a single "::"-separated argument.
+///
+/// Error messages produced by the argument parser should use the format of the
+/// recipe path as passed on the command line.
+///
+/// Additionally, if a recipe is specified with a "::"-separated path, extra
+/// components of that path after a valid recipe must not be used as arguments,
+/// whereas arguments after multiple argument path may be used as arguments. As
+/// an example, `foo bar baz` may refer to recipe `foo::bar` with argument
+/// `baz`, but `foo::bar::baz` is an error, since `bar` is a recipe, not a
+/// module.
+pub(crate) struct ArgumentParser<'src: 'run, 'run> {
+  arguments: &'run [&'run str],
+  next: usize,
+  root: &'run Justfile<'src>,
+}
+
+#[derive(Debug, PartialEq)]
+pub(crate) struct ArgumentGroup<'run> {
+  pub(crate) arguments: Vec<&'run str>,
+  pub(crate) path: Vec<String>,
+}
+
+impl<'src: 'run, 'run> ArgumentParser<'src, 'run> {
+  pub(crate) fn parse_arguments(
+    root: &'run Justfile<'src>,
+    arguments: &'run [&'run str],
+  ) -> RunResult<'src, Vec<ArgumentGroup<'run>>> {
+    let mut groups = Vec::new();
+
+    let mut invocation_parser = Self {
+      arguments,
+      next: 0,
+      root,
+    };
+
+    loop {
+      groups.push(invocation_parser.parse_group()?);
+
+      if invocation_parser.next == arguments.len() {
+        break;
+      }
+    }
+
+    Ok(groups)
+  }
+
+  fn parse_group(&mut self) -> RunResult<'src, ArgumentGroup<'run>> {
+    let (recipe, path) = if let Some(next) = self.next() {
+      if next.contains(':') {
+        let module_path =
+          ModulePath::try_from([next].as_slice()).map_err(|()| Error::UnknownRecipe {
+            recipe: next.into(),
+            suggestion: None,
+          })?;
+        let (recipe, path, _) = self.resolve_recipe(true, &module_path.path)?;
+        self.next += 1;
+        (recipe, path)
+      } else {
+        let (recipe, path, consumed) = self.resolve_recipe(false, self.rest())?;
+        self.next += consumed;
+        (recipe, path)
+      }
+    } else {
+      let (recipe, path, consumed) = self.resolve_recipe(false, self.rest())?;
+      assert_eq!(consumed, 0);
+      (recipe, path)
+    };
+
+    let rest = self.rest();
+
+    let argument_range = recipe.argument_range();
+    let argument_count = cmp::min(rest.len(), recipe.max_arguments());
+    if !argument_range.range_contains(&argument_count) {
+      return Err(Error::ArgumentCountMismatch {
+        recipe: recipe.name(),
+        parameters: recipe.parameters.clone(),
+        found: rest.len(),
+        min: recipe.min_arguments(),
+        max: recipe.max_arguments(),
+      });
+    }
+
+    let arguments = rest[..argument_count].to_vec();
+
+    self.next += argument_count;
+
+    Ok(ArgumentGroup { arguments, path })
+  }
+
+  fn resolve_recipe(
+    &self,
+    module_path: bool,
+    args: &[impl AsRef<str>],
+  ) -> RunResult<'src, (&'run Recipe<'src>, Vec<String>, usize)> {
+    let mut current = self.root;
+    let mut path = Vec::new();
+
+    for (i, arg) in args.iter().enumerate() {
+      let arg = arg.as_ref();
+
+      path.push(arg.to_string());
+
+      if let Some(module) = current.modules.get(arg) {
+        current = module;
+      } else if let Some(recipe) = current.get_recipe(arg) {
+        if module_path && i + 1 < args.len() {
+          return Err(Error::ExpectedSubmoduleButFoundRecipe {
+            path: if module_path {
+              path.join("::")
+            } else {
+              path.join(" ")
+            },
+          });
+        }
+        return Ok((recipe, path, i + 1));
+      } else {
+        if module_path && i + 1 < args.len() {
+          return Err(Error::UnknownSubmodule {
+            path: path.join("::"),
+          });
+        }
+
+        return Err(Error::UnknownRecipe {
+          recipe: if module_path {
+            path.join("::")
+          } else {
+            path.join(" ")
+          },
+          suggestion: current.suggest_recipe(arg),
+        });
+      }
+    }
+
+    if let Some(recipe) = &current.default {
+      recipe.check_can_be_default_recipe()?;
+      path.push(recipe.name().into());
+      Ok((recipe, path, args.len()))
+    } else if current.recipes.is_empty() {
+      Err(Error::NoRecipes)
+    } else {
+      Err(Error::NoDefaultRecipe)
+    }
+  }
+
+  fn next(&self) -> Option<&'run str> {
+    self.arguments.get(self.next).copied()
+  }
+
+  fn rest(&self) -> &[&'run str] {
+    &self.arguments[self.next..]
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use {super::*, tempfile::TempDir};
+
+  trait TempDirExt {
+    fn write(&self, path: &str, content: &str);
+  }
+
+  impl TempDirExt for TempDir {
+    fn write(&self, path: &str, content: &str) {
+      let path = self.path().join(path);
+      fs::create_dir_all(path.parent().unwrap()).unwrap();
+      fs::write(path, content).unwrap();
+    }
+  }
+
+  #[test]
+  fn single_no_arguments() {
+    let justfile = testing::compile("foo:");
+
+    assert_eq!(
+      ArgumentParser::parse_arguments(&justfile, &["foo"]).unwrap(),
+      vec![ArgumentGroup {
+        path: vec!["foo".into()],
+        arguments: Vec::new()
+      }],
+    );
+  }
+
+  #[test]
+  fn single_with_argument() {
+    let justfile = testing::compile("foo bar:");
+
+    assert_eq!(
+      ArgumentParser::parse_arguments(&justfile, &["foo", "baz"]).unwrap(),
+      vec![ArgumentGroup {
+        path: vec!["foo".into()],
+        arguments: vec!["baz"],
+      }],
+    );
+  }
+
+  #[test]
+  fn single_argument_count_mismatch() {
+    let justfile = testing::compile("foo bar:");
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&justfile, &["foo"]).unwrap_err(),
+      Error::ArgumentCountMismatch {
+        recipe: "foo",
+        found: 0,
+        min: 1,
+        max: 1,
+        ..
+      },
+    );
+  }
+
+  #[test]
+  fn single_unknown() {
+    let justfile = testing::compile("foo:");
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&justfile, &["bar"]).unwrap_err(),
+      Error::UnknownRecipe {
+        recipe,
+        suggestion: None
+      } if recipe == "bar",
+    );
+  }
+
+  #[test]
+  fn multiple_unknown() {
+    let justfile = testing::compile("foo:");
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&justfile, &["bar", "baz"]).unwrap_err(),
+      Error::UnknownRecipe {
+        recipe,
+        suggestion: None
+      } if recipe == "bar",
+    );
+  }
+
+  #[test]
+  fn recipe_in_submodule() {
+    let loader = Loader::new();
+    let tempdir = tempfile::tempdir().unwrap();
+    let path = tempdir.path().join("justfile");
+    fs::write(&path, "mod foo").unwrap();
+    fs::create_dir(tempdir.path().join("foo")).unwrap();
+    fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
+    let compilation = Compiler::compile(true, &loader, &path).unwrap();
+
+    assert_eq!(
+      ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "bar"]).unwrap(),
+      vec![ArgumentGroup {
+        path: vec!["foo".into(), "bar".into()],
+        arguments: Vec::new()
+      }],
+    );
+  }
+
+  #[test]
+  fn recipe_in_submodule_unknown() {
+    let loader = Loader::new();
+    let tempdir = tempfile::tempdir().unwrap();
+    let path = tempdir.path().join("justfile");
+    fs::write(&path, "mod foo").unwrap();
+    fs::create_dir(tempdir.path().join("foo")).unwrap();
+    fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
+    let compilation = Compiler::compile(true, &loader, &path).unwrap();
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "zzz"]).unwrap_err(),
+      Error::UnknownRecipe {
+        recipe,
+        suggestion: None
+      } if recipe == "foo zzz",
+    );
+  }
+
+  #[test]
+  fn recipe_in_submodule_path_unknown() {
+    let tempdir = tempfile::tempdir().unwrap();
+    tempdir.write("justfile", "mod foo");
+    tempdir.write("foo.just", "bar:");
+
+    let loader = Loader::new();
+    let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&compilation.justfile, &["foo::zzz"]).unwrap_err(),
+      Error::UnknownRecipe {
+        recipe,
+        suggestion: None
+      } if recipe == "foo::zzz",
+    );
+  }
+
+  #[test]
+  fn module_path_not_consumed() {
+    let tempdir = tempfile::tempdir().unwrap();
+    tempdir.write("justfile", "mod foo");
+    tempdir.write("foo.just", "bar:");
+
+    let loader = Loader::new();
+    let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&compilation.justfile, &["foo::bar::baz"]).unwrap_err(),
+      Error::ExpectedSubmoduleButFoundRecipe {
+        path,
+      } if path == "foo::bar",
+    );
+  }
+
+  #[test]
+  fn no_recipes() {
+    let tempdir = tempfile::tempdir().unwrap();
+    tempdir.write("justfile", "");
+
+    let loader = Loader::new();
+    let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
+      Error::NoRecipes,
+    );
+  }
+
+  #[test]
+  fn default_recipe_requires_arguments() {
+    let tempdir = tempfile::tempdir().unwrap();
+    tempdir.write("justfile", "foo bar:");
+
+    let loader = Loader::new();
+    let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
+      Error::DefaultRecipeRequiresArguments {
+        recipe: "foo",
+        min_arguments: 1,
+      },
+    );
+  }
+
+  #[test]
+  fn no_default_recipe() {
+    let tempdir = tempfile::tempdir().unwrap();
+    tempdir.write("justfile", "import 'foo.just'");
+    tempdir.write("foo.just", "bar:");
+
+    let loader = Loader::new();
+    let compilation = Compiler::compile(true, &loader, &tempdir.path().join("justfile")).unwrap();
+
+    assert_matches!(
+      ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
+      Error::NoDefaultRecipe,
+    );
+  }
+
+  #[test]
+  fn complex_grouping() {
+    let justfile = testing::compile(
+      "
+FOO A B='blarg':
+  echo foo: {{A}} {{B}}
+
+BAR X:
+  echo bar: {{X}}
+
+BAZ +Z:
+  echo baz: {{Z}}
+",
+    );
+
+    assert_eq!(
+      ArgumentParser::parse_arguments(
+        &justfile,
+        &["BAR", "0", "FOO", "1", "2", "BAZ", "3", "4", "5"]
+      )
+      .unwrap(),
+      vec![
+        ArgumentGroup {
+          path: vec!["BAR".into()],
+          arguments: vec!["0"],
+        },
+        ArgumentGroup {
+          path: vec!["FOO".into()],
+          arguments: vec!["1", "2"],
+        },
+        ArgumentGroup {
+          path: vec!["BAZ".into()],
+          arguments: vec!["3", "4", "5"],
+        },
+      ],
+    );
+  }
+}
diff --git a/src/error.rs b/src/error.rs
index 63785d43..ca2acb15 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -95,6 +95,9 @@ pub(crate) enum Error<'src> {
     variable: String,
     suggestion: Option<Suggestion<'src>>,
   },
+  ExpectedSubmoduleButFoundRecipe {
+    path: String,
+  },
   FormatCheckFoundDiff,
   FunctionCall {
     function: Name<'src>,
@@ -162,13 +165,13 @@ pub(crate) enum Error<'src> {
     line_number: Option<usize>,
   },
   UnknownSubmodule {
-    path: ModulePath,
+    path: String,
   },
   UnknownOverrides {
     overrides: Vec<String>,
   },
-  UnknownRecipes {
-    recipes: Vec<String>,
+  UnknownRecipe {
+    recipe: String,
     suggestion: Option<Suggestion<'src>>,
   },
   Unstable {
@@ -365,6 +368,9 @@ impl<'src> ColorDisplay for Error<'src> {
           write!(f, "\n{suggestion}")?;
         }
       }
+      ExpectedSubmoduleButFoundRecipe { path } => {
+        write!(f, "Expected submodule at `{path}` but found recipe.")?;
+      },
       FormatCheckFoundDiff => {
         write!(f, "Formatted justfile differs from original.")?;
       }
@@ -447,10 +453,8 @@ impl<'src> ColorDisplay for Error<'src> {
         let overrides = List::and_ticked(overrides);
         write!(f, "{count} {overrides} overridden on the command line but not present in justfile")?;
       }
-      UnknownRecipes { recipes, suggestion } => {
-        let count = Count("recipe", recipes.len());
-        let recipes = List::or_ticked(recipes);
-        write!(f, "Justfile does not contain {count} {recipes}.")?;
+      UnknownRecipe { recipe, suggestion } => {
+        write!(f, "Justfile does not contain recipe `{recipe}`.")?;
         if let Some(suggestion) = suggestion {
           write!(f, "\n{suggestion}")?;
         }
diff --git a/src/justfile.rs b/src/justfile.rs
index 5b3f0bf7..e108eb91 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -173,66 +173,26 @@ impl<'src> Justfile<'src> {
       _ => {}
     }
 
-    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()?;
-      vec![recipe.name()]
-    } else if self.recipes.is_empty() {
-      return Err(Error::NoRecipes);
-    } else {
-      return Err(Error::NoDefaultRecipe);
-    };
+    let arguments = arguments.iter().map(String::as_str).collect::<Vec<&str>>();
 
-    let mut missing = Vec::new();
-    let mut invocations = Vec::new();
-    let mut scopes = BTreeMap::new();
-    let arena: Arena<Scope> = Arena::new();
-
-    while let Some(first) = remaining.first().copied() {
-      if first.contains("::")
-        && !(first.starts_with(':') || first.ends_with(':') || first.contains(":::"))
-      {
-        remaining = first
-          .split("::")
-          .chain(remaining[1..].iter().copied())
-          .collect();
-
-        continue;
-      }
+    let groups = ArgumentParser::parse_arguments(self, &arguments)?;
 
-      let rest = &remaining[1..];
+    let arena: Arena<Scope> = Arena::new();
+    let mut invocations = Vec::<Invocation>::new();
+    let mut scopes = BTreeMap::new();
 
-      if let Some((invocation, consumed)) = self.invocation(
-        0,
-        &mut Vec::new(),
+    for group in &groups {
+      invocations.push(self.invocation(
         &arena,
-        &mut scopes,
+        &group.arguments,
         config,
         &dotenv,
-        search,
         &scope,
-        first,
-        rest,
-      )? {
-        remaining = rest[consumed..].to_vec();
-        invocations.push(invocation);
-      } else {
-        missing.push(first.to_string());
-        remaining = rest.to_vec();
-      }
-    }
-
-    if !missing.is_empty() {
-      let suggestion = if missing.len() == 1 {
-        self.suggest_recipe(missing.first().unwrap())
-      } else {
-        None
-      };
-      return Err(Error::UnknownRecipes {
-        recipes: missing,
-        suggestion,
-      });
+        &group.path,
+        0,
+        &mut scopes,
+        search,
+      )?);
     }
 
     let mut ran = Ran::default();
@@ -278,21 +238,29 @@ impl<'src> Justfile<'src> {
 
   fn invocation<'run>(
     &'run self,
-    depth: usize,
-    path: &mut Vec<&'run str>,
     arena: &'run Arena<Scope<'src, 'run>>,
-    scopes: &mut BTreeMap<Vec<&'run str>, &'run Scope<'src, 'run>>,
+    arguments: &[&'run str],
     config: &'run Config,
     dotenv: &'run BTreeMap<String, String>,
-    search: &'run Search,
     parent: &'run Scope<'src, 'run>,
-    first: &'run str,
-    rest: &[&'run str],
-  ) -> RunResult<'src, Option<(Invocation<'src, 'run>, usize)>> {
-    if let Some(module) = self.modules.get(first) {
-      path.push(first);
+    path: &'run [String],
+    position: usize,
+    scopes: &mut BTreeMap<&'run [String], &'run Scope<'src, 'run>>,
+    search: &'run Search,
+  ) -> RunResult<'src, Invocation<'src, 'run>> {
+    if position + 1 == path.len() {
+      let recipe = self.get_recipe(&path[position]).unwrap();
+      Ok(Invocation {
+        recipe,
+        module_source: &self.source,
+        arguments: arguments.into(),
+        settings: &self.settings,
+        scope: parent,
+      })
+    } else {
+      let module = self.modules.get(&path[position]).unwrap();
 
-      let scope = if let Some(scope) = scopes.get(path) {
+      let scope = if let Some(scope) = scopes.get(&path[..position]) {
         scope
       } else {
         let scope = Evaluator::evaluate_assignments(
@@ -304,76 +272,21 @@ impl<'src> Justfile<'src> {
           search,
         )?;
         let scope = arena.alloc(scope);
-        scopes.insert(path.clone(), scope);
+        scopes.insert(path, scope);
         scopes.get(path).unwrap()
       };
 
-      if rest.is_empty() {
-        if let Some(recipe) = &module.default {
-          recipe.check_can_be_default_recipe()?;
-          return Ok(Some((
-            Invocation {
-              settings: &module.settings,
-              recipe,
-              arguments: Vec::new(),
-              scope,
-              module_source: &self.source,
-            },
-            depth,
-          )));
-        }
-        Err(Error::NoDefaultRecipe)
-      } else {
-        module.invocation(
-          depth + 1,
-          path,
-          arena,
-          scopes,
-          config,
-          dotenv,
-          search,
-          scope,
-          rest[0],
-          &rest[1..],
-        )
-      }
-    } else if let Some(recipe) = self.get_recipe(first) {
-      if recipe.parameters.is_empty() {
-        Ok(Some((
-          Invocation {
-            arguments: Vec::new(),
-            recipe,
-            scope: parent,
-            settings: &self.settings,
-            module_source: &self.source,
-          },
-          depth,
-        )))
-      } else {
-        let argument_range = recipe.argument_range();
-        let argument_count = cmp::min(rest.len(), recipe.max_arguments());
-        if !argument_range.range_contains(&argument_count) {
-          return Err(Error::ArgumentCountMismatch {
-            recipe: recipe.name(),
-            parameters: recipe.parameters.clone(),
-            found: rest.len(),
-            min: recipe.min_arguments(),
-            max: recipe.max_arguments(),
-          });
-        }
-        Ok(Some((
-          Invocation {
-            arguments: rest[..argument_count].to_vec(),
-            recipe,
-            scope: parent,
-            settings: &self.settings,
-            module_source: &self.source,
-          },
-          depth + argument_count,
-        )))
-      }
-    } else {
-      Ok(None)
+      module.invocation(
+        arena,
+        arguments,
+        config,
+        dotenv,
+        scope,
+        path,
+        position + 1,
+        scopes,
+        search,
+      )
     }
   }
 
@@ -523,21 +436,38 @@ mod tests {
   use Error::*;
 
   run_error! {
-    name: unknown_recipes,
+    name: unknown_recipe_no_suggestion,
     src: "a:\nb:\nc:",
-    args: ["a", "x", "y", "z"],
-    error: UnknownRecipes {
-      recipes,
+    args: ["a", "xyz", "y", "z"],
+    error: UnknownRecipe {
+      recipe,
       suggestion,
     },
     check: {
-      assert_eq!(recipes, &["x", "y", "z"]);
+      assert_eq!(recipe, "xyz");
       assert_eq!(suggestion, None);
     }
   }
 
   run_error! {
-    name: unknown_recipes_show_alias_suggestion,
+    name: unknown_recipe_with_suggestion,
+    src: "a:\nb:\nc:",
+    args: ["a", "x", "y", "z"],
+    error: UnknownRecipe {
+      recipe,
+      suggestion,
+    },
+    check: {
+      assert_eq!(recipe, "x");
+      assert_eq!(suggestion, Some(Suggestion {
+        name: "a",
+        target: None,
+      }));
+    }
+  }
+
+  run_error! {
+    name: unknown_recipe_show_alias_suggestion,
     src: "
       foo:
         echo foo
@@ -545,12 +475,12 @@ mod tests {
       alias z := foo
     ",
     args: ["zz"],
-    error: UnknownRecipes {
-      recipes,
+    error: UnknownRecipe {
+      recipe,
       suggestion,
     },
     check: {
-      assert_eq!(recipes, &["zz"]);
+      assert_eq!(recipe, "zz");
       assert_eq!(suggestion, Some(Suggestion {
         name: "z",
         target: Some("foo"),
diff --git a/src/lib.rs b/src/lib.rs
index a315a841..5275cecf 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -15,7 +15,7 @@
 
 pub(crate) use {
   crate::{
-    alias::Alias, analyzer::Analyzer, assignment::Assignment,
+    alias::Alias, analyzer::Analyzer, argument_parser::ArgumentParser, assignment::Assignment,
     assignment_resolver::AssignmentResolver, ast::Ast, attribute::Attribute, binding::Binding,
     color::Color, color_display::ColorDisplay, command_ext::CommandExt, compilation::Compilation,
     compile_error::CompileError, compile_error_kind::CompileErrorKind, compiler::Compiler,
@@ -113,6 +113,7 @@ pub mod summary;
 
 mod alias;
 mod analyzer;
+mod argument_parser;
 mod assignment;
 mod assignment_resolver;
 mod ast;
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 450ffa33..68b31095 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -150,7 +150,7 @@ impl Subcommand {
         };
 
         match Self::run_inner(config, loader, arguments, overrides, &search) {
-          Err((err @ Error::UnknownRecipes { .. }, true)) => {
+          Err((err @ Error::UnknownRecipe { .. }, true)) => {
             match search.justfile.parent().unwrap().parent() {
               Some(parent) => {
                 unknown_recipes_errors.get_or_insert(err);
@@ -428,7 +428,9 @@ impl Subcommand {
       module = module
         .modules
         .get(name)
-        .ok_or_else(|| Error::UnknownSubmodule { path: path.clone() })?;
+        .ok_or_else(|| Error::UnknownSubmodule {
+          path: path.to_string(),
+        })?;
     }
 
     Self::list_module(config, module, 0);
@@ -588,7 +590,9 @@ impl Subcommand {
       module = module
         .modules
         .get(name)
-        .ok_or_else(|| Error::UnknownSubmodule { path: path.clone() })?;
+        .ok_or_else(|| Error::UnknownSubmodule {
+          path: path.to_string(),
+        })?;
     }
 
     let name = path.path.last().unwrap();
@@ -602,8 +606,8 @@ impl Subcommand {
       println!("{}", recipe.color_display(config.color.stdout()));
       Ok(())
     } else {
-      Err(Error::UnknownRecipes {
-        recipes: vec![name.to_owned()],
+      Err(Error::UnknownRecipe {
+        recipe: name.to_owned(),
         suggestion: module.suggest_recipe(name),
       })
     }


Test patch:
diff --git a/src/testing.rs b/src/testing.rs
index a5977692..5167bc89 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -131,7 +131,7 @@ macro_rules! run_error {
 }
 
 macro_rules! assert_matches {
-  ($expression:expr, $( $pattern:pat_param )|+ $( if $guard:expr )?) => {
+  ($expression:expr, $( $pattern:pat_param )|+ $( if $guard:expr )? $(,)?) => {
     match $expression {
       $( $pattern )|+ $( if $guard )? => {}
       left => panic!(
diff --git a/tests/misc.rs b/tests/misc.rs
index a42e1be3..ad1055a9 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -652,7 +652,7 @@ test! {
   justfile: "hello:",
   args:     ("foo", "bar"),
   stdout:   "",
-  stderr:   "error: Justfile does not contain recipes `foo` or `bar`.\n",
+  stderr:   "error: Justfile does not contain recipe `foo`.\n",
   status:   EXIT_FAILURE,
 }
 
diff --git a/tests/modules.rs b/tests/modules.rs
index 5cccf4e2..43a12541 100644
--- a/tests/modules.rs
+++ b/tests/modules.rs
@@ -115,7 +115,7 @@ fn missing_recipe_after_invalid_path() {
     .test_round_trip(false)
     .arg(":foo::foo")
     .arg("bar")
-    .stderr("error: Justfile does not contain recipes `:foo::foo` or `bar`.\n")
+    .stderr("error: Justfile does not contain recipe `:foo::foo`.\n")
     .status(EXIT_FAILURE)
     .run();
 }
@@ -690,3 +690,94 @@ fn recipes_with_same_name_are_both_run() {
     .stdout("MODULE\nROOT\n")
     .run();
 }
+
+#[test]
+fn submodule_recipe_not_found_error_message() {
+  Test::new()
+    .args(["--unstable", "foo::bar"])
+    .stderr("error: Justfile does not contain submodule `foo`\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn submodule_recipe_not_found_spaced_error_message() {
+  Test::new()
+    .write("foo.just", "bar:\n @echo MODULE")
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .test_round_trip(false)
+    .args(["--unstable", "foo", "baz"])
+    .stderr("error: Justfile does not contain recipe `foo baz`.\nDid you mean `bar`?\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn submodule_recipe_not_found_colon_separated_error_message() {
+  Test::new()
+    .write("foo.just", "bar:\n @echo MODULE")
+    .justfile(
+      "
+        mod foo
+      ",
+    )
+    .test_round_trip(false)
+    .args(["--unstable", "foo::baz"])
+    .stderr("error: Justfile does not contain recipe `foo::baz`.\nDid you mean `bar`?\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn colon_separated_path_does_not_run_recipes() {
+  Test::new()
+    .justfile(
+      "
+        foo:
+          @echo FOO
+
+        bar:
+          @echo BAR
+      ",
+    )
+    .args(["--unstable", "foo::bar"])
+    .stderr("error: Expected submodule at `foo` but found recipe.\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn expected_submodule_but_found_recipe_in_root_error() {
+  Test::new()
+    .justfile("foo:")
+    .arg("foo::baz")
+    .stderr("error: Expected submodule at `foo` but found recipe.\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn expected_submodule_but_found_recipe_in_submodule_error() {
+  Test::new()
+    .justfile("mod foo")
+    .write("foo.just", "bar:")
+    .test_round_trip(false)
+    .args(["--unstable", "foo::bar::baz"])
+    .stderr("error: Expected submodule at `foo::bar` but found recipe.\n")
+    .status(1)
+    .run();
+}
+
+#[test]
+fn colon_separated_path_components_are_not_used_as_arguments() {
+  Test::new()
+    .justfile("foo bar:")
+    .args(["foo::bar"])
+    .stderr("error: Expected submodule at `foo` but found recipe.\n")
+    .status(1)
+    .run();
+}