Repository: dashbitco/nimble_options
Pull request #91 (2022-08-27 21:29:48)

Use typespecs when possible to document types

josevalim: I am actually not the biggest fan because I think it makes understanding the options more complex, specially if you are not familar with typespecs.

Maybe we could support `NimbleOptions.spec` macro though that returns the spec for a given type?
whatyouhide: @josevalim the macro is a good idea in and of its own, I'll work on that.

When it comes to the docs, I think this makes it easier because we can express more complex types, with type documentation linked to them, in a bit easier syntax. The non-typespec docs can be verbose. We can think about it for a bit, I'll work on `NimbleOptions.spec/1` for now 😉 
whatyouhide: @josevalim I added the new `NimbleOptions.option_type_union/1` function. 👍 Ready for review!

Patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index a1535a6..6703d23 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -8,7 +8,7 @@ on:
 
 jobs:
   test:
-    runs-on: ubuntu-18.04
+    runs-on: ubuntu-20.04
     env:
       MIX_ENV: test
       GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
@@ -17,11 +17,11 @@ jobs:
       matrix:
         include:
           - pair:
-              elixir: 1.6.6
-              otp: 19.3.6.13
+              elixir: "1.9.4"
+              otp: "20.3"
           - pair:
-              elixir: 1.13.4
-              otp: 25.0.2
+              elixir: "1.14.0"
+              otp: "25.0.2"
             lint: lint
             coverage: true
     steps:
@@ -38,8 +38,6 @@ jobs:
             deps
             _build
           key: ${{ runner.os }}-mix-${{matrix.pair.elixir}}-${{matrix.pair.otp}}-${{ hashFiles('**/mix.lock') }}
-          restore-keys: |
-            ${{ runner.os }}-mix-
 
       - run: mix deps.get
 
diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex
index c593bb4..73e88e2 100644
--- a/lib/nimble_options.ex
+++ b/lib/nimble_options.ex
@@ -47,6 +47,20 @@ defmodule NimbleOptions do
           type: :string,
           doc: "The title of separate subsection of the options' documentation"
         ],
