defmodule CliAppHelper do
@moduledoc """
Helps with the creation of basic CLI applications for Polymorfiq LLC
"""
@type entrypoint :: module()
@type command :: module()
@doc """
Runs the given CLI Application, utilizing the entrypoint's metadata.
Uses `CliAppHelper.Entrypoint.helper_text/0` to get the application's help text.
Uses `CliAppHelper.Entrypoint.commands/0` to get the application's available commands.
"""
@spec run(entrypoint(), [String.t()]) :: nil
def run(entrypoint, args) do
cmd = Enum.at(args, 0, nil)
resp =
case {cmd, entrypoint.commands()} do
{_, %{^cmd => command}} -> run_command(command, args)
{nil, _} -> {:error, entrypoint.helper_text()}
_ -> {:error, "#{err("Unknown command", cmd)}\n\n#{entrypoint.helper_text()}"}
end
case resp do
{:error, error} ->
IO.puts(:stderr, error)
System.stop(1)
:ok ->
System.stop(0)
end
end
@doc """
Runs the given command with the given args, utilizing the command's metadata to parse options.
Uses `CliAppHelper.Command.helper_text/0` to get the command's help text.
Uses `CliAppHelper.Command.flags/0` to get the command's available command-line flags.
Uses `CliAppHelper.Command.run/2` to run the command with the parsed args and flags.
"""
@spec run_command(command(), [String.t()]) :: :ok | {:error, String.t()}
def run_command(command, args) do
with {:ok, {args, flags}} <- parse_args(args, command) do
command.run(args, flags)
end
end
@spec parse_args([String.t()], command()) ::
{:ok, {args :: [String.t()], flags :: keyword()}} | {:error, String.t()}
defp parse_args(cli_args, command) do
switches =
command.flags()
|> Enum.reduce([], fn {name, props}, curr_switches ->
Keyword.put(curr_switches, name, props[:type])
end)
aliases =
command.flags()
|> Enum.flat_map(fn {name, props} ->
Keyword.get(props, :aliases, []) |> Enum.map(fn curr_alias -> {curr_alias, name} end)
end)
required_flags =
command.flags()
|> Enum.filter(fn {_, props} ->
Keyword.get(props, :required, false)
end)
cli_opts =
OptionParser.parse(cli_args,
strict: switches,
aliases: aliases
)
case cli_opts do
{flags, args, []} ->
with :ok <- validate_required_switches(required_flags, flags) do
{:ok, {args, flags}}
end
{_, _, errors} ->
{:error, get_parse_error(command, errors)}
end
end
@spec validate_required_switches(keyword(), keyword()) :: :ok | {:error, String.t()}
defp validate_required_switches(required_flags, flags) do
required_flags
|> Enum.reduce(:ok, fn {name, _}, curr_state ->
with :ok <- curr_state do
if Keyword.get(flags, name, nil) != nil,
do: :ok,
else: {:error, err("Missing required flag", "--#{name}")}
end
end)
end
@spec get_parse_error(command(), [term()]) :: String.t()
defp get_parse_error(command, errors) do
error_text =
errors
|> Enum.map_join("\n", fn error ->
case error do
message when is_binary(message) ->
message
{flag, nil} when is_binary(flag) ->
err("Unknown flag", flag)
{flag, val} when is_binary(flag) ->
err("Invalid value", "#{flag} cannot be set to '#{val}'")
err ->
err("Unknown error", "#{inspect(err)}")
end
end)
error_text <> "\n\n" <> command.helper_text()
end
@spec err(String.t(), String.t()) :: String.t()
defp err(name, val) do
IO.ANSI.bright() <> "#{name}: " <> IO.ANSI.reset() <> "#{val}"
end
end