use super::*;
#[allow(clippy::doc_markdown)]
pub(crate) struct InvocationParser<'src: 'run, 'run> {
arguments: &'run [&'run str],
next: usize,
root: &'run Justfile<'src>,
}
impl<'src: 'run, 'run> InvocationParser<'src, 'run> {
pub(crate) fn parse_invocations(
root: &'run Justfile<'src>,
arguments: &'run [&'run str],
) -> RunResult<'src, Vec<Invocation<'src, 'run>>> {
let mut invocations = Vec::new();
let mut invocation_parser = Self {
arguments,
next: 0,
root,
};
loop {
invocations.push(invocation_parser.parse_invocation()?);
if invocation_parser.next == arguments.len() {
break;
}
}
Ok(invocations)
}
fn parse_invocation(&mut self) -> RunResult<'src, Invocation<'src, 'run>> {
let recipe = if let Some(next) = self.next() {
if next.contains(':') {
let path = if self.next + 1 == self.arguments.len() {
next.strip_suffix("::").unwrap_or(next)
} else {
next
};
let modulepath =
Modulepath::try_from([path].as_slice()).map_err(|()| Error::UnknownRecipe {
recipe: next.into(),
suggestion: None,
})?;
let (recipe, _) = self.resolve_recipe(true, &modulepath.components)?;
self.next += 1;
recipe
} else {
let (recipe, consumed) = self.resolve_recipe(false, self.rest())?;
self.next += consumed;
recipe
}
} else {
let (recipe, consumed) = self.resolve_recipe(false, self.rest())?;
assert_eq!(consumed, 0);
recipe
};
let mut arguments = vec![Vec::<String>::new(); recipe.parameters.len()];
let long = recipe
.parameters
.iter()
.enumerate()
.filter_map(|(i, parameter)| parameter.long.as_ref().map(|name| (name.as_str(), i)))
.collect::<BTreeMap<&str, usize>>();
let short = recipe
.parameters
.iter()
.enumerate()
.filter_map(|(i, parameter)| parameter.short.map(|name| (name, i)))
.collect::<BTreeMap<char, usize>>();
let positional = recipe
.parameters
.iter()
.enumerate()
.filter_map(|(i, parameter)| (!parameter.is_option()).then_some(i))
.collect::<Vec<usize>>();
let mut end_of_options = long.is_empty() && short.is_empty();
let rest = self.rest();
let mut i = 0;
let mut positional_index = 0;
let mut positional_accepted = 0;
while let Some(argument) = rest.get(i) {
if !end_of_options && *argument == "--" {
end_of_options = true;
i += 1;
} else if !end_of_options && argument.starts_with('-') && *argument != "-" {
let mut name = argument
.strip_prefix("--")
.or_else(|| argument.strip_prefix('-'))
.unwrap();
let value = if let Some((left, right)) = name.split_once('=') {
name = left;
Some(right)
} else {
None
};
let switch = if argument.starts_with("--") {
Switch::Long(name.into())
} else {
if name.chars().count() != 1 {
return Err(Error::MultipleShortOptions {
options: name.into(),
});
}
Switch::Short(name.chars().next().unwrap())
};
let index = match &switch {
Switch::Long(name) => long.get(name.as_str()),
Switch::Short(name) => short.get(name),
};
let Some(&index) = index else {
return Err(Error::UnknownOption {
recipe: recipe.name(),
option: switch,
});
};
let value = if let Some(flag_value) = &recipe.parameters[index].value {
if value.is_some() {
return Err(Error::FlagWithValue {
recipe: recipe.name(),
option: switch,
});
}
i += 1;
flag_value
} else if let Some(value) = value {
i += 1;
value
} else {
let Some(&value) = rest.get(i + 1) else {
return Err(Error::OptionMissingValue {
recipe: recipe.name(),
option: switch,
});
};
i += 2;
value
};
let group = &mut arguments[index];
if !group.is_empty() {
return Err(Error::DuplicateOption {
recipe: recipe.name(),
option: switch,
});
}
group.push((*value).into());
} else {
let Some(&index) = positional.get(positional_index) else {
break;
};
let group = &mut arguments[index];
group.push((*argument).into());
if !recipe.parameters[index].kind.is_variadic() {
positional_index += 1;
}
positional_accepted += 1;
i += 1;
}
}
let mut missing_positional = 0;
for (parameter, group) in recipe.parameters.iter().zip(&arguments) {
if !group.is_empty() {
continue;
}
if parameter.default.is_some() || parameter.kind == ParameterKind::Star {
continue;
}
if let Some(name) = ¶meter.long {
return Err(Error::MissingOption {
recipe: recipe.name(),
option: Switch::Long(name.into()),
});
}
if let Some(name) = ¶meter.short {
return Err(Error::MissingOption {
recipe: recipe.name(),
option: Switch::Short(*name),
});
}
missing_positional += 1;
}
if missing_positional > 0 {
return Err(Error::PositionalArgumentCountMismatch {
recipe: Box::new(recipe.clone()),
found: positional_accepted,
min: recipe
.parameters
.iter()
.filter(|p| p.is_required() && !p.is_option())
.count(),
max: if recipe.parameters.iter().any(|p| p.kind.is_variadic()) {
usize::MAX - 1
} else {
recipe.parameters.iter().filter(|p| !p.is_option()).count()
},
});
}
for (group, parameter) in arguments.iter().zip(&recipe.parameters) {
for argument in group {
parameter.check_pattern_match(recipe, argument)?;
}
}
self.next += i;
Ok(Invocation { arguments, recipe })
}
fn resolve_recipe(
&self,
modulepath: bool,
args: &[impl AsRef<str>],
) -> RunResult<'src, (&'run Recipe<'src>, usize)> {
let mut current = self.root;
let mut path = Vec::new();
for (i, arg) in args.iter().enumerate() {
let arg = arg.as_ref();
path.push(arg.to_string());
if let Some(module) = current.modules.get(arg) {
current = module;
} else if let Some(recipe) = current.get_recipe(arg) {
if modulepath && i + 1 < args.len() {
return Err(Error::ExpectedSubmoduleButFoundRecipe {
path: path.join("::"),
});
}
return Ok((recipe, i + 1));
} else {
if modulepath && i + 1 < args.len() {
return Err(Error::UnknownSubmodule {
path: path.join("::"),
});
}
return Err(Error::UnknownRecipe {
recipe: if modulepath {
path.join("::")
} else {
path.join(" ")
},
suggestion: current.suggest_recipe(arg),
});
}
}
if let Some(recipe) = ¤t.default {
recipe.check_can_be_default_recipe()?;
Ok((recipe, args.len()))
} else if current.recipes.is_empty() {
Err(Error::NoRecipes)
} else {
Err(Error::NoDefaultRecipe)
}
}
fn next(&self) -> Option<&'run str> {
self.arguments.get(self.next).copied()
}
fn rest(&self) -> &[&'run str] {
&self.arguments[self.next..]
}
}
#[cfg(test)]
mod tests {
use {super::*, tempfile::TempDir};
trait TempDirExt {
fn write(&self, path: &str, content: &str);
}
impl TempDirExt for TempDir {
fn write(&self, path: &str, content: &str) {
let path = self.path().join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, content).unwrap();
}
}
#[test]
fn single_no_arguments() {
let justfile = testing::compile("foo:");
let invocations = InvocationParser::parse_invocations(&justfile, &["foo"]).unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "foo");
assert!(invocations[0].arguments.is_empty());
}
#[test]
fn single_with_argument() {
let justfile = testing::compile("foo bar:");
let invocations = InvocationParser::parse_invocations(&justfile, &["foo", "baz"]).unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "foo");
assert_eq!(invocations[0].arguments, vec![vec![String::from("baz")]]);
}
#[test]
fn single_argument_count_mismatch() {
let justfile = testing::compile("foo bar:");
assert_matches!(
InvocationParser::parse_invocations(&justfile, &["foo"]).unwrap_err(),
Error::PositionalArgumentCountMismatch {
recipe: _,
found: 0,
min: 1,
max: 1,
..
},
);
}
#[test]
fn single_unknown() {
let justfile = testing::compile("foo:");
assert_matches!(
InvocationParser::parse_invocations(&justfile, &["bar"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "bar",
);
}
#[test]
fn multiple_unknown() {
let justfile = testing::compile("foo:");
assert_matches!(
InvocationParser::parse_invocations(&justfile, &["bar", "baz"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "bar",
);
}
#[test]
fn recipe_in_submodule() {
let loader = Loader::new();
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("justfile");
fs::write(&path, "mod foo").unwrap();
fs::create_dir(tempdir.path().join("foo")).unwrap();
fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
let compilation = Compiler::compile(&Config::new().unwrap(), &loader, &path).unwrap();
let invocations =
InvocationParser::parse_invocations(&compilation.justfile, &["foo", "bar"]).unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "foo::bar");
assert!(invocations[0].arguments.is_empty());
}
#[test]
fn recipe_in_submodule_unknown() {
let loader = Loader::new();
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("justfile");
fs::write(&path, "mod foo").unwrap();
fs::create_dir(tempdir.path().join("foo")).unwrap();
fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
let compilation = Compiler::compile(&Config::new().unwrap(), &loader, &path).unwrap();
assert_matches!(
InvocationParser::parse_invocations(&compilation.justfile, &["foo", "zzz"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "foo zzz",
);
}
#[test]
fn recipe_in_submodule_path_unknown() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "mod foo");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(
&Config::new().unwrap(),
&loader,
&tempdir.path().join("justfile"),
)
.unwrap();
assert_matches!(
InvocationParser::parse_invocations(&compilation.justfile, &["foo::zzz"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "foo::zzz",
);
}
#[test]
fn module_path_not_consumed() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "mod foo");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(
&Config::new().unwrap(),
&loader,
&tempdir.path().join("justfile"),
)
.unwrap();
assert_matches!(
InvocationParser::parse_invocations(&compilation.justfile, &["foo::bar::baz"]).unwrap_err(),
Error::ExpectedSubmoduleButFoundRecipe {
path,
} if path == "foo::bar",
);
}
#[test]
fn no_recipes() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "");
let loader = Loader::new();
let compilation = Compiler::compile(
&Config::new().unwrap(),
&loader,
&tempdir.path().join("justfile"),
)
.unwrap();
assert_matches!(
InvocationParser::parse_invocations(&compilation.justfile, &[]).unwrap_err(),
Error::NoRecipes,
);
}
#[test]
fn default_recipe_requires_arguments() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "foo bar:");
let loader = Loader::new();
let compilation = Compiler::compile(
&Config::new().unwrap(),
&loader,
&tempdir.path().join("justfile"),
)
.unwrap();
assert_matches!(
InvocationParser::parse_invocations(&compilation.justfile, &[]).unwrap_err(),
Error::DefaultRecipeRequiresArguments {
recipe: "foo",
min_arguments: 1,
},
);
}
#[test]
fn no_default_recipe() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "import 'foo.just'");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(
&Config::new().unwrap(),
&loader,
&tempdir.path().join("justfile"),
)
.unwrap();
assert_matches!(
InvocationParser::parse_invocations(&compilation.justfile, &[]).unwrap_err(),
Error::NoDefaultRecipe,
);
}
#[test]
fn complex_grouping() {
let justfile = testing::compile(
"
FOO A B='blarg':
echo foo: {{A}} {{B}}
BAR X:
echo bar: {{X}}
BAZ +Z:
echo baz: {{Z}}
",
);
let invocations = InvocationParser::parse_invocations(
&justfile,
&["BAR", "0", "FOO", "1", "2", "BAZ", "3", "4", "5"],
)
.unwrap();
assert_eq!(invocations.len(), 3);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "BAR");
assert_eq!(invocations[0].arguments, vec![vec![String::from("0")]]);
assert_eq!(invocations[1].recipe.recipe_path().to_string(), "FOO");
assert_eq!(
invocations[1].arguments,
vec![vec![String::from("1")], vec![String::from("2")]]
);
assert_eq!(invocations[2].recipe.recipe_path().to_string(), "BAZ");
assert_eq!(
invocations[2].arguments,
vec![vec![
String::from("3"),
String::from("4"),
String::from("5")
]]
);
}
#[test]
fn long_argument() {
let justfile = testing::compile(
"
[arg('bar', long='bar')]
foo bar:
",
);
let invocations =
InvocationParser::parse_invocations(&justfile, &["foo", "--bar", "baz"]).unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "foo");
assert_eq!(invocations[0].arguments, vec![vec![String::from("baz")]]);
}
#[test]
fn long_argument_with_positional() {
let justfile = testing::compile(
"
[arg('bar', long='bar')]
foo baz bar:
",
);
let invocations =
InvocationParser::parse_invocations(&justfile, &["foo", "qux", "--bar", "baz"]).unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "foo");
assert_eq!(
invocations[0].arguments,
vec![vec![String::from("qux")], vec![String::from("baz")]]
);
}
#[test]
fn long_argument_terminator() {
let justfile = testing::compile(
"
[arg('bar', long='bar')]
foo baz qux='qux' bar='bar':
",
);
let invocations =
InvocationParser::parse_invocations(&justfile, &["foo", "--", "--bar"]).unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "foo");
assert_eq!(
invocations[0].arguments,
vec![vec![String::from("--bar")], Vec::new(), Vec::new()]
);
}
#[test]
fn trailing_separator_runs_default_recipe() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "mod foo");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(
&Config::new().unwrap(),
&loader,
&tempdir.path().join("justfile"),
)
.unwrap();
let invocations =
InvocationParser::parse_invocations(&compilation.justfile, &["foo::"]).unwrap();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].recipe.recipe_path().to_string(), "foo::bar");
assert!(invocations[0].arguments.is_empty());
}
#[test]
fn trailing_separator_not_last_argument() {
let tempdir = tempfile::tempdir().unwrap();
tempdir.write("justfile", "mod foo");
tempdir.write("foo.just", "bar:");
let loader = Loader::new();
let compilation = Compiler::compile(
&Config::new().unwrap(),
&loader,
&tempdir.path().join("justfile"),
)
.unwrap();
assert_matches!(
InvocationParser::parse_invocations(&compilation.justfile, &["foo::", "bar"]).unwrap_err(),
Error::UnknownRecipe {
recipe,
suggestion: None
} if recipe == "foo::",
);
}
}