+        type_doc: [
+          type: {:or, [:string, {:in, false}]},
+          doc: """
+          The type doc to use *in the documentation* for the option item. If `false`,
+          no type documentation is added to the item. If it's a string, it can be
+          anything. For example, you can use `"a list of PIDs"`, or you can use
+          a typespec reference that ExDoc can link to the type definition, such as
+          `` "`t:binary/0`" ``. You can use Markdown in this documentation. If the
+          `:type_doc` option is not present, NimbleOptions tries to produce a type
+          documentation automatically if it can do it unambiguously. For example,
+          if `type: :integer`, NimbleOptions will use `t:integer/0` as the
+          auto-generated type doc.
+          """
+        ],
         # TODO: remove in v0.5.
         rename_to: [
           type: :atom,
@@ -377,6 +391,61 @@ defmodule NimbleOptions do
     NimbleOptions.Docs.generate(schema, options)
   end
 
+  @doc """
+  Returns the quoted typespec for any option described by the given schema.
+
+  The returned quoted code represents the **type union** for all possible
+  keys in the schema, alongside their type. Nested keyword lists are
+  spec'ed as `t:keyword/0`.
+
+  ## Usage
+
+  Because of how typespecs are treated by the Elixir compiler, you have
+  to use `unquote/1` on the return value of this function to use it
+  in a typespec:
+
+      @type option() :: unquote(NimbleOptions.option_typespec(my_schema))
+
+  This function returns the type union for a single option, to give you
+  flexibility to combine it and use it in your own typespecs. For example,
+  if you only validate part of the options through NimbleOptions, you could
+  write a spec like this:
+
+      @type my_option() ::
+              {:my_opt1, integer()}
+              | {:my_opt2, boolean()}
+              | unquote(NimbleOptions.option_typespec(my_schema))
+
+  If you want to spec a whole schema, you could write something like this:
+
+      @type options() :: [unquote(NimbleOptions.option_typespec(my_schema))]
+
+  ## Example
+
+      schema = [
+        int: [type: :integer],
+        number: [type: {:or, [:integer, :float]}]
+      ]
+
+      @type option() :: unquote(NimbleOptions.option_typespec(schema))
+
+  The code above would essentially compile to:
+
+      @type option() :: {:int, integer()} | {:number, integer() | float()}
+
+  """
+  @doc since: "0.5.0"
+  @spec option_typespec(schema() | t()) :: Macro.t()
+  def option_typespec(schema)
+
+  def option_typespec(schema) when is_list(schema) do
+    NimbleOptions.Docs.schema_to_spec(schema)
+  end
+
+  def option_typespec(%NimbleOptions{schema: schema}) do
+    NimbleOptions.Docs.schema_to_spec(schema)
+  end
+
   @doc false
   def options_schema() do
     @options_schema
diff --git a/lib/nimble_options/docs.ex b/lib/nimble_options/docs.ex
index c9d50af..8804bc9 100644
--- a/lib/nimble_options/docs.ex
+++ b/lib/nimble_options/docs.ex
@@ -1,24 +1,6 @@
 defmodule NimbleOptions.Docs do
   @moduledoc false
 
-  @basic_types [
-    :any,
-    :keyword_list,
-    :non_empty_keyword_list,
-    :atom,
-    :integer,
-    :non_neg_integer,
-    :pos_integer,
-    :float,
-    :mfa,
-    :mod_arg,
-    :string,
-    :boolean,
-    :timeout,
-    :pid,
-    :reference
-  ]
-
   def generate(schema, options) when is_list(schema) and is_list(options) do
     nest_level = Keyword.get(options, :nest_level, 0)
     {docs, sections, _level} = build_docs(schema, {[], [], nest_level})
@@ -58,8 +40,14 @@ defmodule NimbleOptions.Docs do
   end
 
   defp option_doc({key, schema}, {docs, sections, level}) do
+    type_str = if type_str = get_type_str(schema), do: " #{type_str}"
+
     description =
-      [get_required_str(schema), get_doc_str(schema), get_default_str(schema)]
+      [
+        get_required_str(schema),
+        get_doc_str(schema),
+        get_default_str(schema)
+      ]
       |> Enum.reject(&is_nil/1)
       |> case do
         [] -> ""
@@ -67,8 +55,7 @@ defmodule NimbleOptions.Docs do
       end
 
     indent = String.duplicate("  ", level)
-    type = if type = get_type_str(schema[:type]), do: " (#{type})", else: ""
-    doc = indent_doc("  * `#{inspect(key)}`#{type}#{description}\n\n", indent)
+    doc = indent_doc("  * `#{inspect(key)}`#{type_str}#{description}\n\n", indent)
 
     docs = [doc | docs]
 
@@ -98,19 +85,37 @@ defmodule NimbleOptions.Docs do
       do: "The default value is `#{inspect(schema[:default])}`."
   end
 
-  defp get_type_str(nil), do: nil
-  defp get_type_str({:custom, _mod, _fun, _args}), do: nil
-  defp get_type_str({:or, _values}), do: nil
-  defp get_type_str({:fun, arity}), do: "function of arity #{arity}"
-  defp get_type_str(:keyword_list), do: "keyword list"
-  defp get_type_str(:non_empty_keyword_list), do: "non-empty keyword list"
-  defp get_type_str({:keyword_list, _keys}), do: "keyword list"
-  defp get_type_str({:non_empty_keyword_list, _keys}), do: "non-empty keyword list"
-  defp get_type_str({:in, enum}), do: "member of `#{inspect(enum)}`"
-  defp get_type_str(type) when type in @basic_types, do: Atom.to_string(type)
+  defp get_type_str(schema) do
+    if str = get_raw_type_str(schema[:type]) do
+      "(#{str})"
+    end
+  end
 
-  defp get_type_str({:list, subtype}) do
-    if subtype_str = get_type_str(subtype), do: "list of: #{subtype_str}"
+  defp get_raw_type_str(nil), do: nil
+  defp get_raw_type_str({:custom, _mod, _fun, _args}), do: nil
+  defp get_raw_type_str(:mfa), do: "3-element tuple of `t:module/0`, `t:atom/0`, and `[term()]`"
+  defp get_raw_type_str(:mod_arg), do: "2-element tuple of `t:module/0` and `[term()]`"
+  defp get_raw_type_str({:or, _values}), do: nil
+  defp get_raw_type_str({:fun, arity}), do: "function of arity #{arity}"
+  defp get_raw_type_str({:in, enum}), do: "member of `#{inspect(enum)}`"
+  defp get_raw_type_str(:any), do: "`t:term/0`"
+  defp get_raw_type_str(:reference), do: "`t:reference/0`"
+  defp get_raw_type_str(:pid), do: "`t:pid/0`"
+  defp get_raw_type_str(:timeout), do: "`t:timeout/0`"
+  defp get_raw_type_str(:boolean), do: "`t:boolean/0`"
+  defp get_raw_type_str(:atom), do: "`t:atom/0`"
+  defp get_raw_type_str(:integer), do: "`t:integer/0`"
+  defp get_raw_type_str(:non_neg_integer), do: "`t:non_neg_integer/0`"
+  defp get_raw_type_str(:pos_integer), do: "`t:pos_integer/0`"
+  defp get_raw_type_str(:float), do: "`t:float/0`"
+  defp get_raw_type_str(:string), do: "`t:String.t/0`"
+  defp get_raw_type_str(:keyword_list), do: "`t:keyword/0`"
+  defp get_raw_type_str(:non_empty_keyword_list), do: "non-empty `t:keyword/0`"
+  defp get_raw_type_str({:keyword_list, _keys}), do: "`t:keyword/0`"
+  defp get_raw_type_str({:non_empty_keyword_list, _keys}), do: "non-empty `t:keyword/0`"
+
+  defp get_raw_type_str({:list, subtype}) do
+    if subtype_str = get_raw_type_str(subtype), do: "list of #{subtype_str}"
   end
 
   defp indent_doc(text, indent) do
@@ -121,4 +126,54 @@ defmodule NimbleOptions.Docs do
       str -> "#{indent}#{str}"
     end)
   end
