Reform positional argument parsing (#523)

This diff makes positional argument parsing much cleaner, along with
adding a bunch of tests. Just's positional argument parsing is rather,
complex, so hopefully this reform allows it to both be correct and stay
correct.

User-visible changes:

- `just ..` is now accepted, with the same effect as `just ../`

- `just .` is also accepted, with the same effect as `just`

- It is now an error to pass arguments or overrides to subcommands
  that do not accept them, namely `--dump`, `--edit`, `--list`,
  `--show`, and `--summary`. It is also an error to pass arguments to
  `--evaluate`, although `--evaluate` does of course still accept
  overrides.

  (This is a breaking change, but hopefully worth it, as it will allow us
  to add arguments to subcommands which did not previously take
  them, if we so desire.)

- Subcommands which do not accept arguments may now accept a
  single search-directory argument, so `just --list ../` and
  `just --dump foo/` are now accepted, with the former starting the
  search for the justfile to list in the parent directory, and the latter
  starting the search for the justfile to dump in `foo`.
This commit is contained in:
Casey Rodarmor 2019-11-10 18:02:36 -08:00 committed by GitHub
parent aefdcea7d0
commit 177516bcbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 953 additions and 448 deletions

View File

@ -7,6 +7,7 @@ pub(crate) struct AssignmentEvaluator<'a: 'b, 'b> {
pub(crate) evaluated: BTreeMap<&'a str, (bool, String)>,
pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>,
pub(crate) working_directory: &'b Path,
pub(crate) overrides: &'b BTreeMap<String, String>,
}
impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
@ -15,10 +16,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
working_directory: &'b Path,
dotenv: &'b BTreeMap<String, String>,
assignments: &BTreeMap<&'a str, Assignment<'a>>,
overrides: &BTreeMap<String, String>,
) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> {
let mut evaluator = AssignmentEvaluator {
evaluated: empty(),
scope: &empty(),
overrides,
config,
assignments,
working_directory,
@ -55,7 +58,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
}
if let Some(assignment) = self.assignments.get(name) {
if let Some(value) = self.config.overrides.get(name) {
if let Some(value) = self.overrides.get(name) {
self
.evaluated
.insert(name, (assignment.export, value.to_string()));
@ -159,47 +162,40 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{compile, config};
#[test]
fn backtick_code() {
let justfile = compile("a:\n echo {{`f() { return 100; }; f`}}");
let config = config(&["a"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
RuntimeError::Backtick {
run_error! {
name: backtick_code,
src: "
a:
echo {{`f() { return 100; }; f`}}
",
args: ["a"],
error: RuntimeError::Backtick {
token,
output_error: OutputError::Code(code),
} => {
},
check: {
assert_eq!(code, 100);
assert_eq!(token.lexeme(), "`f() { return 100; }; f`");
}
other => panic!("expected a code run error, but got: {}", other),
}
}
#[test]
fn export_assignment_backtick() {
let text = r#"
export exported_variable = "A"
b = `echo $exported_variable`
run_error! {
name: export_assignment_backtick,
src: r#"
export exported_variable = "A"
b = `echo $exported_variable`
recipe:
recipe:
echo {{b}}
"#;
let justfile = compile(text);
let config = config(&["--quiet", "recipe"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
RuntimeError::Backtick {
"#,
args: ["--quiet", "recipe"],
error: RuntimeError::Backtick {
token,
output_error: OutputError::Code(_),
} => {
},
check: {
assert_eq!(token.lexeme(), "`echo $exported_variable`");
}
other => panic!("expected a backtick code errror, but got: {}", other),
}
}
}

View File

@ -57,9 +57,10 @@ pub(crate) use crate::{
function_context::FunctionContext, functions::Functions, interrupt_guard::InterruptGuard,
interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, lexer::Lexer, line::Line,
list::List, load_error::LoadError, module::Module, name::Name, output_error::OutputError,
parameter::Parameter, parser::Parser, platform::Platform, position::Position, recipe::Recipe,
recipe_context::RecipeContext, recipe_resolver::RecipeResolver, runtime_error::RuntimeError,
search::Search, search_config::SearchConfig, search_error::SearchError, shebang::Shebang,
parameter::Parameter, parser::Parser, platform::Platform, position::Position,
positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
recipe_resolver::RecipeResolver, runtime_error::RuntimeError, search::Search,
search_config::SearchConfig, search_error::SearchError, shebang::Shebang,
show_whitespace::ShowWhitespace, state::State, string_literal::StringLiteral,
subcommand::Subcommand, table::Table, token::Token, token_kind::TokenKind, use_color::UseColor,
variables::Variables, verbosity::Verbosity, warning::Warning,

View File

@ -7,12 +7,10 @@ pub(crate) const DEFAULT_SHELL: &str = "sh";
#[derive(Debug, PartialEq)]
pub(crate) struct Config {
pub(crate) arguments: Vec<String>,
pub(crate) color: Color,
pub(crate) dry_run: bool,
pub(crate) highlight: bool,
pub(crate) invocation_directory: PathBuf,
pub(crate) overrides: BTreeMap<String, String>,
pub(crate) quiet: bool,
pub(crate) search_config: SearchConfig,
pub(crate) shell: String,
@ -27,6 +25,9 @@ mod cmd {
pub(crate) const LIST: &str = "LIST";
pub(crate) const SHOW: &str = "SHOW";
pub(crate) const SUMMARY: &str = "SUMMARY";
pub(crate) const ALL: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY, EVALUATE];
pub(crate) const ARGLESS: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY];
}
mod arg {
@ -55,11 +56,6 @@ impl Config {
.version_message("Print version information")
.setting(AppSettings::ColoredHelp)
.setting(AppSettings::TrailingVarArg)
.arg(
Arg::with_name(arg::ARGUMENTS)
.multiple(true)
.help("The recipe(s) to run, defaults to the first recipe in the justfile"),
)
.arg(
Arg::with_name(arg::COLOR)
.long("color")
@ -129,7 +125,7 @@ impl Config {
.number_of_values(2)
.value_names(&["VARIABLE", "VALUE"])
.multiple(true)
.help("Set <VARIABLE> to <VALUE>"),
.help("Override <VARIABLE> with <VALUE>"),
)
.arg(
Arg::with_name(arg::SHELL)
@ -166,15 +162,12 @@ impl Config {
.help("Use <WORKING-DIRECTORY> as working directory. --justfile must also be set")
.requires(arg::JUSTFILE),
)
.group(ArgGroup::with_name("EARLY-EXIT").args(&[
arg::ARGUMENTS,
cmd::DUMP,
cmd::EDIT,
cmd::EVALUATE,
cmd::LIST,
cmd::SHOW,
cmd::SUMMARY,
]));
.arg(
Arg::with_name(arg::ARGUMENTS)
.multiple(true)
.help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"),
)
.group(ArgGroup::with_name("SUBCOMMAND").args(cmd::ALL));
if cfg!(feature = "help4help2man") {
app.version(env!("CARGO_PKG_VERSION")).about(concat!(
@ -216,24 +209,6 @@ impl Config {
.expect("`--color` had no value"),
)?;
let subcommand = if matches.is_present(cmd::EDIT) {
Subcommand::Edit
} else if matches.is_present(cmd::SUMMARY) {
Subcommand::Summary
} else if matches.is_present(cmd::DUMP) {
Subcommand::Dump
} else if matches.is_present(cmd::LIST) {
Subcommand::List
} else if matches.is_present(cmd::EVALUATE) {
Subcommand::Evaluate
} else if let Some(name) = matches.value_of(cmd::SHOW) {
Subcommand::Show {
name: name.to_owned(),
}
} else {
Subcommand::Run
};
let set_count = matches.occurrences_of(arg::SET);
let mut overrides = BTreeMap::new();
if set_count > 0 {
@ -246,60 +221,17 @@ impl Config {
}
}
fn is_override(arg: &&str) -> bool {
arg.chars().skip(1).any(|c| c == '=')
let positional = Positional::from_values(matches.values_of(arg::ARGUMENTS));
for (name, value) in positional.overrides {
overrides.insert(name.to_owned(), value.to_owned());
}
let raw_arguments: Vec<&str> = matches
.values_of(arg::ARGUMENTS)
.map(Iterator::collect)
.unwrap_or_default();
for argument in raw_arguments.iter().cloned().take_while(is_override) {
let i = argument
.char_indices()
.skip(1)
.find(|&(_, c)| c == '=')
.unwrap()
.0;
let name = argument[..i].to_owned();
let value = argument[i + 1..].to_owned();
overrides.insert(name, value);
}
let mut search_directory = None;
let arguments = raw_arguments
.into_iter()
.skip_while(is_override)
.enumerate()
.flat_map(|(i, argument)| {
if i == 0 {
if let Some(i) = argument.rfind('/') {
let (dir, recipe) = argument.split_at(i + 1);
search_directory = Some(PathBuf::from(dir));
if recipe.is_empty() {
return None;
} else {
return Some(recipe);
}
}
}
Some(argument)
})
.map(|argument| argument.to_owned())
.collect::<Vec<String>>();
let search_config = {
let justfile = matches.value_of(arg::JUSTFILE).map(PathBuf::from);
let working_directory = matches.value_of(arg::WORKING_DIRECTORY).map(PathBuf::from);
if let Some(search_directory) = search_directory {
if let Some(search_directory) = positional.search_directory.map(PathBuf::from) {
if justfile.is_some() || working_directory.is_some() {
return Err(ConfigError::SearchDirConflict);
}
@ -323,6 +255,54 @@ impl Config {
}
};
for subcommand in cmd::ARGLESS {
if matches.is_present(subcommand) {
match (!overrides.is_empty(), !positional.arguments.is_empty()) {
(false, false) => {}
(true, false) => {
return Err(ConfigError::SubcommandOverrides {
subcommand: format!("--{}", subcommand.to_lowercase()),
overrides,
});
}
(false, true) => {
return Err(ConfigError::SubcommandArguments {
subcommand: format!("--{}", subcommand.to_lowercase()),
arguments: positional.arguments,
});
}
(true, true) => {
return Err(ConfigError::SubcommandOverridesAndArguments {
subcommand: format!("--{}", subcommand.to_lowercase()),
arguments: positional.arguments,
overrides,
});
}
}
}
}
let subcommand = if matches.is_present(cmd::EDIT) {
Subcommand::Edit
} else if matches.is_present(cmd::SUMMARY) {
Subcommand::Summary
} else if matches.is_present(cmd::DUMP) {
Subcommand::Dump
} else if matches.is_present(cmd::LIST) {
Subcommand::List
} else if let Some(name) = matches.value_of(cmd::SHOW) {
Subcommand::Show {
name: name.to_owned(),
}
} else if matches.is_present(cmd::EVALUATE) {
Subcommand::Evaluate { overrides }
} else {
Subcommand::Run {
arguments: positional.arguments,
overrides,
}
};
Ok(Config {
dry_run: matches.is_present(arg::DRY_RUN),
highlight: !matches.is_present(arg::NO_HIGHLIGHT),
@ -333,8 +313,6 @@ impl Config {
subcommand,
verbosity,
color,
overrides,
arguments,
})
}
@ -365,9 +343,15 @@ impl Config {
}
}
match self.subcommand {
match &self.subcommand {
Dump => self.dump(justfile),
Run | Evaluate => self.run(justfile, &search.working_directory),
Evaluate { overrides } => {
self.run(justfile, &search.working_directory, overrides, &Vec::new())
}
Run {
arguments,
overrides,
} => self.run(justfile, &search.working_directory, overrides, arguments),
List => self.list(justfile),
Show { ref name } => self.show(&name, justfile),
Summary => self.summary(justfile),
@ -497,12 +481,18 @@ impl Config {
Ok(())
}
fn run(&self, justfile: Justfile, working_directory: &Path) -> Result<(), i32> {
fn run(
&self,
justfile: Justfile,
working_directory: &Path,
overrides: &BTreeMap<String, String>,
arguments: &Vec<String>,
) -> Result<(), i32> {
if let Err(error) = InterruptHandler::install() {
warn!("Failed to set CTRL-C handler: {}", error)
}
let result = justfile.run(&self, working_directory);
let result = justfile.run(&self, working_directory, overrides, arguments);
if !self.quiet {
result.eprint(self.color)
@ -582,7 +572,7 @@ OPTIONS:
Print colorful output [default: auto] [possible values: auto, always, never]
-f, --justfile <JUSTFILE> Use <JUSTFILE> as justfile.
--set <VARIABLE> <VALUE> Set <VARIABLE> to <VALUE>
--set <VARIABLE> <VALUE> Override <VARIABLE> with <VALUE>
--shell <SHELL> Invoke <SHELL> to run recipes [default: sh]
-s, --show <RECIPE> Show information about <RECIPE>
-d, --working-directory <WORKING-DIRECTORY>
@ -590,7 +580,7 @@ OPTIONS:
ARGS:
<ARGUMENTS>... The recipe(s) to run, defaults to the first recipe in the justfile";
<ARGUMENTS>... Overrides and recipe(s) to run, defaulting to the first recipe in the justfile";
let app = Config::app().setting(AppSettings::ColorNever);
let mut buffer = Vec::new();
@ -604,11 +594,9 @@ ARGS:
{
name: $name:ident,
args: [$($arg:expr),*],
$(arguments: $arguments:expr,)?
$(color: $color:expr,)?
$(dry_run: $dry_run:expr,)?
$(highlight: $highlight:expr,)?
$(overrides: $overrides:expr,)?
$(quiet: $quiet:expr,)?
$(search_config: $search_config:expr,)?
$(shell: $shell:expr,)?
@ -623,14 +611,9 @@ ARGS:
];
let want = Config {
$(arguments: $arguments.iter().map(|argument| argument.to_string()).collect(),)?
$(color: $color,)?
$(dry_run: $dry_run,)?
$(highlight: $highlight,)?
$(
overrides: $overrides.iter().cloned()
.map(|(key, value): (&str, &str)| (key.to_owned(), value.to_owned())).collect(),
)?
$(quiet: $quiet,)?
$(search_config: $search_config,)?
$(shell: $shell.to_string(),)?
@ -665,17 +648,50 @@ ARGS:
$($arg,)*
];
error(arguments);
let app = Config::app();
app.get_matches_from_safe(arguments).expect_err("Expected clap error");
}
};
{
name: $name:ident,
args: [$($arg:expr),*],
error: $error:pat,
$(check: $check:block,)?
} => {
#[test]
fn $name() {
let arguments = &[
"just",
$($arg,)*
];
let app = Config::app();
let matches = app.get_matches_from_safe(arguments).expect("Matching failes");
match Config::from_matches(&matches).expect_err("config parsing succeeded") {
$error => { $($check)? }
other => panic!("Unexpected config error: {}", other),
}
}
}
}
fn error(arguments: &[&str]) {
let app = Config::app();
if let Ok(matches) = app.get_matches_from_safe(arguments) {
Config::from_matches(&matches).expect_err("config parsing unexpectedly succeeded");
} else {
return;
macro_rules! map {
{} => {
BTreeMap::new()
};
{
$($key:literal : $value:literal),* $(,)?
} => {
{
let mut map: BTreeMap<String, String> = BTreeMap::new();
$(
map.insert($key.to_owned(), $value.to_owned());
)*
map
}
}
}
@ -787,31 +803,46 @@ ARGS:
test! {
name: set_default,
args: [],
overrides: [],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!(),
},
}
test! {
name: set_one,
args: ["--set", "foo", "bar"],
overrides: [("foo", "bar")],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{"foo": "bar"},
},
}
test! {
name: set_empty,
args: ["--set", "foo", ""],
overrides: [("foo", "")],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{"foo": ""},
},
}
test! {
name: set_two,
args: ["--set", "foo", "bar", "--set", "bar", "baz"],
overrides: [("foo", "bar"), ("bar", "baz")],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{"foo": "bar", "bar": "baz"},
},
}
test! {
name: set_override,
args: ["--set", "foo", "bar", "--set", "foo", "baz"],
overrides: [("foo", "baz")],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{"foo": "baz"},
},
}
error! {
@ -864,7 +895,10 @@ ARGS:
test! {
name: subcommand_default,
args: [],
subcommand: Subcommand::Run,
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{},
},
}
test! {
@ -882,7 +916,9 @@ ARGS:
test! {
name: subcommand_evaluate,
args: ["--evaluate"],
subcommand: Subcommand::Evaluate,
subcommand: Subcommand::Evaluate {
overrides: map!{},
},
}
test! {
@ -923,31 +959,46 @@ ARGS:
test! {
name: arguments,
args: ["foo", "bar"],
arguments: ["foo", "bar"],
subcommand: Subcommand::Run {
arguments: vec![String::from("foo"), String::from("bar")],
overrides: map!{},
},
}
test! {
name: arguments_leading_equals,
args: ["=foo"],
arguments: ["=foo"],
subcommand: Subcommand::Run {
arguments: vec!["=foo".to_string()],
overrides: map!{},
},
}
test! {
name: overrides,
args: ["foo=bar", "bar=baz"],
overrides: [("foo", "bar"), ("bar", "baz")],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{"foo": "bar", "bar": "baz"},
},
}
test! {
name: overrides_empty,
args: ["foo=", "bar="],
overrides: [("foo", ""), ("bar", "")],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{"foo": "", "bar": ""},
},
}
test! {
name: overrides_override_sets,
args: ["--set", "foo", "0", "--set", "bar", "1", "foo=bar", "bar=baz"],
overrides: [("foo", "bar"), ("bar", "baz")],
subcommand: Subcommand::Run {
arguments: Vec::new(),
overrides: map!{"foo": "bar", "bar": "baz"},
},
}
test! {
@ -992,10 +1043,10 @@ ARGS:
test! {
name: search_directory_parent_with_recipe,
args: ["../build"],
arguments: ["build"],
search_config: SearchConfig::FromSearchDirectory {
search_directory: PathBuf::from(".."),
},
subcommand: Subcommand::Run { arguments: vec!["build".to_owned()], overrides: BTreeMap::new() },
}
test! {
@ -1017,19 +1068,92 @@ ARGS:
test! {
name: search_directory_child_with_recipe,
args: ["foo/build"],
arguments: ["build"],
search_config: SearchConfig::FromSearchDirectory {
search_directory: PathBuf::from("foo"),
},
subcommand: Subcommand::Run { arguments: vec!["build".to_owned()], overrides: BTreeMap::new() },
}
error! {
name: search_directory_conflict_justfile,
args: ["--justfile", "bar", "foo/build"],
error: ConfigError::SearchDirConflict,
}
error! {
name: search_directory_conflict_working_directory,
args: ["--justfile", "bar", "--working-directory", "baz", "foo/build"],
error: ConfigError::SearchDirConflict,
}
error! {
name: list_arguments,
args: ["--list", "bar"],
error: ConfigError::SubcommandArguments { subcommand, arguments },
check: {
assert_eq!(subcommand, "--list");
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: dump_arguments,
args: ["--dump", "bar"],
error: ConfigError::SubcommandArguments { subcommand, arguments },
check: {
assert_eq!(subcommand, "--dump");
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: edit_arguments,
args: ["--edit", "bar"],
error: ConfigError::SubcommandArguments { subcommand, arguments },
check: {
assert_eq!(subcommand, "--edit");
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: show_arguments,
args: ["--show", "foo", "bar"],
error: ConfigError::SubcommandArguments { subcommand, arguments },
check: {
assert_eq!(subcommand, "--show");
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: summary_arguments,
args: ["--summary", "bar"],
error: ConfigError::SubcommandArguments { subcommand, arguments },
check: {
assert_eq!(subcommand, "--summary");
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: subcommand_overrides_and_arguments,
args: ["--summary", "bar=baz", "bar"],
error: ConfigError::SubcommandOverridesAndArguments { subcommand, arguments, overrides },
check: {
assert_eq!(subcommand, "--summary");
assert_eq!(overrides, map!{"bar": "baz"});
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: summary_overrides,
args: ["--summary", "bar=baz"],
error: ConfigError::SubcommandOverrides { subcommand, overrides },
check: {
assert_eq!(subcommand, "--summary");
assert_eq!(overrides, map!{"bar": "baz"});
},
}
}

View File

@ -17,6 +17,35 @@ pub(crate) enum ConfigError {
"Path-prefixed recipes may not be used with `--working-directory` or `--justfile`."
))]
SearchDirConflict,
#[snafu(display(
"`{}` used with unexpected arguments: {}",
subcommand,
List::and_ticked(arguments)
))]
SubcommandArguments {
subcommand: String,
arguments: Vec<String>,
},
#[snafu(display(
"`{}` used with unexpected overrides: {}; and arguments: {}",
subcommand,
List::and_ticked(overrides.iter().map(|(key, value)| format!("{}={}", key, value))),
List::and_ticked(arguments)))
]
SubcommandOverridesAndArguments {
subcommand: String,
overrides: BTreeMap<String, String>,
arguments: Vec<String>,
},
#[snafu(display(
"`{}` used with unexpected overrides: {}",
subcommand,
List::and_ticked(overrides.iter().map(|(key, value)| format!("{}={}", key, value))),
))]
SubcommandOverrides {
subcommand: String,
overrides: BTreeMap<String, String>,
},
}
impl ConfigError {

View File

@ -46,13 +46,11 @@ impl<'a> Justfile<'a> {
&'a self,
config: &'a Config,
working_directory: &'a Path,
overrides: &'a BTreeMap<String, String>,
arguments: &'a Vec<String>,
) -> RunResult<'a, ()> {
let argvec: Vec<&str> = if !config.arguments.is_empty() {
config
.arguments
.iter()
.map(|argument| argument.as_str())
.collect()
let argvec: Vec<&str> = if !arguments.is_empty() {
arguments.iter().map(|argument| argument.as_str()).collect()
} else if let Some(recipe) = self.first() {
let min_arguments = recipe.min_arguments();
if min_arguments > 0 {
@ -68,8 +66,7 @@ impl<'a> Justfile<'a> {
let arguments = argvec.as_slice();
let unknown_overrides = config
.overrides
let unknown_overrides = overrides
.keys()
.filter(|name| !self.assignments.contains_key(name.as_str()))
.map(|name| name.as_str())
@ -88,9 +85,10 @@ impl<'a> Justfile<'a> {
working_directory,
&dotenv,
&self.assignments,
overrides,
)?;
if config.subcommand == Subcommand::Evaluate {
if let Subcommand::Evaluate { .. } = config.subcommand {
let mut width = 0;
for name in scope.keys() {
width = cmp::max(name.len(), width);
@ -151,7 +149,7 @@ impl<'a> Justfile<'a> {
let mut ran = empty();
for (recipe, arguments) in grouped {
self.run_recipe(&context, recipe, arguments, &dotenv, &mut ran)?
self.run_recipe(&context, recipe, arguments, &dotenv, &mut ran, overrides)?
}
Ok(())
@ -178,14 +176,15 @@ impl<'a> Justfile<'a> {
arguments: &[&'a str],
dotenv: &BTreeMap<String, String>,
ran: &mut BTreeSet<&'a str>,
overrides: &BTreeMap<String, String>,
) -> RunResult<()> {
for dependency_name in &recipe.dependencies {
let lexeme = dependency_name.lexeme();
if !ran.contains(lexeme) {
self.run_recipe(context, &self.recipes[lexeme], &[], dotenv, ran)?;
self.run_recipe(context, &self.recipes[lexeme], &[], dotenv, ran, overrides)?;
}
}
recipe.run(context, arguments, dotenv)?;
recipe.run(context, arguments, dotenv, overrides)?;
ran.insert(recipe.name());
Ok(())
}
@ -226,29 +225,23 @@ impl<'a> Display for Justfile<'a> {
mod tests {
use super::*;
use crate::runtime_error::RuntimeError::*;
use crate::testing::{compile, config};
use testing::compile;
use RuntimeError::*;
#[test]
fn unknown_recipes() {
let justfile = compile("a:\nb:\nc:");
let config = config(&["a", "x", "y", "z"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
UnknownRecipes {
run_error! {
name: unknown_recipes,
src: "a:\nb:\nc:",
args: ["a", "x", "y", "z"],
error: UnknownRecipes {
recipes,
suggestion,
} => {
},
check: {
assert_eq!(recipes, &["x", "y", "z"]);
assert_eq!(suggestion, None);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn run_shebang() {
// this test exists to make sure that shebang recipes
// run correctly. although this script is still
// executed by a shell its behavior depends on the value of a
@ -256,86 +249,79 @@ mod tests {
// whereas in plain recipes variables are not available
// in subsequent lines and execution stops when a line
// fails
let text = "
a:
run_error! {
name: run_shebang,
src: "
a:
#!/usr/bin/env sh
code=200
x() { return $code; }
x
x
";
let justfile = compile(text);
let config = config(&["a"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
Code {
",
args: ["a"],
error: Code {
recipe,
line_number,
code,
} => {
},
check: {
assert_eq!(recipe, "a");
assert_eq!(code, 200);
assert_eq!(line_number, None);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn code_error() {
let justfile = compile("fail:\n @exit 100");
let config = config(&["fail"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
Code {
run_error! {
name: code_error,
src: "
fail:
@exit 100
",
args: ["fail"],
error: Code {
recipe,
line_number,
code,
} => {
},
check: {
assert_eq!(recipe, "fail");
assert_eq!(code, 100);
assert_eq!(line_number, Some(2));
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn run_args() {
let text = r#"
a return code:
@x() { {{return}} {{code + "0"}}; }; x"#;
let justfile = compile(text);
let config = config(&["a", "return", "15"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
Code {
run_error! {
name: run_args,
src: r#"
a return code:
@x() { {{return}} {{code + "0"}}; }; x
"#,
args: ["a", "return", "15"],
error: Code {
recipe,
line_number,
code,
} => {
},
check: {
assert_eq!(recipe, "a");
assert_eq!(code, 150);
assert_eq!(line_number, Some(3));
}
other => panic!("unexpected error: {}", other),
assert_eq!(line_number, Some(2));
}
}
#[test]
fn missing_some_arguments() {
let justfile = compile("a b c d:");
let config = config(&["a", "b", "c"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
ArgumentCountMismatch {
run_error! {
name: missing_some_arguments,
src: "a b c d:",
args: ["a", "b", "c"],
error: ArgumentCountMismatch {
recipe,
parameters,
found,
min,
max,
} => {
},
check: {
let param_names = parameters
.iter()
.map(|p| p.name.lexeme())
@ -346,23 +332,20 @@ a return code:
assert_eq!(min, 3);
assert_eq!(max, 3);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn missing_some_arguments_variadic() {
let justfile = compile("a b c +d:");
let config = config(&["a", "B", "C"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
ArgumentCountMismatch {
run_error! {
name: missing_some_arguments_variadic,
src: "a b c +d:",
args: ["a", "B", "C"],
error: ArgumentCountMismatch {
recipe,
parameters,
found,
min,
max,
} => {
},
check: {
let param_names = parameters
.iter()
.map(|p| p.name.lexeme())
@ -373,24 +356,20 @@ a return code:
assert_eq!(min, 3);
assert_eq!(max, usize::MAX - 1);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn missing_all_arguments() {
let justfile = compile("a b c d:\n echo {{b}}{{c}}{{d}}");
let config = config(&["a"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
ArgumentCountMismatch {
run_error! {
name: missing_all_arguments,
src: "a b c d:\n echo {{b}}{{c}}{{d}}",
args: ["a"],
error: ArgumentCountMismatch {
recipe,
parameters,
found,
min,
max,
} => {
},
check: {
let param_names = parameters
.iter()
.map(|p| p.name.lexeme())
@ -401,24 +380,20 @@ a return code:
assert_eq!(min, 3);
assert_eq!(max, 3);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn missing_some_defaults() {
let justfile = compile("a b c d='hello':");
let config = config(&["a", "b"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
ArgumentCountMismatch {
run_error! {
name: missing_some_defaults,
src: "a b c d='hello':",
args: ["a", "b"],
error: ArgumentCountMismatch {
recipe,
parameters,
found,
min,
max,
} => {
},
check: {
let param_names = parameters
.iter()
.map(|p| p.name.lexeme())
@ -429,24 +404,20 @@ a return code:
assert_eq!(min, 2);
assert_eq!(max, 3);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn missing_all_defaults() {
let justfile = compile("a b c='r' d='h':");
let config = &config(&["a"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
ArgumentCountMismatch {
run_error! {
name: missing_all_defaults,
src: "a b c='r' d='h':",
args: ["a"],
error: ArgumentCountMismatch {
recipe,
parameters,
found,
min,
max,
} => {
},
check: {
let param_names = parameters
.iter()
.map(|p| p.name.lexeme())
@ -457,51 +428,41 @@ a return code:
assert_eq!(min, 1);
assert_eq!(max, 3);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn unknown_overrides() {
let config = config(&["foo=bar", "baz=bob", "a"]);
let justfile = compile("a:\n echo {{`f() { return 100; }; f`}}");
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
UnknownOverrides { overrides } => {
run_error! {
name: unknown_overrides,
src: "
a:
echo {{`f() { return 100; }; f`}}
",
args: ["foo=bar", "baz=bob", "a"],
error: UnknownOverrides { overrides },
check: {
assert_eq!(overrides, &["baz", "foo"]);
}
other => panic!("unexpected error: {}", other),
}
}
#[test]
fn export_failure() {
let text = r#"
export foo = "a"
baz = "c"
export bar = "b"
export abc = foo + bar + baz
run_error! {
name: export_failure,
src: r#"
export foo = "a"
baz = "c"
export bar = "b"
export abc = foo + bar + baz
wut:
wut:
echo $foo $bar $baz
"#;
let config = config(&["--quiet", "wut"]);
let justfile = compile(text);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
Code {
"#,
args: ["--quiet", "wut"],
error: Code {
code: _,
line_number,
recipe,
} => {
},
check: {
assert_eq!(recipe, "wut");
assert_eq!(line_number, Some(8));
}
other => panic!("unexpected error: {}", other),
assert_eq!(line_number, Some(7));
}
}

View File

@ -210,6 +210,46 @@ impl<'a> Lexer<'a> {
}
}
/// True if `text` could be an identifier
pub(crate) fn is_identifier(text: &str) -> bool {
if !text
.chars()
.next()
.map(|c| Self::is_identifier_start(c))
.unwrap_or(false)
{
return false;
}
for c in text.chars().skip(1) {
if !Self::is_identifier_continue(c) {
return false;
}
}
true
}
/// True if `c` can be the first character of an identifier
fn is_identifier_start(c: char) -> bool {
match c {
'a'..='z' | 'A'..='Z' | '_' => true,
_ => false,
}
}
/// True if `c` can be a continuation character of an idenitifier
fn is_identifier_continue(c: char) -> bool {
if Self::is_identifier_start(c) {
return true;
}
match c {
'0'..='9' | '-' => true,
_ => false,
}
}
/// Consume the text and produce a series of tokens
fn tokenize(mut self) -> CompilationResult<'a, Vec<Token<'a>>> {
loop {
@ -362,13 +402,16 @@ impl<'a> Lexer<'a> {
' ' | '\t' => self.lex_whitespace(),
'\'' => self.lex_raw_string(),
'"' => self.lex_cooked_string(),
'a'..='z' | 'A'..='Z' | '_' => self.lex_identifier(),
_ => {
if Self::is_identifier_start(start) {
self.lex_identifier()
} else {
self.advance()?;
Err(self.error(UnknownStartOfToken))
}
}
}
}
/// Lex token beginning with `start` in interpolation state
fn lex_interpolation(
@ -515,13 +558,16 @@ impl<'a> Lexer<'a> {
self.lex_double(Eol)
}
/// Lex identifier: [a-zA-Z_][a-zA-Z0-9_]*
/// Lex name: [a-zA-Z_][a-zA-Z0-9_]*
fn lex_identifier(&mut self) -> CompilationResult<'a, ()> {
while self
.next
.map(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
.unwrap_or(false)
{
// advance over initial character
self.advance()?;
while let Some(c) = self.next {
if !Self::is_identifier_continue(c) {
break;
}
self.advance()?;
}
@ -1667,6 +1713,26 @@ mod tests {
kind: UnknownStartOfToken,
}
error! {
name: invalid_name_start_dash,
input: "-foo",
offset: 0,
line: 0,
column: 0,
width: 1,
kind: UnknownStartOfToken,
}
error! {
name: invalid_name_start_digit,
input: "0foo",
offset: 0,
line: 0,
column: 0,
width: 1,
kind: UnknownStartOfToken,
}
error! {
name: unterminated_string,
input: r#"a = ""#,

View File

@ -62,6 +62,7 @@ mod parser;
mod platform;
mod platform_interface;
mod position;
mod positional;
mod range_ext;
mod recipe;
mod recipe_context;

231
src/positional.rs Normal file
View File

@ -0,0 +1,231 @@
use crate::common::*;
/// A struct containing the parsed representation of positional
/// command-line arguments, i.e. arguments that are not flags,
/// options, or the subcommand.
///
/// The DSL of positional arguments is fairly complex and mostly
/// accidental. There are three possible components: overrides,
/// a search directory, and the rest:
///
/// - Overrides are of the form `NAME=.*`
///
/// - After overrides comes a single optional search_directory argument.
/// This is either '.', '..', or an argument that contains a `/`.
///
/// If the argument contains a `/`, everything before and including
/// the slash is the search directory, and everything after is added
/// to the rest.
///
/// - Everything else is an argument.
///
/// Overrides set the values of top-level variables in the justfile
/// being invoked and are a convenient way to override settings.
///
/// For modes that do not take other arguments, the search directory
/// argument determines where to begin searching for the justfile. This
/// allows command lines like `just -l ..` and `just ../build` to find
/// the same justfile.
///
/// For modes that do take other arguments, the search argument is simply
/// prepended to rest.
#[cfg_attr(test, derive(PartialEq, Debug))]
pub struct Positional {
/// Overrides from values of the form `[a-zA-Z_][a-zA-Z0-9_-]*=.*`
pub overrides: Vec<(String, String)>,
/// An argument equal to '.', '..', or ending with `/`
pub search_directory: Option<String>,
/// Everything else
pub arguments: Vec<String>,
}
impl Positional {
pub fn from_values<'values>(
values: Option<impl IntoIterator<Item = &'values str>>,
) -> Positional {
let mut overrides = Vec::new();
let mut search_directory = None;
let mut arguments = Vec::new();
if let Some(values) = values {
for value in values {
if search_directory.is_none() && arguments.is_empty() {
if let Some(o) = Self::override_from_value(value) {
overrides.push(o);
} else if value == "." || value == ".." {
search_directory = Some(value.to_owned());
} else if let Some(i) = value.rfind('/') {
let (dir, tail) = value.split_at(i + 1);
search_directory = Some(dir.to_owned());
if !tail.is_empty() {
arguments.push(tail.to_owned());
}
} else {
arguments.push(value.to_owned());
}
} else {
arguments.push(value.to_owned());
}
}
}
Positional {
overrides,
search_directory,
arguments,
}
}
/// Parse an override from a value of the form `NAME=.*`.
fn override_from_value(value: &str) -> Option<(String, String)> {
if let Some(equals) = value.find('=') {
let (identifier, equals_value) = value.split_at(equals);
// exclude `=` from value
let value = &equals_value[1..];
if Lexer::is_identifier(identifier) {
Some((identifier.to_owned(), value.to_owned()))
} else {
None
}
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
macro_rules! test {
{
name: $name:ident,
values: $vals:expr,
overrides: $overrides:expr,
search_directory: $search_directory:expr,
arguments: $arguments:expr,
} => {
#[test]
fn $name() {
assert_eq! (
Positional::from_values(Some($vals.iter().cloned())),
Positional {
overrides: $overrides.iter().cloned().map(|(key, value): (&str, &str)| (key.to_owned(), value.to_owned())).collect(),
search_directory: $search_directory.map(|dir: &str| dir.to_owned()),
arguments: $arguments.iter().cloned().map(|arg: &str| arg.to_owned()).collect(),
},
)
}
}
}
test! {
name: no_values,
values: [],
overrides: [],
search_directory: None,
arguments: [],
}
test! {
name: arguments_only,
values: ["foo", "bar"],
overrides: [],
search_directory: None,
arguments: ["foo", "bar"],
}
test! {
name: all_overrides,
values: ["foo=bar", "bar=foo"],
overrides: [("foo", "bar"), ("bar", "foo")],
search_directory: None,
arguments: [],
}
test! {
name: override_not_name,
values: ["foo=bar", "bar.=foo"],
overrides: [("foo", "bar")],
search_directory: None,
arguments: ["bar.=foo"],
}
test! {
name: no_overrides,
values: ["the-dir/", "baz", "bzzd"],
overrides: [],
search_directory: Some("the-dir/"),
arguments: ["baz", "bzzd"],
}
test! {
name: no_search_directory,
values: ["foo=bar", "bar=foo", "baz", "bzzd"],
overrides: [("foo", "bar"), ("bar", "foo")],
search_directory: None,
arguments: ["baz", "bzzd"],
}
test! {
name: no_arguments,
values: ["foo=bar", "bar=foo", "the-dir/"],
overrides: [("foo", "bar"), ("bar", "foo")],
search_directory: Some("the-dir/"),
arguments: [],
}
test! {
name: all_dot,
values: ["foo=bar", "bar=foo", ".", "garnor"],
overrides: [("foo", "bar"), ("bar", "foo")],
search_directory: Some("."),
arguments: ["garnor"],
}
test! {
name: all_dot_dot,
values: ["foo=bar", "bar=foo", "..", "garnor"],
overrides: [("foo", "bar"), ("bar", "foo")],
search_directory: Some(".."),
arguments: ["garnor"],
}
test! {
name: all_slash,
values: ["foo=bar", "bar=foo", "/", "garnor"],
overrides: [("foo", "bar"), ("bar", "foo")],
search_directory: Some("/"),
arguments: ["garnor"],
}
test! {
name: search_directory_after_argument,
values: ["foo=bar", "bar=foo", "baz", "bzzd", "bar/"],
overrides: [("foo", "bar"), ("bar", "foo")],
search_directory: None,
arguments: ["baz", "bzzd", "bar/"],
}
test! {
name: override_after_search_directory,
values: ["..", "a=b"],
overrides: [],
search_directory: Some(".."),
arguments: ["a=b"],
}
test! {
name: override_after_argument,
values: ["a", "a=b"],
overrides: [],
search_directory: None,
arguments: ["a", "a=b"],
}
}

View File

@ -69,6 +69,7 @@ impl<'a> Recipe<'a> {
context: &RecipeContext<'a>,
arguments: &[&'a str],
dotenv: &BTreeMap<String, String>,
overrides: &BTreeMap<String, String>,
) -> RunResult<'a, ()> {
let config = &context.config;
@ -89,6 +90,7 @@ impl<'a> Recipe<'a> {
evaluated: empty(),
working_directory: context.working_directory,
scope: &context.scope,
overrides,
config,
dotenv,
};

View File

@ -25,7 +25,14 @@ impl Search {
}
SearchConfig::FromSearchDirectory { search_directory } => {
let justfile = Self::justfile(search_directory)?;
let search_directory =
search_directory
.canonicalize()
.context(search_error::Canonicalize {
path: search_directory,
})?;
let justfile = Self::justfile(&search_directory)?;
let working_directory = Self::working_directory_from_justfile(&justfile)?;
@ -58,6 +65,7 @@ impl Search {
fn justfile(directory: &Path) -> SearchResult<PathBuf> {
let mut candidates = Vec::new();
let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
io_error,
directory: directory.to_owned(),

View File

@ -1,10 +1,19 @@
use crate::common::*;
#[derive(PartialEq, Clone, Debug)]
pub(crate) enum Subcommand {
Dump,
Edit,
Evaluate,
Run,
Evaluate {
overrides: BTreeMap<String, String>,
},
Run {
overrides: BTreeMap<String, String>,
arguments: Vec<String>,
},
List,
Show { name: String },
Show {
name: String,
},
Summary,
}

View File

@ -32,12 +32,12 @@ macro_rules! analysis_error {
) => {
#[test]
fn $name() {
$crate::testing::error($input, $offset, $line, $column, $width, $kind);
$crate::testing::analysis_error($input, $offset, $line, $column, $width, $kind);
}
};
}
pub(crate) fn error(
pub(crate) fn analysis_error(
src: &str,
offset: usize,
line: usize,
@ -66,27 +66,36 @@ pub(crate) fn error(
}
}
#[test]
fn readme_test() {
let mut justfiles = vec![];
let mut current = None;
macro_rules! run_error {
{
name: $name:ident,
src: $src:expr,
args: $args:expr,
error: $error:pat,
check: $check:block $(,)?
} => {
#[test]
fn $name() {
let config = &$crate::testing::config(&$args);
let current_dir = std::env::current_dir().unwrap();
for line in fs::read_to_string("README.adoc").unwrap().lines() {
if let Some(mut justfile) = current {
if line == "```" {
justfiles.push(justfile);
current = None;
if let Subcommand::Run{ overrides, arguments } = &config.subcommand {
match $crate::compiler::Compiler::compile(&$crate::testing::unindent($src))
.expect("Expected successful compilation")
.run(
config,
&current_dir,
&overrides,
&arguments,
).expect_err("Expected runtime error") {
$error => $check
other => {
panic!("Unexpected run error: {:?}", other);
}
}
} else {
justfile += line;
justfile += "\n";
current = Some(justfile);
}
} else if line == "```make" {
current = Some(String::new());
panic!("Unexpected subcommand: {:?}", config.subcommand);
}
}
for justfile in justfiles {
compile(&justfile);
}
};
}

View File

@ -7,13 +7,16 @@ pub fn tempdir() -> tempfile::TempDir {
.expect("failed to create temporary directory")
}
pub fn assert_stdout(output: &Output, stdout: &str) {
pub fn assert_success(output: &Output) {
if !output.status.success() {
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
panic!(output.status);
}
}
pub fn assert_stdout(output: &Output, stdout: &str) {
assert_success(output);
assert_eq!(String::from_utf8_lossy(&output.stdout), stdout);
}

41
tests/readme.rs Normal file
View File

@ -0,0 +1,41 @@
use std::{fs, process::Command};
use executable_path::executable_path;
use test_utilities::{assert_success, tempdir};
#[test]
fn readme() {
let mut justfiles = vec![];
let mut current = None;
for line in fs::read_to_string("README.adoc").unwrap().lines() {
if let Some(mut justfile) = current {
if line == "```" {
justfiles.push(justfile);
current = None;
} else {
justfile += line;
justfile += "\n";
current = Some(justfile);
}
} else if line == "```make" {
current = Some(String::new());
}
}
for justfile in justfiles {
let tmp = tempdir();
let path = tmp.path().join("justfile");
fs::write(&path, &justfile).unwrap();
let output = Command::new(executable_path("just"))
.current_dir(tmp.path())
.arg("--dump")
.output()
.unwrap();
assert_success(&output);
}
}

View File

@ -121,3 +121,27 @@ fn test_downwards_multiple_path_argument() {
search_test(&path, &["./a/b/"]);
search_test(&path, &["./a/b/default"]);
}
#[test]
fn single_downards() {
let tmp = tmptree! {
justfile: "default:\n\techo ok",
child: {},
};
let path = tmp.path();
search_test(&path, &["child/"]);
}
#[test]
fn single_upwards() {
let tmp = tmptree! {
justfile: "default:\n\techo ok",
child: {},
};
let path = tmp.path().join("child");
search_test(&path, &["../"]);
}