Repository: casey/just
Pull request #2829 (2025-07-18 21:37:49)

Submodules: Fix readme and issue 2824
See commit messages for details.

This fixes issue https://github.com/casey/just/issues/2824 

casey: Thanks for this PR!

I'm not sure about the readme changes, so I reverted them.

I also didn't like the approach of passing in an execution context at the top level. Instead, I re-used the scope dictionary to also store a reference to the justfile, and retrieve it when we retrieve the scope, and build the correct execution context at that point.

Patch:
diff --git a/README.md b/README.md
index 3220ecff..86036a17 100644
--- a/README.md
+++ b/README.md
@@ -3797,10 +3797,9 @@ Available recipes:
     foo ... # foo is a great module!
 ```
 
-Modules are still missing a lot of features, for example, the ability to depend
-on recipes and refer to variables in other modules. See the
-[module improvement tracking issue](https://github.com/casey/just/issues/2252)
-for more information.
+Modules are still missing a lot of features, for example, the ability to refer
+to variables in other modules. See the [module improvement tracking
+issue](https://github.com/casey/just/issues/2252) for more information.
 
 ### Hiding `justfile`s
 
diff --git a/src/justfile.rs b/src/justfile.rs
index f0b26d65..bb113d62 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -3,7 +3,6 @@ use {super::*, serde::Serialize};
 #[derive(Debug)]
 struct Invocation<'src: 'run, 'run> {
   arguments: Vec<&'run str>,
-  module: &'run Justfile<'src>,
   recipe: &'run Recipe<'src>,
 }
 
@@ -69,24 +68,23 @@ impl<'src> Justfile<'src> {
     )
   }
 
-  fn evaluate_assignments<'run>(
+  fn evaluate_scopes<'run>(
     &'run self,
     arena: &'run Arena<Scope<'src, 'run>>,
     config: &'run Config,
     dotenv: &'run BTreeMap<String, String>,
     overrides: &BTreeMap<String, String>,
     root: &'run Scope<'src, 'run>,
-    scopes: &mut BTreeMap<String, &'run Scope<'src, 'run>>,
+    scopes: &mut BTreeMap<String, (&'run Justfile<'src>, &'run Scope<'src, 'run>)>,
     search: &'run Search,
   ) -> RunResult<'src> {
     let scope = Evaluator::evaluate_assignments(config, dotenv, self, overrides, root, search)?;
 
     let scope = arena.alloc(scope);
-    scopes.insert(self.module_path.clone(), scope);
-    scopes.get(&self.module_path).unwrap();
+    scopes.insert(self.module_path.clone(), (self, scope));
 
     for module in self.modules.values() {
-      module.evaluate_assignments(
+      module.evaluate_scopes(
         arena,
         config,
         dotenv,
@@ -128,7 +126,7 @@ impl<'src> Justfile<'src> {
     let root = Scope::root();
     let arena = Arena::new();
     let mut scopes = BTreeMap::new();
-    self.evaluate_assignments(
+    self.evaluate_scopes(
       &arena,
       config,
       &dotenv,
@@ -138,7 +136,7 @@ impl<'src> Justfile<'src> {
       search,
     )?;
 
-    let scope = scopes.get(&self.module_path).unwrap();
+    let scope = scopes.get(&self.module_path).unwrap().1;
 
     match &config.subcommand {
       Subcommand::Command {
@@ -230,13 +228,6 @@ impl<'src> Justfile<'src> {
 
     let ran = Ran::default();
     for invocation in invocations {
-      let context = ExecutionContext {
-        config,
-        dotenv: &dotenv,
-        module: invocation.module,
-        search,
-      };
-
       Self::run_recipe(
         &invocation
           .arguments
@@ -244,11 +235,13 @@ impl<'src> Justfile<'src> {
           .copied()
           .map(str::to_string)
           .collect::<Vec<String>>(),
-        &context,
+        config,
+        &dotenv,
         false,
         &ran,
         invocation.recipe,
         &scopes,
+        search,
       )?;
     }
 
@@ -289,7 +282,6 @@ impl<'src> Justfile<'src> {
       let recipe = self.get_recipe(&path[position]).unwrap();
       Ok(Invocation {
         arguments: arguments.into(),
-        module: self,
         recipe,
       })
     } else {
@@ -308,11 +300,13 @@ impl<'src> Justfile<'src> {
 
   fn run_recipe(
     arguments: &[String],
-    context: &ExecutionContext<'src, '_>,
+    config: &Config,
+    dotenv: &BTreeMap<String, String>,
     is_dependency: bool,
     ran: &Ran<'src>,
     recipe: &Recipe<'src>,
-    scopes: &BTreeMap<String, &Scope<'src, '_>>,
+    scopes: &BTreeMap<String, (&Justfile<'src>, &Scope<'src, '_>)>,
+    search: &Search,
   ) -> RunResult<'src> {
     let mutex = ran.mutex(&recipe.namepath, arguments);
 
@@ -322,41 +316,59 @@ impl<'src> Justfile<'src> {
       return Ok(());
     }
 
-    if !context.config.yes && !recipe.confirm()? {
+    if !config.yes && !recipe.confirm()? {
       return Err(Error::NotConfirmed {
         recipe: recipe.name(),
       });
     }
 
-    let scope = scopes
+    let (module, scope) = scopes
       .get(&recipe.module_path())
       .expect("failed to retrieve scope for module");
 
-    let (outer, positional) =
-      Evaluator::evaluate_parameters(context, is_dependency, arguments, &recipe.parameters, scope)?;
+    let context = ExecutionContext {
+      config,
+      dotenv,
+      module,
+      search,
+    };
+
+    let (outer, positional) = Evaluator::evaluate_parameters(
+      &context,
+      is_dependency,
+      arguments,
+      &recipe.parameters,
+      scope,
+    )?;
 
     let scope = outer.child();
 
-    let mut evaluator = Evaluator::new(context, true, &scope);
+    let mut evaluator = Evaluator::new(&context, true, &scope);
 
     Self::run_dependencies(
-      context,
+      config,
+      &context,
       recipe.priors(),
+      dotenv,
       &mut evaluator,
       ran,
       recipe,
       scopes,
+      search,
     )?;
 
-    recipe.run(context, &scope, &positional, is_dependency)?;
+    recipe.run(&context, &scope, &positional, is_dependency)?;
 
     Self::run_dependencies(
-      context,
+      config,
+      &context,
       recipe.subsequents(),
+      dotenv,
       &mut evaluator,
       &Ran::default(),
       recipe,
       scopes,
+      search,
     )?;
 
     *guard = true;
@@ -365,12 +377,15 @@ impl<'src> Justfile<'src> {
   }
 
   fn run_dependencies<'run>(
+    config: &Config,
     context: &ExecutionContext<'src, 'run>,
     dependencies: &[Dependency<'src>],
+    dotenv: &BTreeMap<String, String>,
     evaluator: &mut Evaluator<'src, 'run>,
     ran: &Ran<'src>,
     recipe: &Recipe<'src>,
-    scopes: &BTreeMap<String, &Scope<'src, 'run>>,
+    scopes: &BTreeMap<String, (&Justfile<'src>, &Scope<'src, 'run>)>,
+    search: &Search,
   ) -> RunResult<'src> {
     if context.config.no_dependencies {
       return Ok(());
@@ -389,10 +404,11 @@ impl<'src> Justfile<'src> {
       thread::scope::<_, RunResult>(|thread_scope| {
         let mut handles = Vec::new();
         for (recipe, arguments) in evaluated {
-          handles.push(
-            thread_scope
-              .spawn(move || Self::run_recipe(&arguments, context, true, ran, recipe, scopes)),
-          );
+          handles.push(thread_scope.spawn(move || {
+            Self::run_recipe(
+              &arguments, config, dotenv, true, ran, recipe, scopes, search,
+            )
+          }));
         }
         for handle in handles {
           handle
@@ -403,7 +419,9 @@ impl<'src> Justfile<'src> {
       })?;
     } else {
       for (recipe, arguments) in evaluated {
-        Self::run_recipe(&arguments, context, true, ran, recipe, scopes)?;
+        Self::run_recipe(
+          &arguments, config, dotenv, true, ran, recipe, scopes, search,
+        )?;
       }
     }
 


Test patch:
diff --git a/tests/modules.rs b/tests/modules.rs
index 6075aa77..9e1a15b9 100644
--- a/tests/modules.rs
+++ b/tests/modules.rs
@@ -601,6 +601,65 @@ fn submodule_shebang_recipes_run_in_submodule_directory() {
     .run();
 }
 
+#[test]
+fn cross_module_dependency_runs_in_submodule_directory() {
+  Test::new()
+    .write("foo/bar", "BAR")
+    .write("foo/mod.just", "foo:\n @cat bar")
+    .justfile(
+      "
+        mod foo
+
+        main: foo::foo
+      ",
+    )
+    .arg("main")
+    .stdout("BAR")
+    .run();
+}
+
+#[test]
+fn cross_module_dependency_with_no_cd_runs_in_invocation_directory() {
+  Test::new()
+    .write("root_file", "ROOT")
+    .write(
+      "foo/mod.just",
+      "
+[no-cd]
+foo:
+  @cat root_file
+      ",
+    )
+    .justfile(
+      "
+        mod foo
+
+        main: foo::foo
+      ",
+    )
+    .arg("main")
+    .stdout("ROOT")
+    .run();
+}
+
+#[test]
+fn nested_cross_module_dependency_runs_in_correct_directory() {
+  Test::new()
+    .write("outer/inner/file", "NESTED")
+    .write("outer/inner/mod.just", "task:\n @cat file")
+    .write("outer/mod.just", "mod inner")
+    .justfile(
+      "
+        mod outer
+
+        main: outer::inner::task
+      ",
+    )
+    .arg("main")
+    .stdout("NESTED")
+    .run();
+}
+
 #[cfg(not(windows))]
 #[test]
 fn module_paths_beginning_with_tilde_are_expanded_to_homdir() {