diff --git a/lib/typespec.ex b/lib/typespec.ex index 15909a7..c83be4d 100644 --- a/lib/typespec.ex +++ b/lib/typespec.ex @@ -147,30 +147,64 @@ defmodule Strukt.Typespec do compose_call(Ecto.UUID, :t, []) mod -> - with {:module, _} <- Code.ensure_compiled(mod), - {:ok, mod_types} <- Code.Typespec.fetch_types(mod), - t0 when not is_nil(t0) <- - Enum.find( - mod_types, - &match?( - {kind, {:t, _, args}} when kind in [:type, :opaque] and length(args) == 0, - &1 - ) - ) do - compose_call( - {:__aliases__, [alias: false], Enum.map(Module.split(mod), &String.to_atom/1)}, - :t, - [] - ) + if defines_module_type?(mod) do + compose_call(module_alias_ast(mod), :t, []) else - _ -> - # Module is unable to be loaded, either due to compiler deadlock, or because - # the module name we have is an alias, or perhaps just plain wrong, so we can't - # assume anything about its type - primitive(:any) + # Fall back to the underlying Ecto type when available, rather than + # relying on debug info being present in the BEAM to discover `t/0`. + ecto_type_to_type_name(mod) || primitive(:any) end end end defp type_to_type_name(_), do: primitive(:any) + + defp defines_module_type?(mod) do + module_defines_type?(mod) or docs_define_type?(mod) or beam_defines_type?(mod) + end + + defp module_defines_type?(mod) do + Module.defines_type?(mod, {:t, 0}) + rescue + ArgumentError -> false + end + + defp docs_define_type?(mod) do + case Code.fetch_docs(mod) do + {:docs_v1, _, _, _, _, _, docs} -> + Enum.any?(docs, &match?({{:type, :t, 0}, _, _, _, _}, &1)) + + _ -> + false + end + end + + defp beam_defines_type?(mod) do + case Code.Typespec.fetch_types(mod) do + {:ok, mod_types} -> + Enum.any?( + mod_types, + &match?({kind, {:t, _, args}} when kind in [:type, :opaque] and length(args) == 0, &1) + ) + + _ -> + false + end + end + + defp ecto_type_to_type_name(mod) do + with {:module, _} <- Code.ensure_loaded(mod), + true <- function_exported?(mod, :type, 0) do + mod.type() + |> type_to_type_name() + else + _ -> nil + end + rescue + _ -> nil + end + + defp module_alias_ast(mod) do + {:__aliases__, [alias: false], Enum.map(Module.split(mod), &String.to_atom/1)} + end end diff --git a/test/strukt_test.exs b/test/strukt_test.exs index aa286a4..36e790b 100644 --- a/test/strukt_test.exs +++ b/test/strukt_test.exs @@ -577,6 +577,26 @@ defmodule Strukt.Test do Fixtures.CustomEctoTypeTypeSepc.expected_type_spec_ast_str() end + test "custom ecto type without beam debug info" do + require Fixtures.NoDebugInfoCustomEctoTypeTypeSpec + + assert inspect( + Strukt.Typespec.generate(%Strukt.Typespec{ + caller: Strukt.Test.Fixtures.NoDebugInfoCustomEctoTypeTypeSpec, + fields: [:uri], + info: %{ + uri: %{ + type: :field, + value_type: Custom.NoDebugInfoEctoType, + required: true + } + }, + embeds: [] + }) + ) == + Fixtures.NoDebugInfoCustomEctoTypeTypeSpec.expected_type_spec_ast_str() + end + defp changeset_errors(%Ecto.Changeset{} = cs) do cs |> Ecto.Changeset.traverse_errors(fn {msg, opts} -> diff --git a/test/support/defstruct_fixtures.ex b/test/support/defstruct_fixtures.ex index 6565641..3c441db 100644 --- a/test/support/defstruct_fixtures.ex +++ b/test/support/defstruct_fixtures.ex @@ -34,6 +34,32 @@ defmodule Custom.EctoType do def dump(_), do: :error end +defmodule Custom.NoDebugInfoEctoType do + @moduledoc "Custom Ecto Type without BEAM debug info for test" + @compile {:debug_info, false} + @type t() :: URI.t() + + use Ecto.Type + def type, do: :map + + def cast(uri) when is_binary(uri), do: {:ok, URI.parse(uri)} + def cast(%URI{} = uri), do: {:ok, uri} + + def cast(_), do: :error + + def load(data) when is_map(data) do + data = + for {key, val} <- data do + {String.to_existing_atom(key), val} + end + + {:ok, struct!(URI, data)} + end + + def dump(%URI{} = uri), do: {:ok, Map.from_struct(uri)} + def dump(_), do: :error +end + defmodule Strukt.Test.Fixtures do use Strukt @@ -116,6 +142,24 @@ defmodule Strukt.Test.Fixtures do end end + defmodule NoDebugInfoCustomEctoTypeTypeSpec do + use Strukt + + @primary_key false + defstruct do + field(:uri, Custom.NoDebugInfoEctoType) + end + + defmacro expected_type_spec_ast_str do + quote context: __MODULE__ do + @type t :: %__MODULE__{ + uri: Custom.NoDebugInfoEctoType.t() + } + end + |> inspect() + end + end + defmodule CustomFields do @moduledoc "This module represents the params keys are not snake case"