lib/nosedrum/text_command/invoker/split.ex

defmodule Nosedrum.TextCommand.Invoker.Split do
  @moduledoc """
  An `OptionParser.split/1`-based command processor.

  This parser supports command prefixes configured via the `nosedrum.prefix`
  configuration variable. You can specify a single prefix (as a string):

      config :nosedrum,
        prefix: "!"

  Or a multiple prefixes (as a list of strings):

      config :nosedrum,
        prefix: ["!", "?"]

  If multiple prefixes are specified, the first match in the list will be used.
  For example, if the prefixes are `["a", "ab"]`, then the message `"ab foo"`
  will invoke the command `b`, with the argument `foo`.  However, if the prefixes
  were ordered `["ab", "a"]`, the message `"abfoo"` would invoke the command
  `foo` with no arguments.

  The default prefix is `.`, and the prefix are looked up at compilation time
  due to the nature of Elixir's binary matching. This means that if you change
  your prefix, you need to recompile this module, usually using
  `mix deps.compile --force nosedrum`.

  This invoker checks predicates and returns predicate failures to the caller.
  """

  @behaviour Nosedrum.TextCommand.Invoker

  # This must be looked up at compilation time due to the nature of Elixir's
  # binary matching. Also, SPEEEEEEEEEEEEEED!!
  @prefix Application.compile_env(:nosedrum, :prefix, ".")

  alias Nosedrum.Helpers
  alias Nosedrum.TextCommand.Predicates
  alias Nostrum.Struct.Message

  @spec remove_prefix(String.t()) :: String.t() | :not_found
  if is_list(@prefix) do
    defp remove_prefix(message) do
      with(
        # Find the prefix that was used in the message, going through the list of prefixes
        real_prefix when real_prefix != :not_found <-
          Enum.find(
            @prefix,
            :not_found,
            &String.starts_with?(message, &1)
          ),
        # Then, just remove the prefix from the message
        prefix_length <- byte_size(real_prefix),
        content <-
          message
          |> binary_part(prefix_length, byte_size(message) - prefix_length)
      ) do
        content
      else
        _no_match -> :not_found
      end
    end
  else
    defp remove_prefix(message) do
      # credo:disable-for-next-line
      with @prefix <> content <- message do
        content
      else
        _no_match -> :not_found
      end
    end
  end

  @doc """
  Handle the given message.

  This involves checking whether the message starts with the given prefix, splitting
  the message into command and arguments, looking up a candidate command, and finally
  resolving it and invoking the module.

  ## Arguments

  - `message`: The message to handle.
  - `storage`: The storage implementation the command invoker should use.
  - `storage_process`: The storage process, ETS table, or similar that is used by
    the storage process. For instance, this allows you to use different ETS tables
    for the `Nosedrum.TextCommand.Storage.ETS` module if you wish.

  ## Return value

  Returns `:ignored` if one of the following applies:
  - The message does not start with the configured prefix.
  - The message only contains the configured prefix.
  - No command could be looked up that matches the command the message invokes.

  ## Examples

      iex> Nosedrum.TextCommand.Invoker.Split.handle_message(%{content: "foo"})
      :ignored
      iex> Nosedrum.TextCommand.Invoker.Split.handle_message(%{content: "."})
      :ignored
  """
  @spec handle_message(Message.t(), module, atom() | pid()) ::
          :ignored
          | {:error, {:unknown_subcommand, String.t(), :known, [String.t() | :default]}}
          | {:error, :predicate, {:error | :noperm, any()}}
          | any()
  def handle_message(
        message,
        storage \\ Nosedrum.TextCommand.Storage.ETS,
        storage_process \\ :nosedrum_commands
      ) do
    with content when content != :not_found <- remove_prefix(message.content),
         [command | args] <- Helpers.quoted_split(content),
         cog when cog != nil <- storage.lookup_command(command, storage_process) do
      handle_command(cog, message, args)
    else
      _mismatch ->
        :ignored
    end
  end

  @spec parse_args(module(), [String.t()]) :: [String.t()] | any()
  defp parse_args(command_module, args) do
    if function_exported?(command_module, :parse_args, 1) do
      command_module.parse_args(args)
    else
      args
    end
  end

  @spec invoke(module(), Message.t(), [String.t()]) ::
          any() | {:error, :predicate, {:noperm | :error, any()}}
  defp invoke(command_module, msg, args) do
    case Predicates.evaluate(msg, command_module.predicates()) do
      :passthrough ->
        command_module.command(msg, parse_args(command_module, args))

      {atom, _reason} = response when atom in [:noperm, :error] ->
        {:error, :predicate, response}
    end
  end

  @spec handle_command(map() | module(), Message.t(), [String.t()]) ::
          :ignored
          | {:error, {:unknown_subcommand, String.t(), :known, [String.t() | :default]}}
          | {:error, :predicate, {:error | :noperm, any()}}
          | any()
  defp handle_command(command_map, msg, original_args) when is_map(command_map) do
    maybe_subcommand = List.first(original_args)

    case Map.fetch(command_map, maybe_subcommand) do
      {:ok, subcommand} ->
        # If we have at least one subcommand, that means `original_args`
        # needs to at least contain one element, so `args` is either empty
        # or the rest of the arguments excluding the subcommand name.
        [_subcommand | args] = original_args
        # Recursively traverse down to a command module via the `is_map/1`
        # guard clause attached to this function head.
        handle_command(subcommand, msg, args)

      :error ->
        # Does the command group have a default command to invoke?
        if Map.has_key?(command_map, :default) do
          # If yes, invoke it with all arguments.
          invoke(command_map.default, msg, original_args)
        else
          {:error, {:unknown_subcommand, maybe_subcommand, :known, Map.keys(command_map)}}
        end
    end
  end

  defp handle_command(command_module, msg, args) do
    invoke(command_module, msg, args)
  end
end