lib/arrows.ex

defmodule Arrows do
  @moduledoc "./README.md" |> File.stream!() |> Enum.drop(1) |> Enum.join()

  defmacro __using__(_options) do
    quote do
      import Kernel, except: [|>: 2]

      import unquote(__MODULE__),
        only: [
          |>: 2,
          |||: 2,
          ~>: 2,
          <~>: 2,
          ok: 1,
          to_ok: 1,
          from_ok: 1
        ]
    end
  end

  import Kernel, except: [|>: 2]

  @doc """
  Enhanced pipe operator with support for ellipsis (`...`) placement.

  This is a more flexible drop-in replacement for the standard Elixir pipe operator (`|>`).

  ## Special Features

  * The ellipsis (`...`) will be replaced with the result of evaluating the left-hand side expression.
  * The right-hand side need not be a function; it can be any expression containing the ellipsis (`...`).
  * You may use the ellipsis multiple times, and the left-hand side will be calculated exactly once.
  * If no ellipsis is present, it behaves like the standard pipe operator (placing the value as the first argument).

  ## Examples

      # Standard first position pipe
      iex> 2 |> Integer.to_string()
      "2"
      
      # Using ellipsis for explicit placement
      iex> 2 |> Integer.to_string(...)
      "2"
      
      # Using ellipsis to place the piped value in a non-first position
      iex> 3 |> String.pad_leading("2", ..., "0")
      "002"
      
      # Using the ellipsis multiple times
      iex> 2 |> Kernel.==(..., ...)
      true
      
      # Nested expressions with ellipsis
      iex> 2 |> String.pad_leading(Integer.to_string(...), 3, "0")
      "002"

      # With expressions and transformations
      iex> 2 |> (... * 3)
      6
      
  """
  defmacro l |> r, do: pipe(:first, :normal, l, r)

  @doc """
  OK-pipe operator.

  Similar to the enhanced pipe (`|>`), but with additional error handling for the following patterns:

  - `{:ok, value}` - Extracts `value` and passes it to the right side
  - `{:error, _}` - Passes through unchanged (short-circuits the pipeline)
  - `:error` - Passes through unchanged (short-circuits the pipeline)
  - `nil` - Passes through unchanged (short-circuits the pipeline)
  - Any other value - Passes the value directly to the right side

  ## Examples

      iex> 2 ~> Integer.to_string()
      "2"
      
      iex> {:ok, 2} ~> Integer.to_string() |> String.pad_leading(3, "0")
      "002"
      
      iex> {:error, :reason} ~> Integer.to_string()
      {:error, :reason}
      
      # Note that this would pass :error to `String.pad_leading` breaking our pipe: 
      # :error ~> Integer.to_string() |> String.pad_leading(2, "0")
      :error
      # Instead we want to do:
      iex> :error ~> Integer.to_string() ~> String.pad_leading(2, "0")
      :error
      
      iex> nil ~> Integer.to_string()
      nil
      
      iex> 2 ~> (... * 2)
      4
      
      # With a non-standard position using ellipsis
      iex> 2 ~> Kernel./(3, ...)
      1.5
  """
  defmacro l ~> r, do: pipe(:first, :ok, l, r)

  @doc """
  Nil-coalescing "or" operator.

  Works like the logical OR (`||`), except it only defaults to the right side if the left side is `nil` (whereas `||` also defaults on `false` and other falsy values).

  ## Examples

      iex> nil ||| "default"
      "default"
      
      iex> false ||| "default"
      false
      
      iex> 0 ||| "default"
      0
      
      iex> "" ||| "default"
      ""
  """
  defmacro l ||| r, do: join(:normal, l, r)

  @doc """
  Error-coalescing operator.

  Similar to the nil-coalescing operator (`|||`), but applies a similar logic of the OK-pipe (`~>`).

  It return the right side value if the left side is:
  - `nil`
  - `:error`
  - `{:error, _}`

  ## Examples

      iex> nil <~> "default"
      "default"
      
      iex> :error <~> "default"
      "default"
      
      iex> {:error, :reason} <~> "default"
      "default"
      
      iex> {:ok, "value"} <~> "default"
      {:ok, "value"}
      
      iex> "value" <~> "default"
      "value"
      
      iex> false <~> "default"
      false
  """
  defmacro l <~> r, do: join(:ok, l, r)

  @doc """
  Converts various values to an OK tuple format.

  - `{:ok, value}` and `{:error, reason}` are returned unchanged
  - `:error` is returned unchanged
  - `nil` is converted to `:error`
  - Any other value `x` is converted to `{:ok, x}`

  ## Examples

      iex> to_ok({:ok, 123})
      {:ok, 123}
      
      iex> to_ok({:error, :reason})
      {:error, :reason}
      
      iex> to_ok(:error)
      :error
      
      iex> to_ok(nil)
      :error
      
      iex> to_ok(123)
      {:ok, 123}
  """
  def to_ok(x) do
    case x do
      {:ok, _} -> x
      {:error, _} -> x
      :error -> :error
      nil -> :error
      x -> {:ok, x}
    end
  end

  @doc """
  Extracts values from OK tuples.

  - `{:ok, value}` returns `value`
  - `{:error, _}` returns `nil`
  - `:error` returns `nil`
  - Any other value is returned unchanged

  ## Examples

      iex> from_ok({:ok, 123})
      123
      
      iex> from_ok({:error, :reason})
      nil
      
      iex> from_ok(:error)
      nil
      
      iex> from_ok(123)
      123
  """
  def from_ok(x) do
    case x do
      {:ok, x} -> x
      {:error, _} -> nil
      :error -> nil
      # lenience
      x -> x
    end
  end

  @doc """
  Wraps a value in an OK tuple if it's not already in a result tuple format.

  - `{:ok, value}`, `{:error, reason}` and `:error` are returned unchanged
  - Any other value `x` is converted to `{:ok, x}`

  ## Examples

      iex> ok({:ok, 123})
      {:ok, 123}
      
      iex> ok({:error, :reason})
      {:error, :reason}
      
      iex> ok(:error)
      :error
      
      iex> ok(123)
      {:ok, 123}
  """
  def ok(x = {:ok, _}), do: x
  def ok(x = {:error, _}), do: x
  def ok(:error), do: :error
  def ok(x), do: {:ok, x}

  @doc """
  Wraps a value in an OK tuple or returns an error tuple with a default error.

  - `{:ok, value}`, `{:error, reason}` and `:error` are returned unchanged
  - `nil` returns `{:error, err}` where `err` is the default error provided in the second argument
  - Any other value `x` returns `{:ok, x}`

  ## Examples

      iex> ok_or({:ok, 123}, :default_error)
      {:ok, 123}
      
      iex> ok_or({:error, :reason}, :default_error)
      {:error, :reason}
      
      iex> ok_or(:error, :default_error)
      :error
      
      iex> ok_or(nil, :default_error)
      {:error, :default_error}
      
      iex> ok_or(123, :default_error)
      {:ok, 123}
  """
  def ok_or(x = {:ok, _}, _), do: x
  def ok_or(x = {:error, _}, _), do: x
  def ok_or(:error, _), do: :error
  def ok_or(nil, err), do: {:error, err}
  def ok_or(ok, _), do: {:ok, ok}

  defp ellipsis(l, arg) do
    Macro.prewalk(arg, 0, fn form, acc ->
      case form do
        {:..., _, ctx} when is_atom(ctx) or ctx == [] -> {l, acc + 1}
        _ -> {form, acc}
      end
    end)
  end

  defp pipe_args(where, l, args) do
    case ellipsis(l, args) do
      {args, 0} when where == :first -> [l | args]
      {args, _} -> args
    end
  end

  defp pipe(where, kind, l, r) do
    v = Macro.var(:ret, __MODULE__)

    case r do
      {name, meta, args} ->
        args = if(is_list(args), do: args, else: [])
        continue = {name, meta, pipe_args(where, v, args)}

        case kind do
          :normal ->
            quote generated: true do
              unquote(v) = unquote(l)
              unquote(continue)
            end

          :ok ->
            quote generated: true do
              case unquote(l) do
                nil -> nil
                :error -> :error
                {:error, _} = unquote(v) -> unquote(v)
                {:ok, unquote(v)} -> unquote(continue)
                unquote(v) -> unquote(continue)
              end
            end
        end

      _ ->
        case ellipsis(l, r) do
          {arg, 0} ->
            raise RuntimeError,
              message: "Can't pipe into #{inspect(r)}: missing ellipsis(`...`) in #{inspect(arg)}"

          {continue, _} ->
            case kind do
              :normal ->
                quote generated: true do
                  unquote(v) = unquote(l)
                  unquote(continue)
                end

              :ok ->
                quote generated: true do
                  case unquote(l) do
                    nil -> nil
                    :error -> :error
                    {:error, _} = unquote(v) -> unquote(v)
                    {:ok, unquote(v)} -> unquote(continue)
                    unquote(v) -> unquote(continue)
                  end
                end
            end
        end
    end
  end

  defp join(kind, l, r) do
    v = Macro.var(:l, __MODULE__)

    case kind do
      :normal ->
        quote generated: true do
          unquote(v) = unquote(l)
          if is_nil(unquote(v)), do: unquote(r), else: unquote(v)
        end

      :ok ->
        quote generated: true do
          case unquote(l) do
            nil -> unquote(r)
            :error -> unquote(r)
            {:error, _} -> unquote(r)
            unquote(v) -> unquote(v)
          end
        end
    end
  end
end