diff options
author | Casey Rodarmor <casey@rodarmor.com> | 2023-12-27 20:27:15 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-12-28 04:27:15 +0000 |
commit | 316ea0129524f69aaff8ba2d5ff2907a3eb761ea (patch) | |
tree | 57d29c0b6c34463762275f129da325390a18af67 | |
parent | bc628215c038c69e7c51e51796b35d505fc95695 (diff) |
Add modules (#1782)
-rw-r--r-- | README.md | 47 | ||||
-rw-r--r-- | src/alias.rs | 4 | ||||
-rw-r--r-- | src/analyzer.rs | 91 | ||||
-rw-r--r-- | src/compile_error.rs | 45 | ||||
-rw-r--r-- | src/compile_error_kind.rs | 10 | ||||
-rw-r--r-- | src/compiler.rs | 81 | ||||
-rw-r--r-- | src/error.rs | 16 | ||||
-rw-r--r-- | src/item.rs | 11 | ||||
-rw-r--r-- | src/justfile.rs | 250 | ||||
-rw-r--r-- | src/keyword.rs | 1 | ||||
-rw-r--r-- | src/node.rs | 3 | ||||
-rw-r--r-- | src/parser.rs | 7 | ||||
-rw-r--r-- | src/recipe.rs | 12 | ||||
-rw-r--r-- | src/recipe_context.rs | 2 | ||||
-rw-r--r-- | src/scope.rs | 4 | ||||
-rw-r--r-- | src/search.rs | 4 | ||||
-rw-r--r-- | src/subcommand.rs | 19 | ||||
-rw-r--r-- | src/summary.rs | 2 | ||||
-rw-r--r-- | src/testing.rs | 2 | ||||
-rw-r--r-- | tests/json.rs | 128 | ||||
-rw-r--r-- | tests/lib.rs | 1 | ||||
-rw-r--r-- | tests/misc.rs | 8 | ||||
-rw-r--r-- | tests/modules.rs | 446 |
23 files changed, 1025 insertions, 169 deletions
@@ -2329,7 +2329,7 @@ And will both invoke recipes `a` and `b` in `foo/justfile`. ### Imports -One `justfile` can include the contents of another using an `import` statement. +One `justfile` can include the contents of another using `import` statements. If you have the following `justfile`: @@ -2366,6 +2366,51 @@ and recipes defined after the `import` statement. Imported files can themselves contain `import`s, which are processed recursively. +### Modules<sup>master</sup> + +A `justfile` can declare modules using `mod` statements. `mod` statements are +currently unstable, so you'll need to use the `--unstable` flag, or set the +`JUST_UNSTABLE` environment variable to use them. + +If you have the following `justfile`: + +```mf +mod bar + +a: + @echo A +``` + +And the following text in `bar.just`: + +```just +b: + @echo B +``` + +`bar.just` will be included in `justfile` as a submodule. Recipes, aliases, and +variables defined in one submodule cannot be used in another, and each module +uses its own settings. + +Recipes in submodules can be invoked as subcommands: + +```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. + +Environment files are loaded for the root justfile. + +Currently, recipes in submodules run with the same working directory as the +root `justfile`, and the `justfile()` and `justfile_directory()` functions +return the path to the root `justfile` and its parent directory. + +See the [module stabilization tracking issue](https://github.com/casey/just/issues/929) for more information. + ### Hiding `justfile`s `just` looks for `justfile`s named `justfile` and `.justfile`, which can be used to keep a `justfile` hidden. diff --git a/src/alias.rs b/src/alias.rs index 0c19fe5a..bb9d4400 100644 --- a/src/alias.rs +++ b/src/alias.rs @@ -13,10 +13,6 @@ pub(crate) struct Alias<'src, T = Rc<Recipe<'src>>> { } impl<'src> Alias<'src, Name<'src>> { - pub(crate) fn line_number(&self) -> usize { - self.name.line - } - pub(crate) fn resolve(self, target: Rc<Recipe<'src>>) -> Alias<'src> { assert_eq!(self.target.lexeme(), target.name.lexeme()); diff --git a/src/analyzer.rs b/src/analyzer.rs index e8745e95..0f138a7c 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -9,7 +9,7 @@ pub(crate) struct Analyzer<'src> { impl<'src> Analyzer<'src> { pub(crate) fn analyze( - loaded: Vec<PathBuf>, + loaded: &[PathBuf], paths: &HashMap<PathBuf, PathBuf>, asts: &HashMap<PathBuf, Ast<'src>>, root: &Path, @@ -19,7 +19,7 @@ impl<'src> Analyzer<'src> { fn justfile( mut self, - loaded: Vec<PathBuf>, + loaded: &[PathBuf], paths: &HashMap<PathBuf, PathBuf>, asts: &HashMap<PathBuf, Ast<'src>>, root: &Path, @@ -31,11 +31,42 @@ impl<'src> Analyzer<'src> { let mut warnings = Vec::new(); + let mut modules: BTreeMap<String, (Name, Justfile)> = BTreeMap::new(); + + let mut definitions: HashMap<&str, (&'static str, Name)> = HashMap::new(); + + let mut define = |name: Name<'src>, + second_type: &'static str, + duplicates_allowed: bool| + -> CompileResult<'src, ()> { + if let Some((first_type, original)) = definitions.get(name.lexeme()) { + if !(*first_type == second_type && duplicates_allowed) { + let (original, redefinition) = if name.line < original.line { + (name, *original) + } else { + (*original, name) + }; + + return Err(redefinition.token().error(Redefinition { + first_type, + second_type, + name: name.lexeme(), + first: original.line, + })); + } + } + + definitions.insert(name.lexeme(), (second_type, name)); + + Ok(()) + }; + while let Some(ast) = stack.pop() { for item in &ast.items { match item { Item::Alias(alias) => { - self.analyze_alias(alias)?; + define(alias.name, "alias", false)?; + Self::analyze_alias(alias)?; self.aliases.insert(alias.clone()); } Item::Assignment(assignment) => { @@ -43,6 +74,19 @@ impl<'src> Analyzer<'src> { self.assignments.insert(assignment.clone()); } Item::Comment(_) => (), + Item::Import { absolute, .. } => { + stack.push(asts.get(absolute.as_ref().unwrap()).unwrap()); + } + Item::Mod { absolute, name } => { + define(*name, "module", false)?; + modules.insert( + name.to_string(), + ( + *name, + Self::analyze(loaded, paths, asts, absolute.as_ref().unwrap())?, + ), + ); + } Item::Recipe(recipe) => { if recipe.enabled() { Self::analyze_recipe(recipe)?; @@ -53,9 +97,6 @@ impl<'src> Analyzer<'src> { self.analyze_set(set)?; self.sets.insert(set.clone()); } - Item::Import { absolute, .. } => { - stack.push(asts.get(absolute.as_ref().unwrap()).unwrap()); - } } } @@ -69,14 +110,7 @@ impl<'src> Analyzer<'src> { AssignmentResolver::resolve_assignments(&self.assignments)?; for recipe in recipes { - if let Some(original) = recipe_table.get(recipe.name.lexeme()) { - if !settings.allow_duplicate_recipes { - return Err(recipe.name.token().error(DuplicateRecipe { - recipe: original.name(), - first: original.line_number(), - })); - } - } + define(recipe.name, "recipe", settings.allow_duplicate_recipes)?; recipe_table.insert(recipe.clone()); } @@ -103,10 +137,14 @@ impl<'src> Analyzer<'src> { }), aliases, assignments: self.assignments, - loaded, + loaded: loaded.into(), recipes, settings, warnings, + modules: modules + .into_iter() + .map(|(name, (_name, justfile))| (name, justfile)) + .collect(), }) } @@ -164,16 +202,9 @@ impl<'src> Analyzer<'src> { Ok(()) } - fn analyze_alias(&self, alias: &Alias<'src, Name<'src>>) -> CompileResult<'src, ()> { + fn analyze_alias(alias: &Alias<'src, Name<'src>>) -> CompileResult<'src, ()> { let name = alias.name.lexeme(); - if let Some(original) = self.aliases.get(name) { - return Err(alias.name.token().error(DuplicateAlias { - alias: name, - first: original.line_number(), - })); - } - for attr in &alias.attributes { if *attr != Attribute::Private { return Err(alias.name.token().error(AliasInvalidAttribute { @@ -232,7 +263,7 @@ mod tests { line: 1, column: 6, width: 3, - kind: DuplicateAlias { alias: "foo", first: 0 }, + kind: Redefinition { first_type: "alias", second_type: "alias", name: "foo", first: 0 }, } analysis_error! { @@ -248,11 +279,11 @@ mod tests { analysis_error! { name: alias_shadows_recipe_before, input: "bar: \n echo bar\nalias foo := bar\nfoo:\n echo foo", - offset: 23, - line: 2, - column: 6, + offset: 34, + line: 3, + column: 0, width: 3, - kind: AliasShadowsRecipe {alias: "foo", recipe_line: 3}, + kind: Redefinition { first_type: "alias", second_type: "recipe", name: "foo", first: 2 }, } analysis_error! { @@ -262,7 +293,7 @@ mod tests { line: 2, column: 6, width: 3, - kind: AliasShadowsRecipe { alias: "foo", recipe_line: 0 }, + kind: Redefinition { first_type: "alias", second_type: "recipe", name: "foo", first: 0 }, } analysis_error! { @@ -302,7 +333,7 @@ mod tests { line: 2, column: 0, width: 1, - kind: DuplicateRecipe{recipe: "a", first: 0}, + kind: Redefinition { first_type: "recipe", second_type: "recipe", name: "a", first: 0 }, } analysis_error! { diff --git a/src/compile_error.rs b/src/compile_error.rs index 3d3718b6..d1af6a0f 100644 --- a/src/compile_error.rs +++ b/src/compile_error.rs @@ -19,6 +19,14 @@ impl<'src> CompileError<'src> { } } +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(), + } +} + impl Display for CompileError<'_> { fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { use CompileErrorKind::*; @@ -82,12 +90,6 @@ impl Display for CompileError<'_> { write!(f, "at most {max} {}", Count("argument", *max)) } } - DuplicateAlias { alias, first } => write!( - f, - "Alias `{alias}` first defined on line {} is redefined on line {}", - first.ordinal(), - self.token.line.ordinal(), - ), DuplicateAttribute { attribute, first } => write!( f, "Recipe attribute `{attribute}` first used on line {} is duplicated on line {}", @@ -97,12 +99,6 @@ impl Display for CompileError<'_> { DuplicateParameter { recipe, parameter } => { write!(f, "Recipe `{recipe}` has duplicate parameter `{parameter}`") } - DuplicateRecipe { recipe, first } => write!( - f, - "Recipe `{recipe}` first defined on line {} is redefined on line {}", - first.ordinal(), - self.token.line.ordinal(), - ), DuplicateSet { setting, first } => write!( f, "Setting `{setting}` first set on line {} is redefined on line {}", @@ -183,6 +179,31 @@ impl Display for CompileError<'_> { write!(f, "Parameter `{parameter}` follows variadic parameter") } ParsingRecursionDepthExceeded => write!(f, "Parsing recursion depth exceeded"), + Redefinition { + first, + first_type, + name, + second_type, + } => { + if first_type == second_type { + write!( + f, + "{} `{name}` first defined on line {} is redefined on line {}", + capitalize(first_type), + first.ordinal(), + self.token.line.ordinal(), + ) + } else { + write!( + f, + "{} `{name}` defined on line {} is redefined as {} {second_type} on line {}", + capitalize(first_type), + first.ordinal(), + if *second_type == "alias" { "an" } else { "a" }, + self.token.line.ordinal(), + ) + } + } RequiredParameterFollowsDefaultParameter { parameter } => write!( f, "Non-default parameter `{parameter}` follows default parameter" diff --git a/src/compile_error_kind.rs b/src/compile_error_kind.rs index 0be55552..4c98d5e2 100644 --- a/src/compile_error_kind.rs +++ b/src/compile_error_kind.rs @@ -25,9 +25,11 @@ pub(crate) enum CompileErrorKind<'src> { min: usize, max: usize, }, - DuplicateAlias { - alias: &'src str, + Redefinition { first: usize, + first_type: &'static str, + name: &'src str, + second_type: &'static str, }, DuplicateAttribute { attribute: &'src str, @@ -37,10 +39,6 @@ pub(crate) enum CompileErrorKind<'src> { recipe: &'src str, parameter: &'src str, }, - DuplicateRecipe { - recipe: &'src str, - first: usize, - }, DuplicateSet { setting: &'src str, first: usize, diff --git a/src/compiler.rs b/src/compiler.rs index 5545f6de..b7b0eca2 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -4,6 +4,7 @@ pub(crate) struct Compiler; impl Compiler { pub(crate) fn compile<'src>( + unstable: bool, loader: &'src Loader, root: &Path, ) -> RunResult<'src, Compilation<'src>> { @@ -25,20 +26,40 @@ impl Compiler { srcs.insert(current.clone(), src); for item in &mut ast.items { - if let Item::Import { relative, absolute } = item { - let import = current.parent().unwrap().join(&relative.cooked).lexiclean(); - if srcs.contains_key(&import) { - return Err(Error::CircularImport { current, import }); + match item { + Item::Mod { name, absolute } => { + if !unstable { + return Err(Error::Unstable { + message: "Modules are currently unstable.".into(), + }); + } + + let parent = current.parent().unwrap(); + + let import = Self::find_module_file(parent, *name)?; + + if srcs.contains_key(&import) { + return Err(Error::CircularImport { current, import }); + } + *absolute = Some(import.clone()); + stack.push(import); } - *absolute = Some(import.clone()); - stack.push(import); + Item::Import { relative, absolute } => { + let import = current.parent().unwrap().join(&relative.cooked).lexiclean(); + if srcs.contains_key(&import) { + return Err(Error::CircularImport { current, import }); + } + *absolute = Some(import.clone()); + stack.push(import); + } + _ => {} } } asts.insert(current.clone(), ast.clone()); } - let justfile = Analyzer::analyze(loaded, &paths, &asts, root)?; + let justfile = Analyzer::analyze(&loaded, &paths, &asts, root)?; Ok(Compilation { asts, @@ -48,6 +69,46 @@ impl Compiler { }) } + fn find_module_file<'src>(parent: &Path, module: Name<'src>) -> RunResult<'src, PathBuf> { + let mut candidates = vec![format!("{module}.just"), format!("{module}/mod.just")] + .into_iter() + .filter(|path| parent.join(path).is_file()) + .collect::<Vec<String>>(); + + let directory = parent.join(module.lexeme()); + + if directory.exists() { + let entries = fs::read_dir(&directory).map_err(|io_error| SearchError::Io { + io_error, + directory: directory.clone(), + })?; + + for entry in entries { + let entry = entry.map_err(|io_error| SearchError::Io { + io_error, + directory: directory.clone(), + })?; + + if let Some(name) = entry.file_name().to_str() { + for justfile_name in search::JUSTFILE_NAMES { + if name.eq_ignore_ascii_case(justfile_name) { + candidates.push(format!("{module}/{name}")); + } + } + } + } + } + + match candidates.as_slice() { + [] => Err(Error::MissingModuleFile { module }), + [file] => Ok(parent.join(file).lexiclean()), + found => Err(Error::AmbiguousModuleFile { + found: found.into(), + module, + }), + } + } + #[cfg(test)] pub(crate) fn test_compile(src: &str) -> CompileResult<Justfile> { let tokens = Lexer::test_lex(src)?; @@ -57,7 +118,7 @@ impl Compiler { asts.insert(root.clone(), ast); let mut paths: HashMap<PathBuf, PathBuf> = HashMap::new(); paths.insert(root.clone(), root.clone()); - Analyzer::analyze(Vec::new(), &paths, &asts, &root) + Analyzer::analyze(&[], &paths, &asts, &root) } } @@ -97,7 +158,7 @@ recipe_b: recipe_c let loader = Loader::new(); let justfile_a_path = tmp.path().join("justfile"); - let compilation = Compiler::compile(&loader, &justfile_a_path).unwrap(); + let compilation = Compiler::compile(false, &loader, &justfile_a_path).unwrap(); assert_eq!(compilation.root_src(), justfile_a); } @@ -129,7 +190,7 @@ recipe_b: let loader = Loader::new(); let justfile_a_path = tmp.path().join("justfile"); - let loader_output = Compiler::compile(&loader, &justfile_a_path).unwrap_err(); + let loader_output = Compiler::compile(false, &loader, &justfile_a_path).unwrap_err(); assert_matches!(loader_output, Error::CircularImport { current, import } if current == tmp.path().join("subdir").join("justfile_b").lexiclean() && diff --git a/src/error.rs b/src/error.rs index f6c1f68c..a445e2e0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,6 +2,10 @@ use super::*; #[derive(Debug)] pub(crate) enum Error<'src> { + AmbiguousModuleFile { + module: Name<'src>, + found: Vec<String>, + }, ArgumentCountMismatch { recipe: &'src str, parameters: Vec<Parameter<'src>>, @@ -105,6 +109,9 @@ pub(crate) enum Error<'src> { path: PathBuf, io_error: io::Error, }, + MissingModuleFile { + module: Name<'src>, + }, NoChoosableRecipes, NoDefaultRecipe, NoRecipes, @@ -167,6 +174,9 @@ impl<'src> Error<'src> { fn context(&self) -> Option<Token<'src>> { match self { + Self::AmbiguousModuleFile { module, .. } | Self::MissingModuleFile { module, .. } => { + Some(module.token()) + } Self::Backtick { token, .. } => Some(*token), Self::Compile { compile_error } => Some(compile_error.context()), Self::FunctionCall { function, .. } => Some(function.token()), @@ -224,6 +234,11 @@ impl<'src> ColorDisplay for Error<'src> { write!(f, "{error}: {message}")?; match self { + AmbiguousModuleFile { module, found } => + write!(f, + "Found multiple source files for module `{module}`: {}", + List::and_ticked(found), + )?, ArgumentCountMismatch { recipe, found, min, max, .. } => { let count = Count("argument", *found); if min == max { @@ -350,6 +365,7 @@ impl<'src> ColorDisplay for Error<'src> { let path = path.display(); write!(f, "Failed to read justfile at `{path}`: {io_error}")?; } + MissingModuleFile { module } => write!(f, "Could not find source file for module `{module}`.")?, NoChoosableRecipes => write!(f, "Justfile contains no choosable recipes.")?, NoDefaultRecipe => write!(f, "Justfile contains no default recipe.")?, NoRecipes => write!(f, "Justfile contains no recipes.")?, diff --git a/src/item.rs b/src/item.rs index a12160cb..a709e360 100644 --- a/src/item.rs +++ b/src/item.rs @@ -6,12 +6,16 @@ pub(crate) enum Item<'src> { Alias(Alias<'src, Name<'src>>), Assignment(Assignment<'src>), Comment(&'src str), - Recipe(UnresolvedRecipe<'src>), - Set(Set<'src>), Import { relative: StringLiteral<'src>, absolute: Option<PathBuf>, }, + Mod { + name: Name<'src>, + absolute: Option<PathBuf>, + }, + Recipe(UnresolvedRecipe<'src>), + Set(Set<'src>), } impl<'src> Display for Item<'src> { @@ -20,9 +24,10 @@ impl<'src> Display for Item<'src> { Item::Alias(alias) => write!(f, "{alias}"), Item::Assignment(assignment) => write!(f, "{assignment}"), Item::Comment(comment) => write!(f, "{comment}"), + Item::Import { relative, .. } => write!(f, "import {relative}"), + Item::Mod { name, .. } => write!(f, "mod {name}"), Item::Recipe(recipe) => write!(f, "{}", recipe.color_display(Color::never())), Item::Set(set) => write!(f, "{set}"), - Item::Import { relative, .. } => write!(f, "import {relative}"), } } } diff --git a/src/justfile.rs b/src/justfile.rs index 45b327d5..23134208 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -1,5 +1,13 @@ use {super::*, serde::Serialize}; +#[derive(Debug)] +struct Invocation<'src: 'run, 'run> { + arguments: &'run [&'run str], + recipe: &'run Recipe<'src>, + settings: &'run Settings<'src>, + scope: &'run Scope<'src, 'run>, +} + #[derive(Debug, PartialEq, Serialize)] pub(crate) struct Justfile<'src> { pub(crate) aliases: Table<'src, Alias<'src>>, @@ -8,6 +16,7 @@ pub(crate) struct Justfile<'src> { pub(crate) default: Option<Rc<Recipe<'src>>>, #[serde(skip)] pub(crate) loaded: Vec<PathBuf>, + pub(crate) modules: BTreeMap<String, Justfile<'src>>, pub(crate) recipes: Table<'src, Rc<Recipe<'src>>>, pub(crate) settings: Settings<'src>, pub(crate) warnings: Vec<Warning>, @@ -67,6 +76,44 @@ impl<'src> Justfile<'src> { .next() } + fn scope<'run>( + &'run self, + config: &'run Config, + dotenv: &'run BTreeMap<String, String>, + search: &'run Search, + overrides: &BTreeMap<String, String>, + parent: &'run Scope<'src, 'run>, + ) -> RunResult<'src, Scope<'src, 'run>> + where + 'src: 'run, + { + let mut scope = parent.child(); + let mut unknown_overrides = Vec::new(); + + for (name, value) in overrides { + if let Some(assignment) = self.assignments.get(name) { + scope.bind(assignment.export, assignment.name, value.clone()); + } else { + unknown_overrides.push(name.clone()); + } + } + + if !unknown_overrides.is_empty() { + return Err(Error::UnknownOverrides { + overrides: unknown_overrides, + }); + } + + Evaluator::evaluate_assignments( + &self.assignments, + config, + dotenv, + scope, + &self.settings, + search, + ) + } + pub(crate) fn run( &self, config: &Config, @@ -92,33 +139,9 @@ impl<'src> Justfile<'src> { BTreeMap::new() }; - let scope = { - let mut scope = Scope::new(); - let mut unknown_overrides = Vec::new(); + let root = Scope::new(); - for (name, value) in overrides { - if let Some(assignment) = self.assignments.get(name) { - scope.bind(assignment.export, assignment.name, value.clone()); - } else { - unknown_overrides.push(name.clone()); - } - } - - if !unknown_overrides.is_empty() { - return Err(Error::UnknownOverrides { - overrides: unknown_overrides, - }); - } - - Evaluator::evaluate_assignments( - &self.assignments, - config, - &dotenv, - scope, - &self.settings, - search, - )? - }; + let scope = self.scope(config, &dotenv, search, overrides, &root)?; match &config.subcommand { Subcommand::Command { @@ -193,13 +216,7 @@ impl<'src> Justfile<'src> { let argvec: Vec<&str> = if !arguments.is_empty() { arguments.iter().map(String::as_str).collect() } else if let Some(recipe) = &self.default { - let min_arguments = recipe.min_arguments(); - if min_arguments > 0 { - return Err(Error::DefaultRecipeRequiresArguments { - recipe: recipe.name.lexeme(), - min_arguments, - }); - } + recipe.check_can_be_default_recipe()?; vec![recipe.name()] } else if self.recipes.is_empty() { return Err(Error::NoRecipes); @@ -209,33 +226,31 @@ impl<'src> Justfile<'src> { let arguments = argvec.as_slice(); - let mut missing = vec![]; - let mut grouped = vec![]; - let mut rest = arguments; - - while let Some((argument, mut tail)) = rest.split_first() { - if let Some(recipe) = self.get_recipe(argument) { - if recipe.parameters.is_empty() { - grouped.push((recipe, &[][..])); - } else { - let argument_range = recipe.argument_range(); - let argument_count = cmp::min(tail.len(), recipe.max_arguments()); - if !argument_range.range_contains(&argument_count) { - return Err(Error::ArgumentCountMismatch { - recipe: recipe.name(), - parameters: recipe.parameters.clone(), - found: tail.len(), - min: recipe.min_arguments(), - max: recipe.max_arguments(), - }); - } - grouped.push((recipe, &tail[0..argument_count])); - tail = &tail[argument_count..]; - } + 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() { + if let Some((invocation, consumed)) = self.invocation( + 0, + &mut Vec::new(), + &arena, + &mut scopes, + config, + &dotenv, + search, + &scope, + first, + rest, + )? { + rest = &rest[consumed..]; + invocations.push(invocation); } else { - missing.push((*argument).to_owned()); + missing.push((*first).to_owned()); } - rest = tail; + remaining = rest; } if !missing.is_empty() { @@ -250,16 +265,23 @@ impl<'src> Justfile<'src> { }); } - let context = RecipeContext { - settings: &self.settings, - config, - scope, - search, - }; - let mut ran = BTreeSet::new(); - for (recipe, arguments) in grouped { - Self::run_recipe(&context, recipe, arguments, &dotenv, search, &mut ran)?; + for invocation in invocations { + let context = RecipeContext { + settings: invocation.settings, + config, + scope: invocation.scope, + search, + }; + + Self::run_recipe( + &context, + invocation.recipe, + invocation.arguments, + &dotenv, + search, + &mut ran, + )?; } Ok(()) @@ -277,6 +299,98 @@ impl<'src> Justfile<'src> { .or_else(|| self.aliases.get(name).map(|alias| alias.target.as_ref())) } + #[allow(clippy::too_many_arguments)] + 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>>, + config: &'run Config, + dotenv: &'run BTreeMap<String, String>, + search: &'run Search, + parent: &'run Scope<'src, 'run>, + first: &'run str, + rest: &'run [&'run str], + ) -> RunResult<'src, Option<(Invocation<'src, 'run>, usize)>> { + if let Some(module) = self.modules.get(first) { + path.push(first); + + let scope = if let Some(scope) = scopes.get(path) { + scope + } else { + let scope = module.scope(config, dotenv, search, &BTreeMap::new(), parent)?; + let scope = arena.alloc(scope); + scopes.insert(path.clone(), 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: &[], + scope, + }, + 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: &[], + recipe, + scope: parent, + settings: &self.settings, + }, + 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], + recipe, + scope: parent, + settings: &self.settings, + }, + depth + argument_count, + ))) + } + } else { + Ok(None) + } + } + fn run_recipe( context: &RecipeContext<'src, '_>, recipe: &Recipe<'src>, @@ -305,7 +419,7 @@ impl<'src> Justfile<'src> { dotenv, |