A simple macro to allow bare captures in pipes

Correct. Although, writing a simple attempt to implement it I found out that & will capture everything that follows it, even if parentheses are used, meaning that using Elixir’s current syntax rules

10 |> &({:ok, &1})) |> &([1,2,3, &1]

is interpreted as

10 |> (&{:ok, &1} |> (&[1, 2, 3, &1]).()).()

which will fail because of ‘nested captures’.

rather than the desired

10 |> (&{:ok, &1}).() |> (&[1, 2, 3, &1]).()

Here is an updated implementation that fixes this, by rewriting the construct a |> &b |> c to a |> (&b) |> c where a, b, and c are arbitrary ASTs .
At first I was a bit concerned that this more rigorous rewrite would alter the behaviour of existing programs, but this is not the case since this syntax would not compile without the extension. The extension is thus backwards compatible.

2 Likes

Maybe I’m doing something wrong, but it appears that this loses the ability to do something like the following:

name
|> &greet(greeting, &1)
|> &{:ok, &1}

Both of those work fine as long as they don’t exist in the same pipeline.

Using the ‘updated implementation’, the following compiles and runs as expected:

defmodule Example do
  import Capturepipe
  import Kernel, except: [|>: 2]

  def main(greeting, name) do
    name
    |> &greet(greeting, &1)
    |> &{:ok, &1}
  end

  def greet(greeting, name) do
    IO.puts("#{greeting}, #{name}!")
  end
end

Maybe you made a mistake with the import statements?

I should have stuck with my original full example test which is failing for me. Does this example test pass for you?..

Nope, it does not; the implementation I made does not seem to handle deeply nested pipes correctly. Essentially something like a |> & b |> c |> d which we would want to convert to a |> (& b).() |> c |> d is still converted to a |> (& b |> c |> d).() which poses problems when there are captures in e.g. c or d.

It is definitely possible to fix this, but I have not had the time to get around to it yet.

1 Like

Looks like Elixir 1.11 will have something like this: https://github.com/elixir-lang/elixir/issues/10154

6 Likes

For who did not keep up with the conversation in the issue+PRs, we realized that an implementation was somewhat non-trivial because of the precedence-difference between the pipe- and capture-operators.

Because of this reason, the issue and related PRs were closed for the time being, and I would attempt to create it as a library for now.

And now I finally found the time to go through with writing this library :grin:.


CapturePipe has now been released as a library.

It supports both bare function-captures and bare anonymous functions.
As far as I can tell, the code is fully backwards-compatible with any existing Elixir-code.

In the end, the implementation consists of a slight alteration of Elixir’s builtin Macro.unpipe/1 (which is altered to turn a structure of nested-pipes-in-captures(-in-pipes-in-captures…) inside-out and Macro.pipe which wraps a capture or anonymous function whenever it is encountered.

I have no doubt the code could be improved upon further (for instance: raising compile-time errors when a capture or function with an arity other than 1 is used rather than failing at runtime) but that is embellishment for later. The foundation is solid, and it would be great if people could try it out.

I am especially interested in if there are still situations in which this code might fail to work when added to existing Elixir-code, because the intent is of course that this enhancement to the pipe operator is completely backwards compatible.

Please let me know! :confetti_ball:

3 Likes

How it will behave with:

list |> Enum.map(& &1 + 1)

As expected:

iex> list = [1,2,3]
iex> list |> Enum.map(& &1 + 1)
[2, 3, 4]

Captures are only manipulated if they are at the ‘top’ level of a pipe. If they are inside of something else like a function call such as in your example, they will be kept exactly as-is.

Very cool. I pulled it into the majority of the modules in my codebase and all tests are still passing. The only issue I run into is the formatter goes nuts with parentheses and turns something like this…

name
|> &greet(greeting, &1)
|> String.upcase()
|> (&(&1 <> "...")).()
|> &("..." <> &1)
|> &{:ok, &1}

…into this…

name
|> (&(greet(greeting, &1)
      |> String.upcase()
      |> (&(&1 <> "...")).()
      |> (&(("..." <> &1)
            |> (&{:ok, &1})))))

Great! Thanks for sharing!

This is unfortunately something that user-code has very little control over; the Elixir formatter exposes (by design) only very little configurable settings. :man_shrugging:

Maybe that adding &: 1 to the locals_without_parens helps but probably not (since the expression spans multiple lines).

1 Like

Just FYI I implemented something very similar in Bunch and have been using it for almost two years. Really helpful thing, especially for prototyping :slight_smile:

1 Like