Repository: casey/just
Pull request #1368 (2022-10-13 01:58:55)

Require `set fallback := true` to enable recipe fallback

nk9: We should add `fallback` to the Sublime syntax, too. Line 185.

Patch:
diff --git a/GRAMMAR.md b/GRAMMAR.md
index c32cb6a9..98b0fbd6 100644
--- a/GRAMMAR.md
+++ b/GRAMMAR.md
@@ -62,6 +62,7 @@ export        : 'export' assignment
 setting       : 'set' 'dotenv-load' boolean?
               | 'set' 'ignore-comments' boolean?
               | 'set' 'export' boolean?
+              | 'set' 'fallback' boolean?
               | 'set' 'positional-arguments' boolean?
               | 'set' 'allow-duplicate-recipes' boolean?
               | 'set' 'windows-powershell' boolean?
diff --git a/README.md b/README.md
index 5032bdcd..97c92202 100644
--- a/README.md
+++ b/README.md
@@ -646,6 +646,7 @@ foo:
 | `allow-duplicate-recipes` | boolean            | False   | Allow recipes appearing later in a `justfile` to override earlier recipes with the same name. |
 | `dotenv-load`             | boolean            | False   | Load a `.env` file, if present.                                                               |
 | `export`                  | boolean            | False   | Export all variables as environment variables.                                                |
+| `fallback`                | boolean            | False   | Search `justfile` in parent directory if the first recipe on the command line is not found.   |
 | `ignore-comments`         | boolean            | False   | Ignore recipe lines beginning with `#`.                                                       |
 | `positional-arguments`    | boolean            | False   | Pass positional arguments.                                                                    |
 | `shell`                   | `[COMMAND, ARGS…]` | -       | Set the command used to invoke recipes and evaluate backticks.                                |
@@ -2063,8 +2064,10 @@ The `--dump` command can be used with `--dump-format json` to print a JSON repre
 
 ### Falling back to parent `justfile`s
 
-If a recipe is not found, `just` will look for `justfile`s in the parent
-directory and up, until it reaches the root directory.
+If a recipe is not found in a `justfile` and the `fallback` setting is set,
+`just` will look for `justfile`s in the parent directory and up, until it
+reaches the root directory. `just` will stop after it reaches a `justfile` in
+which the `fallback` setting is `false` or unset.
 
 This feature is currently unstable, and so must be enabled with the
 `--unstable` flag.
@@ -2072,6 +2075,7 @@ This feature is currently unstable, and so must be enabled with the
 As an example, suppose the current directory contains this `justfile`:
 
 ```make
+set fallback
 foo:
   echo foo
 ```
diff --git a/src/analyzer.rs b/src/analyzer.rs
index bbd4434f..22fe923e 100644
--- a/src/analyzer.rs
+++ b/src/analyzer.rs
@@ -53,6 +53,9 @@ impl<'src> Analyzer<'src> {
         Setting::Export(export) => {
           settings.export = export;
         }
+        Setting::Fallback(fallback) => {
+          settings.fallback = fallback;
+        }
         Setting::IgnoreComments(ignore_comments) => {
           settings.ignore_comments = ignore_comments;
         }
diff --git a/src/keyword.rs b/src/keyword.rs
index 439a1540..11b0f3f4 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -8,6 +8,7 @@ pub(crate) enum Keyword {
   DotenvLoad,
   Else,
   Export,
+  Fallback,
   False,
   If,
   IgnoreComments,
diff --git a/src/node.rs b/src/node.rs
index 65c1c63c..8ce634ce 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -227,6 +227,7 @@ impl<'src> Node<'src> for Set<'src> {
       Setting::AllowDuplicateRecipes(value)
       | Setting::DotenvLoad(value)
       | Setting::Export(value)
+      | Setting::Fallback(value)
       | Setting::PositionalArguments(value)
       | Setting::WindowsPowerShell(value)
       | Setting::IgnoreComments(value) => {
diff --git a/src/parser.rs b/src/parser.rs
index 4e76082c..44c06c0f 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -761,8 +761,9 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
           Some(Setting::AllowDuplicateRecipes(self.parse_set_bool()?))
         }
         Keyword::DotenvLoad => Some(Setting::DotenvLoad(self.parse_set_bool()?)),
-        Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)),
         Keyword::Export => Some(Setting::Export(self.parse_set_bool()?)),
+        Keyword::Fallback => Some(Setting::Fallback(self.parse_set_bool()?)),
+        Keyword::IgnoreComments => Some(Setting::IgnoreComments(self.parse_set_bool()?)),
         Keyword::PositionalArguments => Some(Setting::PositionalArguments(self.parse_set_bool()?)),
         Keyword::WindowsPowershell => Some(Setting::WindowsPowerShell(self.parse_set_bool()?)),
         _ => None,
diff --git a/src/setting.rs b/src/setting.rs
index a30abdc2..74c23e44 100644
--- a/src/setting.rs
+++ b/src/setting.rs
@@ -4,8 +4,9 @@ use super::*;
 pub(crate) enum Setting<'src> {
   AllowDuplicateRecipes(bool),
   DotenvLoad(bool),
-  IgnoreComments(bool),
   Export(bool),
+  Fallback(bool),
+  IgnoreComments(bool),
   PositionalArguments(bool),
   Shell(Shell<'src>),
   WindowsPowerShell(bool),
@@ -17,8 +18,9 @@ impl<'src> Display for Setting<'src> {
     match self {
       Setting::AllowDuplicateRecipes(value)
       | Setting::DotenvLoad(value)
-      | Setting::IgnoreComments(value)
       | Setting::Export(value)
+      | Setting::Fallback(value)
+      | Setting::IgnoreComments(value)
       | Setting::PositionalArguments(value)
       | Setting::WindowsPowerShell(value) => write!(f, "{}", value),
       Setting::Shell(shell) | Setting::WindowsShell(shell) => write!(f, "{}", shell),
diff --git a/src/settings.rs b/src/settings.rs
index 6e27305f..dbb732cc 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -10,6 +10,7 @@ pub(crate) struct Settings<'src> {
   pub(crate) allow_duplicate_recipes: bool,
   pub(crate) dotenv_load: Option<bool>,
   pub(crate) export: bool,
+  pub(crate) fallback: bool,
   pub(crate) ignore_comments: bool,
   pub(crate) positional_arguments: bool,
   pub(crate) shell: Option<Shell<'src>>,
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 90f1bf3e..ef990587 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -135,7 +135,7 @@ impl Subcommand {
         };
 
         match Self::run_inner(config, loader, arguments, overrides, &search) {
-          Err(err @ Error::UnknownRecipes { .. }) => {
+          Err((err @ Error::UnknownRecipes { .. }, true)) => {
             match search.justfile.parent().unwrap().parent() {
               Some(parent) => {
                 unknown_recipes_errors.get_or_insert(err);
@@ -144,7 +144,7 @@ impl Subcommand {
               None => return Err(err),
             }
           }
-          result => return result,
+          result => return result.map_err(|(err, _fallback)| err),
         }
       }
     } else {
@@ -155,6 +155,7 @@ impl Subcommand {
         overrides,
         &Search::find(&config.search_config, &config.invocation_directory)?,
       )
+      .map_err(|(err, _fallback)| err)
     }
   }
 
@@ -164,9 +165,12 @@ impl Subcommand {
     arguments: &[String],
     overrides: &BTreeMap<String, String>,
     search: &Search,
-  ) -> Result<(), Error<'src>> {
-    let (_src, _ast, justfile) = Self::compile(config, loader, search)?;
-    justfile.run(config, search, overrides, arguments)
+  ) -> Result<(), (Error<'src>, bool)> {
+    let (_src, _ast, justfile) =
+      Self::compile(config, loader, search).map_err(|err| (err, false))?;
+    justfile
+      .run(config, search, overrides, arguments)
+      .map_err(|err| (err, justfile.settings.fallback))
   }
 
   fn compile<'src>(


Test patch:
diff --git a/tests/fall_back_to_parent.rs b/tests/fall_back_to_parent.rs
index a5d03cfa..4daa2a50 100644
--- a/tests/fall_back_to_parent.rs
+++ b/tests/fall_back_to_parent.rs
@@ -6,6 +6,40 @@ fn runs_recipe_in_parent_if_not_found_in_current() {
     .tree(tree! {
       bar: {
         justfile: "
+          set fallback
+
+          baz:
+            echo subdir
+        "
+      }
+    })
+    .justfile(
+      "
+      foo:
+        echo root
+    ",
+    )
+    .args(&["--unstable", "foo"])
+    .current_dir("bar")
+    .stderr(format!(
+      "
+      Trying ..{}justfile
+      echo root
+    ",
+      MAIN_SEPARATOR
+    ))
+    .stdout("root\n")
+    .run();
+}
+
+#[test]
+fn setting_accepts_value() {
+  Test::new()
+    .tree(tree! {
+      bar: {
+        justfile: "
+          set fallback := true
+
           baz:
             echo subdir
         "
@@ -36,6 +70,8 @@ fn print_error_from_parent_if_recipe_not_found_in_current() {
     .tree(tree! {
       bar: {
         justfile: "
+          set fallback
+
           baz:
             echo subdir
         "
@@ -64,6 +100,8 @@ fn requires_unstable() {
     .tree(tree! {
       bar: {
         justfile: "
+          set fallback
+
           baz:
             echo subdir
         "
@@ -82,12 +120,38 @@ fn requires_unstable() {
     .run();
 }
 
+#[test]
+fn requires_setting() {
+  Test::new()
+    .tree(tree! {
+      bar: {
+        justfile: "
+          baz:
+            echo subdir
+        "
+      }
+    })
+    .justfile(
+      "
+      foo:
+        echo root
+    ",
+    )
+    .args(&["--unstable", "foo"])
+    .current_dir("bar")
+    .status(EXIT_FAILURE)
+    .stderr("error: Justfile does not contain recipe `foo`.\n")
+    .run();
+}
+
 #[test]
 fn works_with_provided_search_directory() {
   Test::new()
     .tree(tree! {
       bar: {
         justfile: "
+          set fallback
+
           baz:
             echo subdir
         "
@@ -95,6 +159,8 @@ fn works_with_provided_search_directory() {
     })
     .justfile(
       "
+      set fallback
+
       foo:
         echo root
     ",
@@ -118,6 +184,8 @@ fn doesnt_work_with_justfile() {
     .tree(tree! {
       bar: {
         justfile: "
+          set fallback
+
           baz:
             echo subdir
         "
@@ -125,6 +193,8 @@ fn doesnt_work_with_justfile() {
     })
     .justfile(
       "
+      set fallback
+
       foo:
         echo root
     ",
@@ -142,6 +212,8 @@ fn doesnt_work_with_justfile_and_working_directory() {
     .tree(tree! {
       bar: {
         justfile: "
+          set fallback
+
           baz:
             echo subdir
         "
@@ -149,6 +221,8 @@ fn doesnt_work_with_justfile_and_working_directory() {
     })
     .justfile(
       "
+      set fallback
+
       foo:
         echo root
     ",
@@ -173,6 +247,8 @@ fn prints_correct_error_message_when_recipe_not_found() {
     .tree(tree! {
       bar: {
         justfile: "
+          set fallback
+
           bar:
             echo subdir
         "
@@ -196,3 +272,82 @@ fn prints_correct_error_message_when_recipe_not_found() {
     ))
     .run();
 }
+
+#[test]
+fn multiple_levels_of_fallback_work() {
+  Test::new()
+    .tree(tree! {
+      a: {
+        b: {
+          justfile: "
+            set fallback
+
+            foo:
+              echo subdir
+          "
+        },
+        justfile: "
+          set fallback
+
+          bar:
+            echo subdir
+        "
+      }
+    })
+    .justfile(
+      "
+      baz:
+        echo root
+    ",
+    )
+    .args(&["--unstable", "baz"])
+    .current_dir("a/b")
+    .stdout("root\n")
+    .stderr(format!(
+      "
+      Trying ..{}justfile
+      Trying ..{}..{}justfile
+      echo root
+    ",
+      MAIN_SEPARATOR, MAIN_SEPARATOR, MAIN_SEPARATOR
+    ))
+    .run();
+}
+
+#[test]
+fn stop_fallback_when_fallback_is_false() {
+  Test::new()
+    .tree(tree! {
+      a: {
+        b: {
+          justfile: "
+            set fallback
+            foo:
+              echo subdir
+          "
+        },
+        justfile: "
+          bar:
+            echo subdir
+        "
+      }
+    })
+    .justfile(
+      "
+      baz:
+        echo root
+    ",
+    )
+    .args(&["--unstable", "baz"])
+    .current_dir("a/b")
+    .stderr(format!(
+      "
+      Trying ..{}justfile
+      error: Justfile does not contain recipe `baz`.
+      Did you mean `bar`?
+    ",
+      MAIN_SEPARATOR
+    ))
+    .status(EXIT_FAILURE)
+    .run();
+}
diff --git a/tests/json.rs b/tests/json.rs
index 8742b0b3..d70ea6ea 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -42,6 +42,7 @@ fn alias() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "positional_arguments": false,
         "shell": null,
         "ignore_comments": false,
@@ -72,6 +73,7 @@ fn assignment() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -115,6 +117,7 @@ fn body() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -168,6 +171,7 @@ fn dependencies() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -258,6 +262,7 @@ fn dependency_argument() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -312,6 +317,7 @@ fn duplicate_recipes() {
         "allow_duplicate_recipes": true,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -348,6 +354,7 @@ fn doc_comment() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -372,6 +379,7 @@ fn empty_justfile() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -505,6 +513,7 @@ fn parameters() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -577,6 +586,7 @@ fn priors() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -613,6 +623,7 @@ fn private() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -649,6 +660,7 @@ fn quiet() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -676,6 +688,7 @@ fn settings() {
     "
       set dotenv-load
       set export
+      set fallback
       set positional-arguments
       set ignore-comments
       set shell := ['a', 'b', 'c']
@@ -704,6 +717,7 @@ fn settings() {
         "allow_duplicate_recipes": false,
         "dotenv_load": true,
         "export": true,
+        "fallback": true,
         "ignore_comments": true,
         "positional_arguments": true,
         "shell": {
@@ -746,6 +760,7 @@ fn shebang() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "ignore_comments": false,
         "positional_arguments": false,
         "shell": null,
@@ -782,6 +797,7 @@ fn simple() {
         "allow_duplicate_recipes": false,
         "dotenv_load": null,
         "export": false,
+        "fallback": false,
         "positional_arguments": false,
         "shell": null,
         "ignore_comments": false,