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
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.

View File

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

View File

@ -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! {

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<'_> {
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"

View File

@ -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,

View File

@ -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() &&

View File

@ -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.")?,

View File

@ -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}"),
}
}
}

View File

@ -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,
)?;

View File

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

View File

@ -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}")),
}
}
}

View File

@ -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])

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 {
!self.private && !self.attributes.contains(&Attribute::Private)
}

View File

@ -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>,
}

View File

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

View File

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

View File

@ -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>> {

View File

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

View File

@ -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 {

View File

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

View File

@ -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;

View File

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