From 5c3b72a12185c75b4b25a8ccaed31f4ac630beda Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sun, 31 Dec 2023 14:03:49 -0800 Subject: [PATCH] Recipes can be invoked with path syntax (#1809) --- README.md | 7 +++++ src/justfile.rs | 42 ++++++++++++++++++++---------- tests/modules.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index f2cd119..835dfbd 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 6105e15..8bec295 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 = 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, diff --git a/tests/modules.rs b/tests/modules.rs index 533fd67..f2ef01a 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()