From 177516bcbebfc09eed3c43d9d26e3d0bc8709d51 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sun, 10 Nov 2019 18:02:36 -0800 Subject: [PATCH] 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`. --- src/assignment_evaluator.rs | 66 +++-- src/common.rs | 7 +- src/config.rs | 362 ++++++++++++++++++--------- src/config_error.rs | 29 +++ src/justfile.rs | 473 +++++++++++++++++------------------- src/lexer.rs | 84 ++++++- src/lib.rs | 1 + src/positional.rs | 231 ++++++++++++++++++ src/recipe.rs | 2 + src/search.rs | 10 +- src/subcommand.rs | 15 +- src/testing.rs | 51 ++-- test-utilities/src/lib.rs | 5 +- tests/readme.rs | 41 ++++ tests/search.rs | 24 ++ 15 files changed, 953 insertions(+), 448 deletions(-) create mode 100644 src/positional.rs create mode 100644 tests/readme.rs diff --git a/src/assignment_evaluator.rs b/src/assignment_evaluator.rs index 5a84a90..57c0d84 100644 --- a/src/assignment_evaluator.rs +++ b/src/assignment_evaluator.rs @@ -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, } impl<'a, 'b> AssignmentEvaluator<'a, 'b> { @@ -15,10 +16,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { working_directory: &'b Path, dotenv: &'b BTreeMap, assignments: &BTreeMap<&'a str, Assignment<'a>>, + overrides: &BTreeMap, ) -> 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 { - token, - output_error: OutputError::Code(code), - } => { - assert_eq!(code, 100); - assert_eq!(token.lexeme(), "`f() { return 100; }; f`"); - } - other => panic!("expected a code run error, but got: {}", other), + 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`"); } } - #[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: - 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 { + recipe: + echo {{b}} + "#, + args: ["--quiet", "recipe"], + error: RuntimeError::Backtick { token, output_error: OutputError::Code(_), - } => { - assert_eq!(token.lexeme(), "`echo $exported_variable`"); - } - other => panic!("expected a backtick code errror, but got: {}", other), + }, + check: { + assert_eq!(token.lexeme(), "`echo $exported_variable`"); } } } diff --git a/src/common.rs b/src/common.rs index 21ce5fc..125120a 100644 --- a/src/common.rs +++ b/src/common.rs @@ -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, diff --git a/src/config.rs b/src/config.rs index a5bbe20..aee4e66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,12 +7,10 @@ pub(crate) const DEFAULT_SHELL: &str = "sh"; #[derive(Debug, PartialEq)] pub(crate) struct Config { - pub(crate) arguments: Vec, pub(crate) color: Color, pub(crate) dry_run: bool, pub(crate) highlight: bool, pub(crate) invocation_directory: PathBuf, - pub(crate) overrides: BTreeMap, 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 to "), + .help("Override with "), ) .arg( Arg::with_name(arg::SHELL) @@ -166,15 +162,12 @@ impl Config { .help("Use 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::>(); - 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, + arguments: &Vec, + ) -> 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 Use as justfile. - --set Set to + --set Override with --shell Invoke to run recipes [default: sh] -s, --show Show information about -d, --working-directory @@ -590,7 +580,7 @@ OPTIONS: ARGS: - ... The recipe(s) to run, defaults to the first recipe in the justfile"; + ... 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 = 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"}); + }, } } diff --git a/src/config_error.rs b/src/config_error.rs index e063ad5..809b3da 100644 --- a/src/config_error.rs +++ b/src/config_error.rs @@ -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, + }, + #[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, + arguments: Vec, + }, + #[snafu(display( + "`{}` used with unexpected overrides: {}", + subcommand, + List::and_ticked(overrides.iter().map(|(key, value)| format!("{}={}", key, value))), + ))] + SubcommandOverrides { + subcommand: String, + overrides: BTreeMap, + }, } impl ConfigError { diff --git a/src/justfile.rs b/src/justfile.rs index be86860..8c17339 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -46,13 +46,11 @@ impl<'a> Justfile<'a> { &'a self, config: &'a Config, working_directory: &'a Path, + overrides: &'a BTreeMap, + arguments: &'a Vec, ) -> 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, ran: &mut BTreeSet<&'a str>, + overrides: &BTreeMap, ) -> 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,282 +225,244 @@ 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 { - recipes, - suggestion, - } => { - assert_eq!(recipes, &["x", "y", "z"]); - assert_eq!(suggestion, None); - } - other => panic!("unexpected error: {}", other), + 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); } } - #[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 - // variable and continuing even though a command fails, - // whereas in plain recipes variables are not available - // in subsequent lines and execution stops when a line - // fails - let text = " -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 { - recipe, - line_number, - code, - } => { - assert_eq!(recipe, "a"); - assert_eq!(code, 200); - assert_eq!(line_number, None); - } - other => panic!("unexpected error: {}", other), + // 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 + // variable and continuing even though a command fails, + // whereas in plain recipes variables are not available + // in subsequent lines and execution stops when a line + // fails + run_error! { + name: run_shebang, + src: " + a: + #!/usr/bin/env sh + code=200 + x() { return $code; } + x + x + ", + args: ["a"], + error: Code { + recipe, + line_number, + code, + }, + check: { + assert_eq!(recipe, "a"); + assert_eq!(code, 200); + assert_eq!(line_number, None); } } - #[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 { - recipe, - line_number, - code, - } => { - assert_eq!(recipe, "fail"); - assert_eq!(code, 100); - assert_eq!(line_number, Some(2)); - } - other => panic!("unexpected error: {}", other), + 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)); } } - #[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 { - recipe, - line_number, - code, - } => { - assert_eq!(recipe, "a"); - assert_eq!(code, 150); - assert_eq!(line_number, Some(3)); - } - other => panic!("unexpected error: {}", other), + 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(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 { - recipe, - parameters, - found, - min, - max, - } => { - let param_names = parameters - .iter() - .map(|p| p.name.lexeme()) - .collect::>(); - assert_eq!(recipe, "a"); - assert_eq!(param_names, ["b", "c", "d"]); - assert_eq!(found, 2); - assert_eq!(min, 3); - assert_eq!(max, 3); - } - other => panic!("unexpected error: {}", other), + 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()) + .collect::>(); + assert_eq!(recipe, "a"); + assert_eq!(param_names, ["b", "c", "d"]); + assert_eq!(found, 2); + assert_eq!(min, 3); + assert_eq!(max, 3); } } - #[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 { - recipe, - parameters, - found, - min, - max, - } => { - let param_names = parameters - .iter() - .map(|p| p.name.lexeme()) - .collect::>(); - assert_eq!(recipe, "a"); - assert_eq!(param_names, ["b", "c", "d"]); - assert_eq!(found, 2); - assert_eq!(min, 3); - assert_eq!(max, usize::MAX - 1); - } - other => panic!("unexpected error: {}", other), + 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()) + .collect::>(); + assert_eq!(recipe, "a"); + assert_eq!(param_names, ["b", "c", "d"]); + assert_eq!(found, 2); + assert_eq!(min, 3); + assert_eq!(max, usize::MAX - 1); } } - #[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 { - recipe, - parameters, - found, - min, - max, - } => { - let param_names = parameters - .iter() - .map(|p| p.name.lexeme()) - .collect::>(); - assert_eq!(recipe, "a"); - assert_eq!(param_names, ["b", "c", "d"]); - assert_eq!(found, 0); - assert_eq!(min, 3); - assert_eq!(max, 3); - } - other => panic!("unexpected error: {}", other), + 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()) + .collect::>(); + assert_eq!(recipe, "a"); + assert_eq!(param_names, ["b", "c", "d"]); + assert_eq!(found, 0); + assert_eq!(min, 3); + assert_eq!(max, 3); } } - #[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 { - recipe, - parameters, - found, - min, - max, - } => { - let param_names = parameters - .iter() - .map(|p| p.name.lexeme()) - .collect::>(); - assert_eq!(recipe, "a"); - assert_eq!(param_names, ["b", "c", "d"]); - assert_eq!(found, 1); - assert_eq!(min, 2); - assert_eq!(max, 3); - } - other => panic!("unexpected error: {}", other), + 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()) + .collect::>(); + assert_eq!(recipe, "a"); + assert_eq!(param_names, ["b", "c", "d"]); + assert_eq!(found, 1); + assert_eq!(min, 2); + assert_eq!(max, 3); } } - #[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 { - recipe, - parameters, - found, - min, - max, - } => { - let param_names = parameters - .iter() - .map(|p| p.name.lexeme()) - .collect::>(); - assert_eq!(recipe, "a"); - assert_eq!(param_names, ["b", "c", "d"]); - assert_eq!(found, 0); - assert_eq!(min, 1); - assert_eq!(max, 3); - } - other => panic!("unexpected error: {}", other), + 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()) + .collect::>(); + assert_eq!(recipe, "a"); + assert_eq!(param_names, ["b", "c", "d"]); + assert_eq!(found, 0); + assert_eq!(min, 1); + assert_eq!(max, 3); } } - #[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 } => { - assert_eq!(overrides, &["baz", "foo"]); - } - other => panic!("unexpected error: {}", other), + 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"]); } } - #[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: - 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 { - code: _, - line_number, - recipe, - } => { - assert_eq!(recipe, "wut"); - assert_eq!(line_number, Some(8)); - } - other => panic!("unexpected error: {}", other), + wut: + echo $foo $bar $baz + "#, + args: ["--quiet", "wut"], + error: Code { + code: _, + line_number, + recipe, + }, + check: { + assert_eq!(recipe, "wut"); + assert_eq!(line_number, Some(7)); } } diff --git a/src/lexer.rs b/src/lexer.rs index 17baaf6..3427cf2 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -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>> { loop { @@ -362,10 +402,13 @@ impl<'a> Lexer<'a> { ' ' | '\t' => self.lex_whitespace(), '\'' => self.lex_raw_string(), '"' => self.lex_cooked_string(), - 'a'..='z' | 'A'..='Z' | '_' => self.lex_identifier(), _ => { - self.advance()?; - Err(self.error(UnknownStartOfToken)) + if Self::is_identifier_start(start) { + self.lex_identifier() + } else { + self.advance()?; + Err(self.error(UnknownStartOfToken)) + } } } } @@ -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 = ""#, diff --git a/src/lib.rs b/src/lib.rs index 0589936..85fbeb0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ mod parser; mod platform; mod platform_interface; mod position; +mod positional; mod range_ext; mod recipe; mod recipe_context; diff --git a/src/positional.rs b/src/positional.rs new file mode 100644 index 0000000..8b954ca --- /dev/null +++ b/src/positional.rs @@ -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, + /// Everything else + pub arguments: Vec, +} + +impl Positional { + pub fn from_values<'values>( + values: Option>, + ) -> 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"], + } +} diff --git a/src/recipe.rs b/src/recipe.rs index 445f54b..dfdd4f8 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -69,6 +69,7 @@ impl<'a> Recipe<'a> { context: &RecipeContext<'a>, arguments: &[&'a str], dotenv: &BTreeMap, + overrides: &BTreeMap, ) -> 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, }; diff --git a/src/search.rs b/src/search.rs index 74e2123..6db3003 100644 --- a/src/search.rs +++ b/src/search.rs @@ -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 { let mut candidates = Vec::new(); + let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io { io_error, directory: directory.to_owned(), diff --git a/src/subcommand.rs b/src/subcommand.rs index 8022e38..5ea1f1d 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -1,10 +1,19 @@ +use crate::common::*; + #[derive(PartialEq, Clone, Debug)] pub(crate) enum Subcommand { Dump, Edit, - Evaluate, - Run, + Evaluate { + overrides: BTreeMap, + }, + Run { + overrides: BTreeMap, + arguments: Vec, + }, List, - Show { name: String }, + Show { + name: String, + }, Summary, } diff --git a/src/testing.rs b/src/testing.rs index 90d9803..15dec5c 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -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, + ¤t_dir, + &overrides, + &arguments, + ).expect_err("Expected runtime error") { + $error => $check + other => { + panic!("Unexpected run error: {:?}", other); + } + } } else { - justfile += line; - justfile += "\n"; - current = Some(justfile); + panic!("Unexpected subcommand: {:?}", config.subcommand); } - } else if line == "```make" { - current = Some(String::new()); } - } - - for justfile in justfiles { - compile(&justfile); - } + }; } diff --git a/test-utilities/src/lib.rs b/test-utilities/src/lib.rs index 93f0f9d..567beb9 100644 --- a/test-utilities/src/lib.rs +++ b/test-utilities/src/lib.rs @@ -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); } diff --git a/tests/readme.rs b/tests/readme.rs new file mode 100644 index 0000000..4df6aaa --- /dev/null +++ b/tests/readme.rs @@ -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); + } +} diff --git a/tests/search.rs b/tests/search.rs index 0bd1d6e..3603f05 100644 --- a/tests/search.rs +++ b/tests/search.rs @@ -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, &["../"]); +}