+
+  def schema_to_spec(schema) do
+    schema
+    |> Enum.map(fn {key, opt_schema} ->
+      typespec = type_to_spec(opt_schema[:type])
+      quote do: {unquote(key), unquote(typespec)}
+    end)
+    |> unionize_quoted()
+  end
+
+  defp type_to_spec(type) do
+    case type do
+      :any -> quote(do: term())
+      :integer -> quote(do: integer())
+      :non_neg_integer -> quote(do: non_neg_integer())
+      :pos_integer -> quote(do: pos_integer())
+      :atom -> quote(do: atom())
+      :float -> quote(do: float())
+      :boolean -> quote(do: boolean())
+      :pid -> quote(do: pid())
+      :reference -> quote(do: reference())
+      :timeout -> quote(do: timeout())
+      :string -> quote(do: binary())
+      :mfa -> quote(do: {module(), atom(), [term()]})
+      :mod_arg -> quote(do: {module(), [term()]})
+      :keyword_list -> quote(do: keyword())
+      {:keyword_list, _keys} -> quote(do: keyword())
+      :non_empty_keyword_list -> quote(do: keyword())
+      {:non_empty_keyword_list, _keys} -> quote(do: keyword())
+      {:fun, arity} -> function_spec(arity)
+      {:in, _choices} -> quote(do: term())
+      {:custom, _mod, _fun, _args} -> quote(do: term())
+      {:list, subtype} -> quote(do: [unquote(type_to_spec(subtype))])
+      {:or, subtypes} -> subtypes |> Enum.map(&type_to_spec/1) |> unionize_quoted()
+    end
+  end
+
+  defp function_spec(arity) do
+    args = List.duplicate(quote(do: term()), arity)
+
+    quote do
+      unquote_splicing(args) -> term()
+    end
+  end
+
+  defp unionize_quoted(specs) do
+    specs
+    |> Enum.reverse()
+    |> Enum.reduce(&quote(do: unquote(&1) | unquote(&2)))
+  end
 end
diff --git a/mix.exs b/mix.exs
index ceb5770..1ac8d30 100644
--- a/mix.exs
+++ b/mix.exs
@@ -8,7 +8,7 @@ defmodule NimbleOptions.MixProject do
     [
       app: :nimble_options,
       version: @version,
-      elixir: "~> 1.6",
+      elixir: "~> 1.9",
       start_permanent: Mix.env() == :prod,
       deps: deps(),
 


Test patch:
diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs
index 1c7325e..5851715 100644
--- a/test/nimble_options_test.exs
+++ b/test/nimble_options_test.exs
@@ -72,7 +72,7 @@ defmodule NimbleOptionsTest do
       Reason: \
       unknown options [:unknown_schema_option], \
       valid options are: [:type, :required, :default, :keys, \
-      :deprecated, :doc, :subsection, :rename_to] \
+      :deprecated, :doc, :subsection, :type_doc, :rename_to] \
       (in options [:producers, :keys, :*, :keys, :module])\
       """
 
@@ -651,7 +651,8 @@ defmodule NimbleOptionsTest do
                 %ValidationError{
                   key: :mode,
                   value: :unknown,
-                  message: "expected :mode to be in #MapSet<[:active, :passive]>, got: :unknown"
+                  message:
+                    "expected :mode to be in #{inspect(MapSet.new([:active, :passive]))}, got: :unknown"
                 }}
     end
 
@@ -1460,11 +1461,11 @@ defmodule NimbleOptionsTest do
   describe "NimbleOptions.docs/1" do
     test "override docs for recursive keys" do
       docs = """
-        * `:type` (atom) - Required. The type of the option item.
+        * `:type` (`t:atom/0`) - Required. The type of the option item.
 
-        * `:required` (boolean) - Defines if the option item is required. The default value is `false`.
+        * `:required` (`t:boolean/0`) - Defines if the option item is required. The default value is `false`.
 
-        * `:keys` (keyword list) - Defines which set of keys are accepted.
+        * `:keys` (`t:keyword/0`) - Defines which set of keys are accepted.
 
         * `:default` - The default.
 
@@ -1496,17 +1497,17 @@ defmodule NimbleOptionsTest do
       ]
 
       docs = """
-        * `:producer` (non-empty keyword list) - The producer. Supported options:
+        * `:producer` (non-empty `t:keyword/0`) - The producer. Supported options:
 
-          * `:module` (mod_arg) - The module.
+          * `:module` (2-element tuple of `t:module/0` and `[term()]`) - The module.
 
-          * `:rate_limiting` (non-empty keyword list) - A list of options to enable and configure rate limiting. Supported options:
+          * `:rate_limiting` (non-empty `t:keyword/0`) - A list of options to enable and configure rate limiting. Supported options:
 
-            * `:allowed_messages` (pos_integer) - Number of messages per interval.
+            * `:allowed_messages` (`t:pos_integer/0`) - Number of messages per interval.
 
-            * `:interval` (pos_integer) - Required. The interval.
+            * `:interval` (`t:pos_integer/0`) - Required. The interval.
 
-        * `:other_key` (string)
+        * `:other_key` (`t:String.t/0`)
 
       """
 
@@ -1535,11 +1536,11 @@ defmodule NimbleOptionsTest do
       docs = """
         * `:producer` - The producer. Either a string or a keyword list with the following keys:
 
-          * `:allowed_messages` (pos_integer) - Allowed messages.
+          * `:allowed_messages` (`t:pos_integer/0`) - Allowed messages.
 
-          * `:interval` (pos_integer) - Interval.
+          * `:interval` (`t:pos_integer/0`) - Interval.
 
-        * `:other_key` (list of: atom)
+        * `:other_key` (list of `t:atom/0`)
 
       """
 
@@ -1568,11 +1569,11 @@ defmodule NimbleOptionsTest do
       ]
 
       docs = """
-        * `:name` (atom) - Required. The name.
+        * `:name` (`t:atom/0`) - Required. The name.
 
-        * `:producer` (non-empty keyword list) - This is the producer summary. See "Producers options" section below.
+        * `:producer` (non-empty `t:keyword/0`) - This is the producer summary. See "Producers options" section below.
 
-        * `:other_key` (string)
+        * `:other_key` (`t:String.t/0`)
 
       ### Producers options
 
@@ -1580,9 +1581,9 @@ defmodule NimbleOptionsTest do
 
       The available options are:
 
-        * `:module` (mod_arg) - The module.
+        * `:module` (2-element tuple of `t:module/0` and `[term()]`) - The module.
 
-        * `:concurrency` (pos_integer) - The concurrency.
+        * `:concurrency` (`t:pos_integer/0`) - The concurrency.
 
       """
 
@@ -1608,13 +1609,13 @@ defmodule NimbleOptionsTest do
       ]
 
       docs = """
-        * `:name` (string) - The name.
+        * `:name` (`t:String.t/0`) - The name.
 
       This a multiline text.
 
       Another line.
 
-        * `:module` (atom) - The module.
+        * `:module` (`t:atom/0`) - The module.
 
       """
 
