src/erlpipe.erl

%%% vim:ts=2:sw=2:et
%%%-----------------------------------------------------------------------------
%%% @doc Erlang pipeline parse transform
%%%
%%% This transform implements a parser syntax extension that enables application
%%% of cascading function calls using the `/' operator.
%%%
%%% In the `LHS / RHS / ... Last.' notation, the result of evaluation of the LHS
%%% expression is passed as an argument to the RHS expression. This process
%%% continues until the `Last' expression is evaluated.  The head element of the
%%% pipeline must be either a term to which the arithmetic division `/` operator
%%% cannot apply (i.e. not integers, floats, functions), or if you need to pass
%%% integer(s) or float(s), wrap them in a list brackets.
%%%
%%% This transfor is inspired by the similar functionality in Linux (i.e. `|'
%%% pipe) and Elixir (`|>' pipe).
%%%
%%% When using this as a parse transform, include the `{parse_transform,erlpipe}'
%%% compiler option.
%%%
%%% The following examples illustrate the work of the transform, in which:
%%% ```
%%% test1(A)   -> [A]   / fun1 / mod:fun2 / fun3.
%%% test2(A,B) -> [A,B] / fun4 / fun5() / io:format("~p\n", [_]).
%%% '''
%%% will be transformed to:
%%% ```
%%% test1(A)   -> fun3(mod:fun2(fun1(A))).
%%% test2(A,B) -> io:format("~p\n", [fun5(fun4(A,B))]).
%%% '''
%%%
%%% Similarly to Elixir, a special `tap/2' function is implemented, which
%%% passes the given argument to an anonymous function, returning the argument
%%% itself. The following:
%%% ```
%%% f(A) -> A+1.
%%% ...
%%% test_tap() ->
%%%   [10] / tap(f)
%%%        / tap(fun f/1)
%%%        / tap(fun(I) -> I+1 end).
%%% '''
%%% is equivalent to:
%%% ```
%%% ...
%%% test_tap() ->
%%%   begin
%%%     f(10),
%%%     begin
%%%       f(10),
%%%       begin
%%%         (fun(I) -> I end)(10)
%%%         10
%%%       end
%%%     end
%%%   end.
%%% '''
%%%
%%% For debugging the AST of the resulting transform, pass the following
%%% options to the `erlc' compiler:
%%% <dl>
%%% <li>`-Derlpipe_orig' - print the original AST before the transform</li>
%%% <li>`-Derlpipe_ast'  - print the transformed AST</li>
%%% <li>`-Derlpipe_src'  - print the resulting source code after the transform</li>
%%% </dl>
%%%
%%% @author Serge Aleynikov <saleyn(at)gmail(dot)com>
%%% @end
%%%-----------------------------------------------------------------------------
%%% Copyright (c) 2021 Serge Aleynikov
%%%
%%% Permission is hereby granted, free of charge, to any person
%%% obtaining a copy of this software and associated documentation
%%% files (the "Software"), to deal in the Software without restriction,
%%% including without limitation the rights to use, copy, modify, merge,
%%% publish, distribute, sublicense, and/or sell copies of the Software,
%%% and to permit persons to whom the Software is furnished to do
%%% so, subject to the following conditions:
%%%
%%% The above copyright notice and this permission notice shall be included
%%% in all copies or substantial portions of the Software.
%%%
%%% THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
%%% EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
%%% MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
%%% IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
%%% CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
%%% TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
%%% SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
%%%-----------------------------------------------------------------------------
-module(erlpipe).

-export([parse_transform/2]).
-import(etran_util, [transform/2]).

-define(OP, '/').

%% @doc parse_transform entry point
parse_transform(AST, Options) ->
  etran_util:apply_transform(?MODULE, fun replace/1, AST, Options).

replace({op, _Loc, ?OP, Arg, Rhs}) ->
  apply_args(Arg, Rhs);
replace(_Exp) ->
  continue.

apply_args({op, _Loc, ?OP, A, E}, Rhs) ->
  case apply_args(A, E) of
    continue -> continue;
    [Args]   -> apply_args(Rhs, [Args]);
    Args     -> apply_args(Rhs, [Args])
  end;
apply_args({cons, _Loc, _, _} = List, Rhs) ->
  Args = [hd(transform(fun replace/1, [F])) || F <- cons_to_list(List)],
  [E]  = transform(fun replace/1, [Rhs]),
  do_apply(E, Args);
apply_args({Op, _Loc, _} = Arg, RHS) when Op==atom; Op==bin; Op==tuple; Op==string ->
  if is_tuple(RHS) ->
    do_apply(RHS, [Arg]);
  true ->
    do_apply(Arg, RHS)
  end;
%% List comprehension
apply_args({lc,_,_,_}=Lhs, Rhs) ->
  do_apply(Lhs, Rhs);
apply_args(AArgs, Rhs) when is_list(AArgs), is_list(Rhs) ->
  Args = [hd(transform(fun replace/1, [F])) || F <- AArgs],
  [E]  = transform(fun replace/1, Rhs),
  do_apply(E, Args);
apply_args(LHS, RHS) when is_tuple(LHS), is_list(RHS) ->
  do_apply(LHS, RHS);
apply_args(LHS, RHS) when is_tuple(LHS), is_tuple(RHS) ->
  continue.

do_apply({atom, Loc, _V} = Function, Arguments) ->
  {call, Loc, Function, Arguments};

do_apply({remote, Loc, _M, _F} = Function, Arguments) ->
  {call, Loc, Function, Arguments};

do_apply({call, Loc, Fun, []}, Arguments) ->
  {call, Loc, Fun, Arguments};

do_apply({'fun', Loc, {function, Fun, _}}, Arguments) ->
  {call, Loc, {atom, Loc, Fun}, Arguments};
do_apply({'fun', Loc, {function, _Mod, _Fun, _Arity}=F}, Arguments) ->
  {call, Loc, {'fun', Loc, F}, Arguments};
do_apply({'fun', Loc, {clauses, _}}=Fun, Arguments) ->
  {call, Loc, Fun, Arguments};

do_apply({call, _Loc, {atom, ALoc, tap}, [Arg]}, RHS) ->
  %% Tapping into a function's call (the return is a passed-through RHS argument)
  Res = do_apply(Arg, RHS),
  % If we are asked to tap into the fun's call, wrap the call in a block
  {block, ALoc, [{match, ALoc, {var, ALoc, '_'}, Res}, hd(RHS)]};

%% RHS is a tuple when it's the head of a pipeline:
%%   E.g.  [I || I <- L] / ...
do_apply({Op, Loc, Fun, Args} = LHS, RHS) when (Op =:= call orelse Op =:= lc), is_list(RHS) ->
  [NewLHS] = transform(fun(Forms) -> substitute(RHS, Forms) end, [LHS]),
  case NewLHS of
    LHS ->
      {Op, Loc, Fun, RHS ++ Args};
    ResLHS ->
      ResLHS
  end;

%% RHS is a list when it's in the middle of a pipeline:
%%   E.g.  ... / [I || I <- _] / ...
do_apply({Op, _, _, _} = LHS, RHS) when (Op =:= call orelse Op =:= lc), is_tuple(RHS) ->
  do_apply(RHS, [LHS]);

do_apply({Op, Loc, Fun, Args} = LHS, RHS) when Op =:= call; Op =:= lc ->
  [NewLHS] = transform(fun(Forms) -> substitute(RHS, Forms) end, [LHS]),
  case NewLHS of
    LHS when is_list(RHS) ->
      {Op, Loc, Fun, RHS ++ Args};
    LHS ->
      do_apply(RHS, [LHS]);
    ResLHS ->
      ResLHS
  end;

%% Use of operators
do_apply({op, Loc, Op, Lhs, Rhs}, Arguments) ->
  NewLhs = transform(fun replace/1, [Lhs]),
  NewRhs = transform(fun replace/1, [Rhs]),
  [LHS] = transform(fun(Forms) -> substitute(Arguments, Forms) end, NewLhs),
  [RHS] = transform(fun(Forms) -> substitute(Arguments, Forms) end, NewRhs),
  {op, Loc, Op, LHS, RHS};

do_apply({var, _, '_'}, [Arg]) ->
  Arg;
do_apply(Exp, _A) when is_tuple(Exp) ->
  Exp.

cons_to_list({cons, _, A, B}) ->
  [A | cons_to_list(B)];
cons_to_list({nil, _}) ->
  [];
cons_to_list([A]) ->
  [A].

%% Substitute '_', '_1', '_2', ... '_N' with the corresponding argument
substitute(Args, {var, _, V}) when is_atom(V) ->
  case atom_to_list(V) of
    "_" when is_list(Args) ->
      hd(Args);
    "_"    -> Args;
    [$_|T] ->
      try
        M = list_to_integer(T),
        lists:nth(M, Args)
      catch _:_ ->
        continue
      end;
    _ ->
      continue
  end;
substitute(_Args, _) ->
  continue.