Repository: dashbitco/nimble_options
Pull request #99 (2022-12-08 00:56:34)

Add support for a {:struct, struct_name} type specifier


Patch:
diff --git a/lib/nimble_options.ex b/lib/nimble_options.ex
index e39e817..2dd6023 100644
--- a/lib/nimble_options.ex
+++ b/lib/nimble_options.ex
@@ -48,7 +48,7 @@ defmodule NimbleOptions do
           doc: "The title of separate subsection of the options' documentation"
         ],
         type_doc: [
-          type: {:or, [:string, {:in, false}]},
+          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
@@ -169,6 +169,8 @@ defmodule NimbleOptions do
       same position. For example, to describe 3-element tuples with an atom, a string, and
       a list of integers you would use the type `{:tuple, [:atom, :string, {:list, :integer}]}`.
       *Available since v0.4.1*.
+      
+    * `{:struct, struct_name}` - An instance of the struct type given.
 
   ## Example
 
@@ -913,6 +915,18 @@ defmodule NimbleOptions do
     )
   end
 
+  defp validate_type({:struct, struct_name}, key, value) do
+    if match?(%^struct_name{}, value) do
+      {:ok, value}
+    else
+      error_tuple(
+        key,
+        value,
+        "invalid value for #{render_key(key)}: expected #{inspect(struct_name)}, got: #{inspect(value)}"
+      )
+    end
+  end
+
   defp validate_type(nil, key, value) do
     validate_type(:any, key, value)
   end
@@ -945,7 +959,8 @@ defmodule NimbleOptions do
           "{:custom, mod, fun, args}",
           "{:list, subtype}",
           "{:tuple, list_of_subtypes}",
-          "{:map, key_type, value_type}"
+          "{:map, key_type, value_type}",
+          "{:struct, struct_name}"
         ]
 
     Enum.join(types, ", ")
@@ -1030,6 +1045,14 @@ defmodule NimbleOptions do
     {:error, reason} -> {:error, reason}
   end
 
+  def validate_type({:struct, struct_name}) when is_atom(struct_name) do
+    {:ok, {:struct, struct_name}}
+  end
+
+  def validate_type({:struct, struct_name}) do
+    {:error, "invalid struct_name for :struct, expected atom, got #{inspect(struct_name)}"}
+  end
+
   def validate_type(value) do
     {:error, "unknown type #{inspect(value)}.\n\nAvailable types: #{available_types()}"}
   end
diff --git a/lib/nimble_options/docs.ex b/lib/nimble_options/docs.ex
index 3c8e04a..52c5c78 100644
--- a/lib/nimble_options/docs.ex
+++ b/lib/nimble_options/docs.ex
@@ -97,7 +97,14 @@ defmodule NimbleOptions.Docs do
   end
 
   defp get_type_str(schema) do
-    if str = get_raw_type_str(schema[:type]) do
+    str =
+      case Keyword.fetch(schema, :type_doc) do
+        {:ok, false} -> nil
+        {:ok, type_doc} when is_binary(type_doc) -> type_doc
+        :error -> get_raw_type_str(schema[:type])
+      end
+
+    if str do
       "(#{str})"
     end
   end
@@ -126,6 +133,7 @@ defmodule NimbleOptions.Docs do
   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(:map), do: "`t:map/0`"
+  defp get_raw_type_str({:struct, struct_type}), do: "struct of type #{inspect(struct_type)}"
 
   defp get_raw_type_str({:list, subtype}) do
     if subtype_str = get_raw_type_str(subtype), do: "list of #{subtype_str}"
@@ -243,6 +251,9 @@ defmodule NimbleOptions.Docs do
 
       {:or, subtypes} ->
         subtypes |> Enum.map(&type_to_spec/1) |> unionize_quoted()
+
+      {:struct, _struct_name} ->
+        quote(do: atom())
     end
   end
 


Test patch:
diff --git a/test/nimble_options_test.exs b/test/nimble_options_test.exs
index e814cef..f349989 100644
--- a/test/nimble_options_test.exs
+++ b/test/nimble_options_test.exs
@@ -42,7 +42,7 @@ defmodule NimbleOptionsTest do
       Available types: :any, :keyword_list, :non_empty_keyword_list, :map, :atom, \
       :integer, :non_neg_integer, :pos_integer, :float, :mfa, :mod_arg, :string, :boolean, :timeout, \
       :pid, :reference, {:fun, arity}, {:in, choices}, {:or, subtypes}, {:custom, mod, fun, args}, \
