lib/history.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 do
  @moduledoc """
    Saves shell history and optionally variable bindings between shell sessions.

    Allows the user to display history, and re-issue historic commands, made much easier since the
    variable bindings are saved.

    For ease History can be enabled in `~/.iex.exs` for example:

      Code.append_path("~/github/history/_build/dev/lib/iex_history/ebin")
      History.initialize(history_limit: 200, scope: :local, show_date: true, colors: [index: :red])

    Of course `Code.append_path ` may not be required depending on how the project is imported.

    The following options can be set:

      [
        scope: :local,
        history_limit: :infinity,
        hide_history_commands: true,
        prepend_identifiers: true,
        command_display_width: int,
        save_invalid_results: false,
        key_buffer_history: true,
        show_date: true,
        save_bindings: true,
        colors: [
          index: :red,
          date: :green,
          command: :yellow,
          label: :red,
          variable: :green
        ]
      ]

    `:hide_history_commands ` This will prevent all calls to `History.*` from been saved.

    NOTE: `History.x/1` is always hidden. Scope of `:global` will only hide them from output, otherwise they will not be saved.

    `:save_invalid_results ` If set to false, the default, commands that were evaluated incorrectly will not be saved.

    `:key_buffer_history ` If set to true will allow the user to scroll up (ctrl+u) or down (ctrl+k) through history.
    Unlike the standard up/down arrow history this is command based not line based. So pasting of a large structure will only require 1 up or down.
    This mechanism also saves commands that were not properly evaluated; however there is a buffer limit of 75 lines, although this can be changed by updating
    `@history_buffer_size` in `events_server.ex`. This will also not duplicate back to back identical commands.

    `:prepend_identifiers ` If this is enabled it will prepend identifiers when a call to `x = History(val)` is issued.

    For example:

      enabled:
          iex> time = Time.utc_now().second
          14
          iex> new_time = History.x(1)
          22

          iex> new_time
          22                  # New time is assigned to variable time
          iex> time
          13                  # However, the original date variable is unchanged

          iex> History.h()
          1: 2021-09-01 17:13:13: time = Time.utc_now().second
          2: 2021-09-01 17:13:22: new_time =  time = Time.utc_now().second    # We see the binding to new_time

        disabled:
          iex> time = Time.utc_now().second
          43
          iex> new_time = History.x(1)
          50

          iex> new_time       # New time is assigned to variable time
          50
          iex> time
          50                  # However, this time the original time variable has also changed

          iex> History.h
          1: 2021-09-01 17:17:43: time = Time.utc_now().second
          2: 2021-09-01 17:17:50: time = Time.utc_now().second      # We do not see the binding to new_time


    `scope` can be one of `:local, :global `or a `node name`

    If `scope` is `:local` (the default) history will be active on all shells, even those that are remotely connected, but the history for each shell will be unique

    If `scope` is `node()` (e.g. `:mgr@localhost`) history will only be active on that shell

    If `scope` is `:global` history will be shared between all shells. However the saving of variable bindings will be disabled along with the date/time in history

    Furthermore, if a `scope` of `:global` is selected following kernel option must be set, either directly as VM options or via an environment variable:

      export ERL_AFLAGS="-kernel shell_history enabled"

      --erl "-kernel shell_history enabled"

    A word about aliases. Rather than using something like `alias History, as: H`, please use `History.alias(H)` instead.
  """

  @version "4.2"
  @module_name String.trim_leading(Atom.to_string(__MODULE__), "Elixir.")
  @exec_name String.trim_leading(Atom.to_string(__MODULE__) <> ".x", "Elixir.")

  @excluded_functions [".h", ".x", ".c(", ".c "]
  @exclude_from_history for f <- @excluded_functions, do: @module_name <> f

  @default_width 150
  @default_colors [index: :red, date: :green, command: :yellow, label: :red, variable: :green]
  @default_config [scope: :local, history_limit: :infinity, hide_history_commands: true, prepend_identifiers: true,
                   show_date: true, save_bindings: true, command_display_width: @default_width,
                   save_invalid_results: false, key_buffer_history: true, colors: @default_colors]

  @doc """
    Initializes the History app. Takes the following parameters:

      [
        scope: :local,
        history_limit: :infinity,
        hide_history_commands: true,
        prepend_identifiers: true,
        key_buffer_history: true,
        command_display_width: :int,
        save_invalid_results: false,
        show_date: true,
        save_bindings: true,
        colors: [
          index: :red,
          date: :green,
          command: :yellow,
          label: :red,
          variable: :green
        ]
      ]

    Alternatively a filename can be given that was saved with `History.save_config()`

    `scope` can be one of `:local, :global` or a `node()` name
  """
  def initialize(config_or_filename \\ []) do
    config = do_load_config(config_or_filename)
    if history_configured?(config) && not is_enabled?() do
      new_config = init_save_config(config)
      History.Bindings.inject_command("IEx.configure(colors: [syntax_colors: [atom: :black]])")
      History.Events.initialize(new_config)
      |> History.Bindings.initialize()
      |> set_enabled()
      |> present_welcome()
    else
      if is_enabled?(), do: :history_already_enabled, else: :history_disabled
    end
  end

  @doc """
    If you want to setup an alias like `alias History, as: H` rather than using `alias/2`
    from the shell, please use this function instead. So to create an alias of `H` use `History.alias(H)`.
    This allows aliased functions to be handled correctly.
  """
  def alias(name) when is_atom(name) do
    if Process.get(:history_alias) == nil do
      string_name = Atom.to_string(name) |> String.replace("Elixir.", "")
      inject_command_all_servers("alias(#{__MODULE__}, as: #{string_name})")
      excluded = for fun <- @excluded_functions, do: string_name <> fun
      base_name = string_name <> "."
      Process.put(:history_alias, base_name)
      History.Events.send_message({:module_alias, base_name})
      ## TODO: Find a better way
      :persistent_term.put(:history_aliases, excluded ++ :persistent_term.get(:history_aliases, []))
    end
  end

  @doc """
    Displays the current configuration.
  """
  def configuration(), do:
    Process.get(:history_config, [])

  @doc """
    Displays the default configuration.
  """
  def default_config(), do: @default_config

  @doc """
    Displays the current state:

      History version 2.0 is enabled:
        Current history is 199 commands in size.
        Current bindings are 153 variables in size.
  """
  def state() do
    IO.puts("History version #{IO.ANSI.red()}#{@version}#{IO.ANSI.white()} is enabled:")
    IO.puts("  #{History.Events.state()}.")
    IO.puts("  #{History.Bindings.state()}.")
  end

  @doc """
    Displays the entire history.
  """
  def h() do
    is_enabled!()
    try do
      History.Events.get_history()
    catch
      _,_ -> {:error, :not_found}
    end
  end

  @doc """
    If the argument is a string it displays the history that contain or match entirely the passed argument.
    If the argument is a positive integer it displays the command at that index.
    If the argument is a negative number it displays the history that many items from the end.
  """
  @spec h(String.t() | integer) :: atom
  def h(val)

  def h(match) do
    is_enabled!()
    try do
      History.Events.get_history_item(match)
    catch
      _,_ -> {:error, :not_found}
    end
  end

  @doc """
    Specify a range, the atoms :start and :stop can also be used.
  """
  def h(start, stop) do
    is_enabled!()
    try do
      History.Events.get_history_items(start, stop)
    catch
      _,_ -> {:error, :not_found}
    end
  end

  @doc """
    Invokes the command at index 'i'.
  """
  def x(i) do
    is_enabled!()
    try do
      History.Events.execute_history_item(i)
    catch
      _,{:badmatch, nil} -> {:error, :not_found}
      :error,%CompileError{description: descr} -> {:error, descr}
      e,r -> {e, r}
    end
  end

  @doc """
    Copies the command at index 'i' and pastes it to the shell
  """
  def c(i) do
    is_enabled!()
    try do
      History.Events.copy_paste_history_item(i)
    catch
      _,_ -> {:error, :not_found}
    end
  end

  @doc """
    Clears the history and bindings. If `scope` is `:global`
    the IEx session needs restarting for the changes to take effect.
  """
  def clear() do
    History.Events.clear()
    History.Bindings.clear()
    if History.configuration(:scope, :local) == :global, do:
      IO.puts("\n#{IO.ANSI.green()}Please restart your shell session for the changes to take effect")
    :ok
  end

  @doc """
    Clears the history only. If `scope` is `:global`
    the IEx session needs restarting for the changes to take effect. If a value is passed it will clear that many history
    entries from start, otherwise the entire history is cleared.
  """
  def clear_history(val \\ :all) do
    History.Events.clear_history(val)
    if History.configuration(:scope, :local) == :global && val == :all, do:
      IO.puts("\n#{IO.ANSI.green()}Please restart your shell session for the changes to take effect")
    :ok
  end

  @doc """
    Clears the bindings.
  """
  def clear_bindings() do
    History.Bindings.clear()
    :ok
  end

  @doc """
      Clears the history and bindings then stops the service. If `scope` is ` :global` the IEx session needs restarting for the changes to take effect.
  """
  def stop_clear() do
    History.Events.stop_clear()
    History.Bindings.stop_clear()
    if History.configuration(:scope, :local) == :global, do:
      IO.puts("\n#{IO.ANSI.green()}Please restart your shell session for the changes to take effect")
    :ok
  end

  @doc """
    Returns `true` or `false` depending on if history is enabled.
  """
  def is_enabled?() do
    Process.get(:history_is_enabled, false)
  end

  @doc """
    Returns the current shell bindings.
  """
  def get_bindings() do
    History.Bindings.get_bindings()
  end

  @doc """
    Unbinds a variable or list of variables (specify variables as atoms, e.g. foo becomes :foo).
  """
  def unbind(vars) when is_list(vars), do:
    History.Bindings.unbind(vars)
  def unbind(var), do:
    unbind([var])

  @doc """
    Saves the current configuration to file.
  """
  def save_config(filename) do
    data = :io_lib.format("~p.", [configuration()]) |> List.flatten()
    :file.write_file(filename, data)
  end

  @doc """
    Loads the current configuration to file `History.save_config()`.

    NOTE: Not all options can be set during run-time. Instead pass the filename as a single argument to `History.initialize()`
  """
  def load_config(filename) do
    config = do_load_config(filename)
    Process.put(:history_config, config)
    config
  end

  @doc """
    Allows the following options to be changed, but not saved:
      :show_date
      :history_limit
      :hide_history_commands,
      :prepend_identifiers,
      :save_bindings,
      :command_display_width,
      :save_invalid_results,
      :key_buffer_history,
      :colors

  Examples:
      History.configure(:colors, [index: :blue])
      History.configure(:prepend_identifiers, true)
  """
  @spec configure(Atom.t(), any) :: atom
  def configure(kry, val)

  def configure(:show_date, value) when value in [true, false] do
    new_config = List.keyreplace(configuration(), :show_date, 0, {:show_date, value})
    Process.put(:history_config, new_config)
    configuration()
  end

  def configure(:command_display_width, value) when is_integer(value) do
    new_config = List.keyreplace(configuration(), :command_display_width, 0, {:command_display_width, value})
    Process.put(:history_config, new_config)
    configuration()
  end

  def configure(:hide_history_commands, value) when value in [true, false] do
    new_config = List.keyreplace(configuration(), :hide_history_commands, 0, {:hide_history_commands, value})
    History.Events.send_message({:hide_history_commands, value})
    Process.put(:history_config, new_config)
    configuration()
  end

  def configure(:key_buffer_history, value) when value in [true, false] do
    new_config = List.keyreplace(configuration(), :key_buffer_history, 0, {:key_buffer_history, value})
    History.Events.send_message({:key_buffer_history, value})
    Process.put(:history_config, new_config)
    configuration()
  end

  def configure(:save_invalid_results, value) when value in [true, false] do
    new_config = List.keyreplace(configuration(), :save_invalid_results, 0, {:save_invalid_results, value})
    History.Events.send_message({:save_invalid_results, value})
    Process.put(:history_config, new_config)
    configuration()
  end

  def configure(:prepend_identifiers, value) when value in [true, false] do
    new_config = List.keyreplace(configuration(), :prepend_identifiers, 0, {:prepend_identifiers, value})
    History.Events.send_message({:prepend_identifiers, value})
    Process.put(:history_config, new_config)
    configuration()
  end

  def configure(:history_limit, value) when is_integer(value) or value == :infinity do
    new_config = List.keyreplace(configuration(), :history_limit, 0, {:history_limit, value})
    new_value = if value == :infinity, do: History.Events.infinity_limit(), else: value
    History.Events.send_message({:new_history_limit, new_value})
    Process.put(:history_config, new_config)
    configuration()
  end

  def configure(:save_bindings, value) when value in [true, false] do
    if configuration(:scope, :local) != :global do
      current_value = configuration(:save_bindings, true)
      new_config = List.keyreplace(configuration(), :save_bindings, 0, {:save_bindings, value})
      if current_value == true,
          do: History.Bindings.stop_clear(),
          else: History.Bindings.initialize(new_config)
      Process.put(:history_config, new_config)
      configuration()
    else
      {:error, :scope_is_global}
    end
  end

  def configure(:colors, keyword_list) do
    new_colors = Keyword.merge(configuration(:colors, []), keyword_list)
    new_config = List.keyreplace(configuration(), :colors, 0, {:colors, new_colors})
    Process.put(:history_config, new_config)
    configuration()
  end

  @doc false
  def get_color_code(for), do:
    Kernel.apply(IO.ANSI, configuration(:colors, @default_colors)[for], [])

  @doc false
  def get_log_path() do
    filename = :filename.basedir(:user_cache, 'erlang-history') |> to_string()
    File.mkdir_p!(filename)
    filename
  end

  @doc false
  def my_real_node(), do:
    :erlang.node(Process.group_leader())

  @doc false
  def module_name(), do: @module_name

  @doc false
  def exec_name(), do: @exec_name

  @doc false
  def exclude_from_history() do
    aliases = :persistent_term.get(:history_aliases, [])
    @exclude_from_history ++ aliases
  end

  @doc false
  def configuration(item, default), do:
    Keyword.get(configuration(), item, default)

  @doc false
  def persistence_mode(:local) do
    my_node = my_real_node()
    {:ok, true, :local, my_node}
  end

  @doc false
  def persistence_mode(:global), do:
    {:ok, true, :global, :no_node}

  @doc false
  def persistence_mode(node) when is_atom(node) do
    my_node = my_real_node()
    if my_node == node,
       do: {:ok, true, :local, my_node},
       else: {:ok, false, :no_label, :no_node}
  end

  @doc false
  def persistence_mode(node) when is_binary(node) do
    persistence_mode(String.to_atom(node))
  end

  @doc false
  def persistence_mode(_), do:
    {:ok, false, :no_label, :no_node}

  @doc false
  def inject_command(command), do:
    History.Bindings.inject_command(command)

  defp inject_command_all_servers(command) do
    Enum.each(Process.list(),
                  fn(pid) ->
                      if (server = Process.info(pid)[:dictionary][:iex_server]) != nil,
                         do: send(pid, {:eval, server, command, %IEx.State{}})
                  end)
  end

  defp is_enabled!() do
    if not is_enabled?(),
       do: raise(%ArgumentError{message: "History is not enabled"})
  end

  defp do_load_config(filename) when is_binary(filename) do
    {:ok, [config]} = :file.consult(filename)
    config
  end

  defp do_load_config(config), do:
    config

  defp init_save_config(config) do
    infinity_limit = History.Events.infinity_limit()
    colors = Keyword.get(config, :colors, @default_colors)
    new_colors = Enum.map(@default_colors,
      fn({key, default}) -> {key, Keyword.get(colors, key, default)}
      end)
    config = Keyword.delete(config, :colors)
    new_config = Enum.map(@default_config,
      fn({key, default}) ->
        default = if key == :colors, do: new_colors, else: default
        default = if key == :limit do
                     if default > infinity_limit,
                       do: infinity_limit,
                       else: default
                     else
                       default
                     end
        {key, Keyword.get(config, key, default)}
      end)
    if Keyword.get(new_config, :scope, :local) == :global  do
      newer_config = List.keyreplace(new_config, :save_bindings, 0, {:save_bindings, false})
      Process.put(:history_config, newer_config)
      newer_config
    else
      Process.put(:history_config, new_config)
      new_config
    end
  end

  defp history_configured?(config) do
    scope = Keyword.get(config, :scope, :local)
    if History.Events.does_current_scope_match?(scope) do
      my_node = my_real_node()
      if my_node == scope || scope in [:global, :local],
        do: true,
        else: false
    else
      false
    end
  end

  defp present_welcome(:not_ok), do:
    :ok

  defp present_welcome(_), do:
    History.Bindings.inject_command("History.state(); IEx.configure(colors: [syntax_colors: [atom: :cyan]])")

  defp set_enabled(config) do
    Process.put(:history_is_enabled, true)
    config
  end

end