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