lib/pipe_helpers.ex

defmodule PipeHelpers do
  @moduledoc """
  Helpers for piping data. The provided helpers can be grouped into two categories:
  1. generate tuples that fit the return type of the containing function
  2. deal with previous function tuple output without "breaking the pipe"

  ## 1. Return tuples builders

  You can use only the first category to improve readability in your code.
  For instance, lets take a typical `Phoenix.LiveView` code:

      def mount(_params, _session, socket) do
        socket = assign(socket, my_bool_assign: true)
        {:ok, socket}
      end

  With `ok/1`, it can be rewritten as:

      def mount(_params, _session, socket) do
        socket
        |> assign( my_bool_assign: true)
        |> ok()
      end

  In addition to the typical `{:ok, value}` return tuple, the library provides
  the most common return types helpers `noreply/1` `reply/2`. Generic helpers
  are also provided to build whatever tuple you need with `pair/2` and `rpair/2`.

  ## 2. Tuple deconstructors

  In some situations, return type incompatibilities prevent you from using
  an uninterrupted pipe sequence. In this case, the following helpers may help:

  ### 2a Generic unpair

  The generic `unpair/1` allows you to ignore the first element of the pair
  and forward the second for further piping.

  ### 2b Only when "ok" execution

  The `then_ok/2` provides a promise-like solution to handle 'maybe ok'
  results. If the result is an ok-tuple, it executes the function on the second argument
  and passes the result forward (i.e. for further piping). If the result is not an
  ok-tuple (typically an `{:error, message}` tuple), it is forwarded directly.

  For example, let's take the following (very powerful translator) function:

      def translate(source) do
        case source do
          "ola" -> {:ok, "hello"}
          _ -> {:error, "can't translate, sorry"}
        end
      end

  And the quoting function that you want to pipe into:

      def quote(text) do
        text
      end

  The `translate/1` output doesn't fit the `quote/1` input, so the
  following pipe is not possible:

      my_source
      |> translate()
      |> quote()

  With the `then_ok/2` you can handle do:

      my_source
      |> translate()
      |> then_ok(&quote/1)

  This pipe will either return the properly quoted translation or the error tuple
  generated by the `translate/1`.

  > If you intend to successively pipe into more than a single functions after an
  > `{:ok, result}` returning function, you need to only have `{:ok, result}` functions
  > except the last one and always use the `then_ok/2` construct until the end. Doing so
  > you can safely do the error management at the end of the pipe using a `|> case do`
  > irrespective of the function that failed to provide an `{:ok, result}` output.


  The `then_on/3` provides similar behaviour with a more generic/powerful pattern
  matching on the previous function output.

  The functions above have their `tap` counterpart if the idea is to apply a
  conditional side effect without modifying the piped value.
  """

  @doc """
  Wrap into standard `{:ok, result}` tuple.

  ## Example

      iex> socket = "socket"
      ...> socket |> ok()
      {:ok, "socket"}

  """
  def ok(val) do
    {:ok, val}
  end

  @doc """
  Wrap into standard `{:error, result}` tuple.

  ## Example

      iex> :not_found |> err()
      {:error, :not_found}

  """
  def err(val) do
    {:error, val}
  end

  @doc """
  Wrap into `{:noreply, state}` tuple (genserver and phoenix socket format).

  ## Example

      iex> state = "gen server state"
      ...> state |> noreply()
      {:noreply, "gen server state"}

  """
  def noreply(val) do
    {:noreply, val}
  end

  @doc """
  Wrap into `{:reply, reply, state}` tuple (genserver and phoenix socket format).

  ## Example

      iex> state = "gen server state"
      ...> r = "reply"
      ...> state |> reply(r)
      {:reply, "reply", "gen server state"}

  """
  def reply(state, reply) do
    {:reply, reply, state}
  end

  @doc """
  Wrap into `{:halt, state}` tuple (phoenix socket format).

  """
  def halt(state) do
    {:halt, state}
  end

  @doc """
  Wrap into `{:halt, reply, state}` tuple (phoenix socket format).

  """
  def halt(state, reply) do
    {:halt, reply, state}
  end

  @doc """
  Wrap into `{:cont, state}` tuple (phoenix socket format).

  """
  def cont(state) do
    {:cont, state}
  end

  @doc """
  Wrap into `{:reply, reply, state}` tuple (genserver and phoenix socket format).

  ## Example

      iex> state = "gen server state"
      ...> r = "reply"
      ...> r |> rreply(state)
      {:reply, "reply", "gen server state"}

  """
  def rreply(reply, state) do
    {:reply, reply, state}
  end

  @doc """
  Wrap into generic tuple pair.

  ## Example

      iex> 1 |> pair(2)
      {2, 1}

  """
  def pair(val, res) do
    {res, val}
  end

  @doc """
  Wrap into tuple rpair.

  ## Example

      iex> 1 |> rpair(2)
      {1, 2}

  """
  def rpair(val, res) do
    {val, res}
  end

  @doc """
  Unwrap from a tuple pair.

  ## Example

      iex> {:ok, 1} |> unpair()
      1

  """
  def unpair({_val, res}) do
    res
  end

  @doc """
  Unwrap a tuple, but raise if not `{:ok, value}`

  ## Example

      iex> {:ok, "hello"} |> unwrap!()
      "hello"

  """
  def unwrap!(val) do
    {:ok, val} = val
    val
  end

  @doc """
  Execute `fun` function only if `result` is an ok-tuple. Returns the input in any case.

  The `fun` argument is a function taking the right part of the ok-tuple
  as an optional argument.

  ## Example

      iex> {:ok, "somedata"} |> tap_ok(fn -> "only executed when {:ok, ...}" end)
      {:ok, "somedata"}

      iex> {:error, "failed"} |> tap_ok(fn -> "not executed when not {:ok, ...}" end)
      {:error, "failed"}
  """
  def tap_ok(result, fun) do
    then_ok(result, fun)
    result
  end

  @doc """
  Execute `fun` function only if the first arument matches the second one (`value = result` check).
  Returns the input in any case.

  ## Example

      iex> true |> tap_on(true, fn -> "only executed when matching 'true'" end)
      true

      iex> false |> tap_on(true, fn -> "not executed when not matchin 'true'" end)
      false
  """
  def tap_on(result, value, fun) do
    then_on(result, value, fun)
    result
  end

  @doc """
  If `result` is an ok tuple, execute `fun` with the right part of `result` tuple as single argument
  and return `fun` output.
  Otherwise, return `result` as-is.

  ## Example

      iex> {:ok, "somedata"} |> then_ok(fn -> "only returned when result argument is {:ok, ...}" end)
      "only returned when result argument is {:ok, ...}"

      iex> {:error, "failed"} |> then_ok(fn -> "only returned when result argument is {:ok, ...}" end)
      {:error, "failed"}

  """
  def then_ok({:ok, ok_val} = _result, fun) do
    fun
    |> Function.info()
    |> Keyword.get(:arity)
    |> case do
      0 -> fun.()
      1 -> fun.(ok_val)
      _ -> raise "then_ok function arity can only be 0 or 1"
    end
  end

  def then_ok(:ok, fun) do
    fun.()
  end

  def then_ok(result, _fun), do: result

  @doc """
  If `result` is an error tuple, execute `fun` with the right part of `result` tuple as single argument
  and return `fun` output.
  Otherwise, return `result` as-is.

  """
  def on_error({:error, err_val} = _result, fun) do
    fun
    |> Function.info()
    |> Keyword.get(:arity)
    |> case do
      0 -> fun.()
      1 -> fun.(err_val)
      _ -> raise "on_error function arity can only be 0 or 1"
    end
  end

  def on_error(:error, fun) do
    fun.()
  end

  def on_error(result, _fun), do: result

  @doc """
  Then only if value match. See `then_ok/2` and `tap_ok/3`
  """
  def then_on(value, value, fun) do
    fun
    |> Function.info()
    |> Keyword.get(:arity)
    |> case do
      0 -> fun.()
      1 -> fun.(value)
      _ -> raise "then_on function arity can only be 0 or 1"
    end
  end

  def then_on(result, _value, _fun), do: result

  @doc """
  Then only if condition is true.
  """
  def then_if(value, false, _), do: value

  def then_if(value, true, fun) do
    fun
    |> Function.info()
    |> Keyword.get(:arity)
    |> case do
      0 -> fun.()
      1 -> fun.(value)
      _ -> raise "then_if function arity can only be 0 or 1"
    end
  end

  def then_if(result, _cond, _fun), do: result
end