lib/tyyppi.ex

defmodule Tyyppi do
  @moduledoc """
  The main interface to `Tyyppi` library. Usually, functions and macros
  presented is this module are enough to work with `Tyyppi`.


  """

  use Boundary, exports: [Function, Matchers, Stats]

  require Logger

  alias Tyyppi.{Matchers, Stats, T}
  import Tyyppi.T, only: [normalize_params: 1, param_names: 1, parse_definition: 1]

  @doc false
  defguard is_params(params) when is_list(params) or is_atom(params)

  @doc """
  Sigil to simplify specification of `Tyyppi.T.t(term())` type, it’s literally the wrapper for `Tyyppi.parse/1`.

  ## Examples

      iex> import Tyyppi
      iex> ~t[integer()]
      %Tyyppi.T{
        definition: {:type, 0, :integer, []},
        module: nil,
        name: nil,
        params: [],
        quoted: {:integer, [], []},
        source: nil,
        type: :built_in
      }
      ...> ~t[atom]
      %Tyyppi.T{
        definition: {:type, 0, :atom, []},
        module: nil,
        name: nil,
        params: [],
        quoted: {:atom, [], []},
        source: nil,
        type: :built_in
      }
  """
  defmacro sigil_t({:<<>>, _meta, [string]}, []) when is_binary(string) do
    if Version.compare(System.version(), "1.12.0") == :lt do
      quote bind_quoted: [string: string] do
        string
        |> :elixir_interpolation.unescape_chars()
        |> Code.string_to_quoted!()
        |> Tyyppi.parse_quoted()
      end
    else
      quote bind_quoted: [string: string] do
        string
        |> :elixir_interpolation.unescape_string()
        |> Code.string_to_quoted!()
        |> Tyyppi.parse_quoted()
      end
    end
  end

  defmacro sigil_t({:<<>>, meta, pieces}, []) do
    tokens =
      case :elixir_interpolation.unescape_tokens(pieces) do
        {:ok, unescaped_tokens} -> unescaped_tokens
        {:error, reason} -> raise ArgumentError, to_string(reason)
        {:error, reason, _} -> raise ArgumentError, to_string(reason)
      end

    quote do
      unquote({:<<>>, meta, tokens})
      |> Code.string_to_quoted!()
      |> Tyyppi.parse_quoted()
    end
  end

  @doc """
  Parses the type as by spec and returns its `Tyyppi.T` representation.

  _Example:_

      iex> require Tyyppi
      ...> parsed = Tyyppi.parse(GenServer.on_start())
      ...> with %Tyyppi.T{definition: {:type, _, :union, [type | _]}} <- parsed, do: type
      {:type, 0, :tuple, [{:atom, 0, :ok}, {:type, 704, :pid, []}]}
      ...> parsed.module
      GenServer
      ...> parsed.name
      :on_start
      ...> parsed.params
      []
      ...> parsed.quoted
      {{:., [], [GenServer, :on_start]}, [], []}
      ...> parsed.type
      :type
  """
  defmacro parse({:|, _, [_, _]} = type) do
    quote bind_quoted: [union: Macro.escape(type)] do
      union
      |> T.union()
      |> T.parse_definition()
      |> Stats.type()
    end
  end

  defmacro parse([{:->, _, [args, result]}]) do
    type =
      case args do
        [{:..., _, _}] -> {:type, 0, :any}
        args -> {:type, 0, :product, Enum.map(args, &parse_definition/1)}
      end

    result = parse_definition(result)

    quote bind_quoted: [type: Macro.escape(type), result: Macro.escape(result)] do
      Stats.type({:type, 0, :fun, [type, result]})
    end
  end

  defmacro parse({{:., _, [module, fun]}, _, params}) when is_params(params) do
    params = params |> normalize_params() |> length()

    quote bind_quoted: [module: module, fun: fun, params: params] do
      Stats.type({module, fun, params})
    end
  end

  defmacro parse({{:., _, [{:__aliases__, _, aliases}, fun]}, _, params})
           when is_params(params) do
    params = params |> normalize_params() |> length()

    quote bind_quoted: [aliases: aliases, fun: fun, params: params] do
      Stats.type({Module.concat(aliases), fun, params})
    end
  end

  defmacro parse({:%{}, _meta, fields} = quoted) when is_list(fields),
    do: do_parse_map(quoted, __CALLER__)

  defmacro parse({:%, _meta, [struct, {:%{}, meta, fields}]}),
    do: do_parse_map({:%{}, meta, [{:__struct__, struct} | fields]}, __CALLER__)

  defmacro parse({_, _} = tuple), do: do_lookup(tuple)
  defmacro parse({:{}, _, content} = tuple) when is_list(content), do: do_lookup(tuple)

  defmacro parse({fun, _, params}) when is_atom(fun) and is_params(params) do
    quote bind_quoted: [fun: fun, params: param_names(params)] do
      Stats.type({:type, 0, fun, params})
    end
  end

  defmacro parse(any) do
    Logger.debug("[🚰 T.parse/1]: " <> inspect(any))
    do_lookup(any)
  end

  defp do_parse_map({:%{}, _meta, fields} = quoted, caller) when is_list(fields) do
    fields =
      fields
      |> Enum.map(fn
        {{:optional, _, [name]}, type} ->
          {:type, 0, :map_field_assoc, Enum.map([name, type], &parse_quoted(&1).definition)}

        {{:required, _, [name]}, type} ->
          {:type, 0, :map_field_exact, Enum.map([name, type], &parse_quoted(&1).definition)}

        {name, type} ->
          {:type, 0, :map_field_exact, Enum.map([name, type], &parse_quoted(&1).definition)}
      end)
      |> Macro.escape()

    file = caller.file
    quoted = Macro.escape(quoted, prune_metadata: true)

    quote location: :keep do
      %Tyyppi.T{
        definition: {:type, 0, :map, unquote(fields)},
        module: nil,
        name: nil,
        params: [],
        quoted: unquote(quoted),
        source: unquote(file),
        type: :type
      }
    end
  end

  defp do_lookup(any) do
    quote bind_quoted: [any: Macro.escape(any)] do
      any
      |> T.parse_definition()
      |> Stats.type()
    end
  end

  @doc """
  Returns `true` if the `term` passed as the second parameter is of type `type`.
    The first parameter is expected to be a `type` as in specs, e. g. `atom()` or
    `GenServer.on_start()`.

  _Examples:_

      iex> require Tyyppi
      ...> Tyyppi.of?(atom(), :ok)
      true
      ...> Tyyppi.of?(atom(), 42)
      false
      ...> Tyyppi.of?(GenServer.on_start(), {:error, {:already_started, self()}})
      true
      ...> Tyyppi.of?(GenServer.on_start(), :foo)
      false
  """
  defmacro of?(type, term) do
    quote do
      %Tyyppi.T{module: module, definition: definition} = Tyyppi.parse(unquote(type))
      Matchers.of?(module, definition, unquote(term))
    end
  end

  @spec of_type?(Tyyppi.T.t(wrapped), any()) :: boolean() when wrapped: term()
  @doc """
  Returns `true` if the `term` passed as the second parameter is of type `type`.
    The first parameter is expected to be of type `Tyyppi.T.t(term())`.

  _Examples:_

      iex> require Tyyppi
      ...> type = Tyyppi.parse(atom())
      %Tyyppi.T{
        definition: {:type, 0, :atom, []},
        module: nil,
        name: nil,
        params: [],
        quoted: {:atom, [], []},
        source: nil,
        type: :built_in
      }
      ...> Tyyppi.of_type?(type, :ok)
      true
      ...> Tyyppi.of_type?(type, 42)
      false
      ...> type = Tyyppi.parse(GenServer.on_start())
      ...> Tyyppi.of_type?(type, {:error, {:already_started, self()}})
      true
      ...> Tyyppi.of_type?(type, :foo)
      false
  """
  if Application.compile_env(:tyyppi, :strict, false) do
    def of_type?(%T{module: module, definition: definition}, term),
      do: Matchers.of?(module, definition, term)

    def of_type?(nil, term) do
      Logger.debug("[🚰 Tyyppi.of_type?/2]: " <> inspect(term))
      false
    end
  else
    def of_type?(_, _), do: true
  end

  @doc """
  **Experimental:** applies the **local** function given as an argument
    in the form `&Module.fun/arity` or **anonymous** function with arguments.
    Validates the arguments given and the result produced by the call.

  Only named types are supported at the moment.

  If the number of arguments does not fit the arity of the type, returns
    `{:error, {:arity, n}}` where `n` is the number of arguments passed.

  If arguments did not pass the validation, returns `{:error, {:args, [arg1, arg2, ...]}}`
    where `argN` are the arguments passed.

  If both arity and types of arguments are ok, _evaluates_ the function and checks the
    result against the type. Returns `{:ok, result}` _or_ `{:error, {:result, result}}`
    if the validation did not pass.

  _Example:_

  ```elixir
  require Tyyppi

  Tyyppi.apply(MyModule.callback(), &MyModule.on_info/1, 2)
  #⇒ {:ok, [foo_squared: 4]}
  Tyyppi.apply(MyModule.callback(), &MyModule.on_info/1, :ok)
  #⇒ {:error, {:args, :ok}}
  Tyyppi.apply(MyModule.callback(), &MyModule.on_info/1, [])
  #⇒ {:error, {:arity, 0}}
  ```
  """
  defmacro apply(type, fun, args) do
    quote do
      %Tyyppi.T{module: module, definition: definition} = Tyyppi.parse(unquote(type))
      Tyyppi.Function.apply(module, definition, unquote(fun), unquote(args))
    end
  end

  @doc """
  **Experimental:** applies the **external** function given as an argument
    in the form `&Module.fun/arity` or **anonymous** function with arguments.
    Validates the arguments given and the result produced by the call.

    _Examples:_

        iex> require Tyyppi
        ...> Tyyppi.apply((atom() -> binary()),
        ...>    fn a -> to_string(a) end, [:foo])
        {:ok, "foo"}
        ...> result = Tyyppi.apply((atom() -> binary()),
        ...>    fn -> "foo" end, [:foo])
        ...> match?({:error, {:fun, _}}, result)
        true
        ...> Tyyppi.apply((atom() -> binary()),
        ...>    fn _ -> 42 end, ["foo"])
        {:error, {:args, ["foo"]}}
        ...> Tyyppi.apply((atom() -> binary()),
        ...>    fn _ -> 42 end, [:foo])
        {:error, {:result, 42}}
  """
  defmacro apply(fun, args) do
    quote do
      with %{module: module, name: fun, arity: arity} <-
             Map.new(Elixir.Function.info(unquote(fun))),
           {:ok, specs} <- Code.Typespec.fetch_specs(module),
           {{fun, arity}, [spec]} <- Enum.find(specs, &match?({{^fun, ^arity}, _}, &1)),
           do: Tyyppi.Function.apply(module, spec, unquote(fun), unquote(args)),
           else: (result -> {:error, {:no_spec, result}})
    end
  end

  @doc false
  defdelegate parse_quoted(type), to: Tyyppi.T

  @doc false
  defdelegate void_validation(value), to: Tyyppi.Value.Validations, as: :any

  @doc false
  defdelegate void_coercion(value), to: Tyyppi.Value.Coercions, as: :any

  @doc false
  defmacro coproduct(types), do: {:|, [], types}

  @doc false
  defp setup_ast(import?) do
    [
      if(import?,
        do: quote(generated: true, do: import(Tyyppi)),
        else: quote(generated: true, do: require(Tyyppi))
      ),
      quote generated: true, location: :keep do
        import Kernel, except: [defstruct: 1]
        import Tyyppi.Struct, only: [defstruct: 1]

        alias Tyyppi.Value
      end
    ]
  end

  @doc false
  defp can_struct_guard? do
    String.to_integer(System.otp_release()) > 22 and
      Version.compare(System.version(), "1.11.0") != :lt
  end

  @doc false
  defp maybe_struct_guard(struct) do
    name = struct |> Module.split() |> List.last() |> Macro.underscore()

    name = :"is_#{name}"

    if can_struct_guard?() do
      quote generated: true, location: :keep do
        @doc "Helper guard to match instances of struct #{inspect(unquote(struct))}"
        @doc since: "0.9.0", guard: true
        defguard unquote(name)(value)
                 when is_map(value) and value.__struct__ == unquote(struct)
      end
    else
      quote generated: true, location: :keep do
        @doc """
        Stub guard to match instances of struct #{inspect(unquote(struct))}.
        Upgrade to 11.0/23 to make it work.
        """
        @doc since: "0.9.0", guard: true
        defguard unquote(name)(value) when is_map(value)
      end
    end
  end

  @doc false
  defmacro formulae_guard, do: maybe_struct_guard(Formulae)

  @doc false
  defmacro __using__(opts \\ []) do
    import? = Keyword.get(opts, :import, false)

    guards =
      case __CALLER__.context_modules do
        [] -> []
        [_some | _] -> [maybe_struct_guard(Tyyppi.Value)]
      end

    guards ++ setup_ast(import?)
  end

  @doc false
  @spec can_flatten?(type :: module()) :: boolean()
  def can_flatten?(type) do
    {:flatten, 2} in Keyword.take(type.__info__(:functions), [:flatten])
  end

  @doc false
  @spec any :: Tyyppi.T.t(term())
  def any, do: parse(any())
end