diff --git a/README.md b/README.md index 2eea7fed15..7b8c22109b 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 0c19fe5a5e..bb9d440092 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 e8745e954f..0f138a7c3c 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 3d3718b616..d1af6a0f0e 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 0be55552a8..4c98d5e26a 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 5545f6de84..b7b0eca242 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 f6c1f68cb7..a445e2e090 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 a12160cb74..a709e3606f 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 45b327d5c4..231342085d 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; - - 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 = 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>, + 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 212ce2a011..8bde1a8ccc 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 9c27ca9cea..3433bb8fb9 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 73f25ee7f9..019f564e67 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 4ea8bc2b62..e526b8b053 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 b254440f58..0e46f5f85d 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 f78ce570ee..428b677021 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 6eb2910bb7..c14eb55354 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 b587657141..13fd69a90d 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 b8d2ea7c68..1677f69756 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 5d1cf746cd..b5134545ae 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 39e04b68e3..48c2e45b6e 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 04cbf88a10..5caae7d2c8 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 8733507cd1..8629a583f6 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 0000000000..59d356446a --- /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(); +}