diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 0000000..af51681 --- /dev/null +++ b/.dialyzer_ignore.exs @@ -0,0 +1,6 @@ +[ + # fix for `Function ExUnit.CaseTemplate.__proxy__/2 does not exist.` + {"", :unknown_function, 0}, + # fix for `The pattern can never match the type.` + {"lib/construct.ex", :pattern_match, 447}, +] diff --git a/.gitignore b/.gitignore index e517dc8..f9f0a23 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ # Where 3rd-party dependencies like ExDoc output generated docs. /doc +# Plts +/priv/*.plt +/priv/*.plt.hash + # If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump diff --git a/lib/construct.ex b/lib/construct.ex index 52a9a9c..f3ee98f 100644 --- a/lib/construct.ex +++ b/lib/construct.ex @@ -61,8 +61,6 @@ defmodule Construct do unquote(pre_ast) - @type t :: %__MODULE__{} - def make(params \\ %{}, opts \\ []) do Construct.Cast.make(__MODULE__, params, Keyword.merge(opts, unquote(opts))) end @@ -104,7 +102,8 @@ defmodule Construct do Module.eval_quoted __ENV__, {:__block__, [], [ Construct.__defstruct__(@construct_fields, @construct_fields_enforce), - Construct.__types__(@fields)]} + Construct.__types__(@fields), + Construct.__typespecs__(@fields)]} end end @@ -114,7 +113,7 @@ defmodule Construct do If included structure is invalid for some reason — this macro throws an `Struct.DefinitionError` exception with detailed reason. """ - @spec include(t) :: :ok + @spec include(t) :: Macro.t() defmacro include(struct) do quote do module = unquote(struct) @@ -154,7 +153,7 @@ defmodule Construct do By default this option is unset. Notice that you can't use functions as a default value. """ - @spec field(atom, Construct.Type.t, Keyword.t) :: :ok + @spec field(atom, Construct.Type.t, Keyword.t) :: Macro.t() defmacro field(name, type \\ :string, opts \\ []) defmacro field(name, opts, [do: _] = contents) do make_nested_field(name, contents, opts) @@ -176,7 +175,7 @@ defmodule Construct do @doc """ Alias to `c:make/2`, but raises an `Construct.MakeError` exception if params have errors. """ - @callback make!(params :: map, opts :: Keyword.t) :: {:ok, t} | {:error, term} + @callback make!(params :: map, opts :: Keyword.t) :: t @doc """ Alias to `c:make/2`, used to follow `c:Construct.Type.cast/1` callback. @@ -264,6 +263,42 @@ defmodule Construct do end end + @doc false + def __typespecs__(fields) do + typespecs = + Enum.map(fields, fn({name, type, opts}) -> + type = Construct.Type.spec(type) + + type = + case Keyword.fetch(opts, :default) do + {:ok, default} -> + typeof_default = Construct.Type.typeof(default) + + if type == typeof_default do + type + else + quote do: unquote(type) | unquote(typeof_default) + end + + :error -> + type + end + + {name, type} + end) + + modulespec = + {:%, [], + [ + {:__MODULE__, [], Elixir}, + {:%{}, [], typespecs} + ]} + + quote do + @type t :: unquote(modulespec) + end + end + @doc false def __field__(mod, name, type, opts) do check_field_name!(name) diff --git a/lib/construct/exceptions.ex b/lib/construct/exceptions.ex index 12332ad..3f11061 100644 --- a/lib/construct/exceptions.ex +++ b/lib/construct/exceptions.ex @@ -5,8 +5,6 @@ end defmodule Construct.MakeError do defexception [:message] - @spec exception(struct_error | String.t | term) :: struct - when struct_error: %{reason: map, params: map} def exception(%{reason: reason, params: params}) when is_map(reason) do %__MODULE__{message: inspect(traverse_errors(reason, params))} end diff --git a/lib/construct/type.ex b/lib/construct/type.ex index 9a15a3a..2d5b24c 100644 --- a/lib/construct/type.ex +++ b/lib/construct/type.ex @@ -121,7 +121,7 @@ defmodule Construct.Type do :error """ - @spec cast(t, term, options) :: cast_ret + @spec cast(t, term, options) :: cast_ret | any when options: [make_map: boolean] def cast({:array, type}, term, opts) when is_list(term) do @@ -152,7 +152,7 @@ defmodule Construct.Type do @doc """ Behaves like `cast/3`, but without options provided to nested types. """ - @spec cast(t, term) :: cast_ret + @spec cast(t, term) :: cast_ret | any def cast(type, term) @@ -336,10 +336,7 @@ defmodule Construct.Type do defp cast_naive_datetime(%{} = map) do with {:ok, date} <- cast_date(map), {:ok, time} <- cast_time(map) do - case NaiveDateTime.new(date, time) do - {:ok, _} = ok -> ok - {:error, _} -> :error - end + NaiveDateTime.new(date, time) end end defp cast_naive_datetime(_) do @@ -377,6 +374,172 @@ defmodule Construct.Type do end end + ## Typespecs + + @doc """ + Returns typespec AST for given type + + iex> spec([CommaList, {:array, :integer}]) |> Macro.to_string() + "list(:integer)" + + iex> spec({:array, :string}) |> Macro.to_string() + "list(String.t())" + + iex> spec({:map, CustomType}) |> Macro.to_string() + "%{optional(term) => CustomType.t()}" + + iex> spec(:string) |> Macro.to_string() + "String.t()" + + iex> spec(CustomType) |> Macro.to_string() + "CustomType.t()" + """ + @spec spec(t) :: Macro.t() + + def spec(type) when is_list(type) do + type |> List.last() |> spec() + end + + def spec({:array, type}) do + quote do + list(unquote(spec(type))) + end + end + + def spec({:map, type}) do + quote do + %{optional(term) => unquote(spec(type))} + end + end + + def spec(:string) do + quote do + String.t() + end + end + + def spec(:decimal) do + quote do + Decimal.t() + end + end + + def spec(:utc_datetime) do + quote do + DateTime.t() + end + end + + def spec(:naive_datetime) do + quote do + NaiveDateTime.t() + end + end + + def spec(:date) do + quote do + Date.t() + end + end + + def spec(:time) do + quote do + Time.t() + end + end + + def spec(type) when type in @builtin do + type + end + + def spec(type) when is_atom(type) do + quote do + unquote(type).t() + end + end + + def spec(type) do + type + end + + @doc """ + Returns typespec AST for given term + + iex> typeof(nil) |> Macro.to_string() + "nil" + + iex> typeof(1.42) |> Macro.to_string() + "float()" + + iex> typeof("string") |> Macro.to_string() + "String.t()" + + iex> typeof(CustomType) |> Macro.to_string() + "CustomType.t()" + + iex> typeof(&NaiveDateTime.utc_now/0) |> Macro.to_string() + "NaiveDateTime.t()" + """ + @spec spec(t) :: Macro.t() + + def typeof(term) when is_nil(term) do + nil + end + + def typeof(term) when is_integer(term) do + {:integer, [], []} + end + + def typeof(term) when is_float(term) do + {:float, [], []} + end + + def typeof(term) when is_boolean(term) do + {:boolean, [], []} + end + + def typeof(term) when is_binary(term) do + quote do + String.t() + end + end + + def typeof(term) when is_pid(term) do + {:pid, [], []} + end + + def typeof(term) when is_reference(term) do + {:reference, [], []} + end + + def typeof(%{__struct__: struct}) when is_atom(struct) do + quote do + unquote(struct).t() + end + end + + def typeof(term) when is_map(term) do + {:map, [], []} + end + + def typeof(term) when is_atom(term) do + quote do + unquote(term).t() + end + end + + def typeof(term) when is_list(term) do + {:list, [], []} + end + + def typeof(term) when is_function(term, 0) do + term.() |> typeof() + end + + def typeof(_) do + {:term, [], []} + end + ## Helpers defp validate_decimal({:ok, %{__struct__: Decimal, coef: coef}}) when coef in [:inf, :qNaN, :sNaN], @@ -422,8 +585,6 @@ defmodule Construct.Type do {:ok, acc} end - defp map(_, _, _, _, _), do: :error - defp to_i(nil), do: nil defp to_i(int) when is_integer(int), do: int defp to_i(bin) when is_binary(bin) do diff --git a/mix.exs b/mix.exs index 70e5e1d..0923db5 100644 --- a/mix.exs +++ b/mix.exs @@ -8,6 +8,9 @@ defmodule Construct.Mixfile do elixir: "~> 1.4", deps: deps(), elixirc_paths: elixirc_paths(Mix.env), + dialyzer: [ + plt_file: {:no_warn, "priv/dialyzer.plt"} + ], # Hex description: description(), @@ -32,6 +35,7 @@ defmodule Construct.Mixfile do [ {:decimal, "~> 1.5", only: [:dev, :test]}, {:benchfella, "~> 0.3", only: [:dev, :test]}, + {:dialyxir, "~> 1.0.0-rc.7", only: [:dev, :test], runtime: false}, {:earmark, "~> 1.2", only: :dev}, {:ex_doc, "~> 0.19", only: :dev}, {:jason, "~> 1.1", only: :test} diff --git a/mix.lock b/mix.lock index d877227..1a8b110 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,9 @@ %{ "benchfella": {:hex, :benchfella, "0.3.5", "b2122c234117b3f91ed7b43b6e915e19e1ab216971154acd0a80ce0e9b8c05f5", [:mix], [], "hexpm"}, "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, + "dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, "earmark": {:hex, :earmark, "1.2.6", "b6da42b3831458d3ecc57314dff3051b080b9b2be88c2e5aa41cd642a5b044ed", [:mix], [], "hexpm"}, + "erlex": {:hex, :erlex, "0.2.5", "e51132f2f472e13d606d808f0574508eeea2030d487fc002b46ad97e738b0510", [:mix], [], "hexpm"}, "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/priv/.keep b/priv/.keep new file mode 100644 index 0000000..e69de29