-      {:list, subtype}, {:tuple, list_of_subtypes}, {:map, key_type, value_type} \
+      {:list, subtype}, {:tuple, list_of_subtypes}, {:map, key_type, value_type}, {:struct, struct_name} \
       (in options [:stages])\
       """
 
@@ -1243,6 +1243,44 @@ defmodule NimbleOptionsTest do
                   value: %{c: [4, 5, 6], invalid_key: [1, 2, 3]}
                 }}
     end
+
+    test "valid {:struct, struct_name}" do
+      schema = [struct: [type: {:struct, URI}]]
+
+      opts = [struct: %URI{}]
+
+      assert NimbleOptions.validate(opts, schema) == {:ok, opts}
+    end
+
+    test "non-matching {:struct, struct_name}" do
+      schema = [struct: [type: {:struct, URI}]]
+
+      opts = [struct: %NimbleOptions{}]
+
+      assert NimbleOptions.validate(opts, schema) ==
+               {:error,
+                %NimbleOptions.ValidationError{
+                  key: :struct,
+                  keys_path: [],
+                  message:
+                    "invalid value for :struct option: expected URI, got: %NimbleOptions{schema: []}",
+                  value: %NimbleOptions{schema: []}
+                }}
+    end
+
+    test "invalid {:struct, struct_name}" do
+      schema = [struct: [type: {:struct, "123"}]]
+
+      opts = [struct: %URI{}]
+
+      assert_raise(
+        ArgumentError,
+        "invalid NimbleOptions schema. Reason: invalid value for :type option: invalid struct_name for :struct, expected atom, got \"123\" (in options [:struct])",
+        fn ->
+          NimbleOptions.validate(opts, schema)
+        end
+      )
+    end
   end
 
   describe "nested options with predefined keys" do
@@ -1743,6 +1781,27 @@ defmodule NimbleOptionsTest do
       assert NimbleOptions.docs(NimbleOptions.new!(schema)) == docs
     end
 
+    test "uses the type_doc option" do
+      schema = [
+        foo: [type: :string, type_doc: "`t:SomeModule.t/0`", doc: "The foo."],
+        bar: [type: :string, type_doc: "`t:SomeModule.t/0`"],
+        baz: [type: :integer, type_doc: false, doc: "The bar."],
+        quux: [type: :integer, type_doc: false, doc: false]
+      ]
+
+      docs = """
+      * `:foo` (`t:SomeModule.t/0`) - The foo.
+
+      * `:bar` (`t:SomeModule.t/0`)
+
+      * `:baz` - The bar.
+
+      """
+
+      assert NimbleOptions.docs(schema) == docs
+      assert NimbleOptions.docs(NimbleOptions.new!(schema)) == docs
+    end
+
     test "passing specific indentation" do
       nested_schema = [
         allowed_messages: [type: :pos_integer, doc: "Allowed messages."],
@@ -1932,7 +1991,8 @@ defmodule NimbleOptionsTest do
         list_of_kws: [type: {:list, {:keyword_list, []}}],
         map: [type: :map],
         map_of_strings: [type: {:map, :string, :string}],
-        tuple: [type: {:tuple, [:integer, :atom, {:list, :string}]}]
+        tuple: [type: {:tuple, [:integer, :atom, {:list, :string}]}],
+        struct: [type: {:struct, URI}]
       ]
 
       assert NimbleOptions.docs(schema) == """
@@ -1964,6 +2024,8 @@ defmodule NimbleOptionsTest do
 
              * `:tuple` (tuple of `t:integer/0`, `t:atom/0`, list of `t:String.t/0` values)
 
+             * `:struct` (struct of type URI)
+
              """
     end
   end
@@ -2017,7 +2079,8 @@ defmodule NimbleOptionsTest do
         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]}]
+        union_complex: [type: {:or, [{:or, [:integer, :float]}, :boolean]}],
+        struct: [type: {:struct, URI}]
       ]
 
       assert NimbleOptions.option_typespec(schema) ==
@@ -2048,6 +2111,7 @@ defmodule NimbleOptionsTest do
                   | {:list_of_ne_kw, [keyword()]}
                   | {:union_scalar, integer() | boolean() | float()}
                   | {:union_complex, (integer() | float()) | boolean()}
+                  | {:struct, atom()}
                 end)
     end
   end