I'm always a bit hesitant to open issues about pretty printing as it feels like we have bigger fish to fry, but on the other hand the syntax is front and centre for our users so here it goes. To be absolutely clear, this issue is about embracing and improving the current syntax, not about a new syntax for Unison.
The problem
I'd argue that pipelines of multi-line lambdas are extremely common in Unison code: it's a functional language with plenty of combinators. Such pipelines are harder to write in a whitespace sensitive language as you have to keep indenting manually rather than write in brackets and hit "format", but the payoff is that they ought to read nice and uncluttered. Unfortunately, with the current pretty printer you get the worst of both worlds.
Let's look at an example, rendering lambdas on a single line is nice enough:
foo : [Nat]
foo =
use Nat + >
[1, 2, 3] |> List.map (x -> x + 1) |> List.filter (x -> x > 3)
but let's say we need more code in our lambdas:
foo =
[1, 2, 3]
|> List.map (x ->
a = x + 1
a
)
|> List.filter (x ->
a = x + 1
a > 3
)
this doesn't compile, with a not so great error:
I got confused here:
4 | a = x + 1
I was surprised to find a = here.
I was expecting one of these instead:
* ,
* :
* and
* bang
* do
* false
* force
* handle
* if
* infixApp
* let
* newline or semicolon
* or
* quote
* termLink
* true
* tuple
* typeLink
but the trick is that you have to add a let:
foo =
[1, 2, 3]
|> List.map (x -> let
a = x + 1
a
)
|> List.filter (x -> let
a = x + 1
a > 3
)
this imho is already not great, cause it's using two things to delimit a block: () and let, and neither of them are just indentation. What's more, the pretty-printer makes it even uglier:
foo : [Nat]
foo =
use Nat + >
[1, 2, 3] |> List.map
(x -> let
a = x + 1
a) |> List.filter
(x -> let
a = x + 1
a > 3)
now, funny thing is that the parser already accepts a much nicer way of writing this code, which is fully consistent with the indentation rules of Unison:
foo =
[1, 2, 3]
|> List.map cases x ->
a = x + 1
a
|> List.filter cases x ->
a = x + 1
a > 3
but once again, the pretty-printer makes it uglier:
foo : [Nat]
foo =
use Nat + >
[1, 2, 3] |> (List.map cases
x ->
a = x + 1
a) |> (List.filter cases
x ->
a = x + 1
a > 3)
to be clear, this issue isn't limited to the pretty-printer wanting to put |> nextFunction on one line, this parses:
foo =
[1, 2, 3]
|> List.map cases x ->
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|> List.filter cases x ->
a = size x
a > 3
but still gets rendered with ugly parantheses across multiple lines:
foo : [Text]
foo =
use Nat >
[1, 2, 3]
|> (List.map cases
x -> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|> (List.filter cases
x ->
a = Text.size x
a > 3)
and it actually looks much uglier in real code which might have multiple layers of nesting: the syntax is supposed to look haskell-y, but it ends up looking lispy.
Btw, same applies when cases is used for pattern matching. This parses
foo =
[Some 3, None, Some 4]
|> List.map cases
Some a -> a
None -> 0
|> List.filter (x -> x > 0)
and then gets rendered as:
foo : [Nat]
foo =
use Nat >
[Some 3, None, Some 4] |> (List.map cases
Some a -> a
None -> 0) |> List.filter (x -> x > 0)
Proposal
I think we should tweak the syntax so that this style of code is left alone:
foo =
[1, 2, 3]
|> List.map cases x ->
a = x + 1
a
|> List.filter cases x ->
a = x + 1
a > 3
Ideally, this would simply be a pretty-printer change, something along the lines of:
don't try to put |> foo on the same line if the body of the preceding expression is a multi-line block
don't add parentheses around function calls unless they are rendered on a single-line
don't put args to cases on a newline if there's only one case
but there is a snag. Say we're nesting function calls, again following the familiar indentation rules, this example parses just fine:
foo =
[1, 2, 3]
|> List.flatMap cases x ->
[x, x]
|> List.map cases y ->
a = y + 1
a
|> List.filter cases x ->
a = x + 1
a > 3
but there seems to be cases where the pattern matching coverage gets confused:
type Id =
Cloud.run2: (Id ->{Cloud, Exception} a) ->{IO, Exception} ()
Cloud.run2 = todo ""
Cloud.submit2: Environment -> (Id ->{Remote} a) ->{Cloud} a
Cloud.submit2 = todo ""
foo = do
Cloud.run2 cases id ->
Cloud.submit2 default() cases id2 ->
sleep (seconds 3)
sleep (seconds 2)
parses but fails to compile with:
This case would be ignored because it's already covered by the preceding case(s):
11 | Cloud.submit2 default() cases id2 ->
In my opinion the simplest solution would be to add a different syntactical token, with the same parsing rules of cases (which already works parsing wise), to delimit the start of a function. The obvious choice is \ from Haskell or Roc, which is also shorter to type than cases. but I don't particularly care. We could also look into fixing the coverage issue with cases.
With \, you would have:
foo =
[1, 2, 3]
|> List.flatMap \x ->
[x, x]
|> List.map \y ->
a = y + 1
a
|> List.filter \x ->
a = x + 1
a > 3
Dealing with common counter-arguments
I can see two main counter-arguments:
1) You should use named functions if your lambda is more than one line
2) Parentheses help you parse
Point 1) seems very weird to sustain in an FP language, it's the type of thing you hear in languages that are skeptical of FP. I find this to be very clumsy as the only way to get pretty rendered code:
foo : [Nat]
foo =
use Nat +
mapF x =
a = x + 1
a
filterF x =
use Nat >
a = x + 1
a > 3
[1, 2, 3] |> List.map mapF |> List.filter filterF
Point 2) also seems antithetical to the idea of an indentation sensitive language, and we have a strong counterexample in one of the main syntactic ideas of Unison: do blocks.
When composing handlers that take thunks, I get nice syntax:
foo : '{IO, Exception} ()
foo = do
Cloud.run do
Cloud.submit Environment.default() do
use Remote sleep
use Remote.Duration seconds
sleep (seconds 3)
sleep (seconds 2)
but let's say we want to add arguments to our handlers:
Cloud.run2: (Id ->{Cloud, Exception} a) ->{IO, Exception} ()
Cloud.submit2: Environment -> (Id ->{Remote} a) ->{Cloud} a
all of a sudden we're reading Lisp:
foo : '{IO, Exception} ()
foo = do
run2
(id ->
submit2
Environment.default() (id2 -> let
use Remote sleep
use Remote.Duration seconds
sleep (seconds 3)
sleep (seconds 2)))
I'm always a bit hesitant to open issues about pretty printing as it feels like we have bigger fish to fry, but on the other hand the syntax is front and centre for our users so here it goes. To be absolutely clear, this issue is about embracing and improving the current syntax, not about a new syntax for Unison.
The problem
I'd argue that pipelines of multi-line lambdas are extremely common in Unison code: it's a functional language with plenty of combinators. Such pipelines are harder to write in a whitespace sensitive language as you have to keep indenting manually rather than write in brackets and hit "format", but the payoff is that they ought to read nice and uncluttered. Unfortunately, with the current pretty printer you get the worst of both worlds.
Let's look at an example, rendering lambdas on a single line is nice enough:
but let's say we need more code in our lambdas:
this doesn't compile, with a not so great error:
but the trick is that you have to add a
let
:this imho is already not great, cause it's using two things to delimit a block:
()
andlet
, and neither of them are just indentation. What's more, the pretty-printer makes it even uglier:now, funny thing is that the parser already accepts a much nicer way of writing this code, which is fully consistent with the indentation rules of Unison:
but once again, the pretty-printer makes it uglier:
to be clear, this issue isn't limited to the pretty-printer wanting to put
|> nextFunction
on one line, this parses:but still gets rendered with ugly parantheses across multiple lines:
and it actually looks much uglier in real code which might have multiple layers of nesting: the syntax is supposed to look haskell-y, but it ends up looking lispy. Btw, same applies when
cases
is used for pattern matching. This parsesand then gets rendered as:
Proposal
I think we should tweak the syntax so that this style of code is left alone:
Ideally, this would simply be a pretty-printer change, something along the lines of:
|> foo
on the same line if the body of the preceding expression is a multi-line blockcases
on a newline if there's only one casebut there is a snag. Say we're nesting function calls, again following the familiar indentation rules, this example parses just fine:
but there seems to be cases where the pattern matching coverage gets confused:
parses but fails to compile with:
In my opinion the simplest solution would be to add a different syntactical token, with the same parsing rules of
cases
(which already works parsing wise), to delimit the start of a function. The obvious choice is\
from Haskell or Roc, which is also shorter to type thancases
. but I don't particularly care. We could also look into fixing the coverage issue withcases
. With\
, you would have:Dealing with common counter-arguments
I can see two main counter-arguments: 1) You should use named functions if your lambda is more than one line 2) Parentheses help you parse
Point 1) seems very weird to sustain in an FP language, it's the type of thing you hear in languages that are skeptical of FP. I find this to be very clumsy as the only way to get pretty rendered code:
Point 2) also seems antithetical to the idea of an indentation sensitive language, and we have a strong counterexample in one of the main syntactic ideas of Unison:
do
blocks. When composing handlers that take thunks, I get nice syntax:but let's say we want to add arguments to our handlers:
all of a sudden we're reading Lisp: