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) evaluated: BTreeMap<&'a str, (bool, String)>,
pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>, pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>,
pub(crate) working_directory: &'b Path, pub(crate) working_directory: &'b Path,
pub(crate) overrides: &'b BTreeMap<String, String>,
} }
impl<'a, 'b> AssignmentEvaluator<'a, 'b> { impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
@ -15,10 +16,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
working_directory: &'b Path, working_directory: &'b Path,
dotenv: &'b BTreeMap<String, String>, dotenv: &'b BTreeMap<String, String>,
assignments: &BTreeMap<&'a str, Assignment<'a>>, assignments: &BTreeMap<&'a str, Assignment<'a>>,
overrides: &BTreeMap<String, String>,
) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> { ) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> {
let mut evaluator = AssignmentEvaluator { let mut evaluator = AssignmentEvaluator {
evaluated: empty(), evaluated: empty(),
scope: &empty(), scope: &empty(),
overrides,
config, config,
assignments, assignments,
working_directory, working_directory,
@ -55,7 +58,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
} }
if let Some(assignment) = self.assignments.get(name) { 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 self
.evaluated .evaluated
.insert(name, (assignment.export, value.to_string())); .insert(name, (assignment.export, value.to_string()));
@ -159,47 +162,40 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::testing::{compile, config};
#[test] run_error! {
fn backtick_code() { name: backtick_code,
let justfile = compile("a:\n echo {{`f() { return 100; }; f`}}"); src: "
let config = config(&["a"]); a:
let dir = env::current_dir().unwrap(); echo {{`f() { return 100; }; f`}}
match justfile.run(&config, &dir).unwrap_err() { ",
RuntimeError::Backtick { args: ["a"],
error: RuntimeError::Backtick {
token, token,
output_error: OutputError::Code(code), output_error: OutputError::Code(code),
} => { },
check: {
assert_eq!(code, 100); assert_eq!(code, 100);
assert_eq!(token.lexeme(), "`f() { return 100; }; f`"); assert_eq!(token.lexeme(), "`f() { return 100; }; f`");
} }
other => panic!("expected a code run error, but got: {}", other),
}
} }
#[test] run_error! {
fn export_assignment_backtick() { name: export_assignment_backtick,
let text = r#" src: r#"
export exported_variable = "A" export exported_variable = "A"
b = `echo $exported_variable` b = `echo $exported_variable`
recipe: recipe:
echo {{b}} echo {{b}}
"#; "#,
args: ["--quiet", "recipe"],
let justfile = compile(text); error: RuntimeError::Backtick {
let config = config(&["--quiet", "recipe"]);
let dir = env::current_dir().unwrap();
match justfile.run(&config, &dir).unwrap_err() {
RuntimeError::Backtick {
token, token,
output_error: OutputError::Code(_), output_error: OutputError::Code(_),
} => { },
check: {
assert_eq!(token.lexeme(), "`echo $exported_variable`"); 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, function_context::FunctionContext, functions::Functions, interrupt_guard::InterruptGuard,
interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, lexer::Lexer, line::Line, interrupt_handler::InterruptHandler, item::Item, justfile::Justfile, lexer::Lexer, line::Line,
list::List, load_error::LoadError, module::Module, name::Name, output_error::OutputError, list::List, load_error::LoadError, module::Module, name::Name, output_error::OutputError,
parameter::Parameter, parser::Parser, platform::Platform, position::Position, recipe::Recipe, parameter::Parameter, parser::Parser, platform::Platform, position::Position,
recipe_context::RecipeContext, recipe_resolver::RecipeResolver, runtime_error::RuntimeError, positional::Positional, recipe::Recipe, recipe_context::RecipeContext,
search::Search, search_config::SearchConfig, search_error::SearchError, shebang::Shebang, recipe_resolver::RecipeResolver, runtime_error::RuntimeError, search::Search,
search_config::SearchConfig, search_error::SearchError, shebang::Shebang,
show_whitespace::ShowWhitespace, state::State, string_literal::StringLiteral, show_whitespace::ShowWhitespace, state::State, string_literal::StringLiteral,
subcommand::Subcommand, table::Table, token::Token, token_kind::TokenKind, use_color::UseColor, subcommand::Subcommand, table::Table, token::Token, token_kind::TokenKind, use_color::UseColor,
variables::Variables, verbosity::Verbosity, warning::Warning, variables::Variables, verbosity::Verbosity, warning::Warning,

View File

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

View File

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

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 /// Consume the text and produce a series of tokens
fn tokenize(mut self) -> CompilationResult<'a, Vec<Token<'a>>> { fn tokenize(mut self) -> CompilationResult<'a, Vec<Token<'a>>> {
loop { loop {
@ -362,13 +402,16 @@ impl<'a> Lexer<'a> {
' ' | '\t' => self.lex_whitespace(), ' ' | '\t' => self.lex_whitespace(),
'\'' => self.lex_raw_string(), '\'' => self.lex_raw_string(),
'"' => self.lex_cooked_string(), '"' => self.lex_cooked_string(),
'a'..='z' | 'A'..='Z' | '_' => self.lex_identifier(),
_ => { _ => {
if Self::is_identifier_start(start) {
self.lex_identifier()
} else {
self.advance()?; self.advance()?;
Err(self.error(UnknownStartOfToken)) Err(self.error(UnknownStartOfToken))
} }
} }
} }
}
/// Lex token beginning with `start` in interpolation state /// Lex token beginning with `start` in interpolation state
fn lex_interpolation( fn lex_interpolation(
@ -515,13 +558,16 @@ impl<'a> Lexer<'a> {
self.lex_double(Eol) 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, ()> { fn lex_identifier(&mut self) -> CompilationResult<'a, ()> {
while self // advance over initial character
.next self.advance()?;
.map(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
.unwrap_or(false) while let Some(c) = self.next {
{ if !Self::is_identifier_continue(c) {
break;
}
self.advance()?; self.advance()?;
} }
@ -1667,6 +1713,26 @@ mod tests {
kind: UnknownStartOfToken, 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! { error! {
name: unterminated_string, name: unterminated_string,
input: r#"a = ""#, input: r#"a = ""#,

View File

@ -62,6 +62,7 @@ mod parser;
mod platform; mod platform;
mod platform_interface; mod platform_interface;
mod position; mod position;
mod positional;
mod range_ext; mod range_ext;
mod recipe; mod recipe;
mod recipe_context; 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>, context: &RecipeContext<'a>,
arguments: &[&'a str], arguments: &[&'a str],
dotenv: &BTreeMap<String, String>, dotenv: &BTreeMap<String, String>,
overrides: &BTreeMap<String, String>,
) -> RunResult<'a, ()> { ) -> RunResult<'a, ()> {
let config = &context.config; let config = &context.config;
@ -89,6 +90,7 @@ impl<'a> Recipe<'a> {
evaluated: empty(), evaluated: empty(),
working_directory: context.working_directory, working_directory: context.working_directory,
scope: &context.scope, scope: &context.scope,
overrides,
config, config,
dotenv, dotenv,
}; };

View File

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

View File

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

View File

@ -32,12 +32,12 @@ macro_rules! analysis_error {
) => { ) => {
#[test] #[test]
fn $name() { 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, src: &str,
offset: usize, offset: usize,
line: usize, line: usize,
@ -66,27 +66,36 @@ pub(crate) fn error(
} }
} }
#[test] macro_rules! run_error {
fn readme_test() { {
let mut justfiles = vec![]; name: $name:ident,
let mut current = None; 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 Subcommand::Run{ overrides, arguments } = &config.subcommand {
if let Some(mut justfile) = current { match $crate::compiler::Compiler::compile(&$crate::testing::unindent($src))
if line == "```" { .expect("Expected successful compilation")
justfiles.push(justfile); .run(
current = None; config,
&current_dir,
&overrides,
&arguments,
).expect_err("Expected runtime error") {
$error => $check
other => {
panic!("Unexpected run error: {:?}", other);
}
}
} else { } else {
justfile += line; panic!("Unexpected subcommand: {:?}", config.subcommand);
justfile += "\n";
current = Some(justfile);
}
} else if line == "```make" {
current = Some(String::new());
} }
} }
};
for justfile in justfiles {
compile(&justfile);
}
} }

View File

@ -7,13 +7,16 @@ pub fn tempdir() -> tempfile::TempDir {
.expect("failed to create temporary directory") .expect("failed to create temporary directory")
} }
pub fn assert_stdout(output: &Output, stdout: &str) { pub fn assert_success(output: &Output) {
if !output.status.success() { if !output.status.success() {
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout)); eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
panic!(output.status); panic!(output.status);
} }
}
pub fn assert_stdout(output: &Output, stdout: &str) {
assert_success(output);
assert_eq!(String::from_utf8_lossy(&output.stdout), stdout); 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/"]);
search_test(&path, &["./a/b/default"]); 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, &["../"]);
}