defmodule CliMate do defmacro __using__(_) do cli_mod = __CALLER__.module quote bind_quoted: [cli_mod: cli_mod], location: :keep, generated: true do # ----------------------------------------------------------------------- # Shell # ----------------------------------------------------------------------- # Here we just basically rewrite what mix does, because we do not want to # rely on Mix to be started if we build escripts. def put_shell(module) do :persistent_term.put({__MODULE__, :shell}, module) end def shell do :persistent_term.get({__MODULE__, :shell}, __MODULE__) end # ----------------------------------------------------------------------- # Output # ----------------------------------------------------------------------- @doc false def _print(output, _kind, iodata) do IO.puts(output, IO.ANSI.format(iodata)) end def color(color, iodata) do [color, iodata, :default_color] end def error(iodata) do shell()._print(:stderr, :error, [:bright, color(:red, iodata)]) end def warn(iodata) do shell()._print(:stderr, :warn, color(:yellow, iodata)) end def debug(iodata) do shell()._print(:stdio, :debug, color(:cyan, iodata)) end def success(iodata) do shell()._print(:stdio, :info, color(:green, iodata)) end def writeln(iodata) do shell()._print(:stdio, :info, iodata) end def halt(n \\ 0) when is_integer(n) do shell()._halt(n) end def halt_success(iodata) do success(iodata) halt(0) end def halt_error(n \\ 1, iodata) do error(iodata) halt(n) end @doc false def _halt(n) do System.halt(n) end defmodule ProcessShell do @moduledoc false @doc false def cli_mod, do: unquote(cli_mod) @doc false def _print(_output, kind, iodata) do send(message_target(), {cli_mod(), kind, format_message(iodata)}) end defp format_message(iodata) do iodata |> IO.ANSI.format(false) |> :erlang.iolist_to_binary() end defp message_target() do case Process.get(:"$callers") do [parent | _] -> parent _ -> self() end end def _halt(n) do send(message_target(), {cli_mod(), :halt, n}) end end # ----------------------------------------------------------------------- # Defining commands # ----------------------------------------------------------------------- defmodule Option do @moduledoc false @enforce_keys [:key, :doc, :type, :short, :default, :keep] defstruct @enforce_keys @type vtype :: :integer | :float | :string | :count | :boolean @type t :: %__MODULE__{ key: atom, doc: binary, type: vtype, short: atom, default: term, keep: boolean } end defp build_option({key, conf}) when is_atom(key) and is_list(conf) do keep = Keyword.get(conf, :keep, false) type = Keyword.get(conf, :type, :string) doc = Keyword.get(conf, :doc, "") short = Keyword.get(conf, :short, nil) default = case Keyword.fetch(conf, :default) do {:ok, term} -> {:default, term} :error when type == :boolean -> {:default, false} :error -> :skip end opt = %Option{key: key, doc: doc, type: type, short: short, default: default, keep: keep} {key, opt} end defmodule Argument do @moduledoc false @enforce_keys [:key, :required, :cast, :doc] defstruct @enforce_keys @type t :: %__MODULE__{ required: boolean, key: atom, doc: binary, cast: (term -> term) | {module, atom, [term]} } end defp build_argument({key, conf}) when is_atom(key) and is_list(conf) do required = Keyword.get(conf, :required, true) cast = Keyword.get(conf, :cast, nil) doc = Keyword.get(conf, :doc, "") %Argument{key: key, required: required, cast: cast, doc: doc} end defmodule Command do @moduledoc false @enforce_keys [:arguments, :options] defstruct [:arguments, :options, :module, :name] @type t :: %__MODULE__{ arguments: [Argument.t()], options: [{atom, Option.t()}], module: module | nil, name: binary | nil } end defp build_command(conf) do options = conf |> Keyword.get(:options, []) |> Keyword.put_new(:help, type: :boolean, doc: "Displays this help.") |> Enum.map(&build_option/1) arguments = conf |> Keyword.get(:arguments, []) |> Enum.map(&build_argument/1) name = conf |> Keyword.get(:name, nil) module = conf |> Keyword.get(:module, nil) %Command{options: options, arguments: arguments, name: name, module: module} end # ----------------------------------------------------------------------- # Parser # ----------------------------------------------------------------------- def parse(argv, command) when is_list(command) do parse(argv, build_command(command)) end def parse(argv, %Command{} = command) do options = command.options arguments = command.arguments strict = Enum.map(options, fn {key, opt} -> {key, opt_to_switch(opt)} end) aliases = Enum.flat_map(options, fn {_, opt} -> opt_alias(opt) end) with {parsed_options, parsed_arguments, []} <- OptionParser.parse(argv, strict: strict, aliases: aliases), {:ok, %{help: false} = options_found} <- take_opts(options, parsed_options), {:ok, arguments_found} <- take_args(arguments, parsed_arguments) do {:ok, %{options: options_found, arguments: arguments_found}} else {:ok, %{help: true} = options_found} -> {:ok, %{options: options_found, arguments: []}} {_, _, invalid} -> {:error, {:invalid, invalid}} {:error, _} = err -> err end end defp opt_to_switch(%{keep: true, type: t}), do: [t, :keep] defp opt_to_switch(%{keep: false, type: t}), do: t defp opt_alias(%{short: nil}), do: [] defp opt_alias(%{short: a, key: key}), do: [{a, key}] defp take_opts(schemes, opts) do all = Enum.reduce(schemes, %{}, fn scheme, acc -> collect_opt(scheme, opts, acc) end) {:ok, all} end defp collect_opt({key, scheme}, opts, acc) do case scheme.keep do true -> list = collect_list_option(opts, key) Map.put(acc, key, list) false -> case get_opt_value(opts, key, scheme.default) do {:ok, value} -> Map.put(acc, key, value) :skip -> acc end end end def get_opt_value(opts, key, default) do case Keyword.fetch(opts, key) do :error -> case default do {:default, v} -> {:ok, v} :skip -> :skip end {:ok, v} -> {:ok, v} end end defp collect_list_option(opts, key) do opts |> Enum.filter(fn {k, _} -> k == key end) |> Enum.map(&elem(&1, 1)) end defp take_args(schemes = task, args) do take_args(schemes, args, %{}) end defp take_args([%{required: true, key: key} | _], [], _acc) do {:error, {:missing_argument, key}} end defp take_args([%{key: key, cast: cast} | schemes], [value | argv], acc) do case apply_cast(cast, value) do {:ok, casted} -> acc = Map.put(acc, key, casted) take_args(schemes, argv, acc) {:error, reason} -> {:error, {:argument_cast, key, reason}} other -> {:error, {:argument_cast, key, {:bad_return, other}}} end end defp take_args([], [extra | _], _) do {:error, {:extra_argument, extra}} end defp take_args([], [], acc) do {:ok, acc} end defp take_args([%{required: false} | _], [], acc) do {:ok, acc} end defp apply_cast(nil, value) do {:ok, value} end defp apply_cast(f, value) when is_function(f) do f.(value) end defp apply_cast({m, f, a}, value) do apply(m, f, [value | a]) end def parse_or_halt!(argv, command) do case parse(argv, command) do {:ok, %{options: %{help: true}}} -> writeln(format_usage(command)) halt(0) :halt {:ok, parsed} -> parsed {:error, reason} -> writeln(format_usage(command)) error(format_reason(reason)) halt(1) :halt end end defp format_reason({:argument_cast, key, reason}) do ["error when casting argument ", Atom.to_string(key), ": ", ensure_string(reason)] end defp format_reason({:argument_cast, key, {:bad_return, br}}) do ["could not cast argument ", Atom.to_string(key), " bad return: ", inspect(br)] end defp format_reason({:invalid, invalid}) do invalid |> Enum.map(fn {k, _v} -> "invalid option #{k}" end) |> Enum.intersperse("\n") end defp format_reason({:extra_argument, v}) do "unexpected extra argument #{v}" end defp format_reason({:missing_argument, key}) do ["missing argument ", Atom.to_string(key)] end defp format_reason(:other) do :lol end # ----------------------------------------------------------------------- # Usage Format # ----------------------------------------------------------------------- defp format_opts do %{format: :cli} end def format_usage(command, opts \\ []) def format_usage(command, opts) when is_list(command) do format_usage(build_command(command), opts) end def format_usage(%Command{} = command, opts) do opts = Map.merge(format_opts(), Map.new(opts)) header = format_usage_header(command, opts) options = format_usage_opts(command.options, opts) [header, "\n\n", options] end defp format_usage_header(command, opts) do name = format_usage_command_name(command) {title, padding} = case opts.format do :moduledoc -> {"## Usage", " "} _ -> {"Usage", " "} end optarray = case command do %{options: []} -> "" _ -> " [options]" end argslist = case command do %{arguments: []} -> "" %{arguments: args} -> format_usage_args_list(args) end [title, "\n\n", padding, name, optarray, argslist] end defp format_usage_command_name(command) do case command do %Command{name: nil, module: nil} -> "unnamed command" %Command{name: name} when is_binary(name) -> name %Command{module: mod} when is_atom(mod) and mod != nil -> mod |> inspect() |> String.split(".") |> case do ["Mix", "Tasks" | rest] -> "mix #{Enum.map_join(rest, ".", &Macro.underscore/1)}" rest -> Enum.map_join(rest, ".", &Macro.underscore/1) end end end defp format_usage_args_list([%{required: req?, key: key} | rest]) do name = Atom.to_string(key) case req? do true -> [" <", name, ">" | format_usage_args_list(rest)] false -> [[" [<", name, ">" | format_usage_args_list(rest)], "]"] end end defp format_usage_args_list([]) do [] end defp format_usage_opts([], _) do [] end defp format_usage_opts(options, opts) do options = options |> Enum.sort_by(&sort_opts/1) max_opt = max_key_len(options) columns = io_columns() left_padding = 9 + max_opt wrapping = columns - left_padding pad_io = ["\n", String.duplicate(" ", left_padding)] {title, optsdoc} = case opts.format do :moduledoc -> {"## Options", Enum.map(options, &format_usage_opt_md(&1))} f -> {"Options", Enum.map(options, &format_usage_opt(&1, max_opt, wrapping, pad_io))} end opts = [title, "\n\n", optsdoc] end defp sort_opts({_, %Option{short: short, key: key}}) do # Make options with a short before others, and then sort by name s = case short do _ when key == :help -> 2 nil -> 1 _ -> 0 end {s, key} end defp format_usage_opt_md({k, option}) do %Option{type: t, short: s, key: k, doc: doc, default: default} = option short = case s do nil -> [] _ -> ["`-", Atom.to_string(s), "`, "] end name = k |> Atom.to_string() |> String.replace("_", "-") long = ["`--", name, "`"] doc = case doc do "" -> "" nil -> "" text -> [" - ", clean_doc(text)] end ["* ", short, long, doc, "\n"] end defp format_usage_opt({k, option}, max_opt, wrapping, pad_io) do %Option{type: t, short: s, key: k, doc: doc, default: default} = option short = case s do nil -> " " _ -> [?-, Atom.to_string(s)] end name = k |> Atom.to_string() |> String.replace("_", "-") long = ["--", String.pad_trailing(name, max_opt, " ")] doc = case {t, default} do {_, :skip} -> doc {:boolean, _} -> doc {_, {:default, v}} -> doc <> " Defaults to #{ensure_string(v)}." end wrapped_doc = doc |> wrap_doc(wrapping) |> Enum.intersperse(pad_io) [" ", short, " ", long, " ", wrapped_doc, "\n"] end defp clean_doc(doc) do doc |> String.replace("\n", " ") |> String.replace(~r/\s+/, " ") end defp wrap_doc(doc, width) do words = doc |> clean_doc() |> String.split(" ") |> Enum.map(&{&1, String.length(&1)}) Enum.reduce(words, {0, [], []}, fn {word, len}, {line_len, this_line, lines} -> cond do line_len == 0 -> {len, [word | this_line], lines} line_len + 1 + len > width -> {len, [word], [:lists.reverse(this_line) | lines]} :_ -> {line_len + 1 + len, [word, " " | this_line], lines} end end) |> case do {_, [], lines} -> :lists.reverse(lines) {_, current, lines} -> :lists.reverse([:lists.reverse(current) | lines]) end end defp max_key_len(kw) do kw |> Keyword.keys() |> Enum.map(&String.length(Atom.to_string(&1))) |> Enum.max(fn -> 0 end) end defp io_columns do case :io.columns() do {:ok, n} -> n _ -> 100 end end defp ensure_string(str) when is_binary(str) do str end defp ensure_string(term) do to_string(term) rescue _ in Protocol.UndefinedError -> inspect(term) end end end end