diff --git a/README.md b/README.md
index 2eea7fe..7b8c221 100644
--- a/README.md
+++ b/README.md
@@ -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.
+### Modulesmaster
+
+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 0c19fe5..bb9d440 100644
--- a/src/alias.rs
+++ b/src/alias.rs
@@ -13,10 +13,6 @@ pub(crate) struct Alias<'src, T = Rc>> {
}
impl<'src> Alias<'src, Name<'src>> {
- pub(crate) fn line_number(&self) -> usize {
- self.name.line
- }
-
pub(crate) fn resolve(self, target: Rc>) -> Alias<'src> {
assert_eq!(self.target.lexeme(), target.name.lexeme());
diff --git a/src/analyzer.rs b/src/analyzer.rs
index e8745e9..0f138a7 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,
+ loaded: &[PathBuf],
paths: &HashMap,
asts: &HashMap>,
root: &Path,
@@ -19,7 +19,7 @@ impl<'src> Analyzer<'src> {
fn justfile(
mut self,
- loaded: Vec,
+ loaded: &[PathBuf],
paths: &HashMap,
asts: &HashMap>,
root: &Path,
@@ -31,11 +31,42 @@ impl<'src> Analyzer<'src> {
let mut warnings = Vec::new();
+ let mut modules: BTreeMap = 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 3d3718b..d1af6a0 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::() + 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 0be5555..4c98d5e 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 5545f6d..b7b0eca 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::>();
+
+ 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 {
let tokens = Lexer::test_lex(src)?;
@@ -57,7 +118,7 @@ impl Compiler {
asts.insert(root.clone(), ast);
let mut paths: HashMap = 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 f6c1f68..a445e2e 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,
+ },
ArgumentCountMismatch {
recipe: &'src str,
parameters: Vec>,
@@ -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> {
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 a12160c..a709e36 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,
},
+ Mod {
+ name: Name<'src>,
+ absolute: Option,
+ },
+ 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 45b327d..2313420 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>>,
#[serde(skip)]
pub(crate) loaded: Vec,
+ pub(crate) modules: BTreeMap>,
pub(crate) recipes: Table<'src, Rc>>,
pub(crate) settings: Settings<'src>,
pub(crate) warnings: Vec,
@@ -67,6 +76,44 @@ impl<'src> Justfile<'src> {
.next()
}
+ fn scope<'run>(
+ &'run self,
+ config: &'run Config,
+ dotenv: &'run BTreeMap,
+ search: &'run Search,
+ overrides: &BTreeMap,
+ 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;
+ 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((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..];
- }
+ 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>,
+ scopes: &mut BTreeMap, &'run Scope<'src, 'run>>,
+ config: &'run Config,
+ dotenv: &'run BTreeMap,
+ 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,
&recipe.parameters,
arguments,
- &context.scope,
+ context.scope,
context.settings,
search,
)?;
diff --git a/src/keyword.rs b/src/keyword.rs
index 212ce2a..8bde1a8 100644
--- a/src/keyword.rs
+++ b/src/keyword.rs
@@ -15,6 +15,7 @@ pub(crate) enum Keyword {
If,
IgnoreComments,
Import,
+ Mod,
PositionalArguments,
Set,
Shell,
diff --git a/src/node.rs b/src/node.rs
index 9c27ca9..3433bb8 100644
--- a/src/node.rs
+++ b/src/node.rs
@@ -21,9 +21,10 @@ impl<'src> Node<'src> for Item<'src> {
Item::Alias(alias) => alias.tree(),
Item::Assignment(assignment) => assignment.tree(),
Item::Comment(comment) => comment.tree(),
+ Item::Import { relative, .. } => Tree::atom("import").push(format!("{relative}")),
+ Item::Mod { name, .. } => Tree::atom("mod").push(name.lexeme()),
Item::Recipe(recipe) => recipe.tree(),
Item::Set(set) => set.tree(),
- Item::Import { relative, .. } => Tree::atom("import").push(format!("{relative}")),
}
}
}
diff --git a/src/parser.rs b/src/parser.rs
index 73f25ee..019f564 100644
--- a/src/parser.rs
+++ b/src/parser.rs
@@ -335,6 +335,13 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
absolute: None,
});
}
+ Some(Keyword::Mod) if self.next_are(&[Identifier, Identifier]) => {
+ self.presume_keyword(Keyword::Mod)?;
+ items.push(Item::Mod {
+ name: self.parse_name()?,
+ absolute: None,
+ });
+ }
Some(Keyword::Set)
if self.next_are(&[Identifier, Identifier, ColonEquals])
|| self.next_are(&[Identifier, Identifier, Comment, Eof])
diff --git a/src/recipe.rs b/src/recipe.rs
index 4ea8bc2..e526b8b 100644
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -77,6 +77,18 @@ impl<'src, D> Recipe<'src, D> {
}
}
+ pub(crate) fn check_can_be_default_recipe(&self) -> RunResult<'src, ()> {
+ let min_arguments = self.min_arguments();
+ if min_arguments > 0 {
+ return Err(Error::DefaultRecipeRequiresArguments {
+ recipe: self.name.lexeme(),
+ min_arguments,
+ });
+ }
+
+ Ok(())
+ }
+
pub(crate) fn public(&self) -> bool {
!self.private && !self.attributes.contains(&Attribute::Private)
}
diff --git a/src/recipe_context.rs b/src/recipe_context.rs
index b254440..0e46f5f 100644
--- a/src/recipe_context.rs
+++ b/src/recipe_context.rs
@@ -2,7 +2,7 @@ use super::*;
pub(crate) struct RecipeContext<'src: 'run, 'run> {
pub(crate) config: &'run Config,
- pub(crate) scope: Scope<'src, 'run>,
+ pub(crate) scope: &'run Scope<'src, 'run>,
pub(crate) search: &'run Search,
pub(crate) settings: &'run Settings<'src>,
}
diff --git a/src/scope.rs b/src/scope.rs
index f78ce57..428b677 100644
--- a/src/scope.rs
+++ b/src/scope.rs
@@ -8,14 +8,14 @@ pub(crate) struct Scope<'src: 'run, 'run> {
impl<'src, 'run> Scope<'src, 'run> {
pub(crate) fn child(&'run self) -> Scope<'src, 'run> {
- Scope {
+ Self {
parent: Some(self),
bindings: Table::new(),
}
}
pub(crate) fn new() -> Scope<'src, 'run> {
- Scope {
+ Self {
parent: None,
bindings: Table::new(),
}
diff --git a/src/search.rs b/src/search.rs
index 6eb2910..c14eb55 100644
--- a/src/search.rs
+++ b/src/search.rs
@@ -1,7 +1,7 @@
use {super::*, std::path::Component};
const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0];
-const JUSTFILE_NAMES: &[&str] = &["justfile", ".justfile"];
+pub(crate) const JUSTFILE_NAMES: &[&str] = &["justfile", ".justfile"];
const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
pub(crate) struct Search {
@@ -109,7 +109,7 @@ impl Search {
}
}
- fn justfile(directory: &Path) -> SearchResult {
+ pub(crate) fn justfile(directory: &Path) -> SearchResult {
for directory in directory.ancestors() {
let mut candidates = BTreeSet::new();
diff --git a/src/subcommand.rs b/src/subcommand.rs
index b587657..13fd69a 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -79,7 +79,7 @@ impl Subcommand {
}
Dump => Self::dump(config, ast, justfile)?,
Format => Self::format(config, &search, src, ast)?,
- List => Self::list(config, justfile),
+ List => Self::list(config, 0, justfile),
Show { ref name } => Self::show(config, name, justfile)?,
Summary => Self::summary(config, justfile),
Variables => Self::variables(justfile),
@@ -180,7 +180,7 @@ impl Subcommand {
loader: &'src Loader,
search: &Search,
) -> Result, Error<'src>> {
- let compilation = Compiler::compile(loader, &search.justfile)?;
+ let compilation = Compiler::compile(config.unstable, loader, &search.justfile)?;
if config.verbosity.loud() {
for warning in &compilation.justfile.warnings {
@@ -426,7 +426,7 @@ impl Subcommand {
}
}
- fn list(config: &Config, justfile: &Justfile) {
+ fn list(config: &Config, level: usize, justfile: &Justfile) {
// Construct a target to alias map.
let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for alias in justfile.aliases.values() {
@@ -465,9 +465,11 @@ impl Subcommand {
}
let max_line_width = cmp::min(line_widths.values().copied().max().unwrap_or(0), 30);
-
let doc_color = config.color.stdout().doc();
- print!("{}", config.list_heading);
+
+ if level == 0 {
+ print!("{}", config.list_heading);
+ }
for recipe in justfile.public_recipes(config.unsorted) {
let name = recipe.name();
@@ -476,7 +478,7 @@ impl Subcommand {
.chain(recipe_aliases.get(name).unwrap_or(&Vec::new()))
.enumerate()
{
- print!("{}{name}", config.list_prefix);
+ print!("{}{name}", config.list_prefix.repeat(level + 1));
for parameter in &recipe.parameters {
print!(" {}", parameter.color_display(config.color.stdout()));
}
@@ -506,6 +508,11 @@ impl Subcommand {
println!();
}
}
+
+ for (name, module) in &justfile.modules {
+ println!(" {name}:");
+ Self::list(config, level + 1, module);
+ }
}
fn show<'src>(config: &Config, name: &str, justfile: &Justfile<'src>) -> Result<(), Error<'src>> {
diff --git a/src/summary.rs b/src/summary.rs
index b8d2ea7..1677f69 100644
--- a/src/summary.rs
+++ b/src/summary.rs
@@ -28,7 +28,7 @@ mod full {
pub fn summary(path: &Path) -> Result, io::Error> {
let loader = Loader::new();
- match Compiler::compile(&loader, path) {
+ match Compiler::compile(false, &loader, path) {
Ok(compilation) => Ok(Ok(Summary::new(&compilation.justfile))),
Err(error) => Ok(Err(if let Error::Compile { compile_error } = error {
compile_error.to_string()
diff --git a/src/testing.rs b/src/testing.rs
index 5d1cf74..b513454 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -68,7 +68,7 @@ pub(crate) fn analysis_error(
let mut paths: HashMap = HashMap::new();
paths.insert("justfile".into(), "justfile".into());
- match Analyzer::analyze(Vec::new(), &paths, &asts, &root) {
+ match Analyzer::analyze(&[], &paths, &asts, &root) {
Ok(_) => panic!("Analysis unexpectedly succeeded"),
Err(have) => {
let want = CompileError {
diff --git a/tests/json.rs b/tests/json.rs
index 39e04b6..48c2e45 100644
--- a/tests/json.rs
+++ b/tests/json.rs
@@ -1,6 +1,6 @@
use super::*;
-fn test(justfile: &str, value: Value) {
+fn case(justfile: &str, value: Value) {
Test::new()
.justfile(justfile)
.args(["--dump", "--dump-format", "json", "--unstable"])
@@ -10,7 +10,7 @@ fn test(justfile: &str, value: Value) {
#[test]
fn alias() {
- test(
+ case(
"
alias f := foo
@@ -26,6 +26,7 @@ fn alias() {
}
},
"assignments": {},
+ "modules": {},
"recipes": {
"foo": {
"attributes": [],
@@ -61,7 +62,7 @@ fn alias() {
#[test]
fn assignment() {
- test(
+ case(
"foo := 'bar'",
json!({
"aliases": {},
@@ -73,6 +74,7 @@ fn assignment() {
}
},
"first": null,
+ "modules": {},
"recipes": {},
"settings": {
"allow_duplicate_recipes": false,
@@ -95,7 +97,7 @@ fn assignment() {
#[test]
fn body() {
- test(
+ case(
"
foo:
bar
@@ -105,6 +107,7 @@ fn body() {
"aliases": {},
"assignments": {},
"first": "foo",
+ "modules": {},
"recipes": {
"foo": {
"attributes": [],
@@ -143,7 +146,7 @@ fn body() {
#[test]
fn dependencies() {
- test(
+ case(
"
foo:
bar: foo
@@ -152,6 +155,7 @@ fn dependencies() {
"aliases": {},
"assignments": {},
"first": "foo",
+ "modules": {},
"recipes": {
"bar": {
"attributes": [],
@@ -202,7 +206,7 @@ fn dependencies() {
#[test]
fn dependency_argument() {
- test(
+ case(
"
x := 'foo'
foo *args:
@@ -230,6 +234,7 @@ fn dependency_argument() {
"value": "foo",
},
},
+ "modules": {},
"recipes": {
"bar": {
"doc": null,
@@ -298,7 +303,7 @@ fn dependency_argument() {
#[test]
fn duplicate_recipes() {
- test(
+ case(
"
set allow-duplicate-recipes
alias f := foo
@@ -316,6 +321,7 @@ fn duplicate_recipes() {
}
},
"assignments": {},
+ "modules": {},
"recipes": {
"foo": {
"body": [],
@@ -358,12 +364,13 @@ fn duplicate_recipes() {
#[test]
fn doc_comment() {
- test(
+ case(
"# hello\nfoo:",
json!({
"aliases": {},
"first": "foo",
"assignments": {},
+ "modules": {},
"recipes": {
"foo": {
"body": [],
@@ -399,12 +406,13 @@ fn doc_comment() {
#[test]
fn empty_justfile() {
- test(
+ case(
"",
json!({
"aliases": {},
"assignments": {},
"first": null,
+ "modules": {},
"recipes": {},
"settings": {
"allow_duplicate_recipes": false,
@@ -427,7 +435,7 @@ fn empty_justfile() {
#[test]
fn parameters() {
- test(
+ case(
"
a:
b x:
@@ -440,6 +448,7 @@ fn parameters() {
"aliases": {},
"first": "a",
"assignments": {},
+ "modules": {},
"recipes": {
"a": {
"attributes": [],
@@ -570,7 +579,7 @@ fn parameters() {
#[test]
fn priors() {
- test(
+ case(
"
a:
b: a && c
@@ -580,6 +589,7 @@ fn priors() {
"aliases": {},
"assignments": {},
"first": "a",
+ "modules": {},
"recipes": {
"a": {
"body": [],
@@ -649,12 +659,13 @@ fn priors() {
#[test]
fn private() {
- test(
+ case(
"_foo:",
json!({
"aliases": {},
"assignments": {},
"first": "_foo",
+ "modules": {},
"recipes": {
"_foo": {
"body": [],
@@ -690,12 +701,13 @@ fn private() {
#[test]
fn quiet() {
- test(
+ case(
"@foo:",
json!({
"aliases": {},
"assignments": {},
"first": "foo",
+ "modules": {},
"recipes": {
"foo": {
"body": [],
@@ -731,7 +743,7 @@ fn quiet() {
#[test]
fn settings() {
- test(
+ case(
"
set dotenv-load
set dotenv-filename := \"filename\"
@@ -748,6 +760,7 @@ fn settings() {
"aliases": {},
"assignments": {},
"first": "foo",
+ "modules": {},
"recipes": {
"foo": {
"body": [["#!bar"]],
@@ -786,7 +799,7 @@ fn settings() {
#[test]
fn shebang() {
- test(
+ case(
"
foo:
#!bar
@@ -795,6 +808,7 @@ fn shebang() {
"aliases": {},
"assignments": {},
"first": "foo",
+ "modules": {},
"recipes": {
"foo": {
"body": [["#!bar"]],
@@ -830,12 +844,13 @@ fn shebang() {
#[test]
fn simple() {
- test(
+ case(
"foo:",
json!({
"aliases": {},
"assignments": {},
"first": "foo",
+ "modules": {},
"recipes": {
"foo": {
"body": [],
@@ -871,7 +886,7 @@ fn simple() {
#[test]
fn attribute() {
- test(
+ case(
"
[no-exit-message]
foo:
@@ -880,6 +895,7 @@ fn attribute() {
"aliases": {},
"assignments": {},
"first": "foo",
+ "modules": {},
"recipes": {
"foo": {
"attributes": ["no-exit-message"],
@@ -912,3 +928,81 @@ fn attribute() {
}),
);
}
+
+#[test]
+fn module() {
+ Test::new()
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .tree(tree! {
+ "foo.just": "bar:",
+ })
+ .args(["--dump", "--dump-format", "json", "--unstable"])
+ .test_round_trip(false)
+ .stdout(format!(
+ "{}\n",
+ serde_json::to_string(&json!({
+ "aliases": {},
+ "assignments": {},
+ "first": null,
+ "modules": {
+ "foo": {
+ "aliases": {},
+ "assignments": {},
+ "first": "bar",
+ "modules": {},
+ "recipes": {
+ "bar": {
+ "attributes": [],
+ "body": [],
+ "dependencies": [],
+ "doc": null,
+ "name": "bar",
+ "parameters": [],
+ "priors": 0,
+ "private": false,
+ "quiet": false,
+ "shebang": false,
+ }
+ },
+ "settings": {
+ "allow_duplicate_recipes": false,
+ "dotenv_filename": null,
+ "dotenv_load": null,
+ "dotenv_path": null,
+ "export": false,
+ "fallback": false,
+ "positional_arguments": false,
+ "shell": null,
+ "tempdir" : null,
+ "ignore_comments": false,
+ "windows_powershell": false,
+ "windows_shell": null,
+ },
+ "warnings": [],
+ },
+ },
+ "recipes": {},
+ "settings": {
+ "allow_duplicate_recipes": false,
+ "dotenv_filename": null,
+ "dotenv_load": null,
+ "dotenv_path": null,
+ "export": false,
+ "fallback": false,
+ "positional_arguments": false,
+ "shell": null,
+ "tempdir" : null,
+ "ignore_comments": false,
+ "windows_powershell": false,
+ "windows_shell": null,
+ },
+ "warnings": [],
+ }))
+ .unwrap()
+ ))
+ .run();
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index 04cbf88..5caae7d 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -63,6 +63,7 @@ mod invocation_directory;
mod json;
mod line_prefixes;
mod misc;
+mod modules;
mod multibyte_char;
mod newline_escape;
mod no_cd;
diff --git a/tests/misc.rs b/tests/misc.rs
index 8733507..8629a58 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -133,11 +133,11 @@ test! {
name: alias_shadows_recipe,
justfile: "bar:\n echo bar\nalias foo := bar\nfoo:\n echo foo",
stderr: "
- error: Alias `foo` defined on line 3 shadows recipe `foo` defined on line 4
- --> justfile:3:7
+ error: Alias `foo` defined on line 3 is redefined as a recipe on line 4
+ --> justfile:4:1
|
- 3 | alias foo := bar
- | ^^^
+ 4 | foo:
+ | ^^^
",
status: EXIT_FAILURE,
}
diff --git a/tests/modules.rs b/tests/modules.rs
new file mode 100644
index 0000000..59d3564
--- /dev/null
+++ b/tests/modules.rs
@@ -0,0 +1,446 @@
+use super::*;
+
+#[test]
+fn modules_are_unstable() {
+ Test::new()
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .arg("foo")
+ .arg("foo")
+ .stderr(
+ "error: Modules are currently unstable. \
+ Invoke `just` with the `--unstable` flag to enable unstable features.\n",
+ )
+ .status(EXIT_FAILURE)
+ .run();
+}
+
+#[test]
+fn default_recipe_in_submodule_must_have_no_arguments() {
+ Test::new()
+ .write("foo.just", "foo bar:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .stderr("error: Recipe `foo` cannot be used as default recipe since it requires at least 1 argument.\n")
+ .status(EXIT_FAILURE)
+ .run();
+}
+
+#[test]
+fn module_recipes_can_be_run_as_subcommands() {
+ Test::new()
+ .write("foo.just", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("FOO\n")
+ .run();
+}
+
+#[test]
+fn assignments_are_evaluated_in_modules() {
+ Test::new()
+ .write("foo.just", "bar := 'CHILD'\nfoo:\n @echo {{bar}}")
+ .justfile(
+ "
+ mod foo
+ bar := 'PARENT'
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("CHILD\n")
+ .run();
+}
+
+#[test]
+fn module_subcommand_runs_default_recipe() {
+ Test::new()
+ .write("foo.just", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .stdout("FOO\n")
+ .run();
+}
+
+#[test]
+fn modules_can_contain_other_modules() {
+ Test::new()
+ .write("bar.just", "baz:\n @echo BAZ")
+ .write("foo.just", "mod bar")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("bar")
+ .arg("baz")
+ .stdout("BAZ\n")
+ .run();
+}
+
+#[test]
+fn circular_module_imports_are_detected() {
+ Test::new()
+ .write("bar.just", "mod foo")
+ .write("foo.just", "mod bar")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("bar")
+ .arg("baz")
+ .stderr_regex(path_for_regex(
+ "error: Import `.*/foo.just` in `.*/bar.just` is circular\n",
+ ))
+ .status(EXIT_FAILURE)
+ .run();
+}
+
+#[test]
+fn modules_use_module_settings() {
+ Test::new()
+ .write(
+ "foo.just",
+ "set allow-duplicate-recipes\nfoo:\nfoo:\n @echo FOO\n",
+ )
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("FOO\n")
+ .run();
+
+ Test::new()
+ .write("foo.just", "\nfoo:\nfoo:\n @echo FOO\n")
+ .justfile(
+ "
+ mod foo
+
+ set allow-duplicate-recipes
+ ",
+ )
+ .test_round_trip(false)
+ .status(EXIT_FAILURE)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stderr(
+ "
+ error: Recipe `foo` first defined on line 2 is redefined on line 3
+ --> foo.just:3:1
+ |
+ 3 | foo:
+ | ^^^
+ ",
+ )
+ .run();
+}
+
+#[test]
+fn modules_conflict_with_recipes() {
+ Test::new()
+ .write("foo.just", "")
+ .justfile(
+ "
+ mod foo
+ foo:
+ ",
+ )
+ .stderr(
+ "
+ error: Module `foo` defined on line 1 is redefined as a recipe on line 2
+ --> justfile:2:1
+ |
+ 2 | foo:
+ | ^^^
+ ",
+ )
+ .test_round_trip(false)
+ .status(EXIT_FAILURE)
+ .arg("--unstable")
+ .run();
+}
+
+#[test]
+fn modules_conflict_with_aliases() {
+ Test::new()
+ .write("foo.just", "")
+ .justfile(
+ "
+ mod foo
+ bar:
+ alias foo := bar
+ ",
+ )
+ .stderr(
+ "
+ error: Module `foo` defined on line 1 is redefined as an alias on line 3
+ --> justfile:3:7
+ |
+ 3 | alias foo := bar
+ | ^^^
+ ",
+ )
+ .test_round_trip(false)
+ .status(EXIT_FAILURE)
+ .arg("--unstable")
+ .run();
+}
+
+#[test]
+fn modules_conflict_with_other_modules() {
+ Test::new()
+ .write("foo.just", "")
+ .justfile(
+ "
+ mod foo
+ mod foo
+
+ bar:
+ ",
+ )
+ .test_round_trip(false)
+ .status(EXIT_FAILURE)
+ .stderr(
+ "
+ error: Module `foo` first defined on line 1 is redefined on line 2
+ --> justfile:2:5
+ |
+ 2 | mod foo
+ | ^^^
+ ",
+ )
+ .arg("--unstable")
+ .run();
+}
+
+#[test]
+fn modules_are_dumped_correctly() {
+ Test::new()
+ .write("foo.just", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("--dump")
+ .stdout("mod foo\n")
+ .run();
+}
+
+#[test]
+fn modules_can_be_in_subdirectory() {
+ Test::new()
+ .write("foo/mod.just", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("FOO\n")
+ .run();
+}
+
+#[test]
+fn modules_in_subdirectory_can_be_named_justfile() {
+ Test::new()
+ .write("foo/justfile", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("FOO\n")
+ .run();
+}
+
+#[test]
+fn modules_in_subdirectory_can_be_named_justfile_with_any_case() {
+ Test::new()
+ .write("foo/JUSTFILE", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("FOO\n")
+ .run();
+}
+
+#[test]
+fn modules_in_subdirectory_can_have_leading_dot() {
+ Test::new()
+ .write("foo/.justfile", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("FOO\n")
+ .run();
+}
+
+#[test]
+fn modules_require_unambiguous_file() {
+ Test::new()
+ .write("foo/justfile", "foo:\n @echo FOO")
+ .write("foo.just", "foo:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .status(EXIT_FAILURE)
+ .stderr(
+ "
+ error: Found multiple source files for module `foo`: `foo.just` and `foo/justfile`
+ --> justfile:1:5
+ |
+ 1 | mod foo
+ | ^^^
+ ",
+ )
+ .run();
+}
+
+#[test]
+fn missing_module_file_error() {
+ Test::new()
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .status(EXIT_FAILURE)
+ .stderr(
+ "
+ error: Could not find source file for module `foo`.
+ --> justfile:1:5
+ |
+ 1 | mod foo
+ | ^^^
+ ",
+ )
+ .run();
+}
+
+#[test]
+fn list_displays_recipes_in_submodules() {
+ Test::new()
+ .write("foo.just", "bar:\n @echo FOO")
+ .justfile(
+ "
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("--list")
+ .stdout(
+ "
+ Available recipes:
+ foo:
+ bar
+ ",
+ )
+ .run();
+}
+
+#[test]
+fn root_dotenv_is_available_to_submodules() {
+ Test::new()
+ .write("foo.just", "foo:\n @echo $DOTENV_KEY")
+ .justfile(
+ "
+ set dotenv-load
+
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("dotenv-value\n")
+ .run();
+}
+
+#[test]
+fn dotenv_settings_in_submodule_are_ignored() {
+ Test::new()
+ .write(
+ "foo.just",
+ "set dotenv-load := false\nfoo:\n @echo $DOTENV_KEY",
+ )
+ .justfile(
+ "
+ set dotenv-load
+
+ mod foo
+ ",
+ )
+ .test_round_trip(false)
+ .arg("--unstable")
+ .arg("foo")
+ .arg("foo")
+ .stdout("dotenv-value\n")
+ .run();
+}