Add modules (#1782)

This commit is contained in:
Casey Rodarmor 2023-12-27 20:27:15 -08:00 committed by GitHub
parent bc628215c0
commit 316ea01295
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1024 additions and 168 deletions

View File

@ -2329,7 +2329,7 @@ And will both invoke recipes `a` and `b` in `foo/justfile`.
### Imports ### 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`: 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 Imported files can themselves contain `import`s, which are processed
recursively. 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 ### Hiding `justfile`s
`just` looks for `justfile`s named `justfile` and `.justfile`, which can be used to keep a `justfile` hidden. `just` looks for `justfile`s named `justfile` and `.justfile`, which can be used to keep a `justfile` hidden.

View File

@ -13,10 +13,6 @@ pub(crate) struct Alias<'src, T = Rc<Recipe<'src>>> {
} }
impl<'src> Alias<'src, Name<'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> { pub(crate) fn resolve(self, target: Rc<Recipe<'src>>) -> Alias<'src> {
assert_eq!(self.target.lexeme(), target.name.lexeme()); assert_eq!(self.target.lexeme(), target.name.lexeme());

View File

@ -9,7 +9,7 @@ pub(crate) struct Analyzer<'src> {
impl<'src> Analyzer<'src> { impl<'src> Analyzer<'src> {
pub(crate) fn analyze( pub(crate) fn analyze(
loaded: Vec<PathBuf>, loaded: &[PathBuf],
paths: &HashMap<PathBuf, PathBuf>, paths: &HashMap<PathBuf, PathBuf>,
asts: &HashMap<PathBuf, Ast<'src>>, asts: &HashMap<PathBuf, Ast<'src>>,
root: &Path, root: &Path,
@ -19,7 +19,7 @@ impl<'src> Analyzer<'src> {
fn justfile( fn justfile(
mut self, mut self,
loaded: Vec<PathBuf>, loaded: &[PathBuf],
paths: &HashMap<PathBuf, PathBuf>, paths: &HashMap<PathBuf, PathBuf>,
asts: &HashMap<PathBuf, Ast<'src>>, asts: &HashMap<PathBuf, Ast<'src>>,
root: &Path, root: &Path,
@ -31,11 +31,42 @@ impl<'src> Analyzer<'src> {
let mut warnings = Vec::new(); 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() { while let Some(ast) = stack.pop() {
for item in &ast.items { for item in &ast.items {
match item { match item {
Item::Alias(alias) => { Item::Alias(alias) => {
self.analyze_alias(alias)?; define(alias.name, "alias", false)?;
Self::analyze_alias(alias)?;
self.aliases.insert(alias.clone()); self.aliases.insert(alias.clone());
} }
Item::Assignment(assignment) => { Item::Assignment(assignment) => {
@ -43,6 +74,19 @@ impl<'src> Analyzer<'src> {
self.assignments.insert(assignment.clone()); self.assignments.insert(assignment.clone());
} }
Item::Comment(_) => (), 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) => { Item::Recipe(recipe) => {
if recipe.enabled() { if recipe.enabled() {
Self::analyze_recipe(recipe)?; Self::analyze_recipe(recipe)?;
@ -53,9 +97,6 @@ impl<'src> Analyzer<'src> {
self.analyze_set(set)?; self.analyze_set(set)?;
self.sets.insert(set.clone()); 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)?; AssignmentResolver::resolve_assignments(&self.assignments)?;
for recipe in recipes { for recipe in recipes {
if let Some(original) = recipe_table.get(recipe.name.lexeme()) { define(recipe.name, "recipe", settings.allow_duplicate_recipes)?;
if !settings.allow_duplicate_recipes {
return Err(recipe.name.token().error(DuplicateRecipe {
recipe: original.name(),
first: original.line_number(),
}));
}
}
recipe_table.insert(recipe.clone()); recipe_table.insert(recipe.clone());
} }
@ -103,10 +137,14 @@ impl<'src> Analyzer<'src> {
}), }),
aliases, aliases,
assignments: self.assignments, assignments: self.assignments,
loaded, loaded: loaded.into(),
recipes, recipes,
settings, settings,
warnings, warnings,
modules: modules
.into_iter()
.map(|(name, (_name, justfile))| (name, justfile))
.collect(),
}) })
} }
@ -164,16 +202,9 @@ impl<'src> Analyzer<'src> {
Ok(()) 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(); 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 { for attr in &alias.attributes {
if *attr != Attribute::Private { if *attr != Attribute::Private {
return Err(alias.name.token().error(AliasInvalidAttribute { return Err(alias.name.token().error(AliasInvalidAttribute {
@ -232,7 +263,7 @@ mod tests {
line: 1, line: 1,
column: 6, column: 6,
width: 3, width: 3,
kind: DuplicateAlias { alias: "foo", first: 0 }, kind: Redefinition { first_type: "alias", second_type: "alias", name: "foo", first: 0 },
} }
analysis_error! { analysis_error! {
@ -248,11 +279,11 @@ mod tests {
analysis_error! { analysis_error! {
name: alias_shadows_recipe_before, name: alias_shadows_recipe_before,
input: "bar: \n echo bar\nalias foo := bar\nfoo:\n echo foo", input: "bar: \n echo bar\nalias foo := bar\nfoo:\n echo foo",
offset: 23, offset: 34,
line: 2, line: 3,
column: 6, column: 0,
width: 3, width: 3,
kind: AliasShadowsRecipe {alias: "foo", recipe_line: 3}, kind: Redefinition { first_type: "alias", second_type: "recipe", name: "foo", first: 2 },
} }
analysis_error! { analysis_error! {
@ -262,7 +293,7 @@ mod tests {
line: 2, line: 2,
column: 6, column: 6,
width: 3, width: 3,
kind: AliasShadowsRecipe { alias: "foo", recipe_line: 0 }, kind: Redefinition { first_type: "alias", second_type: "recipe", name: "foo", first: 0 },
} }
analysis_error! { analysis_error! {
@ -302,7 +333,7 @@ mod tests {
line: 2, line: 2,
column: 0, column: 0,
width: 1, width: 1,
kind: DuplicateRecipe{recipe: "a", first: 0}, kind: Redefinition { first_type: "recipe", second_type: "recipe", name: "a", first: 0 },
} }
analysis_error! { analysis_error! {

View File

@ -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<'_> { impl Display for CompileError<'_> {
fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut Formatter) -> Result<(), fmt::Error> {
use CompileErrorKind::*; use CompileErrorKind::*;
@ -82,12 +90,6 @@ impl Display for CompileError<'_> {
write!(f, "at most {max} {}", Count("argument", *max)) 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!( DuplicateAttribute { attribute, first } => write!(
f, f,
"Recipe attribute `{attribute}` first used on line {} is duplicated on line {}", "Recipe attribute `{attribute}` first used on line {} is duplicated on line {}",
@ -97,12 +99,6 @@ impl Display for CompileError<'_> {
DuplicateParameter { recipe, parameter } => { DuplicateParameter { recipe, parameter } => {
write!(f, "Recipe `{recipe}` has duplicate parameter `{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!( DuplicateSet { setting, first } => write!(
f, f,
"Setting `{setting}` first set on line {} is redefined on line {}", "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") write!(f, "Parameter `{parameter}` follows variadic parameter")
} }
ParsingRecursionDepthExceeded => write!(f, "Parsing recursion depth exceeded"), 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!( RequiredParameterFollowsDefaultParameter { parameter } => write!(
f, f,
"Non-default parameter `{parameter}` follows default parameter" "Non-default parameter `{parameter}` follows default parameter"

View File

@ -25,9 +25,11 @@ pub(crate) enum CompileErrorKind<'src> {
min: usize, min: usize,
max: usize, max: usize,
}, },
DuplicateAlias { Redefinition {
alias: &'src str,
first: usize, first: usize,
first_type: &'static str,
name: &'src str,
second_type: &'static str,
}, },
DuplicateAttribute { DuplicateAttribute {
attribute: &'src str, attribute: &'src str,
@ -37,10 +39,6 @@ pub(crate) enum CompileErrorKind<'src> {
recipe: &'src str, recipe: &'src str,
parameter: &'src str, parameter: &'src str,
}, },
DuplicateRecipe {
recipe: &'src str,
first: usize,
},
DuplicateSet { DuplicateSet {
setting: &'src str, setting: &'src str,
first: usize, first: usize,

View File

@ -4,6 +4,7 @@ pub(crate) struct Compiler;
impl Compiler { impl Compiler {
pub(crate) fn compile<'src>( pub(crate) fn compile<'src>(
unstable: bool,
loader: &'src Loader, loader: &'src Loader,
root: &Path, root: &Path,
) -> RunResult<'src, Compilation<'src>> { ) -> RunResult<'src, Compilation<'src>> {
@ -25,20 +26,40 @@ impl Compiler {
srcs.insert(current.clone(), src); srcs.insert(current.clone(), src);
for item in &mut ast.items { for item in &mut ast.items {
if let Item::Import { relative, absolute } = item { match item {
let import = current.parent().unwrap().join(&relative.cooked).lexiclean(); Item::Mod { name, absolute } => {
if srcs.contains_key(&import) { if !unstable {
return Err(Error::CircularImport { current, import }); 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()); Item::Import { relative, absolute } => {
stack.push(import); 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()); asts.insert(current.clone(), ast.clone());
} }
let justfile = Analyzer::analyze(loaded, &paths, &asts, root)?; let justfile = Analyzer::analyze(&loaded, &paths, &asts, root)?;
Ok(Compilation { Ok(Compilation {
asts, 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)] #[cfg(test)]
pub(crate) fn test_compile(src: &str) -> CompileResult<Justfile> { pub(crate) fn test_compile(src: &str) -> CompileResult<Justfile> {
let tokens = Lexer::test_lex(src)?; let tokens = Lexer::test_lex(src)?;
@ -57,7 +118,7 @@ impl Compiler {
asts.insert(root.clone(), ast); asts.insert(root.clone(), ast);
let mut paths: HashMap<PathBuf, PathBuf> = HashMap::new(); let mut paths: HashMap<PathBuf, PathBuf> = HashMap::new();
paths.insert(root.clone(), root.clone()); 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 loader = Loader::new();
let justfile_a_path = tmp.path().join("justfile"); 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); assert_eq!(compilation.root_src(), justfile_a);
} }
@ -129,7 +190,7 @@ recipe_b:
let loader = Loader::new(); let loader = Loader::new();
let justfile_a_path = tmp.path().join("justfile"); 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 } assert_matches!(loader_output, Error::CircularImport { current, import }
if current == tmp.path().join("subdir").join("justfile_b").lexiclean() && if current == tmp.path().join("subdir").join("justfile_b").lexiclean() &&

View File

@ -2,6 +2,10 @@ use super::*;
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum Error<'src> { pub(crate) enum Error<'src> {
AmbiguousModuleFile {
module: Name<'src>,
found: Vec<String>,
},
ArgumentCountMismatch { ArgumentCountMismatch {
recipe: &'src str, recipe: &'src str,
parameters: Vec<Parameter<'src>>, parameters: Vec<Parameter<'src>>,
@ -105,6 +109,9 @@ pub(crate) enum Error<'src> {
path: PathBuf, path: PathBuf,
io_error: io::Error, io_error: io::Error,
}, },
MissingModuleFile {
module: Name<'src>,
},
NoChoosableRecipes, NoChoosableRecipes,
NoDefaultRecipe, NoDefaultRecipe,
NoRecipes, NoRecipes,
@ -167,6 +174,9 @@ impl<'src> Error<'src> {
fn context(&self) -> Option<Token<'src>> { fn context(&self) -> Option<Token<'src>> {
match self { match self {
Self::AmbiguousModuleFile { module, .. } | Self::MissingModuleFile { module, .. } => {
Some(module.token())
}
Self::Backtick { token, .. } => Some(*token), Self::Backtick { token, .. } => Some(*token),
Self::Compile { compile_error } => Some(compile_error.context()), Self::Compile { compile_error } => Some(compile_error.context()),
Self::FunctionCall { function, .. } => Some(function.token()), Self::FunctionCall { function, .. } => Some(function.token()),
@ -224,6 +234,11 @@ impl<'src> ColorDisplay for Error<'src> {
write!(f, "{error}: {message}")?; write!(f, "{error}: {message}")?;
match self { match self {
AmbiguousModuleFile { module, found } =>
write!(f,
"Found multiple source files for module `{module}`: {}",
List::and_ticked(found),
)?,
ArgumentCountMismatch { recipe, found, min, max, .. } => { ArgumentCountMismatch { recipe, found, min, max, .. } => {
let count = Count("argument", *found); let count = Count("argument", *found);
if min == max { if min == max {
@ -350,6 +365,7 @@ impl<'src> ColorDisplay for Error<'src> {
let path = path.display(); let path = path.display();
write!(f, "Failed to read justfile at `{path}`: {io_error}")?; 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.")?, NoChoosableRecipes => write!(f, "Justfile contains no choosable recipes.")?,
NoDefaultRecipe => write!(f, "Justfile contains no default recipe.")?, NoDefaultRecipe => write!(f, "Justfile contains no default recipe.")?,
NoRecipes => write!(f, "Justfile contains no recipes.")?, NoRecipes => write!(f, "Justfile contains no recipes.")?,

View File

@ -6,12 +6,16 @@ pub(crate) enum Item<'src> {
Alias(Alias<'src, Name<'src>>), Alias(Alias<'src, Name<'src>>),
Assignment(Assignment<'src>), Assignment(Assignment<'src>),
Comment(&'src str), Comment(&'src str),
Recipe(UnresolvedRecipe<'src>),
Set(Set<'src>),
Import { Import {
relative: StringLiteral<'src>, relative: StringLiteral<'src>,
absolute: Option<PathBuf>, absolute: Option<PathBuf>,
}, },
Mod {
name: Name<'src>,
absolute: Option<PathBuf>,
},
Recipe(UnresolvedRecipe<'src>),
Set(Set<'src>),
} }
impl<'src> Display for Item<'src> { impl<'src> Display for Item<'src> {
@ -20,9 +24,10 @@ impl<'src> Display for Item<'src> {
Item::Alias(alias) => write!(f, "{alias}"), Item::Alias(alias) => write!(f, "{alias}"),
Item::Assignment(assignment) => write!(f, "{assignment}"), Item::Assignment(assignment) => write!(f, "{assignment}"),
Item::Comment(comment) => write!(f, "{comment}"), 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::Recipe(recipe) => write!(f, "{}", recipe.color_display(Color::never())),
Item::Set(set) => write!(f, "{set}"), Item::Set(set) => write!(f, "{set}"),
Item::Import { relative, .. } => write!(f, "import {relative}"),
} }
} }
} }

View File

@ -1,5 +1,13 @@
use {super::*, serde::Serialize}; 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)] #[derive(Debug, PartialEq, Serialize)]
pub(crate) struct Justfile<'src> { pub(crate) struct Justfile<'src> {
pub(crate) aliases: Table<'src, Alias<'src>>, pub(crate) aliases: Table<'src, Alias<'src>>,
@ -8,6 +16,7 @@ pub(crate) struct Justfile<'src> {
pub(crate) default: Option<Rc<Recipe<'src>>>, pub(crate) default: Option<Rc<Recipe<'src>>>,
#[serde(skip)] #[serde(skip)]
pub(crate) loaded: Vec<PathBuf>, pub(crate) loaded: Vec<PathBuf>,
pub(crate) modules: BTreeMap<String, Justfile<'src>>,
pub(crate) recipes: Table<'src, Rc<Recipe<'src>>>, pub(crate) recipes: Table<'src, Rc<Recipe<'src>>>,
pub(crate) settings: Settings<'src>, pub(crate) settings: Settings<'src>,
pub(crate) warnings: Vec<Warning>, pub(crate) warnings: Vec<Warning>,
@ -67,6 +76,44 @@ impl<'src> Justfile<'src> {
.next() .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( pub(crate) fn run(
&self, &self,
config: &Config, config: &Config,
@ -92,33 +139,9 @@ impl<'src> Justfile<'src> {
BTreeMap::new() BTreeMap::new()
}; };
let scope = { let root = Scope::new();
let mut scope = Scope::new();
let mut unknown_overrides = Vec::new();
for (name, value) in overrides { let scope = self.scope(config, &dotenv, search, overrides, &root)?;
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,
)?
};
match &config.subcommand { match &config.subcommand {
Subcommand::Command { Subcommand::Command {
@ -193,13 +216,7 @@ impl<'src> Justfile<'src> {
let argvec: Vec<&str> = if !arguments.is_empty() { let argvec: Vec<&str> = if !arguments.is_empty() {
arguments.iter().map(String::as_str).collect() arguments.iter().map(String::as_str).collect()
} else if let Some(recipe) = &self.default { } else if let Some(recipe) = &self.default {
let min_arguments = recipe.min_arguments(); recipe.check_can_be_default_recipe()?;
if min_arguments > 0 {
return Err(Error::DefaultRecipeRequiresArguments {
recipe: recipe.name.lexeme(),
min_arguments,
});
}
vec![recipe.name()] vec![recipe.name()]
} else if self.recipes.is_empty() { } else if self.recipes.is_empty() {
return Err(Error::NoRecipes); return Err(Error::NoRecipes);
@ -209,33 +226,31 @@ impl<'src> Justfile<'src> {
let arguments = argvec.as_slice(); let arguments = argvec.as_slice();
let mut missing = vec![]; let mut missing = Vec::new();
let mut grouped = vec![]; let mut invocations = Vec::new();
let mut rest = arguments; let mut remaining = arguments;
let mut scopes = BTreeMap::new();
let arena: Arena<Scope> = Arena::new();
while let Some((argument, mut tail)) = rest.split_first() { while let Some((first, mut rest)) = remaining.split_first() {
if let Some(recipe) = self.get_recipe(argument) { if let Some((invocation, consumed)) = self.invocation(
if recipe.parameters.is_empty() { 0,
grouped.push((recipe, &[][..])); &mut Vec::new(),
} else { &arena,
let argument_range = recipe.argument_range(); &mut scopes,
let argument_count = cmp::min(tail.len(), recipe.max_arguments()); config,
if !argument_range.range_contains(&argument_count) { &dotenv,
return Err(Error::ArgumentCountMismatch { search,
recipe: recipe.name(), &scope,
parameters: recipe.parameters.clone(), first,
found: tail.len(), rest,
min: recipe.min_arguments(), )? {
max: recipe.max_arguments(), rest = &rest[consumed..];
}); invocations.push(invocation);
}
grouped.push((recipe, &tail[0..argument_count]));
tail = &tail[argument_count..];
}
} else { } else {
missing.push((*argument).to_owned()); missing.push((*first).to_owned());
} }
rest = tail; remaining = rest;
} }
if !missing.is_empty() { 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(); let mut ran = BTreeSet::new();
for (recipe, arguments) in grouped { for invocation in invocations {
Self::run_recipe(&context, recipe, arguments, &dotenv, search, &mut ran)?; let context = RecipeContext {
settings: invocation.settings,
config,
scope: invocation.scope,
search,
};
Self::run_recipe(
&context,
invocation.recipe,
invocation.arguments,
&dotenv,
search,
&mut ran,
)?;
} }
Ok(()) Ok(())
@ -277,6 +299,98 @@ impl<'src> Justfile<'src> {
.or_else(|| self.aliases.get(name).map(|alias| alias.target.as_ref())) .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( fn run_recipe(
context: &RecipeContext<'src, '_>, context: &RecipeContext<'src, '_>,
recipe: &Recipe<'src>, recipe: &Recipe<'src>,
@ -305,7 +419,7 @@ impl<'src> Justfile<'src> {
dotenv, dotenv,
&recipe.parameters, &recipe.parameters,
arguments, arguments,
&context.scope, context.scope,
context.settings, context.settings,
search, search,
)?; )?;

View File

@ -15,6 +15,7 @@ pub(crate) enum Keyword {
If, If,
IgnoreComments, IgnoreComments,
Import, Import,
Mod,
PositionalArguments, PositionalArguments,
Set, Set,
Shell, Shell,

View File

@ -21,9 +21,10 @@ impl<'src> Node<'src> for Item<'src> {
Item::Alias(alias) => alias.tree(), Item::Alias(alias) => alias.tree(),
Item::Assignment(assignment) => assignment.tree(), Item::Assignment(assignment) => assignment.tree(),
Item::Comment(comment) => comment.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::Recipe(recipe) => recipe.tree(),
Item::Set(set) => set.tree(), Item::Set(set) => set.tree(),
Item::Import { relative, .. } => Tree::atom("import").push(format!("{relative}")),
} }
} }
} }

View File

@ -335,6 +335,13 @@ impl<'tokens, 'src> Parser<'tokens, 'src> {
absolute: None, 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) Some(Keyword::Set)
if self.next_are(&[Identifier, Identifier, ColonEquals]) if self.next_are(&[Identifier, Identifier, ColonEquals])
|| self.next_are(&[Identifier, Identifier, Comment, Eof]) || self.next_are(&[Identifier, Identifier, Comment, Eof])

View File

@ -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 { pub(crate) fn public(&self) -> bool {
!self.private && !self.attributes.contains(&Attribute::Private) !self.private && !self.attributes.contains(&Attribute::Private)
} }

View File

@ -2,7 +2,7 @@ use super::*;
pub(crate) struct RecipeContext<'src: 'run, 'run> { pub(crate) struct RecipeContext<'src: 'run, 'run> {
pub(crate) config: &'run Config, 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) search: &'run Search,
pub(crate) settings: &'run Settings<'src>, pub(crate) settings: &'run Settings<'src>,
} }

View File

@ -8,14 +8,14 @@ pub(crate) struct Scope<'src: 'run, 'run> {
impl<'src, 'run> Scope<'src, 'run> { impl<'src, 'run> Scope<'src, 'run> {
pub(crate) fn child(&'run self) -> Scope<'src, 'run> { pub(crate) fn child(&'run self) -> Scope<'src, 'run> {
Scope { Self {
parent: Some(self), parent: Some(self),
bindings: Table::new(), bindings: Table::new(),
} }
} }
pub(crate) fn new() -> Scope<'src, 'run> { pub(crate) fn new() -> Scope<'src, 'run> {
Scope { Self {
parent: None, parent: None,
bindings: Table::new(), bindings: Table::new(),
} }

View File

@ -1,7 +1,7 @@
use {super::*, std::path::Component}; use {super::*, std::path::Component};
const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0]; 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"]; const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
pub(crate) struct Search { pub(crate) struct Search {
@ -109,7 +109,7 @@ impl Search {
} }
} }
fn justfile(directory: &Path) -> SearchResult<PathBuf> { pub(crate) fn justfile(directory: &Path) -> SearchResult<PathBuf> {
for directory in directory.ancestors() { for directory in directory.ancestors() {
let mut candidates = BTreeSet::new(); let mut candidates = BTreeSet::new();

View File

@ -79,7 +79,7 @@ impl Subcommand {
} }
Dump => Self::dump(config, ast, justfile)?, Dump => Self::dump(config, ast, justfile)?,
Format => Self::format(config, &search, src, ast)?, 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)?, Show { ref name } => Self::show(config, name, justfile)?,
Summary => Self::summary(config, justfile), Summary => Self::summary(config, justfile),
Variables => Self::variables(justfile), Variables => Self::variables(justfile),
@ -180,7 +180,7 @@ impl Subcommand {
loader: &'src Loader, loader: &'src Loader,
search: &Search, search: &Search,
) -> Result<Compilation<'src>, Error<'src>> { ) -> Result<Compilation<'src>, Error<'src>> {
let compilation = Compiler::compile(loader, &search.justfile)?; let compilation = Compiler::compile(config.unstable, loader, &search.justfile)?;
if config.verbosity.loud() { if config.verbosity.loud() {
for warning in &compilation.justfile.warnings { 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. // Construct a target to alias map.
let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for alias in justfile.aliases.values() { 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 max_line_width = cmp::min(line_widths.values().copied().max().unwrap_or(0), 30);
let doc_color = config.color.stdout().doc(); 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) { for recipe in justfile.public_recipes(config.unsorted) {
let name = recipe.name(); let name = recipe.name();
@ -476,7 +478,7 @@ impl Subcommand {
.chain(recipe_aliases.get(name).unwrap_or(&Vec::new())) .chain(recipe_aliases.get(name).unwrap_or(&Vec::new()))
.enumerate() .enumerate()
{ {
print!("{}{name}", config.list_prefix); print!("{}{name}", config.list_prefix.repeat(level + 1));
for parameter in &recipe.parameters { for parameter in &recipe.parameters {
print!(" {}", parameter.color_display(config.color.stdout())); print!(" {}", parameter.color_display(config.color.stdout()));
} }
@ -506,6 +508,11 @@ impl Subcommand {
println!(); 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>> { fn show<'src>(config: &Config, name: &str, justfile: &Justfile<'src>) -> Result<(), Error<'src>> {

View File

@ -28,7 +28,7 @@ mod full {
pub fn summary(path: &Path) -> Result<Result<Summary, String>, io::Error> { pub fn summary(path: &Path) -> Result<Result<Summary, String>, io::Error> {
let loader = Loader::new(); let loader = Loader::new();
match Compiler::compile(&loader, path) { match Compiler::compile(false, &loader, path) {
Ok(compilation) => Ok(Ok(Summary::new(&compilation.justfile))), Ok(compilation) => Ok(Ok(Summary::new(&compilation.justfile))),
Err(error) => Ok(Err(if let Error::Compile { compile_error } = error { Err(error) => Ok(Err(if let Error::Compile { compile_error } = error {
compile_error.to_string() compile_error.to_string()

View File

@ -68,7 +68,7 @@ pub(crate) fn analysis_error(
let mut paths: HashMap<PathBuf, PathBuf> = HashMap::new(); let mut paths: HashMap<PathBuf, PathBuf> = HashMap::new();
paths.insert("justfile".into(), "justfile".into()); 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"), Ok(_) => panic!("Analysis unexpectedly succeeded"),
Err(have) => { Err(have) => {
let want = CompileError { let want = CompileError {

View File

@ -1,6 +1,6 @@
use super::*; use super::*;
fn test(justfile: &str, value: Value) { fn case(justfile: &str, value: Value) {
Test::new() Test::new()
.justfile(justfile) .justfile(justfile)
.args(["--dump", "--dump-format", "json", "--unstable"]) .args(["--dump", "--dump-format", "json", "--unstable"])
@ -10,7 +10,7 @@ fn test(justfile: &str, value: Value) {
#[test] #[test]
fn alias() { fn alias() {
test( case(
" "
alias f := foo alias f := foo
@ -26,6 +26,7 @@ fn alias() {
} }
}, },
"assignments": {}, "assignments": {},
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"attributes": [], "attributes": [],
@ -61,7 +62,7 @@ fn alias() {
#[test] #[test]
fn assignment() { fn assignment() {
test( case(
"foo := 'bar'", "foo := 'bar'",
json!({ json!({
"aliases": {}, "aliases": {},
@ -73,6 +74,7 @@ fn assignment() {
} }
}, },
"first": null, "first": null,
"modules": {},
"recipes": {}, "recipes": {},
"settings": { "settings": {
"allow_duplicate_recipes": false, "allow_duplicate_recipes": false,
@ -95,7 +97,7 @@ fn assignment() {
#[test] #[test]
fn body() { fn body() {
test( case(
" "
foo: foo:
bar bar
@ -105,6 +107,7 @@ fn body() {
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "foo", "first": "foo",
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"attributes": [], "attributes": [],
@ -143,7 +146,7 @@ fn body() {
#[test] #[test]
fn dependencies() { fn dependencies() {
test( case(
" "
foo: foo:
bar: foo bar: foo
@ -152,6 +155,7 @@ fn dependencies() {
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "foo", "first": "foo",
"modules": {},
"recipes": { "recipes": {
"bar": { "bar": {
"attributes": [], "attributes": [],
@ -202,7 +206,7 @@ fn dependencies() {
#[test] #[test]
fn dependency_argument() { fn dependency_argument() {
test( case(
" "
x := 'foo' x := 'foo'
foo *args: foo *args:
@ -230,6 +234,7 @@ fn dependency_argument() {
"value": "foo", "value": "foo",
}, },
}, },
"modules": {},
"recipes": { "recipes": {
"bar": { "bar": {
"doc": null, "doc": null,
@ -298,7 +303,7 @@ fn dependency_argument() {
#[test] #[test]
fn duplicate_recipes() { fn duplicate_recipes() {
test( case(
" "
set allow-duplicate-recipes set allow-duplicate-recipes
alias f := foo alias f := foo
@ -316,6 +321,7 @@ fn duplicate_recipes() {
} }
}, },
"assignments": {}, "assignments": {},
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"body": [], "body": [],
@ -358,12 +364,13 @@ fn duplicate_recipes() {
#[test] #[test]
fn doc_comment() { fn doc_comment() {
test( case(
"# hello\nfoo:", "# hello\nfoo:",
json!({ json!({
"aliases": {}, "aliases": {},
"first": "foo", "first": "foo",
"assignments": {}, "assignments": {},
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"body": [], "body": [],
@ -399,12 +406,13 @@ fn doc_comment() {
#[test] #[test]
fn empty_justfile() { fn empty_justfile() {
test( case(
"", "",
json!({ json!({
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": null, "first": null,
"modules": {},
"recipes": {}, "recipes": {},
"settings": { "settings": {
"allow_duplicate_recipes": false, "allow_duplicate_recipes": false,
@ -427,7 +435,7 @@ fn empty_justfile() {
#[test] #[test]
fn parameters() { fn parameters() {
test( case(
" "
a: a:
b x: b x:
@ -440,6 +448,7 @@ fn parameters() {
"aliases": {}, "aliases": {},
"first": "a", "first": "a",
"assignments": {}, "assignments": {},
"modules": {},
"recipes": { "recipes": {
"a": { "a": {
"attributes": [], "attributes": [],
@ -570,7 +579,7 @@ fn parameters() {
#[test] #[test]
fn priors() { fn priors() {
test( case(
" "
a: a:
b: a && c b: a && c
@ -580,6 +589,7 @@ fn priors() {
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "a", "first": "a",
"modules": {},
"recipes": { "recipes": {
"a": { "a": {
"body": [], "body": [],
@ -649,12 +659,13 @@ fn priors() {
#[test] #[test]
fn private() { fn private() {
test( case(
"_foo:", "_foo:",
json!({ json!({
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "_foo", "first": "_foo",
"modules": {},
"recipes": { "recipes": {
"_foo": { "_foo": {
"body": [], "body": [],
@ -690,12 +701,13 @@ fn private() {
#[test] #[test]
fn quiet() { fn quiet() {
test( case(
"@foo:", "@foo:",
json!({ json!({
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "foo", "first": "foo",
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"body": [], "body": [],
@ -731,7 +743,7 @@ fn quiet() {
#[test] #[test]
fn settings() { fn settings() {
test( case(
" "
set dotenv-load set dotenv-load
set dotenv-filename := \"filename\" set dotenv-filename := \"filename\"
@ -748,6 +760,7 @@ fn settings() {
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "foo", "first": "foo",
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"body": [["#!bar"]], "body": [["#!bar"]],
@ -786,7 +799,7 @@ fn settings() {
#[test] #[test]
fn shebang() { fn shebang() {
test( case(
" "
foo: foo:
#!bar #!bar
@ -795,6 +808,7 @@ fn shebang() {
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "foo", "first": "foo",
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"body": [["#!bar"]], "body": [["#!bar"]],
@ -830,12 +844,13 @@ fn shebang() {
#[test] #[test]
fn simple() { fn simple() {
test( case(
"foo:", "foo:",
json!({ json!({
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "foo", "first": "foo",
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"body": [], "body": [],
@ -871,7 +886,7 @@ fn simple() {
#[test] #[test]
fn attribute() { fn attribute() {
test( case(
" "
[no-exit-message] [no-exit-message]
foo: foo:
@ -880,6 +895,7 @@ fn attribute() {
"aliases": {}, "aliases": {},
"assignments": {}, "assignments": {},
"first": "foo", "first": "foo",
"modules": {},
"recipes": { "recipes": {
"foo": { "foo": {
"attributes": ["no-exit-message"], "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();
}

View File

@ -63,6 +63,7 @@ mod invocation_directory;
mod json; mod json;
mod line_prefixes; mod line_prefixes;
mod misc; mod misc;
mod modules;
mod multibyte_char; mod multibyte_char;
mod newline_escape; mod newline_escape;
mod no_cd; mod no_cd;

View File

@ -133,11 +133,11 @@ test! {
name: alias_shadows_recipe, name: alias_shadows_recipe,
justfile: "bar:\n echo bar\nalias foo := bar\nfoo:\n echo foo", justfile: "bar:\n echo bar\nalias foo := bar\nfoo:\n echo foo",
stderr: " stderr: "
error: Alias `foo` defined on line 3 shadows recipe `foo` defined on line 4 error: Alias `foo` defined on line 3 is redefined as a recipe on line 4
--> justfile:3:7 --> justfile:4:1
| |
3 | alias foo := bar 4 | foo:
| ^^^ | ^^^
", ",
status: EXIT_FAILURE, status: EXIT_FAILURE,
} }

446
tests/modules.rs Normal file
View File

@ -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();
}