Skip to main content

lib/clik.ex

defmodule Clik do
  alias Clik.CommandEnvironment
  alias Clik.{Command, Configuration}
  alias Clik.Output.HelpFormatter

  @typedoc "Raw args from CLI"
  @type argv :: [] | [String.t()]

  @typedoc "Error during arg parsing or command dispatch"
  @type error ::
          {:missing_option, atom()}
          | {:unknown_command, atom()}
          | {:unknown_options, [String.t()]}
          | {:error, :missing_command}
          | {:error, atom()}

  @typedoc "Execution result"
  @type result :: :ok | error()

  @doc """
  CLI execution entry point.

  Parses CLI args based on the configuration and calls the correct command.
  `run/2` should be called from the main function.

  ## Example
  ```
  defmodule MyCLIApp do
    alias Clik.Configuration

    def main(args) do
      config =
        Configuration.add_command!(%Configuration{}, Command.new!(:default, MyCLIApp.DefaultCommand))
      case Clik.run(config, args) do
        :ok ->
          :erlang.halt(0)
        {:error, reason} ->
          :erlang.halt(1)
    end
  end
  ```
  """
  @doc since: "0.1.0"
  @spec run(Configuration.t(), argv(), CommandEnvironment.t()) :: result()
  def run(config, args, env) do
    if Enum.count(config.commands) == 1 do
      dispatch_to_default(config, env, args)
    else
      resolve_and_dispatch(config, env, args)
    end
  end

  defp dispatch_to_default(config, env, args) do
    {:ok, options} = Configuration.prepare(config)

    case OptionParser.parse(args, options) do
      {parsed, remaining, []} ->
        case check_parsed_options(Configuration.options!(config), parsed) do
          {:ok, updated_options} ->
            [cmd] = Map.values(config.commands)
            run_command(cmd, config, %{env | options: updated_options, arguments: remaining})

          error ->
            error
        end

      {_, _, errors} ->
        option_names = for {option, _} <- errors, do: option
        {:unknown_options, option_names}
    end
  end

  defp resolve_and_dispatch(config, env, args) do
    case OptionParser.parse(args, switches: [help: :boolean], aliases: [h: :help]) do
      {parsed, [], _} ->
        if Keyword.has_key?(parsed, :help) do
          show_script_help(config, env)
        else
          {:error, :missing_command}
        end

      {_, [command_name | _], _} ->
        with {:ok, resolved} <- resolve_command_name(config, command_name) do
          updated = args -- [command_name]
          dispatch_to_command(resolved, config, env, updated)
        end
    end
  end

  defp dispatch_to_command(command_name, config, env, args) do
    {:ok, options} = Configuration.prepare(config, command_name)

    case OptionParser.parse(args, options) do
      {parsed, remaining, []} ->
        case check_parsed_options(Configuration.options!(config, command_name), parsed) do
          {:ok, updated_options} ->
            cmd = Configuration.command!(config, command_name)
            run_command(cmd, config, %{env | options: updated_options, arguments: remaining})

          error ->
            error
        end

      {_, _, errors} ->
        option_names = for {option, _} <- errors, do: option
        {:unknown_options, option_names}
    end
  end

  defp resolve_command_name(config, name) do
    case convert_command_name(name) do
      nil ->
        {:unknown_command, name}

      converted ->
        cond do
          Configuration.has_command?(config, converted) ->
            {:ok, converted}

          true ->
            {:unknown_command, name}
        end
    end
  end

  defp convert_command_name(name) when is_bitstring(name) do
    try do
      String.to_existing_atom(name)
    rescue
      ArgumentError ->
        nil
    end
  end

  defp check_parsed_options(options, parsed) do
    {required, defaults} =
      Map.values(options)
      |> Enum.filter(&(&1.required == true or &1.default != nil))
      |> Enum.split_with(&(&1.required == true))

    case check_required(required, parsed) do
      true ->
        add_defaults(defaults, parsed)

      error ->
        error
    end
  end

  defp check_required(required, parsed) do
    Enum.reduce_while(required, true, fn opt, _ ->
      if Keyword.has_key?(parsed, opt.long) do
        {:cont, true}
      else
        {:halt, {:missing_option, opt.long}}
      end
    end)
  end

  defp add_defaults(defaults, parsed) do
    updated =
      Enum.reduce(defaults, parsed, fn opt, acc ->
        if Keyword.has_key?(acc, opt.long) do
          acc
        else
          [{opt.long, opt.default} | acc]
        end
      end)

    {:ok, updated}
  end

  defp run_command(cmd, config, env) do
    if Keyword.has_key?(env.options, :help) do
      show_command_help(config, env, cmd.name)
    else
      Command.run(cmd, env)
    end
  end

  defp show_script_help(config, env) do
    doc = HelpFormatter.script_help(config)
    Clik.Renderable.render(doc, env.output)
  end

  defp show_command_help(config, env, cmd_name) do
    doc = HelpFormatter.command_help!(config, cmd_name)
    Clik.Renderable.render(doc, env.output)
  end
end