lib/history/history_bindings.ex

#
# MIT License
#
# Copyright (c) 2021 Matthew Evans
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#

defmodule History.Bindings do

  @ets_name "ets_history_bindings"
  @store_name "history_bindings"
  @bindings_check_interval 2500

  @doc false
  def initialize(:not_ok), do: :not_ok

  @doc false
  def initialize(config) do
    scope = Keyword.get(config, :scope, :local)
    save_bindings? = Keyword.get(config, :save_bindings, true)
    if save_bindings?, do: History.persistence_mode(scope) |> do_initialize()
    config
  end

  @doc false
  def do_initialize({:ok, true, scope, my_node}) do
    db_labels = init_stores(scope, my_node)
    server_pid = find_server()
    shell_pid = self()
    reg_name = make_reg_name()
    leader = Process.group_leader()
    load_bindings(db_labels)

    if Process.whereis(reg_name) == nil do
      spawn(fn ->
        Process.register(self(), reg_name)
        Process.monitor(server_pid)
        Process.monitor(shell_pid)
        Process.send_after(self(), :check_bindings, @bindings_check_interval)
        binding_evaluator_loop(%{binding_count: 0, shell_pid: shell_pid, server_pid: server_pid, group_leader_pid: leader, db_labels: db_labels})
      end)
    end

  end

  @doc false
  def do_initialize(_), do: :not_ok

  @doc false
  def get_bindings() do
    try do
      :ets.tab2list(Process.get(:history_bindings_ets_label))
    catch
      _,_ -> []
    end
  end

  @doc false
  def get_value(label, ets_name) do
    case :ets.lookup(ets_name, label) do
      [{_, value}] -> value
      _ -> nil
    end
  end

  @doc false
  def unbind(vars) do
    save_bindings? = History.configuration(:save_bindings, true)
    if save_bindings? do
      send_msg({:unbind, vars, self()})
      wait_rsp(:ok_done)
      set_bindings_for_shell()
      :ok
    else
      :bindings_disabled
    end
  end

  @doc false
  def clear() do
    save_bindings? = History.configuration(:save_bindings, true)
    if save_bindings? do
      send_msg({:clear, self()})
      wait_rsp(:ok_done)
      clear_bindings_from_shell()
    else
      :bindings_disabled
    end
  end

  @doc false
  def stop_clear() do
    save_bindings? = History.configuration(:save_bindings, true)
    if save_bindings? do
      send_msg({:stop_clear, self()})
      wait_rsp(:ok_done)
      clear_bindings_from_shell()
    else
      :bindings_disabled
    end
  end

  @doc false
  def state(pretty \\ false) do
    save_bindings? = History.configuration(:save_bindings, true)
    if save_bindings? do
      send_msg({:state, self()})
      {_, rsp} = wait_rsp({:state, :_})
      string = "#{IO.ANSI.white()}Current bindings are #{IO.ANSI.red()}#{rsp.binding_count}#{IO.ANSI.white()} variables in size"
      if pretty, do: IO.puts("#{string}"), else: string
    else
      string = "Bindings are not been saved."
      if pretty, do: IO.puts("#{string}"), else: string
    end
  end

  @doc false
  def raw_state() do
    save_bindings? = History.configuration(:save_bindings, true)
    if save_bindings? do
      send_msg({:state, self()})
      wait_rsp({:state, :_})
    else
      :bindings_disabled
    end
  end

  @doc false
  def inject_command(command) do
    server = find_server()
    send(self(), {:eval, server, command, %IEx.State{}})
  end

  @doc false
  def find_server(), do:
    Process.info(self())[:dictionary][:iex_server]

  defp init_stores(scope, my_node) do
    str_label = if scope in [:node, :local],
                   do: "#{scope}_#{my_node}",
                   else: Atom.to_string(scope)
    ets_name = String.to_atom("#{@ets_name}_#{str_label}")
    store_name = String.to_atom("#{@store_name}_#{str_label}")
    store_filename = to_charlist("#{History.get_log_path()}/bindings_#{str_label}.dat")
    Process.put(:history_bindings_ets_label, ets_name)

    if :ets.info(ets_name) == :undefined do
      :ets.new(ets_name, [:named_table, :public])
      :ets.give_away(ets_name, :erlang.whereis(:init), [])
    end
    History.Store.open_store(store_name, store_filename, scope)

    %{ets_name: ets_name, store_name: store_name, store_filename: store_filename}
  end

  defp binding_evaluator_loop(%{db_labels: db_labels} = config) do
    receive do
      {:state, pid} ->
        size = :ets.info(config.db_labels.ets_name, :size)
        send(pid, {:state, %{config | binding_count: size}})
        binding_evaluator_loop(config)

      {:clear, pid} ->
        :ets.delete_all_objects(db_labels.ets_name)
        History.Store.delete_all_objects(db_labels.store_name)
        send(pid, :ok_done)
        size = :ets.info(config.db_labels.ets_name, :size)
        binding_evaluator_loop(%{config | binding_count: size})

      {:stop_clear, pid} ->
        :ets.delete_all_objects(db_labels.ets_name)
        History.Store.delete_all_objects(db_labels.store_name)
        History.Store.close_store(db_labels.store_name)
        send(pid, :ok_done)

      {:unbind, vars, pid} ->
        Enum.map(vars, fn label ->
          :ets.delete(db_labels.ets_name, label)
          History.Store.delete_data(db_labels.store_name, label)
        end)
        size = :ets.info(config.db_labels.ets_name, :size)
        send(pid, :ok_done)
        binding_evaluator_loop(%{config | binding_count: size})

      :check_bindings ->
        new_bindings = get_bindings_from_shell(config)
        persist_bindings(new_bindings, db_labels)
        Process.send_after(self(), :check_bindings, @bindings_check_interval)
        binding_evaluator_loop(config)

      {:DOWN, _ref, :process, _object, _reason} ->
        :ok

      _ ->
        binding_evaluator_loop(config)
    end
  end

  defp persist_bindings([], _), do: :ok

  defp persist_bindings(bindings, %{ets_name: ets_name, store_name: store_name}) do
    Enum.map(bindings, fn {label, value} ->
      case :ets.lookup(ets_name, label) do
        _ when value == :could_not_bind ->
          :ets.delete(ets_name, label)
          History.Store.delete_data(store_name, label)

        [{_, ^value}] ->
          :ok

        _ ->
          :ets.insert(ets_name, {label, value})
          History.Store.save_data(store_name, {label, value})
      end
    end)
  end

  defp get_bindings_from_shell(%{shell_pid: shell_pid, server_pid: server_pid} = _config) do
    variables =
      IEx.Evaluator.variables_from_binding(shell_pid, server_pid, "")
      |> Enum.map(&String.to_atom(&1))

    bindings =
      for var <- variables do
            try do
              elem(IEx.Evaluator.value_from_binding(shell_pid, server_pid, var, %{}), 1)
            catch
              _,_ -> :could_not_bind
            end
         end
    Enum.zip(variables, bindings)
  end

  defp load_bindings(%{ets_name: ets_name, store_name: store_name}) do
    bindings = History.Store.foldl(store_name, [],
              fn {label, value}, acc ->
                :ets.insert(ets_name, {label, value})
                ["#{label} = History.Bindings.get_value(:#{label},:#{ets_name}); " | acc]
            end) |> List.to_string()
    inject_command(bindings <> " :ok")
  end

  defp clear_bindings_from_shell() do
    inject_command("IEx.Evaluator.init(:ack, History.Bindings.find_server(), Process.group_leader(), [binding: []])")
  end

  defp set_bindings_for_shell() do
    ets_name = Process.get(:history_bindings_ets_label)
    clear_bindings_from_shell()
    bindings = :ets.foldl(
                   fn {label, _value}, acc ->
                     ["#{label} = History.Bindings.get_value(:#{label},:#{ets_name}); " | acc]
                   end, [], ets_name) |> List.to_string()
    inject_command(bindings <> " :ok")
  end

  defp make_reg_name() do
    gl_node = History.my_real_node() |> Atom.to_string()
    String.to_atom("history_binding_finder_#{gl_node}")
  end

  defp send_msg(event) do
    try do
      send(Process.whereis(make_reg_name()), event)
    catch
      _,_ -> :error
    end
  end

  defp wait_rsp(what) do
    receive do
      ^what -> :ok;
      {:state, state} -> {:state, state}
    after
      1000 -> :nok
    end
  end

end