Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion GRAMMAR.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)*

Expand Down Expand Up @@ -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 `|>`.
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion src/highlighter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ impl<'src> Highlighter<'src> {
}

fn scan_operator(&self, start: usize) -> Option<usize> {
for operator in [">=", "<=", "==", "!=", "&&", "||"] {
for operator in [">=", "<=", "==", "!=", "&&", "||", "|>"] {
if self.content[start..].starts_with(operator) {
return Some(start + operator.len());
}
Expand Down Expand Up @@ -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]
Expand Down
102 changes: 100 additions & 2 deletions src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ where
(Expression::UnaryOp(op, Box::new(rhs)), error.span())
};

atom.pratt((
let operand = atom.pratt((
postfix(
8,
arguments,
Expand Down Expand Up @@ -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())
},
)
})
}

Expand Down Expand Up @@ -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()
Expand Down
167 changes: 167 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()?
Expand Down
Loading