defmodule Vexil do @moduledoc """ Documentation for `Vexil`. """ require Vexil.Parsers alias Vexil.{Parsers, Structs, Utils} @type argv() :: Utils.argv() @type flags() :: list({atom(), Structs.Flags.t()}) @type options() :: list({atom(), Structs.Options.t()}) @type found() :: list({:ok | :error, atom(), any()}) @type validate_argv_error() :: {:error, :invalid_argv} @type validate_opts_error() :: {:error, :invalid_flag | :invalid_option | :required_option_has_default | :invalid_parser, atom()} | {:error, :conflicting_key, String.t()} | {:error, :flags_not_keywords | :options_not_keywords} @type parse_spec_item() :: {:flags, flags()} | {:options, options()} | {:obey_double_dash, boolean()} | {:error_early, boolean()} @type parse_spec() :: list(parse_spec_item()) @type parsed_items() :: %{flags: parsed_flags(), options: parsed_options(), argv: argv()} @type parse_result() :: {:ok, parsed_items(), {list(find_options_error()), list(find_flags_error())}} | validate_opts_error() | validate_argv_error() | find_options_error() | find_flags_error() @type find_options_error() :: {:error, :unknown_option, String.t()} | {:error, :duplicate_option, atom()} | {:error, :invalid_value, atom(), String.t()} | {:error, :missing_required_options, list(atom())} @type parsed_options() :: %{atom() => any()} @type find_options_result() :: {:ok, parsed_options(), list(find_options_error()), argv()} | find_options_error() @type find_flags_error() :: {:error, :unknown_flag, String.t()} | {:error, :duplicate_flag, atom()} @type parsed_flags() :: %{atom() => pos_integer() | boolean()} @type find_flags_result() :: {:ok, parsed_flags(), list(find_flags_error()), argv()} | find_flags_error() @ungreedy_parsers [:integer, :float] @spec parse(argv(), parse_spec()) :: parse_result() def parse(argv, opts \\ []) when is_list(argv) do double_dash = Keyword.get(opts, :obey_double_dash, false) error_early = Keyword.get(opts, :error_early, false) opt_flags = Keyword.get(opts, :flags, []) opt_options = Keyword.get(opts, :options, []) with :ok <- validate_argv(argv), :ok <- validate_opts(opt_flags, opt_options), {argv, argv_remainder} <- Utils.split_double_dash(argv, double_dash), {:ok, options, option_errors, argv} <- find_options(argv, opt_options, opt_flags, error_early), {:ok, flags, flag_errors, argv} <- find_flags(argv, opt_flags, opt_options, option_errors, error_early) do { :ok, %{flags: flags, options: options, argv: argv ++ argv_remainder}, {option_errors, flag_errors} } end end @spec parse!(argv(), parse_spec()) :: any() def parse!(argv, opts \\ []) do case Vexil.parse(argv, opts) do {:ok, result, {[], []}} -> result # TODO: MultiError? {:ok, _, {[err | _], _}} -> Utils.bangify_parse_error(err) {:ok, _, {_, [err | _]}} -> Utils.bangify_parse_error(err) err -> Utils.bangify_parse_error(err) end end @spec validate_argv(argv()) :: :ok | validate_argv_error() defp validate_argv(argv) when is_list(argv) do if Enum.all?(argv, &is_binary/1) do :ok else {:error, :invalid_argv} end end @spec validate_opts(flags(), options()) :: :ok | validate_opts_error() defp validate_opts(flags, options) do # TODO: enforce single character short get_conflicts = fn -> flag_names = Enum.map(flags, fn {_, flag} -> {flag.short, flag.long} end) |> Enum.reduce([], fn {short, long}, acc -> [short | [long | acc]] end) option_names = Enum.map(options, fn {_, opt} -> {opt.short, opt.long} end) |> Enum.reduce([], fn {short, long}, acc -> [short | [long | acc]] end) flag_names |> Enum.reduce(option_names, fn key, acc -> [key | acc] end) |> Enum.frequencies() |> Enum.filter(fn {_, v} -> v != 1 end) |> Enum.map(fn {k, _} -> k end) end with {:bad_flags, true} <- {:bad_flags, Keyword.keyword?(flags)}, {:bad_options, true} <- {:bad_options, Keyword.keyword?(options)}, {:invalid_flag, nil} <- { :invalid_flag, Enum.find(flags, fn {_, struct} -> !match?(%Structs.Flag{}, struct) end) }, {:invalid_option, nil} <- { :invalid_option, Enum.find(options, fn {_, struct} -> !match?(%Structs.Option{}, struct) end) }, {:conflicting_key, []} <- {:conflicting_key, get_conflicts.()}, {:required_option_has_default, nil} <- { :required_option_has_default, Enum.find(options, fn {_, struct} -> struct.required && struct.default != nil end) }, {:invalid_parser, nil} <- { :invalid_parser, Enum.find(options, fn {_, struct} -> struct.parser not in Parsers.all() and not is_function(struct.parser) end) } do :ok else {:bad_flags, _} -> {:error, :flags_not_keywords} {:bad_options, _} -> {:error, :options_not_keywords} {:invalid_flag, {bad_flag, _}} -> {:error, :invalid_flag, bad_flag} {:invalid_option, {bad_option, _}} -> {:error, :invalid_option, bad_option} {:conflicting_key, [bad_key | _]} -> {:error, :conflicting_key, bad_key} {:required_option_has_default, {bad_option, _}} -> {:error, :required_option_has_default, bad_option} {:invalid_parser, {bad_option, _}} -> {:error, :invalid_parser, bad_option} end end @spec find_options(argv(), options(), flags(), boolean(), found(), argv()) :: find_options_result() defp find_options( argv, wanted_options, wanted_flags, error_early, seen_options \\ [], remainder \\ [] ) do consume_option = fn lookup_name, tail, comparison_key -> {name, option} = Enum.find(wanted_options, {nil, nil}, fn {_, opt} -> case comparison_key do :long -> opt.long == lookup_name :short -> opt.short == lookup_name end end) is_flag = Enum.find(wanted_flags, fn {_, flag} -> case comparison_key do :long -> flag.long == lookup_name :short -> flag.short == lookup_name end end) cond do # Ignore anything that is listed as a flag so that we don't error right after for unknown option is_flag -> fixed_name = case comparison_key do :long -> "--" <> lookup_name :short -> "-" <> lookup_name end find_options(tail, wanted_options, wanted_flags, error_early, seen_options, [ fixed_name | remainder ]) !option -> err = {:error, :unknown_option, lookup_name} fixed_name = case comparison_key do :long -> "--" <> lookup_name :short -> "-" <> lookup_name end if error_early, do: err, else: find_options( tail, wanted_options, wanted_flags, error_early, [err | seen_options], [fixed_name | remainder] ) not option.multiple && Enum.find(seen_options, fn {:ok, opt_name, _} -> opt_name == name _ -> false end) -> err = {:error, :duplicate_option, name} if error_early, do: err, else: find_options( tail, wanted_options, wanted_flags, error_early, [err | seen_options], remainder ) true -> {value, tail} = if option.greedy and option.parser not in @ungreedy_parsers do Utils.consume_argv_greedy(tail) else [head | tail] = tail {[head], tail} end {success, value} = case option.parser do parser when parser in Parsers.all() -> apply(Parsers, parser, [value, option.greedy]) # Run custom parser parser when is_function(parser) -> parser.(value, option.greedy) end result = if success == :error, do: {:error, :invalid_value, name, value}, else: {success, name, value} if success == :error and error_early, do: result, else: find_options( tail, wanted_options, wanted_flags, error_early, [result | seen_options], remainder ) end end case argv do [] -> found_options = seen_options |> Enum.filter(&match?({:ok, _, _}, &1)) |> Enum.reduce([], fn {_, name, value}, acc -> should_be_list = wanted_options[name].multiple || wanted_options[name].greedy cond do should_be_list and acc[name] -> val = case value do [item] -> [item | acc[name]] # might need to fiddle with some ordering memes (or just dont care about ordering) [_ | _] -> value ++ acc[name] _ -> [value | acc[name]] end Keyword.put(acc, name, val) should_be_list -> val = case value do [_ | _] -> value _ -> [value] end Keyword.put(acc, name, val) true -> Keyword.put(acc, name, value) end end) found_names = Enum.map(found_options, &elem(&1, 0)) all_errors = Enum.filter(seen_options, fn {:error, _type, _arg0, _arg1} -> true {:error, _type, _arg0} -> true _ -> false end) defaulted_options = wanted_options |> Enum.filter(fn {name, opt} -> not opt.required and name not in found_names end) |> Enum.map(fn {name, opt} -> {name, opt.default} end) missing = wanted_options |> Enum.filter(fn {name, opt} -> opt.required and name not in found_names end) |> Enum.map(fn {name, _} -> name end) missing = if missing != [], do: {:error, :missing_required_options, missing}, else: nil all_errors = if missing, do: [missing | all_errors], else: all_errors if error_early && missing do missing else result = (found_options ++ defaulted_options) |> Enum.into(%{}) { :ok, result, all_errors, Enum.reverse(remainder) } end ["--" <> long | tail] -> {long, tail} = Utils.split_eq(long, tail) consume_option.(long, tail, :long) ["-" <> short = original | tail] -> {short, tail} = Utils.split_eq(short, tail) if String.length(short) > 1 do find_options(tail, wanted_options, wanted_flags, error_early, seen_options, [ original | remainder ]) else consume_option.(short, tail, :short) end [head | tail] -> find_options(tail, wanted_options, wanted_flags, error_early, seen_options, [ head | remainder ]) end end @spec find_flags(argv(), flags(), options(), find_options_error(), boolean(), found(), argv()) :: find_flags_result() defp find_flags( argv, wanted_flags, wanted_options, option_errors, error_early, seen_flags \\ [], remainder \\ [] ) do consume_flag = fn lookups, tail, comparison_key -> results = lookups |> Enum.reduce({[], []}, fn lookup_name, {result_acc, name_acc} -> {name, flag} = Enum.find(wanted_flags, {nil, nil}, fn {_, flag} -> case comparison_key do :long -> flag.long == lookup_name :short -> flag.short == lookup_name end end) is_option = Enum.find(wanted_options, fn {_, opt} -> case comparison_key do :long -> opt.long == lookup_name :short -> opt.short == lookup_name end end) cond do # TODO: do we add prefix? Also this probably doesn't actually need # to be checked since options get consumed prior to flags (we also # need to special case grouped flags in options parsing but I'm too # lazy to do so right now oopsie) is_option -> prefix = if comparison_key == :long, do: "--", else: "-" value = {:remainder, prefix <> lookup_name} {[value | result_acc], name_acc} !flag && !Enum.find(option_errors, fn {:error, :unknown_option, bad_name} -> bad_name == lookup_name _ -> false end) -> value = {:error, :unknown_flag, lookup_name} {[value | result_acc], name_acc} !flag -> {result_acc, name_acc} # TODO: there's probably a more elegant solution to this not flag.multiple && (name in name_acc || Enum.find(seen_flags, fn {:ok, flag_name, _} -> flag_name == name _ -> false end)) -> value = {:error, :duplicate_flag, name} {[value | result_acc], name_acc} true -> value = {:ok, name, true} {[value | result_acc], [name | name_acc]} end end) |> elem(0) first_error = results |> Enum.find(&match?({:error, _, _}, &1)) results_no_remainders = Enum.filter(results, fn x -> !match?({:remainder, _, _}, x) end) remainders = results |> Enum.filter(&match?({:remainder, _}, &1)) |> Enum.map(&elem(&1, 1)) if error_early and first_error, do: first_error, else: find_flags( tail, wanted_flags, wanted_options, option_errors, error_early, results_no_remainders ++ seen_flags, remainders ++ remainder ) end case argv do [] -> found_flags = seen_flags |> Enum.filter(&match?({:ok, _, _}, &1)) # Get amount of times each name occurs (used for multiple flags like verbosity) |> Enum.frequencies_by(&elem(&1, 1)) |> Enum.map(fn {name, count} -> if wanted_flags[name].multiple, do: {name, count}, else: {name, true} end) unprovided_flags = wanted_flags |> Enum.filter(fn {name, _} -> found_flags[name] == nil end) |> Enum.map(fn {name, _} -> {name, false} end) all_errors = seen_flags |> Enum.filter(&match?({:error, _type, _arg0}, &1)) |> Enum.uniq() result = (found_flags ++ unprovided_flags) |> Enum.into(%{}) {:ok, result, all_errors, Enum.reverse(remainder)} ["--" <> long | tail] -> consume_flag.([long], tail, :long) ["-" <> short | tail] -> # Allow grouping short flags together like `-abcdeeee` flags = if(String.length(short) > 1, do: String.graphemes(short), else: [short]) |> Enum.reverse() consume_flag.(flags, tail, :short) [head | tail] -> find_flags(tail, wanted_flags, wanted_options, option_errors, error_early, seen_flags, [ head | remainder ]) end end end