@@ -1629,9 +1630,9 @@ defmodule NimbleOptionsTest do
       ]
 
       docs = """
-        * `:name` (atom) - An atom.
+        * `:name` (`t:atom/0`) - An atom.
 
-        * `:count` (integer)
+        * `:count` (`t:integer/0`)
 
       """
 
@@ -1670,7 +1671,7 @@ defmodule NimbleOptionsTest do
       assert {:ok, ^opts} = NimbleOptions.validate(opts, schema)
 
       assert NimbleOptions.docs(schema) == """
-               * `:custom_keys` (keyword list) - Custom keys
+               * `:custom_keys` (`t:keyword/0`) - Custom keys
 
              """
     end
@@ -1701,23 +1702,23 @@ defmodule NimbleOptionsTest do
 
                * `:f` (function of arity 3)
 
-               * `:kw` (keyword list)
+               * `:kw` (`t:keyword/0`)
 
-               * `:nonempty_kw` (non-empty keyword list)
+               * `:nonempty_kw` (non-empty `t:keyword/0`)
 
                * `:in_range` (member of `1..10`)
 
                * `:in_list` (member of `[:email, :username]`)
 
-               * `:int` (integer)
+               * `:int` (`t:integer/0`)
 
-               * `:ref` (reference)
+               * `:ref` (`t:reference/0`)
 
-               * `:list_of_ints` (list of: integer)
+               * `:list_of_ints` (list of `t:integer/0`)
 
-               * `:nested_list_of_ints` (list of: list of: integer)
+               * `:nested_list_of_ints` (list of list of `t:integer/0`)
 
-               * `:list_of_kws` (list of: keyword list)
+               * `:list_of_kws` (list of `t:keyword/0`)
 
              """
     end
@@ -1744,6 +1745,65 @@ defmodule NimbleOptionsTest do
     end
   end
 
+  describe "option_typespec/1" do
+    test "all possible types" do
+      schema = [
+        any: [type: :any],
+        keyword_list: [type: :keyword_list],
+        non_empty_keyword_list: [type: :non_empty_keyword_list],
+        atom: [type: :atom],
+        integer: [type: :integer],
+        non_neg_integer: [type: :non_neg_integer],
+        pos_integer: [type: :pos_integer],
+        float: [type: :float],
+        mfa: [type: :mfa],
+        mod_arg: [type: :mod_arg],
+        string: [type: :string],
+        boolean: [type: :boolean],
+        timeout: [type: :timeout],
+        pid: [type: :pid],
+        reference: [type: :reference],
+        fun: [type: {:fun, 3}],
+        member: [type: {:in, 1..10}],
+        custom: [type: {:custom, __MODULE__, :fun, []}],
+        list_of_int: [type: {:list, :integer}],
+        list_of_list_of_int: [type: {:list, {:list, :integer}}],
+        list_of_kw: [type: {:list, {:keyword_list, []}}],
+        list_of_ne_kw: [type: {:list, {:non_empty_keyword_list, []}}],
+        union_scalar: [type: {:or, [:integer, :boolean, :float]}],
+        union_complex: [type: {:or, [{:or, [:integer, :float]}, :boolean]}]
+      ]
+
+      assert NimbleOptions.option_typespec(schema) ==
+               (quote do
+                  {:any, term()}
+                  | {:keyword_list, keyword()}
+                  | {:non_empty_keyword_list, keyword()}
+                  | {:atom, atom()}
+                  | {:integer, integer()}
+                  | {:non_neg_integer, non_neg_integer()}
+                  | {:pos_integer, pos_integer()}
+                  | {:float, float()}
+                  | {:mfa, {module(), atom(), [term()]}}
+                  | {:mod_arg, {module(), [term()]}}
+                  | {:string, binary()}
+                  | {:boolean, boolean()}
+                  | {:timeout, timeout()}
+                  | {:pid, pid()}
+                  | {:reference, reference()}
+                  | {:fun, (term(), term(), term() -> term())}
+                  | {:member, term()}
+                  | {:custom, term()}
+                  | {:list_of_int, [integer()]}
+                  | {:list_of_list_of_int, [[integer()]]}
+                  | {:list_of_kw, [keyword()]}
+                  | {:list_of_ne_kw, [keyword()]}
+                  | {:union_scalar, integer() | boolean() | float()}
+                  | {:union_complex, (integer() | float()) | boolean()}
+                end)
+    end
+  end
+
   @compile_time_wrapper NimbleOptions.new!(an_option: [])
 
   describe "wrapper struct" do