lib/jx.ex

defmodule Jx do
  @moduledoc """
  This is the documentation for the Jx framework project.
  """

  defstruct [binding: %{}, no_match: false, index: [], expr: nil]

  @doc false
  def apply_binding(term, %Jx{binding: binding}) do
    Macro.prewalk(term, fn 
      ({:j, meta, args} = t) ->
        name = meta[:jx_name]
        case binding[name] do
          nil -> t
          val -> quote do
            unquote(val).(unquote_splicing(args))
          end
        end

      ({var_name, _meta, context} = t) when is_atom(context) ->
        case binding[var_name] do
          nil -> t
          val -> Macro.escape(val)
        end

      (t) -> t
    end)
  end

  @doc false
  def bind_function({:j, meta, _}, value, %Jx{} = context) do
    case Keyword.get(meta, :jx_name) do
      nil ->
        raise ArgumentError
      name ->
        put_in context.binding[name], value
    end
  end

  defimpl Inspect do
    def inspect(%Jx{no_match: true}, _opts) do
      "#Jx<no match>"
    end

    def inspect(%Jx{binding: %{} = binding}, _opts) do
      binding 
      |> Enum.sort 
      |> Enum.map(fn {k, v} -> "#{k}=#{inspect(v)}" end) 
      |> Enum.join(", ")
      |> (&"#Jx<#{&1}>").()
    end

    def inspect(%Jx{binding: binding}, _opts) do
      binding 
      |> Enum.sort 
      |> Enum.map(fn {k, v} -> "#{k}=#{inspect(v)}" end) 
      |> Enum.join(", ")
      |> (&"#Jx<#{&1}>").()
    end
  end

  defmodule J do
    @moduledoc false

    def j(expr) do
      Jx.FunctionMatching.j(expr)
    end
  end

  @doc """
  Makes the match expression following it "jacked" allowing 'j' prefixed variables to be bound to functions.

  Under the hood a call is introduced to a function that implements the specific logic to handle the expresssion.
  The expression is passed to the implementing function in quoted form but with with any 'j' prefixed variables
  being called as functions such as `jx.(...)` replaced with `j(...)` function calls.

  ## Examples
  ```elixir 
  iex> require Jx; import Jx
  iex> j a = 1
  #Jx<a=1>
  ```

  ```elixir
  iex> require Jx; import Jx
  iex> j [[2, 0, a, b], [1, a, b]] = [jx.(2023), jx.(123)]
  #Jx<a=2, b=3, jx=&Integer.digits/1>
  iex> jx.(a * 10 + b)
  [2, 3]
  ```
  """
  defmacro j(expr) do
    macro_j(expr)
  end

  defp macro_j({:<-, _, [args, context_var]}) do
    {_left, acc} = parse_left(args, MapSet.new)

    variable_matchers =
      acc
      |> Enum.filter(fn :j -> false; _ -> true end)
      |> Enum.flat_map(fn
        (:j) -> []
        (name) ->
          [{name, Macro.var(name, nil)}]
      end)
      |> Enum.sort
    match_lhs = quote do
      %Jx{binding: %{unquote_splicing(variable_matchers)}, no_match: false}
    end

    quote do
      unquote(match_lhs) = Jx.FunctionMatching.next(unquote(context_var))
    end
  end

  defp macro_j({:=, meta, [left, right]}) do
    {left, acc} = parse_left(left, MapSet.new)
    {right, acc} = parse_right(right, acc)

    term = Macro.escape({:=, meta, [left, right]})
    match_rhs = quote do
      Jx.J.j(unquote(term))
    end

    variable_matchers =
      acc
      |> Enum.filter(fn :j -> false; _ -> true end)
      |> Enum.flat_map(fn
        (:j) -> []
        (name) ->
          [{name, Macro.var(name, nil)}]
      end)
      |> Enum.sort
    match_lhs = quote do
      %Jx{binding: %{unquote_splicing(variable_matchers)}, no_match: false}
    end

    quote do
      unquote(match_lhs) = unquote(match_rhs)
    end
  end

  defp macro_j(_) do
    raise ArgumentError
  end

  defp parse_left(term, acc) do
    case term do
      {name, _meta, context} when is_atom(name) and is_atom(context) ->
        # if name |> Atom.to_string |> String.starts_with?("j") do

        #   raise ArgumentError

        # end


        {term, MapSet.put(acc, name)}

      {:^, _, _} -> # Pin operator not supported yet.

        raise ArgumentError

      {name, meta, args} when is_atom(name) and is_list(args) ->
        {args, acc} = parse_left(args, acc)
        {{name, meta, args}, acc}

      {a, b} ->
        {[a, b], acc} = Enum.map_reduce([a, b], acc, &parse_left/2)
        {{a, b}, acc}

      list when is_list(list) ->
        Enum.map_reduce(list, acc, &parse_left/2)

      t when is_number(t) or is_atom(t) or is_binary(t) ->
        {t, acc}
    end
  end

  defp parse_right({a, b}, acc) do
    {a, acc} = parse_right(a, acc)
    {b, acc} = parse_right(b, acc)
    {{a, b}, acc}
  end

  defp parse_right(args, acc) when is_list(args) do
    Enum.map_reduce(args, acc, &parse_right/2)
  end

  defp parse_right({{:., dot_meta, dot_args}, meta, args}, acc) when is_list(args) do
    function_variable_name = case dot_args do
      [{name, _, _}] when is_atom(name) -> name
      _ -> nil
    end

    is_j_variable = function_variable_name |> Atom.to_string |> String.starts_with?("j")

    if is_j_variable do
      meta = Keyword.put(meta, :jx_name, function_variable_name)
      acc = MapSet.put(acc, function_variable_name)
      {args, acc} = parse_right(args, acc)
      {{:j, meta, args}, acc}
    else
      {dot_args, acc} = parse_right(dot_args, acc)
      {args, acc} = parse_right(args, acc)

      {{{:., dot_meta, dot_args}, meta, args}, acc}
    end
  end

  defp parse_right({name, meta, args}, acc) when is_atom(name) and is_list(args) do
    {args, acc} = parse_right(args, acc)
    {{name, meta, args}, acc}
  end

  defp parse_right(term, acc) do
    {term, acc}
  end
end