Magritte - yet another "better pipe operator" library

Simple and clean library that is inspired by discussion in Elixir’s issue tracker and uses operator suggested by @josevalim (however it may change, other operator that I was considering was &_)

Short example of how this works:

# Old pipes work as it have been before
iex> 5 |> Integer.to_string()
"5"
iex> 5 |> Integer.to_string(2)
"101"

# You can use `...` to mark the point where the expression should be inserted
iex> 5 |> Integer.to_string(10, ...)
"20"

For now it works only with function calls, so unfortunately no support for stuff like {:ok, ...}, [..., 2], or %{a: ...}. It also do not work with things like 1 |> struct(URI, port: ...) as the ... operator must be top level.

I wanted to create another one (the other one is @Qqwy’s CapturePipe) to test out using another operator, that would be IMHO less confusing than &1 in CapturePipe.

11 Likes

Nice work. It might be obvious to others, but you may wish to add in the docs that you need to use Magritte not import Magritte.

1 Like

Yes, I am still writing the docs to be clearer on the usage.

1 Like

I’m very eager to see how this will develop. CapturePipe has two disadvantages, both related to the fact that & is already used in a somewhat different way in existing code:

  • There are some theoretical edge cases in which it could have surprising results. Essentially, when someone puts a pipe inside a capture, this is always turned inside-out by CapturePipe (because that’s how it works) and in certain cases this might lead to unexpected behaviour.
  • The formatter formats the original source code and has no clue that & will be shuffled around, so it will very much clobber the pipelines that contain it.

Magritte has neither of these disadvantages. I personally do not like using ... as chosen ‘fill in’ argument that much because it is used in many other programming languages to stand for multiple (e.g. variadic) arguments. I’d definitely be a proponent of using &_. This still does need some thought (unless a new dedicated token is added to Elixir for it) because while foo(1, &_, 3) works fine, &_ + 2 will be parsed as if it was the (nonsensical) capture &(_ + 2). Maybe there is a way to deal with this gracefully, I don’t know at this time.

6 Likes

I was also thinking about ^_ which would bind stronger than &_, but would be surprising behaviour once again, as it is “non standard” usage of ^ operator.

For anyone interested, I have created small GitHub Poll about which is the preferred operator for such “here be dragons” behaviour in |>:

I would :heart: to hear your opinions.

Since it’s directly related to the capture operator, i’d argue that anything who doesn’t contain & would cause more confusion, after all, it’s an “extension” of the current behavior, so it should stay on track with what it uses now. I’m not sure that i’d like &_, but at least, it keeps consistency with the overall use of this operator.

Note: I’ve clicked on ... in your poll, because i didn’t realise it would not open a page for me to select an option :yum:, but my vote goes for &_.

1 Like

I like ... honestly. & is already overloaded, so sometimes I worry about adding new meanings. ... is relatively safe, doesn’t cause precedence issues, and is unlikely to be used as a variable.

11 Likes

Thanks for pointing this out, I have added the info in top post.

That was my reasoning for this choice as well. I just wanted to know what others think about it.

To me, this is simply a consistency point of view, it really looks like using a completely different operator, with “only” the objective of extending the capabilities of what’s already there. It’s like having two different syntax to, in the end, do something quite similar to what the capture operator already does, hence why i though that using something like &_ was a better idea.

The & issues means that it’s better to just put it out of the options ?

... could be a variable !?

2 Likes

Or function:

defmodule Foo do
   defp ..., do: IO.puts("Hello there")

  def run do
    ...
    IO.puts("General Kenobi")
  end
end
3 Likes

Not sure if this is a bug or just a spot that needs a better error message, but using ... inside a pipe inside a fn inside a pipe fails:

iex(3)> [1,2,3] |> Enum.map(fn x -> x |> Integer.to_string(10, ...) end)

** (FunctionClauseError) no function clause matching in Magritte.locate/4    
    
    The following arguments were given to Magritte.locate/4:
    
        # 1
        nil
    
        # 2
        0
    
        # 3
        nil
    
        # 4
        []
    
    Attempted function clauses (showing 4 out of 4):
    
        defp locate([{:..., _, args} | rest], pos, nil, acc) when args == [] or not(is_list(args))
        defp locate([{:..., _, args} | _], pos, found, _acc) when args == [] or not(is_list(args))
        defp locate([arg | rest], pos, found, args)
        defp locate([], _, found, args)
    
    (magritte 0.1.1) lib/magritte.ex:91: Magritte.locate/4
    (magritte 0.1.1) lib/magritte.ex:81: Magritte.find_pos/1
    (magritte 0.1.1) lib/magritte.ex:68: Magritte.unpipe/3
    (magritte 0.1.1) lib/magritte.ex:64: Magritte.unpipe/3
    (magritte 0.1.1) lib/magritte.ex:61: Magritte.unpipe/2
    (magritte 0.1.1) expanding macro: Magritte.|>/2
    iex:3: (file)
    (magritte 0.1.1) expanding macro: Magritte.|>/2
    iex:3: (file)

It wouldn’t work anyway, but I will check that out.

Is there something which makes the top-level usage absolutely mandatory? In theory it should be possible to traverse the AST, right?

Would you be open for a PR implementing that?

Nothing really, except convenience and less surprising behaviour. Because there would be question in case of something like:

foo
|> bar(fn ->
  baz(1, ...) # should this work?
end)

And if the above should work, then how about:

foo
|> bar(fn ->
  2 |> baz(1, ...) # which value should be used there?

If this should outer pipeline, then there is no way to use ... in nested pipeline, if nested then the behaviour is inconsistent.

Additionally current implementation disallows duplicated ... which would IMHO feel illogical in case of traversing whole tree.

I see nothing wong with this.

I also put some thought into this and I’d argue that ... should always refer to the inner pipe.

But there is an argument to be made to disallow using ... in a nested pipe, similar to how nested captures (&) are disallowed. At the very least this should print a warning in my opinion.

Thanks, fixed on master.

This example could be reduced to the example that is almost a shame that it wasn’t a test:

x |> foo

EDIT: Released 0.1.2 that fixes this shameful bug.

“Plain” examples often seems right or “nothing wrong”. Like famous C++ example

std::vector<int> v = {1,2,3};
int& ref = v[1];
v.push_back(4);
std::cout << ref;

That is the problem with such examples.

Yeah, but & &1 + &1 is allowed, while 1 |> Kernel.+(..., ...) is not allowed in Magritte, as it would require additional variable and could cause problems with macros (for example with Logger macros which lazily evaluate arguments).

Maybe a dumb question, but if it is an operator that works with it’s left and right sides, why should it care about nesting?
I mean, a |> foo(b, ...) is reasonable, buta |> foo(fn x -> bar(x, ...) end) seems completely out of scope for the for the operator’s semantic.