lib/zig/quote_erl.ex

defmodule Zig.QuoteErl do
  def quote_erl(quoted, substitutions \\ []) do
    # erlang separates tokenization and parsing into separate modules.
    # tokenization occurs in the erl_scan module, and this produces a tuple
    # result as follows:
    {:ok, tokens, _} =
      quoted
      |> String.to_charlist()
      |> :erl_scan.string()

    # forms, according to the erlang parser, are what you input when you're
    # compiling a .erl file (versus working with erlang in a console).  We
    # need to pre-chunk the token stream into individual 'forms' which are
    # delimited by the dot token.
    form_list =
      tokens
      |> substitute_unquoted(substitutions)
      |> Enum.chunk_while(
        [],
        fn
          dot = {:dot, _}, acc ->
            {:cont, Enum.reverse([dot | acc]), []}

          other, acc ->
            {:cont, [other | acc]}
        end,
        fn acc -> {:cont, acc} end
      )

    Enum.map(form_list, fn form ->
      case :erl_parse.parse_form(form) do
        {:ok, result} -> result
      end
    end)
  rescue
    e ->
      IO.warn("quote_erl failed with #{inspect(e)}")
      reraise e, __STACKTRACE__
  end

  defp substitute_unquoted(tokens, substitutions, so_far \\ [])

  @lparen :"("
  @rparen :")"

  # unquote a splatted value
  defp substitute_unquoted(
         [
           {:atom, _, :unquote},
           {@lparen, _},
           {:..., _},
           {:atom, line, symbol},
           {@rparen, _} | rest
         ],
         substitutions,
         so_far
       ) do
    new_so_far =
      substitutions
      |> escaped_substitution(symbol, line, splat: true)
      |> Enum.intersperse({:",", line})
      |> Enum.reverse(so_far)

    substitute_unquoted(rest, substitutions, new_so_far)
  end

  # unquote a single value
  defp substitute_unquoted(
         [{:atom, _, :unquote}, {@lparen, _}, {:atom, line, symbol}, {@rparen, _} | rest],
         substitutions,
         so_far
       ) do
    substitution = escaped_substitution(substitutions, symbol, line)
    substitute_unquoted(rest, substitutions, [substitution | so_far])
  end

  # unquote spliced prongs
  defp substitute_unquoted(
         [{:atom, _, :splice_prongs}, {@lparen, _}, {:atom, line, symbol}, {@rparen, _} | rest],
         substitutions,
         so_far
       ) do
    escaped =
      substitutions
      |> Keyword.fetch!(symbol)
      |> Enum.map(fn clause ->
        {:ok, tokens, _} =
          clause
          |> String.to_charlist()
          |> :erl_scan.string()

        tokens
      end)
      |> Enum.intersperse([{:";", line}])
      |> List.flatten()

    substitute_unquoted(rest, substitutions, Enum.reverse(escaped, so_far))
  end

  defp substitute_unquoted([head | rest], substitutions, so_far),
    do: substitute_unquoted(rest, substitutions, [head | so_far])

  defp substitute_unquoted([], _substitutions, so_far), do: Enum.reverse(so_far)

  defp escaped_substitution(substitutions, key, line, opts \\ []) do
    splat = Keyword.get(opts, :splat, false)

    case Keyword.fetch!(substitutions, key) do
      list when is_list(list) and splat ->
        Enum.map(list, &escape(&1, line))

      term ->
        escape(term, line)
    end
  end

  defp escape(atom, line) when is_atom(atom), do: {:atom, line, atom}
  defp escape(charlist, line) when is_list(charlist), do: {:string, line, charlist}
  defp escape({:var, atom}, line) when is_atom(atom), do: {:var, line, atom}
end