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