defmodule Selecto.Extensions do @moduledoc """ Shared extension loading and callback dispatch for the Selecto ecosystem. """ @typedoc "Normalized extension entries: `{module, opts}`" @type normalized_spec :: {module(), keyword()} @doc """ Normalize extension specs from any supported format. Supported entries: - `MyExtension` - `{MyExtension, key: "value"}` - `%{module: MyExtension, opts: [...]}` """ @spec normalize_specs(term()) :: [normalized_spec()] def normalize_specs(nil), do: [] def normalize_specs([]), do: [] def normalize_specs(specs) when is_list(specs) do Enum.map(specs, &normalize_spec!/1) end def normalize_specs(spec), do: [normalize_spec!(spec)] @doc """ Extract normalized extension specs from a domain-like map. """ @spec from_domain(map()) :: [normalized_spec()] def from_domain(%{} = domain) do domain |> Map.get(:extensions, []) |> normalize_specs() end def from_domain(_), do: [] @doc """ Extract normalized extension specs from a selecto/domain-like value. """ @spec from_source(term()) :: [normalized_spec()] def from_source(%{extensions: extensions}) do normalize_specs(extensions) end def from_source(%{config: %{extensions: extensions}}) do normalize_specs(extensions) end def from_source(%{domain: %{} = domain}) do from_domain(domain) end def from_source(%{} = domain), do: from_domain(domain) def from_source(_), do: [] @doc """ Apply `merge_domain/2` callbacks for extensions in declaration order. """ @spec merge_domain_extensions(map(), [normalized_spec()]) :: map() def merge_domain_extensions(%{} = domain, specs) when is_list(specs) do Enum.reduce(specs, domain, fn {module, opts}, acc -> maybe_invoke(module, :merge_domain, [acc, opts], acc) end) end def merge_domain_extensions(%{} = domain, _), do: domain @doc """ Apply `updato_domain/2` callbacks for extensions in declaration order. """ @spec merge_updato_domain_extensions(map(), [normalized_spec()]) :: map() def merge_updato_domain_extensions(%{} = domain, specs) when is_list(specs) do Enum.reduce(specs, domain, fn {module, opts}, acc -> maybe_invoke(module, :updato_domain, [acc, opts], acc) end) end def merge_updato_domain_extensions(%{} = domain, _), do: domain @doc """ Return additional overlay DSL import modules from extension callbacks. """ @spec overlay_dsl_modules([normalized_spec()]) :: [module()] def overlay_dsl_modules(specs) when is_list(specs) do specs |> Enum.flat_map(fn {module, opts} -> maybe_invoke(module, :overlay_dsl_modules, [opts], []) |> List.wrap() end) |> Enum.uniq() end @doc """ Execute compile-time `overlay_setup/2` callbacks. """ @spec setup_overlay_extensions(module(), [normalized_spec()]) :: :ok def setup_overlay_extensions(overlay_module, specs) when is_atom(overlay_module) and is_list(specs) do Enum.each(specs, fn {module, opts} -> _ = maybe_invoke(module, :overlay_setup, [overlay_module, opts], :ok) end) :ok end @doc """ Build and deep-merge extension overlay fragments. """ @spec overlay_fragments(module(), [normalized_spec()]) :: map() def overlay_fragments(overlay_module, specs) when is_atom(overlay_module) and is_list(specs) do Enum.reduce(specs, %{}, fn {module, opts}, acc -> fragment = module |> maybe_invoke(:overlay_fragment, [overlay_module, opts], %{}) |> ensure_map() deep_merge(acc, fragment) end) end @doc """ Return additional `selecto_components` views contributed by extensions. """ @spec components_views(term(), [normalized_spec()]) :: [tuple()] def components_views(selecto_or_domain, specs) when is_list(specs) do Enum.flat_map(specs, fn {module, opts} -> maybe_invoke(module, :components_views, [selecto_or_domain, opts], []) |> List.wrap() |> Enum.filter(&valid_view_tuple?/1) end) end @doc """ Resolve extension-provided Ecto->Selecto type mapping. Returns the first non-nil mapping from extensions in declaration order. """ @spec ecto_type_to_selecto_type(term(), [normalized_spec()]) :: atom() | nil def ecto_type_to_selecto_type(ecto_type, specs) when is_list(specs) do Enum.find_value(specs, fn {module, opts} -> maybe_invoke(module, :ecto_type_to_selecto_type, [ecto_type, opts], nil) end) end def ecto_type_to_selecto_type(_ecto_type, _specs), do: nil @doc """ Deep-merge maps recursively. """ @spec deep_merge(map(), map()) :: map() def deep_merge(left, right) when is_map(left) and is_map(right) do Map.merge(left, right, fn _key, left_val, right_val -> if is_map(left_val) and is_map(right_val), do: deep_merge(left_val, right_val), else: right_val end) end def deep_merge(left, _right), do: left defp normalize_spec!(module) when is_atom(module) do {module, []} end defp normalize_spec!({module, opts}) when is_atom(module) and is_list(opts) do {module, opts} end defp normalize_spec!({module, %{} = opts}) when is_atom(module) do {module, Map.to_list(opts)} end defp normalize_spec!(%{module: module, opts: opts}) when is_atom(module) and is_list(opts) do {module, opts} end defp normalize_spec!(%{module: module, opts: %{} = opts}) when is_atom(module) do {module, Map.to_list(opts)} end defp normalize_spec!(invalid) do raise ArgumentError, "Invalid Selecto extension spec: #{inspect(invalid)}. " <> "Expected module, {module, opts}, or %{module: module, opts: opts}." end defp maybe_invoke(module, callback, args, fallback) when is_atom(module) and is_atom(callback) and is_list(args) do arity = length(args) if Code.ensure_loaded?(module) and function_exported?(module, callback, arity) do apply(module, callback, args) else fallback end end defp maybe_invoke(_module, _callback, _args, fallback), do: fallback defp valid_view_tuple?({id, view_module, name, options}) when is_atom(id) and is_atom(view_module) and is_binary(name) and is_map(options), do: true defp valid_view_tuple?(_), do: false defp ensure_map(%{} = map), do: map defp ensure_map(_), do: %{} end