lib/tyyppi/struct.ex

defmodule Tyyppi.Struct do
  import Kernel, except: [defstruct: 1]

  alias Tyyppi.{T, Value}
  require T

  @moduledoc """
  Creates the typed struct with spec bound to each field.

  ## Usage

  See `Tyyppi.Example.Struct` for the example of why and how to use `Tyyppi.Struct`.

  ### Example

      iex> defmodule MyStruct do
      ...>   @type my_type :: :ok | {:error, term()}
      ...>   Tyyppi.Struct.defstruct foo: atom(), bar: GenServer.on_start(), baz: my_type()
      ...> end
      iex> types = MyStruct.types()
      ...> types[:foo]
      %Tyyppi.T{
        definition: {:type, 0, :atom, []},
        module: nil,
        name: nil,
        params: [],
        source: nil,
        type: :built_in
      }
      ...> types[:baz]
      %Tyyppi.T{
        definition: {:type, 0, :union, [
          {:atom, 0, :ok},
          {:type, 0, :tuple, [
            {:atom, 0, :error}, {:type, 0, :term, []}]}]},
        module: Test.Tyyppi.Struct.MyStruct,
        name: :my_type,
        params: [],
        quoted: {{:., [], [Test.Tyyppi.Struct.MyStruct, :my_type]}, [], []},
        source: :user_type,
        type: :type
      }

  ## Defaults

  Since there is no place for default values in the struct declaration, where types
    are first class citizens, defaults might be specified through `@defaults` module
    attribute. Omitted fields there will be considered having `nil` default value.

      iex> %Tyyppi.Example.Struct{}
      %Tyyppi.Example.Struct{
        bar: {:ok, :erlang.list_to_pid('<0.0.0>')}, baz: {:error, :reason}, foo: nil}

  ## Upserts

      iex> {ex, pid} = {%Tyyppi.Example.Struct{}, :erlang.list_to_pid('<0.0.0>')}
      iex> Tyyppi.Example.Struct.update(ex, foo: :foo, bar: {:ok, pid}, baz: :ok)
      {:ok, %Tyyppi.Example.Struct{
        bar: {:ok, :erlang.list_to_pid('<0.0.0>')},
        baz: :ok,
        foo: :foo}}
      iex> Tyyppi.Example.Struct.update(ex, foo: :foo, bar: {:ok, pid}, baz: 42)
      {:error, [baz: [type: [expected: "Tyyppi.Example.Struct.my_type()", got: 42]]]}

  ## `Access`

      iex> pid = :erlang.list_to_pid('<0.0.0>')
      iex> ex = %Tyyppi.Example.Struct{foo: :foo, bar: {:ok, pid}, baz: :ok}
      iex> put_in(ex, [:foo], :foo_sna)
      %Tyyppi.Example.Struct{
        bar: {:ok, :erlang.list_to_pid('<0.0.0>')},
        baz: :ok,
        foo: :foo_sna}
      iex> put_in(ex, [:foo], 42)
      ** (ArgumentError) could not put/update key :foo with value 42 ([foo: [type: [expected: \"atom()\", got: 42]]])

  """
  @doc """
  Declares a typed struct. The accepted argument is the keyword of
  `{field_name, type}` tuples. See `Tyyppi.Example.Struct` for an example.
  """
  defmacro defstruct(definition) when is_list(definition) do
    typespec = typespec(definition, __CALLER__)
    struct_typespec = [{:__struct__, {:__MODULE__, [], Elixir}} | typespec]

    quoted_types =
      quote bind_quoted: [definition: Macro.escape(definition)] do
        # FIXME Private types
        user_type = fn {type, _, _} ->
          __MODULE__
          |> Module.get_attribute(:type)
          |> Enum.find(&match?({_, {:"::", _, [{^type, _, _} | _]}, _}, &1))
          |> case do
            nil ->
              nil

            {kind, {:"::", _, [{type, _, params}, definition]}, _} ->
              params = T.normalize_params(params)
              param_names = T.param_names(params)

              %T{
                T.parse_quoted(definition)
                | type: kind,
                  module: __MODULE__,
                  name: type,
                  params: params,
                  source: :user_type,
                  quoted:
                    quote(do: unquote(__MODULE__).unquote(type)(unquote_splicing(param_names)))
                    |> Macro.prewalk(fn
                      {id, meta, params} when is_list(meta) ->
                        {id, Enum.reject(meta, &match?({:generated, true}, &1)), params}

                      other ->
                        other
                    end)
              }
          end
        end

        Enum.map(definition, fn {key, type} ->
          {key, user_type.(type) || %T{quoted: type}}
        end)
      end

    declaration = do_declaration(quoted_types, struct_typespec, __CALLER__.line)
    validation = do_validation()
    casts_and_validates = do_casts_and_validates()
    update = do_update()
    generation = do_generation()
    mod = Macro.expand(__CALLER__.module, __ENV__)
    as_value = do_as_value(Macro.escape(T.parse_quoted({{:., [], [mod, :t]}, [], []})))
    collectable = do_collectable()
    enumerable = do_enumerable()

    jason =
      if Application.get_env(:tyyppi, :jason, false) or Code.ensure_loaded?(Jason.Encoder),
        do: quote(do: @derive(Jason.Encoder)),
        else: []

    flatten =
      quote do
        defdelegate flatten(struct), to: Tyyppi.Struct
        defdelegate flatten(struct, opts), to: Tyyppi.Struct
      end

    [
      jason,
      declaration,
      validation,
      casts_and_validates,
      update,
      generation,
      as_value,
      collectable,
      enumerable,
      flatten
    ]
  end

  @doc "Puts the value to target under specified key, if passes validation"
  @spec put(target :: struct, key :: atom(), value :: any()) ::
          {:ok, struct} | {:error, keyword()}
        when struct: %{required(atom()) => any()}
  def put(%type{} = target, key, value) when is_atom(key), do: type.update(target, [{key, value}])

  @doc "Puts the value to target under specified key, if passes validation, raises otherwise"
  @spec put!(target :: struct, key :: atom(), value :: any()) :: struct
        when struct: %{required(atom()) => any()}
  def put!(%_type{} = target, key, value) when is_atom(key) do
    case put(target, key, value) do
      {:ok, data} ->
        data

      {:error, error} ->
        raise(ArgumentError,
          message:
            "could not put/update key :#{key} with value #{inspect(value)} (#{inspect(error)})"
        )
    end
  end

  @doc "Updates the value in target under specified key, if passes validation"
  @spec update(target :: struct, key :: atom(), updater :: (any() -> any())) ::
          {:ok, struct} | {:error, any()}
        when struct: %{__struct__: atom()}
  def update(%_type{} = target, key, fun) when is_atom(key) and is_function(fun, 1),
    do: put(target, key, fun.(target[key]))

  @doc "Updates the value in target under specified key, if passes validation, raises otherwise"
  @spec update!(target :: struct, key :: atom(), updater :: (any() -> any())) ::
          struct | no_return()
        when struct: %{__struct__: atom()}
  def update!(%_type{} = target, key, fun) when is_atom(key) and is_function(fun, 1),
    do: put!(target, key, fun.(target[key]))

  @doc false
  @spec typespec(types :: {atom(), T.ast()} | [{atom(), T.ast()}], Macro.Env.t()) ::
          {atom(), T.ast()} | [{atom(), T.ast()}]
  def typespec({k, type}, _env) when is_atom(type), do: {k, {type, [], []}}

  def typespec({k, {{:., meta, [aliases, type]}, submeta, args}}, env) when is_atom(type),
    do: {k, {{:., meta, [Macro.expand(aliases, env), type]}, submeta, args}}

  def typespec({k, {module, type}}, env) when is_atom(module) and is_atom(type) do
    modules = module |> Module.split() |> Enum.map(&:"#{&1}")
    {k, {{:., [], [Macro.expand({:__aliases__, [], modules}, env), type]}, [], []}}
  end

  def typespec({k, type}, _env), do: {k, type}

  def typespec(types, env) when is_list(types),
    do: Enum.map(types, &typespec(&1, env))

  #############################################################################
  defp do_declaration(quoted_types, struct_typespec, line) do
    quote generated: true, location: :keep do
      if is_nil(Module.get_attribute(__MODULE__, :moduledoc)) do
        Module.put_attribute(
          __MODULE__,
          :moduledoc,
          {unquote(line),
           """
           The implementation of `Tyyppi.Struct`, exposing the type and `Access`
             implementation to deal with this object.
           """}
        )
      end

      alias Tyyppi.Struct

      @quoted_types unquote(quoted_types)
      @fields Keyword.keys(@quoted_types)

      @typedoc ~s"""
      The type describing `#{inspect(__MODULE__)}`. This type will be used to validate
        upserts when called via `Access` and/or `Tyyppi.Struct.put/3`,
        `Tyyppi.Struct.update/3`, both delegating to generated
        `#{inspect(__MODULE__)}.update/2`.

      Upon insertion, the value will be coerced to the expected type
        when available, the type itself will be validated, and then the
        custom validation will be applied when applicable.
      """
      @type t :: %{unquote_splicing(struct_typespec)}

      @doc ~s"""
      Returns the field types of this struct as keyword of
        `{field :: atom, type :: Tyyppi.T.t(term())}` pairs.
      """
      @spec types :: [{atom(), T.t(wrapped)}] when wrapped: term()
      def types do
        Enum.map(@quoted_types, fn
          {k, %T{definition: nil, quoted: quoted}} ->
            {^k, quoted} = Tyyppi.Struct.typespec({k, quoted}, __ENV__)
            {k, T.parse_quoted(quoted)}

          user ->
            user
        end)
      end

      # FIXME to check defaults we’ll need to create a blank struct and to `put_in/3`
      #       all the defaults in `__before_compile__/1`
      defaults = Module.get_attribute(__MODULE__, :defaults, [])
      struct_declaration = Enum.map(@fields, &{&1, Keyword.get(defaults, &1)})

      Kernel.defstruct(struct_declaration)

      use Tyyppi.Access, @fields
    end
  end

  defp do_validation do
    quote generated: true, location: :keep do
      @doc """
      This function is supposed to be overwritten in the implementation in cases
        when custom validation is required.

      It would be called after all casts and type validations, if the succeeded
      """
      @spec validate(t()) :: Tyyppi.Valuable.either()
      def validate(%__MODULE__{} = s) do
        s
        |> Enum.reduce({s, []}, fn
          {key, %type{} = value}, {%__MODULE__{} = acc, errors} ->
            if type.__info__(:functions)[:validate] == 1 do
              case type.validate(value) do
                {:ok, value} -> {Map.put(acc, key, value), errors}
                {:error, error} -> {acc, error ++ errors}
              end
            else
              {acc, errors}
            end

          {_, _}, {acc, errors} ->
            {acc, errors}
        end)
        |> case do
          {validated, []} -> {:ok, validated}
          {_, errors} -> {:error, errors}
        end
      end

      @spec errors(t()) :: list()
      def errors(%__MODULE__{} = s) do
        Enum.reduce(s, [], fn
          {key, %type{} = value}, errors ->
            if type.__info__(:functions)[:validate] == 1 do
              case type.validate(value) do
                {:ok, value} ->
                  if type.__info__(:functions)[:valid?] != 1 or type.valid?(value),
                    do: errors,
                    else: [{key, :invalid_value} | errors]

                {:error, error} ->
                  error ++ errors
              end
            else
              errors
            end

          {_, _}, errors ->
            errors
        end)
      end

      @spec valid?(t()) :: boolean()
      def valid?(%__MODULE__{} = s) do
        Enum.reduce_while(s, true, fn
          _, false ->
            {:halt, false}

          {_, %Tyyppi.Value{} = v}, true ->
            {:cont, Tyyppi.Value.valid?(v)}

          {_, %type{} = v}, true ->
            {:cont, type.__info__(:functions)[:valid?] == 1 && type.valid?(v)}

          _, true ->
            {:cont, true}
        end)
      end

      defoverridable validate: 1, valid?: 1
    end
  end

  defp do_collectable do
    quote generated: true, location: :keep, unquote: false do
      fields = @fields

      defimpl Collectable do
        @moduledoc false
        alias Tyyppi.Struct

        def into(original) do
          {original,
           fn
             acc, {:cont, {k, v}} when k in unquote(fields) -> Struct.put!(acc, k, v)
             acc, :done -> acc
             _, :halt -> :ok
           end}
        end
      end
    end
  end

  defp do_enumerable do
    quote generated: true, location: :keep, unquote: false do
      fields = @fields
      count = length(fields)

      defimpl Enumerable do
        @moduledoc false
        alias Tyyppi.Struct

        def slice(enumerable), do: {:error, __MODULE__}

        def count(_), do: {:ok, unquote(count)}

        def member?(map, {field, value}) when field in unquote(fields),
          do: {:ok, match?({:ok, ^value}, :maps.find(field, map))}

        def member?(_, _), do: {:ok, false}

        def reduce(map, acc, fun),
          do: do_reduce(map |> Map.from_struct() |> :maps.to_list(), acc, fun)

        defp do_reduce(_, {:halt, acc}, _fun), do: {:halted, acc}

        defp do_reduce(list, {:suspend, acc}, fun),
          do: {:suspended, acc, &do_reduce(list, &1, fun)}

        defp do_reduce([], {:cont, acc}, _fun), do: {:done, acc}
        defp do_reduce([h | t], {:cont, acc}, fun), do: do_reduce(t, fun.(h, acc), fun)
      end
    end
  end

  defp do_as_value({:%{}, [], [{:__struct__, Tyyppi.T} | rest]} = type) do
    quoted = Keyword.get(rest, :quoted, {:{}, [], [:any, [], []]})

    quote generated: true, location: :keep do
      @spec as_value(keyword()) :: Tyyppi.Value.t(unquote(quoted))
      @doc "Factory for `#{__MODULE__}` wrapped by `Tyyppi.Value`"
      def as_value(values \\ []) do
        value = struct!(__MODULE__, values)

        %Tyyppi.Value{
          value: value,
          type: unquote(type),
          validation: &__MODULE__.validate/1,
          generation: &__MODULE__.generation/1
        }
      end
    end
  end

  defp do_casts_and_validates do
    quote generated: true, location: :keep, unquote: false do
      funs =
        Enum.flat_map(@fields, fn field ->
          @doc false
          defp unquote(:"cast_#{field}")(value), do: value
          @doc false
          defp do_cast(unquote(field), value), do: unquote(:"cast_#{field}")(value)

          @doc false
          defp unquote(:"validate_#{field}")(value), do: {:ok, value}
          @doc false
          defp do_validate(unquote(field), value) do
            case :erlang.phash2(1, 1) do
              0 -> unquote(:"validate_#{field}")(value)
              1 -> {:error, :hack_to_fool_dialyzer}
            end
          end

          [{:"cast_#{field}", 1}, {:"validate_#{field}", 1}]
        end)

      defoverridable funs
    end
  end

  defp do_update do
    quote generated: true, location: :keep, unquote: false do
      @doc """
      Updates the struct
      """
      @spec update(target :: t(), values :: keyword()) :: {:ok, t()} | {:error, keyword()}
      def update(%__MODULE__{} = target, values) when is_list(values) do
        types = types()

        values =
          Enum.reduce(values, %{result: [], errors: []}, fn {field, value}, acc ->
            cast = do_cast(field, value)

            cast =
              case {Tyyppi.Value.value_type?(types[field]), match?(%Tyyppi.Value{}, cast), target} do
                {true, false, %{^field => %Tyyppi.Value{} = value}} ->
                  put_in(value, [:value], cast)

                _ ->
                  value = get_in(target, [field])

                  if T.collectable?(value) and T.enumerable?(cast),
                    do: Enum.into(cast, value),
                    else: cast
              end

            acc =
              cond do
                match?(%Tyyppi.Value{}, cast) and is_list(cast[:errors]) ->
                  %{acc | errors: [{field, cast[:errors]} | acc.errors]}

                Tyyppi.of_type?(types[field], cast) ->
                  case do_validate(field, cast) do
                    {:ok, result} ->
                      %{acc | result: [{field, cast} | acc.result]}

                    {:error, error} ->
                      %{
                        acc
                        | errors: [
                            {field, [validation: [message: error, got: value, cast: cast]]}
                            | acc.errors
                          ]
                      }
                  end

                true ->
                  error = {field, [type: [expected: to_string(types[field]), got: value]]}
                  %{acc | errors: [error | acc.errors]}
              end
          end)

        with %{result: update, errors: []} <- values,
             candidate = Map.merge(target, Map.new(update)),
             {:ok, result} <- validate(candidate) do
          {:ok, result}
        else
          %{errors: errors} -> {:error, errors}
          {:error, errors} -> {:error, errors}
        end
      end
    end
  end

  defp do_generation do
    quote generated: true, location: :keep, unquote: false do
      defp prop_test, do: quote(do: unquote(Tyyppi.Value.Generations.prop_test()))

      @dialyzer {:nowarn_function, generation_leaf: 1}
      defp generation_leaf(args),
        do: {{:., [], [prop_test(), :constant]}, [], [{:{}, [], args}]}

      @dialyzer {:nowarn_function, generation_clause: 3}
      defp generation_clause(this, {field, arg}, acc) do
        {{:., [], [prop_test(), :bind]}, [],
         [
           {{:., [], [{:__aliases__, [alias: false], [:Tyyppi, :Struct]}, :generation]}, [],
            [{{:., [], [this, field]}, [no_parens: true], []}]},
           {:fn, [], [{:->, [], [[arg], acc]}]}
         ]}
      end

      @dialyzer {:nowarn_function, generation_bound: 2}
      defp generation_bound(this, fields) do
        args = fields |> length() |> Macro.generate_arguments(__MODULE__) |> Enum.reverse()
        fields_args = Enum.zip(fields, args)

        Enum.reduce(fields_args, generation_leaf(args), &generation_clause(this, &1, &2))
      end

      defmacrop do_generation(this, fields),
        do: generation_bound(this, Macro.expand(fields, __CALLER__))

      @doc false
      @dialyzer {:nowarn_function, generation: 1}
      @spec generation(t()) :: Tyyppi.Valuable.generation()

      def generation(%Value{value: %__MODULE__{} = value}), do: generation(value)

      def generation(%__MODULE__{} = this) do
        prop_test = Value.Generations.prop_test()

        this
        |> do_generation(@fields)
        |> prop_test.map(&Tuple.to_list/1)
        |> prop_test.map(&Enum.zip(@fields, &1))
        |> prop_test.map(&Enum.into(&1, this))
      end

      defoverridable generation: 1
    end
  end

  ##############################################################################

  @behaviour Tyyppi.Valuable

  @impl Tyyppi.Valuable
  # FIXME
  def coerce(%_type{} = s), do: {:ok, s}

  @impl Tyyppi.Valuable
  def validate(%type{} = s), do: type.validate(s)

  @impl Tyyppi.Valuable
  def generation(%type{} = value), do: type.generation(value)

  @impl Tyyppi.Valuable
  def flatten(value, opts \\ [])

  def flatten(%Value{} = value, opts),
    do: Value.flatten(value, opts)

  def flatten(%_type{} = value, opts) do
    force = Keyword.get(opts, :force, true)
    squeeze = Keyword.get(opts, :squeeze, false)

    if is_nil(Enumerable.impl_for(value)) do
      value
    else
      result =
        Enum.reduce(value, %{}, fn
          {key, %Value{} = value}, acc ->
            value |> Value.flatten() |> flatten_once(acc, key, opts)

          {key, %subtype{} = value}, acc ->
            if force or Tyyppi.can_flatten?(subtype),
              do: value |> subtype.flatten() |> flatten_once(acc, key, opts),
              else: Map.put(acc, to_string(key), value)

          {key, value}, acc ->
            Map.put(acc, to_string(key), value)
        end)

      case squeeze do
        true ->
          Enum.reduce(result, %{}, fn
            {_, nil}, acc -> acc
            {k, v}, acc -> Map.put(acc, k, v)
          end)

        false ->
          result

        f when is_function(f, 1) ->
          Enum.reduce(result, %{}, fn {k, v}, acc ->
            case f.({k, v}) do
              {:ok, v} -> Map.put(acc, k, v)
              :squeeze -> acc
            end
          end)
      end
    end
  end

  defp flatten_once(value, acc, key, opts) do
    joiner = Keyword.get(opts, :joiner, "_")

    case value do
      %_type{} = struct ->
        if is_nil(Enumerable.impl_for(struct)) do
          Map.put(acc, to_string(key), Value.flatten(value, opts))
        else
          struct
          |> Map.new(fn {k, v} -> {Enum.join([key, k], joiner), Value.flatten(v, opts)} end)
          |> Map.merge(acc)
        end

      %{} = map ->
        map
        |> Map.new(fn {k, v} -> {Enum.join([key, k], joiner), Value.flatten(v, opts)} end)
        |> Map.merge(acc)

      _ ->
        Map.put(acc, to_string(key), Value.flatten(value, opts))
    end
  end
end