Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve rendering of functions in pipelines #5451

Open
SystemFw opened this issue Nov 12, 2024 · 0 comments
Open

Improve rendering of functions in pipelines #5451

SystemFw opened this issue Nov 12, 2024 · 0 comments

Comments

@SystemFw
Copy link
Contributor

SystemFw commented Nov 12, 2024

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)))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant