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:
parent
aefdcea7d0
commit
177516bcbe
@ -7,6 +7,7 @@ pub(crate) struct AssignmentEvaluator<'a: 'b, 'b> {
|
||||
pub(crate) evaluated: BTreeMap<&'a str, (bool, String)>,
|
||||
pub(crate) scope: &'b BTreeMap<&'a str, (bool, String)>,
|
||||
pub(crate) working_directory: &'b Path,
|
||||
pub(crate) overrides: &'b BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
||||
@ -15,10 +16,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
||||
working_directory: &'b Path,
|
||||
dotenv: &'b BTreeMap<String, String>,
|
||||
assignments: &BTreeMap<&'a str, Assignment<'a>>,
|
||||
overrides: &BTreeMap<String, String>,
|
||||
) -> RunResult<'a, BTreeMap<&'a str, (bool, String)>> {
|
||||
let mut evaluator = AssignmentEvaluator {
|
||||
evaluated: empty(),
|
||||
scope: &empty(),
|
||||
overrides,
|
||||
config,
|
||||
assignments,
|
||||
working_directory,
|
||||
@ -55,7 +58,7 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
||||
}
|
||||
|
||||
if let Some(assignment) = self.assignments.get(name) {
|
||||
if let Some(value) = self.config.overrides.get(name) {
|
||||
if let Some(value) = self.overrides.get(name) {
|
||||
self
|
||||
.evaluated
|
||||
.insert(name, (assignment.export, value.to_string()));
|
||||
@ -159,47 +162,40 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::{compile, config};
|
||||
|
||||
#[test]
|
||||
fn backtick_code() {
|
||||
let justfile = compile("a:\n echo {{`f() { return 100; }; f`}}");
|
||||
let config = config(&["a"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
RuntimeError::Backtick {
|
||||
run_error! {
|
||||
name: backtick_code,
|
||||
src: "
|
||||
a:
|
||||
echo {{`f() { return 100; }; f`}}
|
||||
",
|
||||
args: ["a"],
|
||||
error: RuntimeError::Backtick {
|
||||
token,
|
||||
output_error: OutputError::Code(code),
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
assert_eq!(code, 100);
|
||||
assert_eq!(token.lexeme(), "`f() { return 100; }; f`");
|
||||
}
|
||||
other => panic!("expected a code run error, but got: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_assignment_backtick() {
|
||||
let text = r#"
|
||||
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 {
|
||||
"#,
|
||||
args: ["--quiet", "recipe"],
|
||||
error: RuntimeError::Backtick {
|
||||
token,
|
||||
output_error: OutputError::Code(_),
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
assert_eq!(token.lexeme(), "`echo $exported_variable`");
|
||||
}
|
||||
other => panic!("expected a backtick code errror, but got: {}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
362
src/config.rs
362
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<String>,
|
||||
pub(crate) color: Color,
|
||||
pub(crate) dry_run: bool,
|
||||
pub(crate) highlight: bool,
|
||||
pub(crate) invocation_directory: PathBuf,
|
||||
pub(crate) overrides: BTreeMap<String, String>,
|
||||
pub(crate) quiet: bool,
|
||||
pub(crate) search_config: SearchConfig,
|
||||
pub(crate) shell: String,
|
||||
@ -27,6 +25,9 @@ mod cmd {
|
||||
pub(crate) const LIST: &str = "LIST";
|
||||
pub(crate) const SHOW: &str = "SHOW";
|
||||
pub(crate) const SUMMARY: &str = "SUMMARY";
|
||||
|
||||
pub(crate) const ALL: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY, EVALUATE];
|
||||
pub(crate) const ARGLESS: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY];
|
||||
}
|
||||
|
||||
mod arg {
|
||||
@ -55,11 +56,6 @@ impl Config {
|
||||
.version_message("Print version information")
|
||||
.setting(AppSettings::ColoredHelp)
|
||||
.setting(AppSettings::TrailingVarArg)
|
||||
.arg(
|
||||
Arg::with_name(arg::ARGUMENTS)
|
||||
.multiple(true)
|
||||
.help("The recipe(s) to run, defaults to the first recipe in the justfile"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name(arg::COLOR)
|
||||
.long("color")
|
||||
@ -129,7 +125,7 @@ impl Config {
|
||||
.number_of_values(2)
|
||||
.value_names(&["VARIABLE", "VALUE"])
|
||||
.multiple(true)
|
||||
.help("Set <VARIABLE> to <VALUE>"),
|
||||
.help("Override <VARIABLE> with <VALUE>"),
|
||||
)
|
||||
.arg(
|
||||
Arg::with_name(arg::SHELL)
|
||||
@ -166,15 +162,12 @@ impl Config {
|
||||
.help("Use <WORKING-DIRECTORY> as working directory. --justfile must also be set")
|
||||
.requires(arg::JUSTFILE),
|
||||
)
|
||||
.group(ArgGroup::with_name("EARLY-EXIT").args(&[
|
||||
arg::ARGUMENTS,
|
||||
cmd::DUMP,
|
||||
cmd::EDIT,
|
||||
cmd::EVALUATE,
|
||||
cmd::LIST,
|
||||
cmd::SHOW,
|
||||
cmd::SUMMARY,
|
||||
]));
|
||||
.arg(
|
||||
Arg::with_name(arg::ARGUMENTS)
|
||||
.multiple(true)
|
||||
.help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"),
|
||||
)
|
||||
.group(ArgGroup::with_name("SUBCOMMAND").args(cmd::ALL));
|
||||
|
||||
if cfg!(feature = "help4help2man") {
|
||||
app.version(env!("CARGO_PKG_VERSION")).about(concat!(
|
||||
@ -216,24 +209,6 @@ impl Config {
|
||||
.expect("`--color` had no value"),
|
||||
)?;
|
||||
|
||||
let subcommand = if matches.is_present(cmd::EDIT) {
|
||||
Subcommand::Edit
|
||||
} else if matches.is_present(cmd::SUMMARY) {
|
||||
Subcommand::Summary
|
||||
} else if matches.is_present(cmd::DUMP) {
|
||||
Subcommand::Dump
|
||||
} else if matches.is_present(cmd::LIST) {
|
||||
Subcommand::List
|
||||
} else if matches.is_present(cmd::EVALUATE) {
|
||||
Subcommand::Evaluate
|
||||
} else if let Some(name) = matches.value_of(cmd::SHOW) {
|
||||
Subcommand::Show {
|
||||
name: name.to_owned(),
|
||||
}
|
||||
} else {
|
||||
Subcommand::Run
|
||||
};
|
||||
|
||||
let set_count = matches.occurrences_of(arg::SET);
|
||||
let mut overrides = BTreeMap::new();
|
||||
if set_count > 0 {
|
||||
@ -246,60 +221,17 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_override(arg: &&str) -> bool {
|
||||
arg.chars().skip(1).any(|c| c == '=')
|
||||
let positional = Positional::from_values(matches.values_of(arg::ARGUMENTS));
|
||||
|
||||
for (name, value) in positional.overrides {
|
||||
overrides.insert(name.to_owned(), value.to_owned());
|
||||
}
|
||||
|
||||
let raw_arguments: Vec<&str> = matches
|
||||
.values_of(arg::ARGUMENTS)
|
||||
.map(Iterator::collect)
|
||||
.unwrap_or_default();
|
||||
|
||||
for argument in raw_arguments.iter().cloned().take_while(is_override) {
|
||||
let i = argument
|
||||
.char_indices()
|
||||
.skip(1)
|
||||
.find(|&(_, c)| c == '=')
|
||||
.unwrap()
|
||||
.0;
|
||||
|
||||
let name = argument[..i].to_owned();
|
||||
let value = argument[i + 1..].to_owned();
|
||||
|
||||
overrides.insert(name, value);
|
||||
}
|
||||
|
||||
let mut search_directory = None;
|
||||
|
||||
let arguments = raw_arguments
|
||||
.into_iter()
|
||||
.skip_while(is_override)
|
||||
.enumerate()
|
||||
.flat_map(|(i, argument)| {
|
||||
if i == 0 {
|
||||
if let Some(i) = argument.rfind('/') {
|
||||
let (dir, recipe) = argument.split_at(i + 1);
|
||||
|
||||
search_directory = Some(PathBuf::from(dir));
|
||||
|
||||
if recipe.is_empty() {
|
||||
return None;
|
||||
} else {
|
||||
return Some(recipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(argument)
|
||||
})
|
||||
.map(|argument| argument.to_owned())
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
let search_config = {
|
||||
let justfile = matches.value_of(arg::JUSTFILE).map(PathBuf::from);
|
||||
let working_directory = matches.value_of(arg::WORKING_DIRECTORY).map(PathBuf::from);
|
||||
|
||||
if let Some(search_directory) = search_directory {
|
||||
if let Some(search_directory) = positional.search_directory.map(PathBuf::from) {
|
||||
if justfile.is_some() || working_directory.is_some() {
|
||||
return Err(ConfigError::SearchDirConflict);
|
||||
}
|
||||
@ -323,6 +255,54 @@ impl Config {
|
||||
}
|
||||
};
|
||||
|
||||
for subcommand in cmd::ARGLESS {
|
||||
if matches.is_present(subcommand) {
|
||||
match (!overrides.is_empty(), !positional.arguments.is_empty()) {
|
||||
(false, false) => {}
|
||||
(true, false) => {
|
||||
return Err(ConfigError::SubcommandOverrides {
|
||||
subcommand: format!("--{}", subcommand.to_lowercase()),
|
||||
overrides,
|
||||
});
|
||||
}
|
||||
(false, true) => {
|
||||
return Err(ConfigError::SubcommandArguments {
|
||||
subcommand: format!("--{}", subcommand.to_lowercase()),
|
||||
arguments: positional.arguments,
|
||||
});
|
||||
}
|
||||
(true, true) => {
|
||||
return Err(ConfigError::SubcommandOverridesAndArguments {
|
||||
subcommand: format!("--{}", subcommand.to_lowercase()),
|
||||
arguments: positional.arguments,
|
||||
overrides,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let subcommand = if matches.is_present(cmd::EDIT) {
|
||||
Subcommand::Edit
|
||||
} else if matches.is_present(cmd::SUMMARY) {
|
||||
Subcommand::Summary
|
||||
} else if matches.is_present(cmd::DUMP) {
|
||||
Subcommand::Dump
|
||||
} else if matches.is_present(cmd::LIST) {
|
||||
Subcommand::List
|
||||
} else if let Some(name) = matches.value_of(cmd::SHOW) {
|
||||
Subcommand::Show {
|
||||
name: name.to_owned(),
|
||||
}
|
||||
} else if matches.is_present(cmd::EVALUATE) {
|
||||
Subcommand::Evaluate { overrides }
|
||||
} else {
|
||||
Subcommand::Run {
|
||||
arguments: positional.arguments,
|
||||
overrides,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Config {
|
||||
dry_run: matches.is_present(arg::DRY_RUN),
|
||||
highlight: !matches.is_present(arg::NO_HIGHLIGHT),
|
||||
@ -333,8 +313,6 @@ impl Config {
|
||||
subcommand,
|
||||
verbosity,
|
||||
color,
|
||||
overrides,
|
||||
arguments,
|
||||
})
|
||||
}
|
||||
|
||||
@ -365,9 +343,15 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
match self.subcommand {
|
||||
match &self.subcommand {
|
||||
Dump => self.dump(justfile),
|
||||
Run | Evaluate => self.run(justfile, &search.working_directory),
|
||||
Evaluate { overrides } => {
|
||||
self.run(justfile, &search.working_directory, overrides, &Vec::new())
|
||||
}
|
||||
Run {
|
||||
arguments,
|
||||
overrides,
|
||||
} => self.run(justfile, &search.working_directory, overrides, arguments),
|
||||
List => self.list(justfile),
|
||||
Show { ref name } => self.show(&name, justfile),
|
||||
Summary => self.summary(justfile),
|
||||
@ -497,12 +481,18 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(&self, justfile: Justfile, working_directory: &Path) -> Result<(), i32> {
|
||||
fn run(
|
||||
&self,
|
||||
justfile: Justfile,
|
||||
working_directory: &Path,
|
||||
overrides: &BTreeMap<String, String>,
|
||||
arguments: &Vec<String>,
|
||||
) -> Result<(), i32> {
|
||||
if let Err(error) = InterruptHandler::install() {
|
||||
warn!("Failed to set CTRL-C handler: {}", error)
|
||||
}
|
||||
|
||||
let result = justfile.run(&self, working_directory);
|
||||
let result = justfile.run(&self, working_directory, overrides, arguments);
|
||||
|
||||
if !self.quiet {
|
||||
result.eprint(self.color)
|
||||
@ -582,7 +572,7 @@ OPTIONS:
|
||||
Print colorful output [default: auto] [possible values: auto, always, never]
|
||||
|
||||
-f, --justfile <JUSTFILE> Use <JUSTFILE> as justfile.
|
||||
--set <VARIABLE> <VALUE> Set <VARIABLE> to <VALUE>
|
||||
--set <VARIABLE> <VALUE> Override <VARIABLE> with <VALUE>
|
||||
--shell <SHELL> Invoke <SHELL> to run recipes [default: sh]
|
||||
-s, --show <RECIPE> Show information about <RECIPE>
|
||||
-d, --working-directory <WORKING-DIRECTORY>
|
||||
@ -590,7 +580,7 @@ OPTIONS:
|
||||
|
||||
|
||||
ARGS:
|
||||
<ARGUMENTS>... The recipe(s) to run, defaults to the first recipe in the justfile";
|
||||
<ARGUMENTS>... Overrides and recipe(s) to run, defaulting to the first recipe in the justfile";
|
||||
|
||||
let app = Config::app().setting(AppSettings::ColorNever);
|
||||
let mut buffer = Vec::new();
|
||||
@ -604,11 +594,9 @@ ARGS:
|
||||
{
|
||||
name: $name:ident,
|
||||
args: [$($arg:expr),*],
|
||||
$(arguments: $arguments:expr,)?
|
||||
$(color: $color:expr,)?
|
||||
$(dry_run: $dry_run:expr,)?
|
||||
$(highlight: $highlight:expr,)?
|
||||
$(overrides: $overrides:expr,)?
|
||||
$(quiet: $quiet:expr,)?
|
||||
$(search_config: $search_config:expr,)?
|
||||
$(shell: $shell:expr,)?
|
||||
@ -623,14 +611,9 @@ ARGS:
|
||||
];
|
||||
|
||||
let want = Config {
|
||||
$(arguments: $arguments.iter().map(|argument| argument.to_string()).collect(),)?
|
||||
$(color: $color,)?
|
||||
$(dry_run: $dry_run,)?
|
||||
$(highlight: $highlight,)?
|
||||
$(
|
||||
overrides: $overrides.iter().cloned()
|
||||
.map(|(key, value): (&str, &str)| (key.to_owned(), value.to_owned())).collect(),
|
||||
)?
|
||||
$(quiet: $quiet,)?
|
||||
$(search_config: $search_config,)?
|
||||
$(shell: $shell.to_string(),)?
|
||||
@ -665,17 +648,50 @@ ARGS:
|
||||
$($arg,)*
|
||||
];
|
||||
|
||||
error(arguments);
|
||||
let app = Config::app();
|
||||
|
||||
app.get_matches_from_safe(arguments).expect_err("Expected clap error");
|
||||
}
|
||||
};
|
||||
{
|
||||
name: $name:ident,
|
||||
args: [$($arg:expr),*],
|
||||
error: $error:pat,
|
||||
$(check: $check:block,)?
|
||||
} => {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let arguments = &[
|
||||
"just",
|
||||
$($arg,)*
|
||||
];
|
||||
|
||||
let app = Config::app();
|
||||
|
||||
let matches = app.get_matches_from_safe(arguments).expect("Matching failes");
|
||||
|
||||
match Config::from_matches(&matches).expect_err("config parsing succeeded") {
|
||||
$error => { $($check)? }
|
||||
other => panic!("Unexpected config error: {}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn error(arguments: &[&str]) {
|
||||
let app = Config::app();
|
||||
if let Ok(matches) = app.get_matches_from_safe(arguments) {
|
||||
Config::from_matches(&matches).expect_err("config parsing unexpectedly succeeded");
|
||||
} else {
|
||||
return;
|
||||
macro_rules! map {
|
||||
{} => {
|
||||
BTreeMap::new()
|
||||
};
|
||||
{
|
||||
$($key:literal : $value:literal),* $(,)?
|
||||
} => {
|
||||
{
|
||||
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||
$(
|
||||
map.insert($key.to_owned(), $value.to_owned());
|
||||
)*
|
||||
map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -787,31 +803,46 @@ ARGS:
|
||||
test! {
|
||||
name: set_default,
|
||||
args: [],
|
||||
overrides: [],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!(),
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: set_one,
|
||||
args: ["--set", "foo", "bar"],
|
||||
overrides: [("foo", "bar")],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{"foo": "bar"},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: set_empty,
|
||||
args: ["--set", "foo", ""],
|
||||
overrides: [("foo", "")],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{"foo": ""},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: set_two,
|
||||
args: ["--set", "foo", "bar", "--set", "bar", "baz"],
|
||||
overrides: [("foo", "bar"), ("bar", "baz")],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{"foo": "bar", "bar": "baz"},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: set_override,
|
||||
args: ["--set", "foo", "bar", "--set", "foo", "baz"],
|
||||
overrides: [("foo", "baz")],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{"foo": "baz"},
|
||||
},
|
||||
}
|
||||
|
||||
error! {
|
||||
@ -864,7 +895,10 @@ ARGS:
|
||||
test! {
|
||||
name: subcommand_default,
|
||||
args: [],
|
||||
subcommand: Subcommand::Run,
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
@ -882,7 +916,9 @@ ARGS:
|
||||
test! {
|
||||
name: subcommand_evaluate,
|
||||
args: ["--evaluate"],
|
||||
subcommand: Subcommand::Evaluate,
|
||||
subcommand: Subcommand::Evaluate {
|
||||
overrides: map!{},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
@ -923,31 +959,46 @@ ARGS:
|
||||
test! {
|
||||
name: arguments,
|
||||
args: ["foo", "bar"],
|
||||
arguments: ["foo", "bar"],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: vec![String::from("foo"), String::from("bar")],
|
||||
overrides: map!{},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: arguments_leading_equals,
|
||||
args: ["=foo"],
|
||||
arguments: ["=foo"],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: vec!["=foo".to_string()],
|
||||
overrides: map!{},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: overrides,
|
||||
args: ["foo=bar", "bar=baz"],
|
||||
overrides: [("foo", "bar"), ("bar", "baz")],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{"foo": "bar", "bar": "baz"},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: overrides_empty,
|
||||
args: ["foo=", "bar="],
|
||||
overrides: [("foo", ""), ("bar", "")],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{"foo": "", "bar": ""},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
name: overrides_override_sets,
|
||||
args: ["--set", "foo", "0", "--set", "bar", "1", "foo=bar", "bar=baz"],
|
||||
overrides: [("foo", "bar"), ("bar", "baz")],
|
||||
subcommand: Subcommand::Run {
|
||||
arguments: Vec::new(),
|
||||
overrides: map!{"foo": "bar", "bar": "baz"},
|
||||
},
|
||||
}
|
||||
|
||||
test! {
|
||||
@ -992,10 +1043,10 @@ ARGS:
|
||||
test! {
|
||||
name: search_directory_parent_with_recipe,
|
||||
args: ["../build"],
|
||||
arguments: ["build"],
|
||||
search_config: SearchConfig::FromSearchDirectory {
|
||||
search_directory: PathBuf::from(".."),
|
||||
},
|
||||
subcommand: Subcommand::Run { arguments: vec!["build".to_owned()], overrides: BTreeMap::new() },
|
||||
}
|
||||
|
||||
test! {
|
||||
@ -1017,19 +1068,92 @@ ARGS:
|
||||
test! {
|
||||
name: search_directory_child_with_recipe,
|
||||
args: ["foo/build"],
|
||||
arguments: ["build"],
|
||||
search_config: SearchConfig::FromSearchDirectory {
|
||||
search_directory: PathBuf::from("foo"),
|
||||
},
|
||||
subcommand: Subcommand::Run { arguments: vec!["build".to_owned()], overrides: BTreeMap::new() },
|
||||
}
|
||||
|
||||
error! {
|
||||
name: search_directory_conflict_justfile,
|
||||
args: ["--justfile", "bar", "foo/build"],
|
||||
error: ConfigError::SearchDirConflict,
|
||||
}
|
||||
|
||||
error! {
|
||||
name: search_directory_conflict_working_directory,
|
||||
args: ["--justfile", "bar", "--working-directory", "baz", "foo/build"],
|
||||
error: ConfigError::SearchDirConflict,
|
||||
}
|
||||
|
||||
error! {
|
||||
name: list_arguments,
|
||||
args: ["--list", "bar"],
|
||||
error: ConfigError::SubcommandArguments { subcommand, arguments },
|
||||
check: {
|
||||
assert_eq!(subcommand, "--list");
|
||||
assert_eq!(arguments, &["bar"]);
|
||||
},
|
||||
}
|
||||
|
||||
error! {
|
||||
name: dump_arguments,
|
||||
args: ["--dump", "bar"],
|
||||
error: ConfigError::SubcommandArguments { subcommand, arguments },
|
||||
check: {
|
||||
assert_eq!(subcommand, "--dump");
|
||||
assert_eq!(arguments, &["bar"]);
|
||||
},
|
||||
}
|
||||
|
||||
error! {
|
||||
name: edit_arguments,
|
||||
args: ["--edit", "bar"],
|
||||
error: ConfigError::SubcommandArguments { subcommand, arguments },
|
||||
check: {
|
||||
assert_eq!(subcommand, "--edit");
|
||||
assert_eq!(arguments, &["bar"]);
|
||||
},
|
||||
}
|
||||
|
||||
error! {
|
||||
name: show_arguments,
|
||||
args: ["--show", "foo", "bar"],
|
||||
error: ConfigError::SubcommandArguments { subcommand, arguments },
|
||||
check: {
|
||||
assert_eq!(subcommand, "--show");
|
||||
assert_eq!(arguments, &["bar"]);
|
||||
},
|
||||
}
|
||||
|
||||
error! {
|
||||
name: summary_arguments,
|
||||
args: ["--summary", "bar"],
|
||||
error: ConfigError::SubcommandArguments { subcommand, arguments },
|
||||
check: {
|
||||
assert_eq!(subcommand, "--summary");
|
||||
assert_eq!(arguments, &["bar"]);
|
||||
},
|
||||
}
|
||||
|
||||
error! {
|
||||
name: subcommand_overrides_and_arguments,
|
||||
args: ["--summary", "bar=baz", "bar"],
|
||||
error: ConfigError::SubcommandOverridesAndArguments { subcommand, arguments, overrides },
|
||||
check: {
|
||||
assert_eq!(subcommand, "--summary");
|
||||
assert_eq!(overrides, map!{"bar": "baz"});
|
||||
assert_eq!(arguments, &["bar"]);
|
||||
},
|
||||
}
|
||||
|
||||
error! {
|
||||
name: summary_overrides,
|
||||
args: ["--summary", "bar=baz"],
|
||||
error: ConfigError::SubcommandOverrides { subcommand, overrides },
|
||||
check: {
|
||||
assert_eq!(subcommand, "--summary");
|
||||
assert_eq!(overrides, map!{"bar": "baz"});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,35 @@ pub(crate) enum ConfigError {
|
||||
"Path-prefixed recipes may not be used with `--working-directory` or `--justfile`."
|
||||
))]
|
||||
SearchDirConflict,
|
||||
#[snafu(display(
|
||||
"`{}` used with unexpected arguments: {}",
|
||||
subcommand,
|
||||
List::and_ticked(arguments)
|
||||
))]
|
||||
SubcommandArguments {
|
||||
subcommand: String,
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
#[snafu(display(
|
||||
"`{}` used with unexpected overrides: {}; and arguments: {}",
|
||||
subcommand,
|
||||
List::and_ticked(overrides.iter().map(|(key, value)| format!("{}={}", key, value))),
|
||||
List::and_ticked(arguments)))
|
||||
]
|
||||
SubcommandOverridesAndArguments {
|
||||
subcommand: String,
|
||||
overrides: BTreeMap<String, String>,
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
#[snafu(display(
|
||||
"`{}` used with unexpected overrides: {}",
|
||||
subcommand,
|
||||
List::and_ticked(overrides.iter().map(|(key, value)| format!("{}={}", key, value))),
|
||||
))]
|
||||
SubcommandOverrides {
|
||||
subcommand: String,
|
||||
overrides: BTreeMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ConfigError {
|
||||
|
241
src/justfile.rs
241
src/justfile.rs
@ -46,13 +46,11 @@ impl<'a> Justfile<'a> {
|
||||
&'a self,
|
||||
config: &'a Config,
|
||||
working_directory: &'a Path,
|
||||
overrides: &'a BTreeMap<String, String>,
|
||||
arguments: &'a Vec<String>,
|
||||
) -> RunResult<'a, ()> {
|
||||
let argvec: Vec<&str> = if !config.arguments.is_empty() {
|
||||
config
|
||||
.arguments
|
||||
.iter()
|
||||
.map(|argument| argument.as_str())
|
||||
.collect()
|
||||
let argvec: Vec<&str> = if !arguments.is_empty() {
|
||||
arguments.iter().map(|argument| argument.as_str()).collect()
|
||||
} else if let Some(recipe) = self.first() {
|
||||
let min_arguments = recipe.min_arguments();
|
||||
if min_arguments > 0 {
|
||||
@ -68,8 +66,7 @@ impl<'a> Justfile<'a> {
|
||||
|
||||
let arguments = argvec.as_slice();
|
||||
|
||||
let unknown_overrides = config
|
||||
.overrides
|
||||
let unknown_overrides = overrides
|
||||
.keys()
|
||||
.filter(|name| !self.assignments.contains_key(name.as_str()))
|
||||
.map(|name| name.as_str())
|
||||
@ -88,9 +85,10 @@ impl<'a> Justfile<'a> {
|
||||
working_directory,
|
||||
&dotenv,
|
||||
&self.assignments,
|
||||
overrides,
|
||||
)?;
|
||||
|
||||
if config.subcommand == Subcommand::Evaluate {
|
||||
if let Subcommand::Evaluate { .. } = config.subcommand {
|
||||
let mut width = 0;
|
||||
for name in scope.keys() {
|
||||
width = cmp::max(name.len(), width);
|
||||
@ -151,7 +149,7 @@ impl<'a> Justfile<'a> {
|
||||
|
||||
let mut ran = empty();
|
||||
for (recipe, arguments) in grouped {
|
||||
self.run_recipe(&context, recipe, arguments, &dotenv, &mut ran)?
|
||||
self.run_recipe(&context, recipe, arguments, &dotenv, &mut ran, overrides)?
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -178,14 +176,15 @@ impl<'a> Justfile<'a> {
|
||||
arguments: &[&'a str],
|
||||
dotenv: &BTreeMap<String, String>,
|
||||
ran: &mut BTreeSet<&'a str>,
|
||||
overrides: &BTreeMap<String, String>,
|
||||
) -> RunResult<()> {
|
||||
for dependency_name in &recipe.dependencies {
|
||||
let lexeme = dependency_name.lexeme();
|
||||
if !ran.contains(lexeme) {
|
||||
self.run_recipe(context, &self.recipes[lexeme], &[], dotenv, ran)?;
|
||||
self.run_recipe(context, &self.recipes[lexeme], &[], dotenv, ran, overrides)?;
|
||||
}
|
||||
}
|
||||
recipe.run(context, arguments, dotenv)?;
|
||||
recipe.run(context, arguments, dotenv, overrides)?;
|
||||
ran.insert(recipe.name());
|
||||
Ok(())
|
||||
}
|
||||
@ -226,29 +225,23 @@ impl<'a> Display for Justfile<'a> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::runtime_error::RuntimeError::*;
|
||||
use crate::testing::{compile, config};
|
||||
use testing::compile;
|
||||
use RuntimeError::*;
|
||||
|
||||
#[test]
|
||||
fn unknown_recipes() {
|
||||
let justfile = compile("a:\nb:\nc:");
|
||||
let config = config(&["a", "x", "y", "z"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
UnknownRecipes {
|
||||
run_error! {
|
||||
name: unknown_recipes,
|
||||
src: "a:\nb:\nc:",
|
||||
args: ["a", "x", "y", "z"],
|
||||
error: UnknownRecipes {
|
||||
recipes,
|
||||
suggestion,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
assert_eq!(recipes, &["x", "y", "z"]);
|
||||
assert_eq!(suggestion, None);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_shebang() {
|
||||
// this test exists to make sure that shebang recipes
|
||||
// run correctly. although this script is still
|
||||
// executed by a shell its behavior depends on the value of a
|
||||
@ -256,86 +249,79 @@ mod tests {
|
||||
// whereas in plain recipes variables are not available
|
||||
// in subsequent lines and execution stops when a line
|
||||
// fails
|
||||
let text = "
|
||||
run_error! {
|
||||
name: run_shebang,
|
||||
src: "
|
||||
a:
|
||||
#!/usr/bin/env sh
|
||||
code=200
|
||||
x() { return $code; }
|
||||
x
|
||||
x
|
||||
";
|
||||
let justfile = compile(text);
|
||||
let config = config(&["a"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
Code {
|
||||
",
|
||||
args: ["a"],
|
||||
error: Code {
|
||||
recipe,
|
||||
line_number,
|
||||
code,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
assert_eq!(recipe, "a");
|
||||
assert_eq!(code, 200);
|
||||
assert_eq!(line_number, None);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_error() {
|
||||
let justfile = compile("fail:\n @exit 100");
|
||||
let config = config(&["fail"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
Code {
|
||||
run_error! {
|
||||
name: code_error,
|
||||
src: "
|
||||
fail:
|
||||
@exit 100
|
||||
",
|
||||
args: ["fail"],
|
||||
error: Code {
|
||||
recipe,
|
||||
line_number,
|
||||
code,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
assert_eq!(recipe, "fail");
|
||||
assert_eq!(code, 100);
|
||||
assert_eq!(line_number, Some(2));
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_args() {
|
||||
let text = r#"
|
||||
run_error! {
|
||||
name: run_args,
|
||||
src: 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 {
|
||||
@x() { {{return}} {{code + "0"}}; }; x
|
||||
"#,
|
||||
args: ["a", "return", "15"],
|
||||
error: Code {
|
||||
recipe,
|
||||
line_number,
|
||||
code,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
assert_eq!(recipe, "a");
|
||||
assert_eq!(code, 150);
|
||||
assert_eq!(line_number, Some(3));
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
assert_eq!(line_number, Some(2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_some_arguments() {
|
||||
let justfile = compile("a b c d:");
|
||||
let config = config(&["a", "b", "c"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
ArgumentCountMismatch {
|
||||
run_error! {
|
||||
name: missing_some_arguments,
|
||||
src: "a b c d:",
|
||||
args: ["a", "b", "c"],
|
||||
error: ArgumentCountMismatch {
|
||||
recipe,
|
||||
parameters,
|
||||
found,
|
||||
min,
|
||||
max,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
let param_names = parameters
|
||||
.iter()
|
||||
.map(|p| p.name.lexeme())
|
||||
@ -346,23 +332,20 @@ a return code:
|
||||
assert_eq!(min, 3);
|
||||
assert_eq!(max, 3);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_some_arguments_variadic() {
|
||||
let justfile = compile("a b c +d:");
|
||||
let config = config(&["a", "B", "C"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
ArgumentCountMismatch {
|
||||
run_error! {
|
||||
name: missing_some_arguments_variadic,
|
||||
src: "a b c +d:",
|
||||
args: ["a", "B", "C"],
|
||||
error: ArgumentCountMismatch {
|
||||
recipe,
|
||||
parameters,
|
||||
found,
|
||||
min,
|
||||
max,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
let param_names = parameters
|
||||
.iter()
|
||||
.map(|p| p.name.lexeme())
|
||||
@ -373,24 +356,20 @@ a return code:
|
||||
assert_eq!(min, 3);
|
||||
assert_eq!(max, usize::MAX - 1);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_all_arguments() {
|
||||
let justfile = compile("a b c d:\n echo {{b}}{{c}}{{d}}");
|
||||
let config = config(&["a"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
ArgumentCountMismatch {
|
||||
run_error! {
|
||||
name: missing_all_arguments,
|
||||
src: "a b c d:\n echo {{b}}{{c}}{{d}}",
|
||||
args: ["a"],
|
||||
error: ArgumentCountMismatch {
|
||||
recipe,
|
||||
parameters,
|
||||
found,
|
||||
min,
|
||||
max,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
let param_names = parameters
|
||||
.iter()
|
||||
.map(|p| p.name.lexeme())
|
||||
@ -401,24 +380,20 @@ a return code:
|
||||
assert_eq!(min, 3);
|
||||
assert_eq!(max, 3);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_some_defaults() {
|
||||
let justfile = compile("a b c d='hello':");
|
||||
let config = config(&["a", "b"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
ArgumentCountMismatch {
|
||||
run_error! {
|
||||
name: missing_some_defaults,
|
||||
src: "a b c d='hello':",
|
||||
args: ["a", "b"],
|
||||
error: ArgumentCountMismatch {
|
||||
recipe,
|
||||
parameters,
|
||||
found,
|
||||
min,
|
||||
max,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
let param_names = parameters
|
||||
.iter()
|
||||
.map(|p| p.name.lexeme())
|
||||
@ -429,24 +404,20 @@ a return code:
|
||||
assert_eq!(min, 2);
|
||||
assert_eq!(max, 3);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_all_defaults() {
|
||||
let justfile = compile("a b c='r' d='h':");
|
||||
let config = &config(&["a"]);
|
||||
let dir = env::current_dir().unwrap();
|
||||
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
ArgumentCountMismatch {
|
||||
run_error! {
|
||||
name: missing_all_defaults,
|
||||
src: "a b c='r' d='h':",
|
||||
args: ["a"],
|
||||
error: ArgumentCountMismatch {
|
||||
recipe,
|
||||
parameters,
|
||||
found,
|
||||
min,
|
||||
max,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
let param_names = parameters
|
||||
.iter()
|
||||
.map(|p| p.name.lexeme())
|
||||
@ -457,27 +428,24 @@ a return code:
|
||||
assert_eq!(min, 1);
|
||||
assert_eq!(max, 3);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_overrides() {
|
||||
let config = config(&["foo=bar", "baz=bob", "a"]);
|
||||
let justfile = compile("a:\n echo {{`f() { return 100; }; f`}}");
|
||||
let dir = env::current_dir().unwrap();
|
||||
|
||||
match justfile.run(&config, &dir).unwrap_err() {
|
||||
UnknownOverrides { overrides } => {
|
||||
run_error! {
|
||||
name: unknown_overrides,
|
||||
src: "
|
||||
a:
|
||||
echo {{`f() { return 100; }; f`}}
|
||||
",
|
||||
args: ["foo=bar", "baz=bob", "a"],
|
||||
error: UnknownOverrides { overrides },
|
||||
check: {
|
||||
assert_eq!(overrides, &["baz", "foo"]);
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_failure() {
|
||||
let text = r#"
|
||||
run_error! {
|
||||
name: export_failure,
|
||||
src: r#"
|
||||
export foo = "a"
|
||||
baz = "c"
|
||||
export bar = "b"
|
||||
@ -485,23 +453,16 @@ 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 {
|
||||
"#,
|
||||
args: ["--quiet", "wut"],
|
||||
error: Code {
|
||||
code: _,
|
||||
line_number,
|
||||
recipe,
|
||||
} => {
|
||||
},
|
||||
check: {
|
||||
assert_eq!(recipe, "wut");
|
||||
assert_eq!(line_number, Some(8));
|
||||
}
|
||||
other => panic!("unexpected error: {}", other),
|
||||
assert_eq!(line_number, Some(7));
|
||||
}
|
||||
}
|
||||
|
||||
|
80
src/lexer.rs
80
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<Token<'a>>> {
|
||||
loop {
|
||||
@ -362,13 +402,16 @@ impl<'a> Lexer<'a> {
|
||||
' ' | '\t' => self.lex_whitespace(),
|
||||
'\'' => self.lex_raw_string(),
|
||||
'"' => self.lex_cooked_string(),
|
||||
'a'..='z' | 'A'..='Z' | '_' => self.lex_identifier(),
|
||||
_ => {
|
||||
if Self::is_identifier_start(start) {
|
||||
self.lex_identifier()
|
||||
} else {
|
||||
self.advance()?;
|
||||
Err(self.error(UnknownStartOfToken))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lex token beginning with `start` in interpolation state
|
||||
fn lex_interpolation(
|
||||
@ -515,13 +558,16 @@ impl<'a> Lexer<'a> {
|
||||
self.lex_double(Eol)
|
||||
}
|
||||
|
||||
/// Lex identifier: [a-zA-Z_][a-zA-Z0-9_]*
|
||||
/// Lex name: [a-zA-Z_][a-zA-Z0-9_]*
|
||||
fn lex_identifier(&mut self) -> CompilationResult<'a, ()> {
|
||||
while self
|
||||
.next
|
||||
.map(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// advance over initial character
|
||||
self.advance()?;
|
||||
|
||||
while let Some(c) = self.next {
|
||||
if !Self::is_identifier_continue(c) {
|
||||
break;
|
||||
}
|
||||
|
||||
self.advance()?;
|
||||
}
|
||||
|
||||
@ -1667,6 +1713,26 @@ mod tests {
|
||||
kind: UnknownStartOfToken,
|
||||
}
|
||||
|
||||
error! {
|
||||
name: invalid_name_start_dash,
|
||||
input: "-foo",
|
||||
offset: 0,
|
||||
line: 0,
|
||||
column: 0,
|
||||
width: 1,
|
||||
kind: UnknownStartOfToken,
|
||||
}
|
||||
|
||||
error! {
|
||||
name: invalid_name_start_digit,
|
||||
input: "0foo",
|
||||
offset: 0,
|
||||
line: 0,
|
||||
column: 0,
|
||||
width: 1,
|
||||
kind: UnknownStartOfToken,
|
||||
}
|
||||
|
||||
error! {
|
||||
name: unterminated_string,
|
||||
input: r#"a = ""#,
|
||||
|
@ -62,6 +62,7 @@ mod parser;
|
||||
mod platform;
|
||||
mod platform_interface;
|
||||
mod position;
|
||||
mod positional;
|
||||
mod range_ext;
|
||||
mod recipe;
|
||||
mod recipe_context;
|
||||
|
231
src/positional.rs
Normal file
231
src/positional.rs
Normal 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"],
|
||||
}
|
||||
}
|
@ -69,6 +69,7 @@ impl<'a> Recipe<'a> {
|
||||
context: &RecipeContext<'a>,
|
||||
arguments: &[&'a str],
|
||||
dotenv: &BTreeMap<String, String>,
|
||||
overrides: &BTreeMap<String, String>,
|
||||
) -> RunResult<'a, ()> {
|
||||
let config = &context.config;
|
||||
|
||||
@ -89,6 +90,7 @@ impl<'a> Recipe<'a> {
|
||||
evaluated: empty(),
|
||||
working_directory: context.working_directory,
|
||||
scope: &context.scope,
|
||||
overrides,
|
||||
config,
|
||||
dotenv,
|
||||
};
|
||||
|
@ -25,7 +25,14 @@ impl Search {
|
||||
}
|
||||
|
||||
SearchConfig::FromSearchDirectory { search_directory } => {
|
||||
let justfile = Self::justfile(search_directory)?;
|
||||
let search_directory =
|
||||
search_directory
|
||||
.canonicalize()
|
||||
.context(search_error::Canonicalize {
|
||||
path: search_directory,
|
||||
})?;
|
||||
|
||||
let justfile = Self::justfile(&search_directory)?;
|
||||
|
||||
let working_directory = Self::working_directory_from_justfile(&justfile)?;
|
||||
|
||||
@ -58,6 +65,7 @@ impl Search {
|
||||
|
||||
fn justfile(directory: &Path) -> SearchResult<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
|
||||
io_error,
|
||||
directory: directory.to_owned(),
|
||||
|
@ -1,10 +1,19 @@
|
||||
use crate::common::*;
|
||||
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
pub(crate) enum Subcommand {
|
||||
Dump,
|
||||
Edit,
|
||||
Evaluate,
|
||||
Run,
|
||||
Evaluate {
|
||||
overrides: BTreeMap<String, String>,
|
||||
},
|
||||
Run {
|
||||
overrides: BTreeMap<String, String>,
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
List,
|
||||
Show { name: String },
|
||||
Show {
|
||||
name: String,
|
||||
},
|
||||
Summary,
|
||||
}
|
||||
|
@ -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(
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! run_error {
|
||||
{
|
||||
name: $name:ident,
|
||||
src: $src:expr,
|
||||
args: $args:expr,
|
||||
error: $error:pat,
|
||||
check: $check:block $(,)?
|
||||
} => {
|
||||
#[test]
|
||||
fn readme_test() {
|
||||
let mut justfiles = vec![];
|
||||
let mut current = None;
|
||||
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);
|
||||
}
|
||||
} else if line == "```make" {
|
||||
current = Some(String::new());
|
||||
panic!("Unexpected subcommand: {:?}", config.subcommand);
|
||||
}
|
||||
}
|
||||
|
||||
for justfile in justfiles {
|
||||
compile(&justfile);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -7,13 +7,16 @@ pub fn tempdir() -> tempfile::TempDir {
|
||||
.expect("failed to create temporary directory")
|
||||
}
|
||||
|
||||
pub fn assert_stdout(output: &Output, stdout: &str) {
|
||||
pub fn assert_success(output: &Output) {
|
||||
if !output.status.success() {
|
||||
eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr));
|
||||
eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout));
|
||||
panic!(output.status);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_stdout(output: &Output, stdout: &str) {
|
||||
assert_success(output);
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout), stdout);
|
||||
}
|
||||
|
||||
|
41
tests/readme.rs
Normal file
41
tests/readme.rs
Normal 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);
|
||||
}
|
||||
}
|
@ -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, &["../"]);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user