diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b50033f..6e64a2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added - Align doc-comments in `--list` output (#273) - Add `arch()`, `os()`, and `os_family()` functions (#277) +- Add `env_var(key)` and `env_var_or_default(key, default)` functions (#280) ## [0.3.4] - 2017-10-06 ### Added diff --git a/README.asc b/README.asc index 2c27e372..494f1bd6 100644 --- a/README.asc +++ b/README.asc @@ -259,7 +259,7 @@ Just provides a few built-in functions that might be useful when writing recipes - `os()` – Operating system. Possible values are: `"android"`, `"bitrig"`, `"dragonfly"`, `"emscripten"`, `"freebsd"`, `"haiku"`, `"ios"`, `"linux"`, `"macos"`, `"netbsd"`, `"openbsd"`, `"solaris"`, and `"windows"`. -- `os_family()` - Operating system family; possible values are: `"unix"` and `"windows"`. +- `os_family()` – Operating system family; possible values are: `"unix"` and `"windows"`. For example: @@ -273,6 +273,12 @@ $ just system-info This is an x86_64 machine ``` +==== Environment Variables + +- `env_var(key)` – Retrieves the environment variable with name `key`, aborting if it is not present. + +- `env_var_or_default(key, default)` – Retrieves the environment variable with name `key`, returning `default` if it is not present. + === Command Evaluation Using Backticks Backticks can be used to store the result of commands: diff --git a/src/assignment_evaluator.rs b/src/assignment_evaluator.rs index 963f8a11..33a55224 100644 --- a/src/assignment_evaluator.rs +++ b/src/assignment_evaluator.rs @@ -99,7 +99,12 @@ impl<'a, 'b> AssignmentEvaluator<'a, 'b> { }) } } - Expression::Call{name, ..} => ::functions::evaluate_function(name), + Expression::Call{name, arguments: ref call_arguments, ref token} => { + let call_arguments = call_arguments.iter().map(|argument| { + self.evaluate_expression(argument, &arguments) + }).collect::, RuntimeError>>()?; + ::functions::evaluate_function(&token, name, &call_arguments) + } Expression::String{ref cooked_string} => Ok(cooked_string.cooked.clone()), Expression::Backtick{raw, ref token} => { if self.dry_run { diff --git a/src/assignment_resolver.rs b/src/assignment_resolver.rs index 44935c3d..637754b4 100644 --- a/src/assignment_resolver.rs +++ b/src/assignment_resolver.rs @@ -75,7 +75,9 @@ impl<'a: 'b, 'b> AssignmentResolver<'a, 'b> { return Err(token.error(UndefinedVariable{variable: name})); } } - Expression::Call{ref token, ..} => ::functions::resolve_function(token)?, + Expression::Call{ref token, ref arguments, ..} => { + ::functions::resolve_function(token, arguments.len())? + } Expression::Concatination{ref lhs, ref rhs} => { self.resolve_expression(lhs)?; self.resolve_expression(rhs)?; @@ -89,17 +91,6 @@ impl<'a: 'b, 'b> AssignmentResolver<'a, 'b> { #[cfg(test)] mod test { use super::*; - use TokenKind::*; - - compilation_error_test! { - name: unclosed_interpolation_delimiter, - input: "a:\n echo {{ foo", - index: 15, - line: 1, - column: 12, - width: Some(0), - kind: UnexpectedToken{expected: vec![Plus, Eol, InterpolationEnd], found: Dedent}, - } compilation_error_test! { name: circular_variable_dependency, diff --git a/src/compilation_error.rs b/src/compilation_error.rs index b874ab06..4d629a22 100644 --- a/src/compilation_error.rs +++ b/src/compilation_error.rs @@ -1,6 +1,6 @@ use common::*; -use misc::{Or, write_error_context, show_whitespace}; +use misc::{Or, write_error_context, show_whitespace, maybe_s}; pub type CompilationResult<'a, T> = Result>; @@ -24,6 +24,7 @@ pub enum CompilationErrorKind<'a> { DuplicateRecipe{recipe: &'a str, first: usize}, DuplicateVariable{variable: &'a str}, ExtraLeadingWhitespace, + FunctionArgumentCountMismatch{function: &'a str, found: usize, expected: usize}, InconsistentLeadingWhitespace{expected: &'a str, found: &'a str}, Internal{message: String}, InvalidEscapeSequence{character: char}, @@ -109,6 +110,13 @@ impl<'a> Display for CompilationError<'a> { ExtraLeadingWhitespace => { writeln!(f, "Recipe line has extra leading whitespace")?; } + FunctionArgumentCountMismatch{function, found, expected} => { + writeln!( + f, + "Function `{}` called with {} argument{} but takes {}", + function, found, maybe_s(found), expected + )?; + } InconsistentLeadingWhitespace{expected, found} => { writeln!(f, "Recipe line has inconsistent leading whitespace. \ diff --git a/src/expression.rs b/src/expression.rs index 3af6bddb..5c370ebc 100644 --- a/src/expression.rs +++ b/src/expression.rs @@ -3,7 +3,7 @@ use common::*; #[derive(PartialEq, Debug)] pub enum Expression<'a> { Backtick{raw: &'a str, token: Token<'a>}, - Call{name: &'a str, token: Token<'a>}, + Call{name: &'a str, token: Token<'a>, arguments: Vec>}, Concatination{lhs: Box>, rhs: Box>}, String{cooked_string: CookedString<'a>}, Variable{name: &'a str, token: Token<'a>}, @@ -26,11 +26,21 @@ impl<'a> Expression<'a> { impl<'a> Display for Expression<'a> { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { match *self { - Expression::Backtick {raw, .. } => write!(f, "`{}`", raw)?, - Expression::Call {name, .. } => write!(f, "{}()", name)?, - Expression::Concatination{ref lhs, ref rhs } => write!(f, "{} + {}", lhs, rhs)?, - Expression::String {ref cooked_string} => write!(f, "\"{}\"", cooked_string.raw)?, - Expression::Variable {name, .. } => write!(f, "{}", name)?, + Expression::Backtick {raw, .. } => write!(f, "`{}`", raw)?, + Expression::Concatination{ref lhs, ref rhs } => write!(f, "{} + {}", lhs, rhs)?, + Expression::String {ref cooked_string } => write!(f, "\"{}\"", cooked_string.raw)?, + Expression::Variable {name, .. } => write!(f, "{}", name)?, + Expression::Call {name, ref arguments, ..} => { + write!(f, "{}(", name)?; + for (i, argument) in arguments.iter().enumerate() { + if i > 0 { + write!(f, ", {}", argument)?; + } else { + write!(f, "{}", argument)?; + } + } + write!(f, ")")?; + } } Ok(()) } @@ -64,15 +74,15 @@ pub struct Functions<'a> { } impl<'a> Iterator for Functions<'a> { - type Item = &'a Token<'a>; + type Item = (&'a Token<'a>, usize); - fn next(&mut self) -> Option<&'a Token<'a>> { + fn next(&mut self) -> Option { match self.stack.pop() { None | Some(&Expression::String{..}) | Some(&Expression::Backtick{..}) | Some(&Expression::Variable{..}) => None, - Some(&Expression::Call{ref token, ..}) => Some(token), + Some(&Expression::Call{ref token, ref arguments, ..}) => Some((token, arguments.len())), Some(&Expression::Concatination{ref lhs, ref rhs}) => { self.stack.push(lhs); self.stack.push(rhs); diff --git a/src/functions.rs b/src/functions.rs index fcbb2cc5..5b13e570 100644 --- a/src/functions.rs +++ b/src/functions.rs @@ -1,33 +1,104 @@ use common::*; use target; -pub fn resolve_function<'a>(token: &Token<'a>) -> CompilationResult<'a, ()> { - if !&["arch", "os", "os_family"].contains(&token.lexeme) { - Err(token.error(CompilationErrorKind::UnknownFunction{function: token.lexeme})) - } else { - Ok(()) +lazy_static! { + static ref FUNCTIONS: Map<&'static str, Function> = vec![ + ("arch", Function::Nullary(arch )), + ("os", Function::Nullary(os )), + ("os_family", Function::Nullary(os_family )), + ("env_var", Function::Unary (env_var )), + ("env_var_or_default", Function::Binary (env_var_or_default)), + ].into_iter().collect(); +} + +enum Function { + Nullary(fn( ) -> Result), + Unary (fn(&str ) -> Result), + Binary (fn(&str, &str) -> Result), +} + +impl Function { + fn argc(&self) -> usize { + use self::Function::*; + match *self { + Nullary(_) => 0, + Unary(_) => 1, + Binary(_) => 2, + } } } -pub fn evaluate_function<'a>(name: &'a str) -> RunResult<'a, String> { - match name { - "arch" => Ok(arch().to_string()), - "os" => Ok(os().to_string()), - "os_family" => Ok(os_family().to_string()), - _ => Err(RuntimeError::Internal { +pub fn resolve_function<'a>(token: &Token<'a>, argc: usize) -> CompilationResult<'a, ()> { + let name = token.lexeme; + if let Some(function) = FUNCTIONS.get(&name) { + use self::Function::*; + match (function, argc) { + (&Nullary(_), 0) => Ok(()), + (&Unary(_), 1) => Ok(()), + (&Binary(_), 2) => Ok(()), + _ => { + Err(token.error(CompilationErrorKind::FunctionArgumentCountMismatch{ + function: name, found: argc, expected: function.argc(), + })) + } + } + } else { + Err(token.error(CompilationErrorKind::UnknownFunction{function: token.lexeme})) + } +} + +pub fn evaluate_function<'a>(token: &Token<'a>, name: &'a str, arguments: &[String]) -> RunResult<'a, String> { + if let Some(function) = FUNCTIONS.get(name) { + use self::Function::*; + let argc = arguments.len(); + match (function, argc) { + (&Nullary(f), 0) => f() + .map_err(|message| RuntimeError::FunctionCall{token: token.clone(), message}), + (&Unary(f), 1) => f(&arguments[0]) + .map_err(|message| RuntimeError::FunctionCall{token: token.clone(), message}), + (&Binary(f), 2) => f(&arguments[0], &arguments[1]) + .map_err(|message| RuntimeError::FunctionCall{token: token.clone(), message}), + _ => { + Err(RuntimeError::Internal { + message: format!("attempted to evaluate function `{}` with {} arguments", name, argc) + }) + } + } + } else { + Err(RuntimeError::Internal { message: format!("attempted to evaluate unknown function: `{}`", name) }) } } -pub fn arch() -> &'static str { - target::arch() +pub fn arch() -> Result { + Ok(target::arch().to_string()) } -pub fn os() -> &'static str { - target::os() +pub fn os() -> Result { + Ok(target::os().to_string()) } -pub fn os_family() -> &'static str { - target::os_family() +pub fn os_family() -> Result { + Ok(target::os_family().to_string()) +} + +pub fn env_var<'a>(key: &str) -> Result { + use std::env::VarError::*; + match env::var(key) { + Err(NotPresent) => Err(format!("environment variable `{}` not present", key)), + Err(NotUnicode(os_string)) => + Err(format!("environment variable `{}` not unicode: {:?}", key, os_string)), + Ok(value) => Ok(value), + } +} + +pub fn env_var_or_default<'a>(key: &str, default: &str) -> Result { + use std::env::VarError::*; + match env::var(key) { + Err(NotPresent) => Ok(default.to_string()), + Err(NotUnicode(os_string)) => + Err(format!("environment variable `{}` not unicode: {:?}", key, os_string)), + Ok(value) => Ok(value), + } } diff --git a/src/lexer.rs b/src/lexer.rs index 9e726f4c..6841bb7a 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -134,6 +134,7 @@ impl<'a> Lexer<'a> { static ref PAREN_L: Regex = token(r"[(]" ); static ref PAREN_R: Regex = token(r"[)]" ); static ref AT: Regex = token(r"@" ); + static ref COMMA: Regex = token(r"," ); static ref COMMENT: Regex = token(r"#([^!\n\r].*)?$" ); static ref EOF: Regex = token(r"(?-m)$" ); static ref EOL: Regex = token(r"\n|\r\n" ); @@ -209,6 +210,8 @@ impl<'a> Lexer<'a> { (captures.get(1).unwrap().as_str(), captures.get(2).unwrap().as_str(), Colon) } else if let Some(captures) = AT.captures(self.rest) { (captures.get(1).unwrap().as_str(), captures.get(2).unwrap().as_str(), At) + } else if let Some(captures) = COMMA.captures(self.rest) { + (captures.get(1).unwrap().as_str(), captures.get(2).unwrap().as_str(), Comma) } else if let Some(captures) = PAREN_L.captures(self.rest) { (captures.get(1).unwrap().as_str(), captures.get(2).unwrap().as_str(), ParenL) } else if let Some(captures) = PAREN_R.captures(self.rest) { @@ -332,6 +335,7 @@ mod test { At => "@", Backtick => "`", Colon => ":", + Comma => ",", Comment{..} => "#", Dedent => "<", Eof => ".", @@ -420,8 +424,8 @@ mod test { summary_test! { tokenize_recipe_multiple_interpolations, - "foo:#ok\n {{a}}0{{b}}1{{c}}", - "N:#$>^{N}_{N}_{N}<.", + "foo:,#ok\n {{a}}0{{b}}1{{c}}", + "N:,#$>^{N}_{N}_{N}<.", } summary_test! { diff --git a/src/parser.rs b/src/parser.rs index 1e94aac5..560ac520 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -220,10 +220,11 @@ impl<'a> Parser<'a> { return Err(self.unexpected_token(&token, &[Text, InterpolationStart, Eol])); } else { fragments.push(Fragment::Expression{ - expression: self.expression(true)? + expression: self.expression()? }); + if let Some(token) = self.expect(InterpolationEnd) { - return Err(self.unexpected_token(&token, &[InterpolationEnd])); + return Err(self.unexpected_token(&token, &[Plus, InterpolationEnd])); } } } @@ -248,7 +249,7 @@ impl<'a> Parser<'a> { Ok(()) } - fn expression(&mut self, interpolation: bool) -> CompilationResult<'a, Expression<'a>> { + fn expression(&mut self) -> CompilationResult<'a, Expression<'a>> { let first = self.tokens.next().unwrap(); let lhs = match first.kind { Name => { @@ -256,10 +257,11 @@ impl<'a> Parser<'a> { if let Some(token) = self.expect(ParenL) { return Err(self.unexpected_token(&token, &[ParenL])); } + let arguments = self.arguments()?; if let Some(token) = self.expect(ParenR) { - return Err(self.unexpected_token(&token, &[ParenR])); + return Err(self.unexpected_token(&token, &[Name, StringToken, ParenR])); } - Expression::Call {name: first.lexeme, token: first} + Expression::Call {name: first.lexeme, token: first, arguments} } else { Expression::Variable {name: first.lexeme, token: first} } @@ -275,21 +277,31 @@ impl<'a> Parser<'a> { }; if self.accepted(Plus) { - let rhs = self.expression(interpolation)?; + let rhs = self.expression()?; Ok(Expression::Concatination{lhs: Box::new(lhs), rhs: Box::new(rhs)}) - } else if interpolation && self.peek(InterpolationEnd) { - Ok(lhs) - } else if let Some(token) = self.expect_eol() { - if interpolation { - return Err(self.unexpected_token(&token, &[Plus, Eol, InterpolationEnd])) - } else { - Err(self.unexpected_token(&token, &[Plus, Eol])) - } } else { Ok(lhs) } } + fn arguments(&mut self) -> CompilationResult<'a, Vec>> { + let mut arguments = Vec::new(); + + while !self.peek(ParenR) && !self.peek(Eof) && !self.peek(Eol) && !self.peek(InterpolationEnd) { + arguments.push(self.expression()?); + if !self.accepted(Comma) { + if self.peek(ParenR) { + break; + } else { + let next = self.tokens.next().unwrap(); + return Err(self.unexpected_token(&next, &[Comma, ParenR])); + } + } + } + + Ok(arguments) + } + fn assignment(&mut self, name: Token<'a>, export: bool) -> CompilationResult<'a, ()> { if self.assignments.contains_key(name.lexeme) { return Err(name.error(DuplicateVariable {variable: name.lexeme})); @@ -297,7 +309,12 @@ impl<'a> Parser<'a> { if export { self.exports.insert(name.lexeme); } - let expression = self.expression(false)?; + + let expression = self.expression()?; + if let Some(token) = self.expect_eol() { + return Err(self.unexpected_token(&token, &[Plus, Eol])); + } + self.assignments.insert(name.lexeme, expression); self.assignment_tokens.insert(name.lexeme, name); Ok(()) @@ -606,6 +623,32 @@ c = a + b + a + b", {{b}} {{c}}", } + summary_test! { + unary_functions, + " +x = arch() + +a: + {{os()}} {{os_family()}}", + "x = arch() + +a: + {{os()}} {{os_family()}}", + } + + summary_test! { + env_functions, + r#" +x = env_var('foo',) + +a: + {{env_var_or_default('foo' + 'bar', 'baz',)}} {{env_var(env_var("baz"))}}"#, + r#"x = env_var("foo") + +a: + {{env_var_or_default("foo" + "bar", "baz")}} {{env_var(env_var("baz"))}}"#, + } + compilation_error_test! { name: missing_colon, input: "a b c\nd e f", @@ -773,7 +816,7 @@ c = a + b + a + b", line: 1, column: 12, width: Some(0), - kind: UnexpectedToken{expected: vec![Plus, Eol, InterpolationEnd], found: Dedent}, + kind: UnexpectedToken{expected: vec![Plus, InterpolationEnd], found: Dedent}, } compilation_error_test! { @@ -783,7 +826,7 @@ c = a + b + a + b", line: 0, column: 8, width: Some(0), - kind: UnexpectedToken{expected: vec![ParenR], found: Eof}, + kind: UnexpectedToken{expected: vec![Name, StringToken, ParenR], found: Eof}, } compilation_error_test! { @@ -793,7 +836,7 @@ c = a + b + a + b", line: 1, column: 12, width: Some(2), - kind: UnexpectedToken{expected: vec![ParenR], found: InterpolationEnd}, + kind: UnexpectedToken{expected: vec![Name, StringToken, ParenR], found: InterpolationEnd}, } compilation_error_test! { diff --git a/src/recipe_resolver.rs b/src/recipe_resolver.rs index 5c21db29..d2ce0e3d 100644 --- a/src/recipe_resolver.rs +++ b/src/recipe_resolver.rs @@ -41,8 +41,8 @@ impl<'a, 'b> RecipeResolver<'a, 'b> { for line in &recipe.lines { for fragment in line { if let Fragment::Expression{ref expression, ..} = *fragment { - for function in expression.functions() { - if let Err(error) = ::functions::resolve_function(function) { + for (function, argc) in expression.functions() { + if let Err(error) = ::functions::resolve_function(function, argc) { return Err(CompilationError { text: text, index: error.index, diff --git a/src/runtime_error.rs b/src/runtime_error.rs index f1261915..db0c103e 100644 --- a/src/runtime_error.rs +++ b/src/runtime_error.rs @@ -25,14 +25,15 @@ pub enum RuntimeError<'a> { Backtick{token: Token<'a>, output_error: OutputError}, Code{recipe: &'a str, line_number: Option, code: i32}, Cygpath{recipe: &'a str, output_error: OutputError}, + FunctionCall{token: Token<'a>, message: String}, Internal{message: String}, IoError{recipe: &'a str, io_error: io::Error}, Shebang{recipe: &'a str, command: String, argument: Option, io_error: io::Error}, Signal{recipe: &'a str, line_number: Option, signal: i32}, TmpdirIoError{recipe: &'a str, io_error: io::Error}, - Unknown{recipe: &'a str, line_number: Option}, UnknownOverrides{overrides: Vec<&'a str>}, UnknownRecipes{recipes: Vec<&'a str>, suggestion: Option<&'a str>}, + Unknown{recipe: &'a str, line_number: Option}, } impl<'a> RuntimeError<'a> { @@ -117,6 +118,10 @@ impl<'a> Display for RuntimeError<'a> { but output was not utf8: {}", recipe, utf8_error)?; } }, + FunctionCall{ref token, ref message} => { + write!(f, "Call to function `{}` failed: {}\n", token.lexeme, message)?; + error_token = Some(token); + } Shebang{recipe, ref command, ref argument, ref io_error} => { if let Some(ref argument) = *argument { write!(f, "Recipe `{}` with shebang `#!{} {}` execution error: {}", @@ -161,11 +166,11 @@ impl<'a> Display for RuntimeError<'a> { error_token = Some(token); } OutputError::Signal(signal) => { - write!(f, "Backtick was terminated by signal {}", signal)?; + write!(f, "Backtick was terminated by signal {}\n", signal)?; error_token = Some(token); } OutputError::Unknown => { - write!(f, "Backtick failed for an unknown reason")?; + write!(f, "Backtick failed for an unknown reason\n")?; error_token = Some(token); } OutputError::Io(ref io_error) => { @@ -181,7 +186,7 @@ impl<'a> Display for RuntimeError<'a> { error_token = Some(token); } OutputError::Utf8(ref utf8_error) => { - write!(f, "Backtick succeeded but stdout was not utf8: {}", utf8_error)?; + write!(f, "Backtick succeeded but stdout was not utf8: {}\n", utf8_error)?; error_token = Some(token); } }, diff --git a/src/token.rs b/src/token.rs index 861006ee..9ca0c79f 100644 --- a/src/token.rs +++ b/src/token.rs @@ -29,6 +29,7 @@ pub enum TokenKind { At, Backtick, Colon, + Comma, Comment, Dedent, Eof, @@ -53,6 +54,7 @@ impl Display for TokenKind { write!(f, "{}", match *self { Backtick => "backtick", Colon => "':'", + Comma => "','", Comment => "comment", Dedent => "dedent", Eof => "end of file", diff --git a/tests/integration.rs b/tests/integration.rs index 0bbcb7a8..4ee2eee0 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1203,6 +1203,34 @@ foo: status: EXIT_SUCCESS, } +integration_test! { + name: env_var_functions, + justfile: r#" +p = env_var('PATH') +b = env_var_or_default('ZADDY', 'HTAP') +x = env_var_or_default('XYZ', 'ABC') + +foo: + /bin/echo '{{p}}' '{{b}}' '{{x}}' +"#, + args: (), + stdout: format!("{} HTAP ABC\n", env::var("PATH").unwrap()).as_str(), + stderr: format!("/bin/echo '{}' 'HTAP' 'ABC'\n", env::var("PATH").unwrap()).as_str(), + status: EXIT_SUCCESS, +} + +integration_test! { + name: env_var_failure, + justfile: "a:\n echo {{env_var('ZADDY')}}", + args: ("a"), + stdout: "", + stderr: "error: Call to function `env_var` failed: environment variable `ZADDY` not present + | +2 | echo {{env_var('ZADDY')}} + | ^^^^^^^ +", + status: EXIT_FAILURE, +} integration_test! { name: quiet_recipe,