Add variadic parameters (#127)

Recipes may now have a final variadic parameter:

```make
foo bar+:
  @echo {{bar}}
```

Variadic parameters accept one or more arguments, and expand to a string containing those arguments separated by spaces:

```sh
$ just foo a b c d e
a b c d e
```

I elected to accept one or more arguments instead of zero or more arguments since unexpectedly empty arguments can sometimes be dangerous. 

```make
clean dir:
  rm -rf {{dir}}/bin
```

If `dir` is empty in the above recipe, you'll delete `/bin`, which is probably not what was intended.
This commit is contained in:
Casey Rodarmor 2016-11-18 07:03:34 -08:00 committed by GitHub
parent 9ece0b9a6b
commit 1ac5b4ea42
5 changed files with 223 additions and 34 deletions

View File

@ -4,7 +4,7 @@ justfile grammar
Justfiles are processed by a mildly context-sensitive tokenizer Justfiles are processed by a mildly context-sensitive tokenizer
and a recursive descent parser. The grammar is mostly LL(1), and a recursive descent parser. The grammar is mostly LL(1),
although an extra token of lookahead is used to distinguish between although an extra token of lookahead is used to distinguish between
export assignments and recipes with arguments. export assignments and recipes with parameters.
tokens tokens
------ ------
@ -51,9 +51,9 @@ expression : STRING
| BACKTICK | BACKTICK
| expression '+' expression | expression '+' expression
recipe : '@'? NAME argument* ':' dependencies? body? recipe : '@'? NAME parameter* ('+' parameter)? ':' dependencies? body?
argument : NAME parameter : NAME
| NAME '=' STRING | NAME '=' STRING
| NAME '=' RAW_STRING | NAME '=' RAW_STRING

View File

@ -264,6 +264,22 @@ Testing server:unit...
./test --tests unit server ./test --tests unit server
``` ```
The last parameter to a recipe may be variadic, indicated with a `+` before the argument name:
```make
backup +FILES:
scp {{FILES}} me@server.com:
```
Variadic parameters accept one or more arguments and expand to a string containing those arguments separated by spaces:
```sh
$ just backup FAQ.md GRAMMAR.md
scp FAQ.md GRAMMAR.md me@server.com:
FAQ.md 100% 1831 1.8KB/s 00:00
GRAMMAR.md 100% 1666 1.6KB/s 00:00
```
Variables can be exported to recipes as environment variables: Variables can be exported to recipes as environment variables:
```make ```make

View File

@ -1038,16 +1038,16 @@ Recipe `recipe` failed on line 3 with exit code 100\u{1b}[0m\n",
#[test] #[test]
fn dump() { fn dump() {
let text =" let text = r#"
# this recipe does something # this recipe does something
recipe: recipe a b +d:
@exit 100"; @exit 100"#;
integration_test( integration_test(
&["--dump"], &["--dump"],
text, text,
0, 0,
"# this recipe does something "# this recipe does something
recipe: recipe a b +d:
@exit 100 @exit 100
", ",
"", "",
@ -1481,3 +1481,59 @@ a b=":
"#, "#,
); );
} }
#[test]
fn variadic_recipe() {
integration_test(
&["a", "0", "1", "2", "3", " 4 "],
"
a x y +z:
echo {{x}} {{y}} {{z}}
",
0,
"0 1 2 3 4\n",
"echo 0 1 2 3 4 \n",
);
}
#[test]
fn variadic_ignore_default() {
integration_test(
&["a", "0", "1", "2", "3", " 4 "],
"
a x y +z='HELLO':
echo {{x}} {{y}} {{z}}
",
0,
"0 1 2 3 4\n",
"echo 0 1 2 3 4 \n",
);
}
#[test]
fn variadic_use_default() {
integration_test(
&["a", "0", "1"],
"
a x y +z='HELLO':
echo {{x}} {{y}} {{z}}
",
0,
"0 1 HELLO\n",
"echo 0 1 HELLO\n",
);
}
#[test]
fn variadic_too_few() {
integration_test(
&["a", "0", "1"],
"
a x y +z:
echo {{x}} {{y}} {{z}}
",
255,
"",
"error: Recipe `a` got 2 arguments but takes at least 3\n",
);
}

View File

@ -19,6 +19,7 @@ pub use app::app;
use app::UseColor; use app::UseColor;
use regex::Regex; use regex::Regex;
use std::borrow::Cow;
use std::collections::{BTreeMap as Map, BTreeSet as Set}; use std::collections::{BTreeMap as Map, BTreeSet as Set};
use std::fmt::Display; use std::fmt::Display;
use std::io::prelude::*; use std::io::prelude::*;
@ -68,28 +69,33 @@ fn contains<T: PartialOrd>(range: &Range<T>, i: T) -> bool {
#[derive(PartialEq, Debug)] #[derive(PartialEq, Debug)]
struct Recipe<'a> { struct Recipe<'a> {
line_number: usize,
name: &'a str,
doc: Option<&'a str>,
lines: Vec<Vec<Fragment<'a>>>,
dependencies: Vec<&'a str>, dependencies: Vec<&'a str>,
dependency_tokens: Vec<Token<'a>>, dependency_tokens: Vec<Token<'a>>,
doc: Option<&'a str>,
line_number: usize,
lines: Vec<Vec<Fragment<'a>>>,
name: &'a str,
parameters: Vec<Parameter<'a>>, parameters: Vec<Parameter<'a>>,
shebang: bool,
quiet: bool, quiet: bool,
shebang: bool,
} }
#[derive(PartialEq, Debug)] #[derive(PartialEq, Debug)]
struct Parameter<'a> { struct Parameter<'a> {
name: &'a str, default: Option<String>,
default: Option<String>, name: &'a str,
token: Token<'a>, token: Token<'a>,
variadic: bool,
} }
impl<'a> Display for Parameter<'a> { impl<'a> Display for Parameter<'a> {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
let green = maybe_green(f.alternate()); let green = maybe_green(f.alternate());
let cyan = maybe_cyan(f.alternate()); let cyan = maybe_cyan(f.alternate());
let purple = maybe_purple(f.alternate());
if self.variadic {
write!(f, "{}", purple.paint("+"))?;
}
write!(f, "{}", cyan.paint(self.name))?; write!(f, "{}", cyan.paint(self.name))?;
if let Some(ref default) = self.default { if let Some(ref default) = self.default {
let escaped = default.chars().flat_map(char::escape_default).collect::<String>();; let escaped = default.chars().flat_map(char::escape_default).collect::<String>();;
@ -275,7 +281,11 @@ impl<'a> Recipe<'a> {
fn argument_range(&self) -> Range<usize> { fn argument_range(&self) -> Range<usize> {
self.parameters.iter().filter(|p| !p.default.is_some()).count() self.parameters.iter().filter(|p| !p.default.is_some()).count()
.. ..
self.parameters.len() + 1 if self.parameters.iter().any(|p| p.variadic) {
std::usize::MAX
} else {
self.parameters.len() + 1
}
} }
fn run( fn run(
@ -290,16 +300,30 @@ impl<'a> Recipe<'a> {
warn!("{}===> Running recipe `{}`...{}", cyan.prefix(), self.name, cyan.suffix()); warn!("{}===> Running recipe `{}`...{}", cyan.prefix(), self.name, cyan.suffix());
} }
let argument_map = self.parameters.iter().enumerate() let mut argument_map = Map::new();
.map(|(i, parameter)| if i < arguments.len() {
Ok((parameter.name, arguments[i])) let mut rest = arguments;
} else if let Some(ref default) = parameter.default { for parameter in &self.parameters {
Ok((parameter.name, default.as_str())) let value = if rest.is_empty() {
match parameter.default {
Some(ref default) => Cow::Borrowed(default.as_str()),
None => return Err(RunError::InternalError{
message: "missing parameter without default".to_string()
}),
}
} else { } else {
Err(RunError::InternalError{ if parameter.variadic {
message: "missing parameter without default".to_string() let value = Cow::Owned(rest.to_vec().join(" "));
}) rest = &[];
}).collect::<Result<Vec<_>, _>>()?.into_iter().collect(); value
} else {
let value = Cow::Borrowed(rest[0]);
rest = &rest[1..];
value
}
};
argument_map.insert(parameter.name, value);
}
let mut evaluator = Evaluator { let mut evaluator = Evaluator {
evaluated: empty(), evaluated: empty(),
@ -689,7 +713,7 @@ impl<'a, 'b> Evaluator<'a, 'b> {
fn evaluate_line( fn evaluate_line(
&mut self, &mut self,
line: &[Fragment<'a>], line: &[Fragment<'a>],
arguments: &Map<&str, &str> arguments: &Map<&str, Cow<str>>
) -> Result<String, RunError<'a>> { ) -> Result<String, RunError<'a>> {
let mut evaluated = String::new(); let mut evaluated = String::new();
for fragment in line { for fragment in line {
@ -727,7 +751,7 @@ impl<'a, 'b> Evaluator<'a, 'b> {
fn evaluate_expression( fn evaluate_expression(
&mut self, &mut self,
expression: &Expression<'a>, expression: &Expression<'a>,
arguments: &Map<&str, &str> arguments: &Map<&str, Cow<str>>
) -> Result<String, RunError<'a>> { ) -> Result<String, RunError<'a>> {
Ok(match *expression { Ok(match *expression {
Expression::Variable{name, ..} => { Expression::Variable{name, ..} => {
@ -786,6 +810,7 @@ enum ErrorKind<'a> {
OuterShebang, OuterShebang,
ParameterShadowsVariable{parameter: &'a str}, ParameterShadowsVariable{parameter: &'a str},
RequiredParameterFollowsDefaultParameter{parameter: &'a str}, RequiredParameterFollowsDefaultParameter{parameter: &'a str},
ParameterFollowsVariadicParameter{parameter: &'a str},
UndefinedVariable{variable: &'a str}, UndefinedVariable{variable: &'a str},
UnexpectedToken{expected: Vec<TokenKind>, found: TokenKind}, UnexpectedToken{expected: Vec<TokenKind>, found: TokenKind},
UnknownDependency{recipe: &'a str, unknown: &'a str}, UnknownDependency{recipe: &'a str, unknown: &'a str},
@ -1002,6 +1027,14 @@ fn maybe_cyan(colors: bool) -> ansi_term::Style {
} }
} }
fn maybe_purple(colors: bool) -> ansi_term::Style {
if colors {
ansi_term::Style::new().fg(ansi_term::Color::Purple)
} else {
ansi_term::Style::default()
}
}
fn maybe_bold(colors: bool) -> ansi_term::Style { fn maybe_bold(colors: bool) -> ansi_term::Style {
if colors { if colors {
ansi_term::Style::new().bold() ansi_term::Style::new().bold()
@ -1066,6 +1099,9 @@ impl<'a> Display for CompileError<'a> {
RequiredParameterFollowsDefaultParameter{parameter} => { RequiredParameterFollowsDefaultParameter{parameter} => {
writeln!(f, "non-default parameter `{}` follows default parameter", parameter)?; writeln!(f, "non-default parameter `{}` follows default parameter", parameter)?;
} }
ParameterFollowsVariadicParameter{parameter} => {
writeln!(f, "parameter `{}` follows a varidic parameter", parameter)?;
}
MixedLeadingWhitespace{whitespace} => { MixedLeadingWhitespace{whitespace} => {
writeln!(f, writeln!(f,
"found a mix of tabs and spaces in leading whitespace: `{}`\n\ "found a mix of tabs and spaces in leading whitespace: `{}`\n\
@ -1846,8 +1882,28 @@ impl<'a> Parser<'a> {
} }
let mut parsed_parameter_with_default = false; let mut parsed_parameter_with_default = false;
let mut parsed_variadic_parameter = false;
let mut parameters: Vec<Parameter> = vec![]; let mut parameters: Vec<Parameter> = vec![];
while let Some(parameter) = self.accept(Name) { loop {
let plus = self.accept(Plus);
let parameter = match self.accept(Name) {
Some(parameter) => parameter,
None => if let Some(plus) = plus {
return Err(self.unexpected_token(&plus, &[Name]));
} else {
break
},
};
let variadic = plus.is_some();
if parsed_variadic_parameter {
return Err(parameter.error(ErrorKind::ParameterFollowsVariadicParameter {
parameter: parameter.lexeme,
}));
}
if parameters.iter().any(|p| p.name == parameter.lexeme) { if parameters.iter().any(|p| p.name == parameter.lexeme) {
return Err(parameter.error(ErrorKind::DuplicateParameter { return Err(parameter.error(ErrorKind::DuplicateParameter {
recipe: name.lexeme, parameter: parameter.lexeme recipe: name.lexeme, parameter: parameter.lexeme
@ -1873,11 +1929,13 @@ impl<'a> Parser<'a> {
} }
parsed_parameter_with_default |= default.is_some(); parsed_parameter_with_default |= default.is_some();
parsed_variadic_parameter = variadic;
parameters.push(Parameter { parameters.push(Parameter {
name: parameter.lexeme, default: default,
default: default, name: parameter.lexeme,
token: parameter, token: parameter,
variadic: variadic,
}); });
} }
@ -1885,9 +1943,9 @@ impl<'a> Parser<'a> {
// if we haven't accepted any parameters, an equals // if we haven't accepted any parameters, an equals
// would have been fine as part of an assignment // would have been fine as part of an assignment
if parameters.is_empty() { if parameters.is_empty() {
return Err(self.unexpected_token(&token, &[Name, Colon, Equals])); return Err(self.unexpected_token(&token, &[Name, Plus, Colon, Equals]));
} else { } else {
return Err(self.unexpected_token(&token, &[Name, Colon])); return Err(self.unexpected_token(&token, &[Name, Plus, Colon]));
} }
} }

View File

@ -277,6 +277,26 @@ foo a="b\t":
"#, r#"foo a='b\t':"#); "#, r#"foo a='b\t':"#);
} }
#[test]
fn parse_variadic() {
parse_summary(r#"
foo +a:
"#, r#"foo +a:"#);
}
#[test]
fn parse_variadic_string_default() {
parse_summary(r#"
foo +a="Hello":
"#, r#"foo +a='Hello':"#);
}
#[test] #[test]
fn parse_raw_string_default() { fn parse_raw_string_default() {
parse_summary(r#" parse_summary(r#"
@ -400,7 +420,7 @@ fn missing_colon() {
line: 0, line: 0,
column: 5, column: 5,
width: Some(1), width: Some(1),
kind: ErrorKind::UnexpectedToken{expected: vec![Name, Colon], found: Eol}, kind: ErrorKind::UnexpectedToken{expected: vec![Name, Plus, Colon], found: Eol},
}); });
} }
@ -456,6 +476,19 @@ fn missing_default_backtick() {
}); });
} }
#[test]
fn parameter_after_variadic() {
let text = "foo +a bbb:";
parse_error(text, CompileError {
text: text,
index: 7,
line: 0,
column: 7,
width: Some(3),
kind: ErrorKind::ParameterFollowsVariadicParameter{parameter: "bbb"}
});
}
#[test] #[test]
fn required_after_default() { fn required_after_default() {
let text = "hello arg='foo' bar:"; let text = "hello arg='foo' bar:";
@ -825,6 +858,19 @@ fn unknown_second_interpolation_variable() {
}); });
} }
#[test]
fn plus_following_parameter() {
let text = "a b c+:";
parse_error(text, CompileError {
text: text,
index: 5,
line: 0,
column: 5,
width: Some(1),
kind: ErrorKind::UnexpectedToken{expected: vec![Name], found: Plus},
});
}
#[test] #[test]
fn tokenize_order() { fn tokenize_order() {
let text = r" let text = r"
@ -913,6 +959,19 @@ fn missing_some_arguments() {
} }
} }
#[test]
fn missing_some_arguments_variadic() {
match parse_success("a b c +d:").run(&["a", "B", "C"], &Default::default()).unwrap_err() {
RunError::ArgumentCountMismatch{recipe, found, min, max} => {
assert_eq!(recipe, "a");
assert_eq!(found, 2);
assert_eq!(min, 3);
assert_eq!(max, super::std::usize::MAX - 1);
},
other => panic!("expected an code run error, but got: {}", other),
}
}
#[test] #[test]
fn missing_all_arguments() { fn missing_all_arguments() {
match parse_success("a b c d:\n echo {{b}}{{c}}{{d}}") match parse_success("a b c d:\n echo {{b}}{{c}}{{d}}")