defmodule Crutches.Option do @moduledoc """ Convenience functions for dealing with function option handling. This provides a mechanism for declaring default options and merging these with those given by any caller. # Usage When you have a function with the following head, the use of this module may be beneficial. Of course you can have as much required `args` as you want. `options` is a keyword list. def foo(args, options) Usage is pretty simple. Declare a module attribute with the name of your function. It should be a keyword list with the keys `:valid` and `:defaults`. `:valid` should contain a list of atoms. `:defaults` should contain another keyword list with the default options of your function. ## Example @function_name [ valid: ~w(foo bar)a defaults: [ foo: "some", bar: "value" ] ] When this is done, you can declare your function head like this: def function_name(args, opts \\ []]) And then you're all set to actually write the meat of your function. (You of course don't need a function head if your function only consists of one clause.) def function_name(args, opts) do # This validates and merges the options, throwing on error. opts = Crutches.Options.combine!(opts, @function_name) # You can now use the options. do_something_with(opts[:foo]) end """ @type key :: atom @type value :: any @type t :: [{key, value}] @type t(value) :: [{key, value}] @type ok(value) :: {:ok, value} @type error :: {:error, any} @doc """ Validates the `opts` keyword list according to `config`, combines defaults. For intended use see the module documentation. # Config variable The `config` parameter should be a keyword list with the following keys: - `:valid` ([atom]) --- Parameters that your function accepts. - `:defaults` ([atom: any]) --- Default values for the options in `:valid`. Returns `{:ok, opts}` on succes, `{:error, invalid_keys}` on failure. # Examples iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]] iex> Option.combine([foo: "another"], config) {:ok, [bar: "value", foo: "another"]} iex> config = [valid: ~w(bar baz)a, defaults: [bar: "good", baz: "values"]] iex> Option.combine([boom: "this blows up"], config) {:error, [:boom]} """ @spec combine(t, t([atom]) | t(t)) :: ok(t) | error def combine(opts, config) do combine(opts, config, &Elixir.Keyword.merge(&1, &2)) end @doc """ This function is the same as `combine/2`, except it returns `options` on validation succes and throws `ArgumentError` on validation failure. # Examples iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]] iex> Option.combine!([foo: "another"], config) [bar: "value", foo: "another"] iex> config = [valid: ~w(bar baz)a, defaults: [bar: "good", baz: "values"]] iex> Option.combine!([boom: "this blows up"], config) ** (ArgumentError) invalid key boom """ @spec combine!(t, t([atom]) | t(t)) :: t def combine!(opts, config) do combine!(opts, config, &Elixir.Keyword.merge(&1, &2)) end @doc """ Validate `opts` according to `config`, combines according to `combinator` Behavior is the same as `combine/2`, except that you can specify how `opts` and `config[:defaults]` are merged by passing a `combinator` function. This function should combine the two keyword lists into one. It receives `config[:defaults]` as the first parameter and the validated `opts` as the second. # Examples Contrived example showing of the use of `combinator`. iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]] iex> combinator = &Keyword.merge/2 iex> Option.combine([foo: "again"], config, combinator) {:ok, [bar: "value", foo: "again"]} """ @spec combine(t, t | t(t), (t, t -> t)) :: ok(t) | error def combine(opts, config, combinator) do case validate(opts, config[:valid]) do {:ok, _} -> {:ok, config[:defaults] |> combinator.(opts) |> sort_options} {:error, invalid} -> {:error, invalid} end end defp sort_options(options) do Enum.sort(options, fn {key1, _}, {key2, _} -> key1 <= key2 end) end @doc ~S""" Throwing version of `combine/3` # Examples iex> config = [valid: ~w(foo bar)a, defaults: [foo: "some", bar: "value"]] iex> combinator = fn(_, _) -> nil end iex> Option.combine!([baz: "fail"], config, combinator) ** (ArgumentError) invalid key baz """ @spec combine!(t, t | t(t), (t, t -> t)) :: t def combine!(opts, config, combinator) do case combine(opts, config, combinator) do {:ok, opts} -> opts {:error, invalid} -> invalid = invalid |> Enum.join(" ") raise ArgumentError, message: "invalid key #{invalid}" end end @doc ~S""" Checks a `opts` for keys not in `valid`. Returns {:ok, []} if all options are kosher, otherwise {:error, list}, where list is a list of all invalid keys. # Examples iex> Option.validate([good: "", good_opt: ""], [:good, :good_opt]) {:ok, []} iex> Option.validate([good: "", bad: ""], [:good]) {:error, [:bad]} """ @spec validate(t, [atom]) :: ok([]) | error def validate(opts, valid) do if Enum.empty?(invalid_options(opts, valid)) do {:ok, []} else {:error, invalid_options(opts, valid)} end end @doc ~S""" Throwing version of `Option.validate` # Examples iex> Option.validate!([good: "", bad: ""], [:good]) ** (ArgumentError) invalid key bad iex> Option.validate!([good: "", bad: "", worse: ""], [:good]) ** (ArgumentError) invalid key bad, worse iex> Option.validate!([good: ""], [:good]) true """ @spec validate!(t, [atom]) :: true def validate!(opts, valid) do case validate(opts, valid) do {:ok, _} -> true {:error, invalid_options} -> raise ArgumentError, "invalid key " <> Enum.join(invalid_options, ", ") end end @doc ~S""" Check `opts` for keys not in `valid`. Return `false` when a bad key is found, otherwise return `true`. # Examples iex> Option.all_valid?([good: "", good_opt: ".", bad: "!"], [:good, :good_opt]) false iex> Option.all_valid?([good: "", good_opt: "."], [:good, :good_opt]) true """ @spec all_valid?(t, [atom]) :: boolean def all_valid?(opts, valid) do Enum.empty?(invalid_options(opts, valid)) end defp invalid_options(opts, valid) do opts |> Keyword.keys() |> Enum.reject(&(&1 in valid)) end end