unisonweb / unison

A friendly programming language from the future
https://unison-lang.org
Other
5.81k stars 271 forks source link

Improve rendering of functions in pipelines #5451

Open SystemFw opened 1 week ago

SystemFw commented 1 week ago

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:

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)))