lib/map_sorter/sort_specs.ex

defmodule MapSorter.SortSpecs do
  @moduledoc """
  Converts the AST of sort specs into the AST of an `Enum.sort/2` compare
  function.
  """

  alias MapSorter.{Compare, Log, SortSpec}

  @typedoc "A list of sort specs"
  @type t :: [SortSpec.t()]

  @doc ~S'''
  Converts `sort_specs` into the AST of an `Enum.sort/2` compare function.
  
  ## Examples
  
      # Compile time sort specs...
      iex> alias MapSorter.SortSpecs
      iex> sort_specs = [:dob]
      iex> {:ok, comp_fun_ast} = SortSpecs.to_quoted(sort_specs)
      iex> {:&, _meta, [args]} = comp_fun_ast
      iex> {comp_fun, []} = Code.eval_quoted(comp_fun_ast)
      iex> is_function(comp_fun, 2) and Macro.to_string(args)
      """
      cond do
        &1[:dob] < &2[:dob] -> true
        &1[:dob] > &2[:dob] -> false
        true -> true or &1 * &2
      end
      """
      |> String.trim_trailing()
  
      # Compile time sort specs...
      iex> alias MapSorter.SortSpecs
      iex> sort_specs = fn -> [desc: :dob, asc: :bmi] end
      iex> {:ok, comp_fun_ast} = sort_specs.() |> SortSpecs.to_quoted()
      iex> {:&, _meta, [args]} = comp_fun_ast
      iex> {comp_fun, []} = Code.eval_quoted(comp_fun_ast)
      iex> is_function(comp_fun, 2) and Macro.to_string(args)
      """
      cond do
        &1[:dob] > &2[:dob] -> true
        &1[:dob] < &2[:dob] -> false
        &1[:bmi] < &2[:bmi] -> true
        &1[:bmi] > &2[:bmi] -> false
        true -> true or &1 * &2
      end
      """
      |> String.trim_trailing()
  
      # Compile time sort specs...
      iex> alias MapSorter.SortSpecs
      iex> sort_specs = [:dob, desc: :likes]
      iex> {:ok, comp_fun_ast} = SortSpecs.to_quoted(sort_specs)
      iex> {:&, _meta, [args]} = comp_fun_ast
      iex> {comp_fun, []} = Code.eval_quoted(comp_fun_ast)
      iex> is_function(comp_fun, 2) and Macro.to_string(args)
      """
      cond do
        &1[:dob] < &2[:dob] -> true
        &1[:dob] > &2[:dob] -> false
        &1[:likes] > &2[:likes] -> true
        &1[:likes] < &2[:likes] -> false
        true -> true or &1 * &2
      end
      """
      |> String.trim_trailing()
  
      # Runtime sort specs...
      iex> alias MapSorter.SortSpecs
      iex> sort_specs = quote do: Tuple.to_list({:dob, {:desc, :likes}})
      iex> {:ok, comp_fun_ast} = SortSpecs.to_quoted(sort_specs)
      iex> {{:., _, nested_args}, _meta, [args]} = comp_fun_ast
      iex> {comp_fun, []} = Code.eval_quoted(comp_fun_ast)
      iex> is_function(comp_fun, 2) and
      ...> {Macro.to_string(nested_args), Macro.to_string(args)}
      {"[MapSorter.Compare, :fun]", "Tuple.to_list({:dob, {:desc, :likes}})"}
  '''
  @spec to_quoted(t | Macro.t()) :: {:ok, Macro.t()} | {:error, Macro.t()}
  def to_quoted(sort_specs) when is_list(sort_specs) do
    {:ok, fun_ast} = Compare.heredoc(sort_specs) |> Code.string_to_quoted()
    :ok = Log.debug(:compile_time_comp_fun_ast, {sort_specs, fun_ast, __ENV__})
    {:ok, fun_ast}
  end

  # Sort specs cannot be a map...
  def to_quoted({:%{}, _, _} = sort_specs) do
    {:error, sort_specs}
  end

  # Sort specs cannot be a tuple...
  def to_quoted({:{}, _, _} = sort_specs) do
    {:error, sort_specs}
  end

  # Sort specs evaluated at runtime...
  def to_quoted({_, meta, _} = sort_specs) when is_list(meta) do
    # Injected code should refer to a function by its fully qualified name...
    fun_ast = quote do: MapSorter.Compare.fun(unquote(sort_specs))
    :ok = Log.debug(:runtime_comp_fun_ast, {sort_specs, fun_ast, __ENV__})
    {:ok, fun_ast}
  end

  # Sort specs cannot be other terms...
  def to_quoted(sort_specs) do
    {:error, sort_specs}
  end
end