Skip to main content

lib/bound_cond.ex

defmodule BoundCond do
  @moduledoc """
  `bound_cond/1` — a `cond` whose clauses can thread interim variables.

  Plain `cond` runs the first truthy clause's body, but each clause is isolated:
  a value computed while testing one condition can't be reused by the next.
  `with` *can* thread state, but it models a single happy path with fallbacks —
  not several branches of equal priority. `bound_cond/1` fills that gap.

  Clauses are tried top to bottom, exactly like `cond`. To introduce variables
  for the clauses that follow, add a `:bind ->` step:

      import BoundCond

      bound_cond do
        in_range?( x, y) ->
          n

        :bind ->
          last = get_last( x)
          pos = get_pos( y)

        pos > last ->
          last

        true ->
          pos
      end

  A `:bind ->` clause never "matches": its body runs only when execution falls
  through to it, and the variables it binds stay in scope for every clause below
  it — but, like `cond`, nothing bound inside a `bound_cond` leaks out of it
  (not even an unconditional `:bind`). If no clause matches, a `CondClauseError`
  is raised, just like `cond`.

  It expands to nested `if`/`else` wrapped in a `case` (used only to open a
  scope, so the bindings can't leak), so there is effectively no runtime
  overhead.
  """

  @doc """
  A `cond` whose clauses can share interim variables.

  Use one or more `:bind -> ...` steps to bind variables for the clauses that
  follow them.

  ## Examples

      iex> import BoundCond
      BoundCond
      iex> x = 7
      7
      iex> bound_cond do
      ...>   x < 0 ->
      ...>    :negative
      ...>
      ...>   :bind ->
      ...>     doubled = x * 2
      ...>
      ...>   doubled > 10 ->
      ...>     { :big, doubled}
      ...>
      ...>   true ->
      ...>     { :small, doubled}
      ...> end
      {:big, 14}
  """
  defmacro bound_cond( do: clauses) do
    body =
      clauses
      |> List.wrap()
      |> build()

    # The `case` is there to open a scope: keeps everything bound inside.
    quote do
      case nil do
        _ -> unquote( body)
      end
    end
  end

  # `:bind ->` — run the body for its bindings, then continue. The body is
  # spliced into the surrounding block (a bare `__block__` is not a new scope),
  # so anything it binds is visible to every clause built into `rest`.
  defp build([{ :->, _, [[ :bind], body]} | rest]) do
    quote do
      unquote( body)
      unquote( build( rest))
    end
  end

  # `condition -> body` — every clause compiles to one of these.
  defp build([{ :->, _, [[ condition], body]} | rest]) do
    quote do
      if unquote( condition) do
        unquote( body)
      else
        unquote( build( rest))
      end
    end
  end

  # No clause matched — mirror `cond`.
  defp build( []) do
    quote do
      raise CondClauseError
    end
  end

  # A clause head that isn't a single condition (or `:bind`).
  defp build([{ :->, _, [ head, _body]} | _rest]) do
    raise ArgumentError,
          "bound_cond: each clause needs exactly one condition or `:bind`, got: " <>
            Macro.to_string({ :->, [], [ head, :...]})
  end
end