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
and a recursive descent parser. The grammar is mostly LL(1),
although an extra token of lookahead is used to distinguish between
export assignments and recipes with arguments.
export assignments and recipes with parameters.
tokens
------
@ -51,9 +51,9 @@ expression : STRING
| BACKTICK
| expression '+' expression
recipe : '@'? NAME argument* ':' dependencies? body?
recipe : '@'? NAME parameter* ('+' parameter)? ':' dependencies? body?
argument : NAME
parameter : NAME
| NAME '=' STRING
| NAME '=' RAW_STRING

View File

@ -264,6 +264,22 @@ Testing server:unit...
./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:
```make

View File

@ -1038,16 +1038,16 @@ Recipe `recipe` failed on line 3 with exit code 100\u{1b}[0m\n",
#[test]
fn dump() {
let text ="
let text = r#"
# this recipe does something
recipe:
@exit 100";
recipe a b +d:
@exit 100"#;
integration_test(
&["--dump"],
text,
0,
"# this recipe does something
recipe:
recipe a b +d:
@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 regex::Regex;
use std::borrow::Cow;
use std::collections::{BTreeMap as Map, BTreeSet as Set};
use std::fmt::Display;
use std::io::prelude::*;
@ -68,28 +69,33 @@ fn contains<T: PartialOrd>(range: &Range<T>, i: T) -> bool {
#[derive(PartialEq, Debug)]
struct Recipe<'a> {
line_number: usize,
name: &'a str,
doc: Option<&'a str>,
lines: Vec<Vec<Fragment<'a>>>,
dependencies: Vec<&'a str>,
dependency_tokens: Vec<Token<'a>>,
doc: Option<&'a str>,
line_number: usize,
lines: Vec<Vec<Fragment<'a>>>,
name: &'a str,
parameters: Vec<Parameter<'a>>,
shebang: bool,
quiet: bool,
shebang: bool,
}
#[derive(PartialEq, Debug)]
struct Parameter<'a> {
name: &'a str,
default: Option<String>,
name: &'a str,
token: Token<'a>,
variadic: bool,
}
impl<'a> Display for Parameter<'a> {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
let green = maybe_green(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))?;
if let Some(ref default) = self.default {
let escaped = default.chars().flat_map(char::escape_default).collect::<String>();;
@ -275,8 +281,12 @@ impl<'a> Recipe<'a> {
fn argument_range(&self) -> Range<usize> {
self.parameters.iter().filter(|p| !p.default.is_some()).count()
..
if self.parameters.iter().any(|p| p.variadic) {
std::usize::MAX
} else {
self.parameters.len() + 1
}
}
fn run(
&self,
@ -290,16 +300,30 @@ impl<'a> Recipe<'a> {
warn!("{}===> Running recipe `{}`...{}", cyan.prefix(), self.name, cyan.suffix());
}
let argument_map = self.parameters.iter().enumerate()
.map(|(i, parameter)| if i < arguments.len() {
Ok((parameter.name, arguments[i]))
} else if let Some(ref default) = parameter.default {
Ok((parameter.name, default.as_str()))
} else {
Err(RunError::InternalError{
let mut argument_map = Map::new();
let mut rest = arguments;
for parameter in &self.parameters {
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()
})
}).collect::<Result<Vec<_>, _>>()?.into_iter().collect();
}),
}
} else {
if parameter.variadic {
let value = Cow::Owned(rest.to_vec().join(" "));
rest = &[];
value
} else {
let value = Cow::Borrowed(rest[0]);
rest = &rest[1..];
value
}
};
argument_map.insert(parameter.name, value);
}
let mut evaluator = Evaluator {
evaluated: empty(),
@ -689,7 +713,7 @@ impl<'a, 'b> Evaluator<'a, 'b> {
fn evaluate_line(
&mut self,
line: &[Fragment<'a>],
arguments: &Map<&str, &str>
arguments: &Map<&str, Cow<str>>
) -> Result<String, RunError<'a>> {
let mut evaluated = String::new();
for fragment in line {
@ -727,7 +751,7 @@ impl<'a, 'b> Evaluator<'a, 'b> {
fn evaluate_expression(
&mut self,
expression: &Expression<'a>,
arguments: &Map<&str, &str>
arguments: &Map<&str, Cow<str>>
) -> Result<String, RunError<'a>> {
Ok(match *expression {
Expression::Variable{name, ..} => {
@ -786,6 +810,7 @@ enum ErrorKind<'a> {
OuterShebang,
ParameterShadowsVariable{parameter: &'a str},
RequiredParameterFollowsDefaultParameter{parameter: &'a str},
ParameterFollowsVariadicParameter{parameter: &'a str},
UndefinedVariable{variable: &'a str},
UnexpectedToken{expected: Vec<TokenKind>, found: TokenKind},
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 {
if colors {
ansi_term::Style::new().bold()
@ -1066,6 +1099,9 @@ impl<'a> Display for CompileError<'a> {
RequiredParameterFollowsDefaultParameter{parameter} => {
writeln!(f, "non-default parameter `{}` follows default parameter", parameter)?;
}
ParameterFollowsVariadicParameter{parameter} => {
writeln!(f, "parameter `{}` follows a varidic parameter", parameter)?;
}
MixedLeadingWhitespace{whitespace} => {
writeln!(f,
"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_variadic_parameter = false;
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) {
return Err(parameter.error(ErrorKind::DuplicateParameter {
recipe: name.lexeme, parameter: parameter.lexeme
@ -1873,11 +1929,13 @@ impl<'a> Parser<'a> {
}
parsed_parameter_with_default |= default.is_some();
parsed_variadic_parameter = variadic;
parameters.push(Parameter {
name: parameter.lexeme,
default: default,
name: parameter.lexeme,
token: parameter,
variadic: variadic,
});
}
@ -1885,9 +1943,9 @@ impl<'a> Parser<'a> {
// if we haven't accepted any parameters, an equals
// would have been fine as part of an assignment
if parameters.is_empty() {
return Err(self.unexpected_token(&token, &[Name, Colon, Equals]));
return Err(self.unexpected_token(&token, &[Name, Plus, Colon, Equals]));
} 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':"#);
}
#[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]
fn parse_raw_string_default() {
parse_summary(r#"
@ -400,7 +420,7 @@ fn missing_colon() {
line: 0,
column: 5,
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]
fn required_after_default() {
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]
fn tokenize_order() {
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]
fn missing_all_arguments() {
match parse_success("a b c d:\n echo {{b}}{{c}}{{d}}")