From 1ac5b4ea42f65a7f8a1dd4aee0c866bde8a1f62e Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 18 Nov 2016 07:03:34 -0800 Subject: [PATCH] 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. --- GRAMMAR.md | 6 +-- README.md | 16 +++++++ src/integration.rs | 64 ++++++++++++++++++++++++-- src/lib.rs | 110 ++++++++++++++++++++++++++++++++++----------- src/unit.rs | 61 ++++++++++++++++++++++++- 5 files changed, 223 insertions(+), 34 deletions(-) diff --git a/GRAMMAR.md b/GRAMMAR.md index a5ea6e4..2970d7a 100644 --- a/GRAMMAR.md +++ b/GRAMMAR.md @@ -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 diff --git a/README.md b/README.md index 152052c..4c0636b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/integration.rs b/src/integration.rs index f6ef7f4..08c8c24 100644 --- a/src/integration.rs +++ b/src/integration.rs @@ -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", + ); +} diff --git a/src/lib.rs b/src/lib.rs index 3304355..2d46b25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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(range: &Range, i: T) -> bool { #[derive(PartialEq, Debug)] struct Recipe<'a> { - line_number: usize, - name: &'a str, - doc: Option<&'a str>, - lines: Vec>>, dependencies: Vec<&'a str>, dependency_tokens: Vec>, + doc: Option<&'a str>, + line_number: usize, + lines: Vec>>, + name: &'a str, parameters: Vec>, - shebang: bool, quiet: bool, + shebang: bool, } #[derive(PartialEq, Debug)] struct Parameter<'a> { - name: &'a str, - default: Option, - token: Token<'a>, + default: Option, + 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::();; @@ -275,7 +281,11 @@ impl<'a> Recipe<'a> { fn argument_range(&self) -> Range { 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( @@ -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())) + 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() + }), + } } else { - Err(RunError::InternalError{ - message: "missing parameter without default".to_string() - }) - }).collect::, _>>()?.into_iter().collect(); + 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> ) -> Result> { 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> ) -> Result> { 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, 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 = 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, - token: parameter, + 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])); } } diff --git a/src/unit.rs b/src/unit.rs index bf155fa..b65b6c5 100644 --- a/src/unit.rs +++ b/src/unit.rs @@ -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}}")