diff --git a/GRAMMAR.txt b/GRAMMAR.txt index 4795eea..40f2bc5 100644 --- a/GRAMMAR.txt +++ b/GRAMMAR.txt @@ -70,7 +70,9 @@ while : 'while' '(' expression ')' block parameters : IDENTIFIER (',' IDENTIFIER)* -expression : logical_or +expression : pipeline + +pipeline : logical_or ('|>' logical_or)* logical_or : logical_and ('||' logical_and)* @@ -123,3 +125,7 @@ The reserved words excluded from `IDENTIFIER` are `break`, `continue`, `else`, A `NUMBER` cannot be immediately followed by an identifier continuation character. + +Each expression following `|>` must be a function call. A pipeline inserts +its left operand as the first argument of that call and associates left to +right. All other operators bind more tightly than `|>`. diff --git a/README.md b/README.md index de29bb1..1fbf8f3 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,29 @@ if (index == null) { } ``` +### Pipelines + +Use `|>` to insert a value as the first argument of a function call: + +```rust +25 |> sqrt() |> println() +[1, 2] |> append(3) |> sum() |> println() +``` + +These are shorthand for `println(sqrt(25))` and +`println(sum(append([1, 2], 3)))`. Pipelines associate from left to right +and have lower precedence than every other operator, so +`1 + 2 |> sqrt()` means `sqrt(1 + 2)`. Parenthesize a pipeline to use its +result in another operation: `(25 |> sqrt()) + 1`. + +The expression after `|>` must be a call. Calls through lists and returned +functions work too: `1 |> functions[0](2)` means `functions[0](1, 2)`, +and `1 |> make()(2)` means `make()(1, 2)`. + +Pipelines follow normal call evaluation: the callee and argument count are +checked first, then arguments are evaluated once in order, starting with +the piped expression. + ### Built-ins **val** offers a many built-in functions and constants: diff --git a/src/highlighter.rs b/src/highlighter.rs index bd2d875..9da93ab 100644 --- a/src/highlighter.rs +++ b/src/highlighter.rs @@ -207,7 +207,7 @@ impl<'src> Highlighter<'src> { } fn scan_operator(&self, start: usize) -> Option { - for operator in [">=", "<=", "==", "!=", "&&", "||"] { + for operator in [">=", "<=", "==", "!=", "&&", "||", "|>"] { if self.content[start..].starts_with(operator) { return Some(start + operator.len()); } @@ -317,6 +317,20 @@ mod tests { ); } + #[test] + fn pipe_operator() { + assert_eq!( + Highlighter::new("1 |> foo()").collect_highlight_spans(), + [ + HighlightSpan::new(0, 1, HighlightKind::Number), + HighlightSpan::new(2, 4, HighlightKind::Operator), + HighlightSpan::new(5, 8, HighlightKind::Function), + HighlightSpan::new(8, 9, HighlightKind::Operator), + HighlightSpan::new(9, 10, HighlightKind::Operator), + ] + ); + } + #[test] fn scientific_notation() { #[track_caller] diff --git a/src/parser.rs b/src/parser.rs index b553e4a..7f16d95 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -335,7 +335,7 @@ where (Expression::UnaryOp(op, Box::new(rhs)), error.span()) }; - atom.pratt(( + let operand = atom.pratt(( postfix( 8, arguments, @@ -416,7 +416,26 @@ where padded_parser(just("||")).to(BinaryOp::LogicalOr), binary, ), - )) + )); + + let target = + operand + .clone() + .try_map(|(expression, span), _| match expression { + Expression::FunctionCall(function, arguments) => { + Ok((function, arguments)) + } + _ => Err(Rich::custom(span, "Pipe target must be a function call")), + }); + + operand.foldl_with( + padded_parser(just("|>")).ignore_then(target).repeated(), + |lhs, (function, mut arguments), error| { + arguments.insert(0, lhs); + + (Expression::FunctionCall(function, arguments), error.span()) + }, + ) }) } @@ -733,6 +752,85 @@ mod tests { .run(); } + #[test] + fn pipe_operator() { + #[track_caller] + fn case(program: &str, ast: &str) { + Test::new().program(program).ast(ast).run(); + } + + case( + "25 |> sqrt() |> println()", + "statements(expression(function_call(identifier(println), function_call(identifier(sqrt), number(25)))))", + ); + case( + "1 + 2 * 3 |> foo(bar())", + "statements(expression(function_call(identifier(foo), binary_op(+, number(1), binary_op(*, number(2), number(3))), function_call(identifier(bar)))))", + ); + case( + "false || true && 1 < 2 == true |> foo()", + "statements(expression(function_call(identifier(foo), binary_op(||, boolean(false), binary_op(&&, boolean(true), binary_op(==, binary_op(<, number(1), number(2)), boolean(true)))))))", + ); + case( + "1 |> foo(2)(3)", + "statements(expression(function_call(function_call(identifier(foo), number(2)), number(1), number(3))))", + ); + case( + "1 |> foo[0](2)", + "statements(expression(function_call(list_access(identifier(foo), number(0)), number(1), number(2))))", + ); + case( + "1 |> foo(2 |> bar())", + "statements(expression(function_call(identifier(foo), number(1), function_call(identifier(bar), number(2)))))", + ); + case( + "(1 |> foo()) + 2", + "statements(expression(binary_op(+, function_call(identifier(foo), number(1)), number(2))))", + ); + case( + "1\n// foo\n|> bar()\n|> baz()", + "statements(expression(function_call(identifier(baz), function_call(identifier(bar), number(1)))))", + ); + } + + #[test] + fn pipe_operator_requires_call() { + #[track_caller] + fn case(program: &str) { + let errors = parse(program).unwrap_err(); + + assert!( + errors.iter().any(|error| { + error.to_string() == "Pipe target must be a function call" + }), + "{program}: {errors:?}", + ); + } + + case("1 |> foo"); + case("1 |> 2"); + case("1 |> foo() + 2"); + case("1 |> foo()[0]"); + } + + #[test] + fn pipe_operator_spans() { + let program = parse("1 |> foo(2)").unwrap(); + let Program::Statements(statements) = program.0; + let Statement::Expression(( + Expression::FunctionCall(function, arguments), + span, + )) = &statements[0].0 + else { + panic!("expected function call"); + }; + + assert_eq!(*span, SimpleSpan::from(0..11)); + assert_eq!(function.1, SimpleSpan::from(5..8)); + assert_eq!(arguments[0].1, SimpleSpan::from(0..1)); + assert_eq!(arguments[1].1, SimpleSpan::from(9..10)); + } + #[test] fn power_right_associativity() { Test::new() diff --git a/tests/integration.rs b/tests/integration.rs index 3711bdd..edfa579 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -2780,6 +2780,173 @@ fn operator_precedence() -> Result { .run() } +#[test] +fn pipe_operator() -> Result { + Test::new()? + .program("25 |> sqrt() |> println()") + .expected_stdout(Exact("5\n")) + .run() +} + +#[test] +fn pipe_operator_arithmetic_precedence() -> Result { + Test::new()? + .program("1 + 2 * 4 |> sqrt() |> println()") + .expected_stdout(Exact("3\n")) + .run() +} + +#[test] +fn pipe_operator_builtin_function_and_constant() -> Result { + Test::new()? + .program("(1 |> e()) == e(1) |> println()") + .expected_stdout(Exact("true\n")) + .run() +} + +#[test] +fn pipe_operator_evaluation_order() -> Result { + Test::new()? + .program(indoc! { + " + fn foo() { + println('foo') + fn(bar, baz) { bar + baz } + } + + fn bar() { + println('bar') + 2 + } + + fn baz() { + println('baz') + 3 + } + + bar() |> foo()(baz()) |> println() + " + }) + .expected_stdout(Exact("foo\nbar\nbaz\n5\n")) + .run() +} + +#[test] +fn pipe_operator_function_returning_callee() -> Result { + Test::new()? + .program(indoc! { + " + foo = fn(bar) { + fn(baz, bob) { + bar + baz * bob + } + } + + 2 |> foo(3)(4) |> println() + " + }) + .expected_stdout(Exact("11\n")) + .run() +} + +#[test] +fn pipe_operator_invalid_calls() -> Result { + #[track_caller] + fn case(program: &str, expected: &str) -> Result { + Test::new()? + .program(program) + .expected_status(1) + .expected_stdout(Empty) + .expected_stderr(Contains(expected)) + .run() + } + + case("println('bar') |> foo()", "Function `foo` is not defined")?; + case( + indoc! { + " + foo = 1 + println('bar') |> foo() + " + }, + "`foo` is not a function", + )?; + case("println('bar') |> ['foo'][0]()", "'foo' is not a function")?; + case( + "println('bar') |> abs(println('baz'))", + "Function `abs` expects 1 argument, got 2", + )?; + case( + indoc! { + " + fn foo() {} + println('bar') |> foo() + " + }, + "Function `foo` expects 0 arguments, got 1", + ) +} + +#[test] +fn pipe_operator_list_callee() -> Result { + Test::new()? + .program(indoc! { + " + foo = [fn(bar, baz) { bar - baz }] + 5 |> foo[0](2) |> println() + " + }) + .expected_stdout(Exact("3\n")) + .run() +} + +#[test] +fn pipe_operator_logical_precedence() -> Result { + Test::new()? + .program("false || true && 1 < 2 == true |> println()") + .expected_stdout(Exact("true\n")) + .run() +} + +#[test] +fn pipe_operator_multiline() -> Result { + Test::new()? + .program(indoc! { + " + 25 + // foo + |> sqrt() + |> println() + " + }) + .expected_stdout(Exact("5\n")) + .run() +} + +#[test] +fn pipe_operator_nested_argument() -> Result { + Test::new()? + .program("[1, 2] |> append(9 |> sqrt()) |> println()") + .expected_stdout(Exact("[1, 2, 3]\n")) + .run() +} + +#[test] +fn pipe_operator_parenthesized() -> Result { + Test::new()? + .program("println((25 |> sqrt()) + 1)") + .expected_stdout(Exact("6\n")) + .run() +} + +#[test] +fn pipe_operator_with_arguments() -> Result { + Test::new()? + .program("[1, 2] |> append(3) |> sum() |> println()") + .expected_stdout(Exact("6\n")) + .run() +} + #[test] fn power() -> Result { Test::new()?