Current section

Files

Jump to
zot lib zot context.ex
Raw

lib/zot/context.ex

defmodule Zot.Context do
@moduledoc ~S"""
Context management for parsing / validating values.
"""
import Zot.Issue, only: [issue: 2, issue: 3, prepend_path: 2]
import Zot.Utils, only: [is_mfa: 1, resolve: 1, type: 1]
alias __MODULE__
@typedoc ~S"""
Represents a segment in a path.
"""
@type segment :: String.t() | atom | non_neg_integer
@typedoc ~S"""
Zot's context struct encapsulates all that is needed to parse and
validate a value.
"""
@type t :: %Context{
type: struct,
input: term,
output: term,
path: [segment],
issues: [Zot.Issue.t()],
score: integer,
opts: keyword,
valid?: boolean
}
defstruct [:type, :input, :output, path: [], issues: [], score: 0, opts: [], valid?: true]
@doc ~S"""
Creates a new context.
"""
@spec new(type, input, opts) :: t
when type: struct,
input: term,
opts: keyword
def new(type(_) = type, input, opts \\ [])
when is_list(opts),
do: %Context{type: type, opts: opts, input: input, output: input}
@doc ~S"""
Puts the given path into the context.
"""
@spec put_path(t, [segment]) :: t
def put_path(%Context{} = ctx, path)
when is_list(path),
do: %{ctx | path: path}
@doc ~S"""
Appends new issues to the context, what causes the context to be
marked as invalid.
"""
@spec append_issues(t, [Zot.Issue.t()]) :: t
def append_issues(%Context{} = ctx, []), do: ctx
def append_issues(%Context{issues: issues} = ctx, [_ | _] = new_issues), do: %{ctx | issues: issues ++ new_issues, valid?: false}
@doc ~S"""
Increments the context's score by the given amount.
"""
@spec inc_score(t, integer) :: t
def inc_score(ctx, inc \\ 1)
def inc_score(%Context{} = ctx, 0), do: ctx
def inc_score(%Context{score: score} = ctx, inc) when is_integer(inc), do: %{ctx | score: score + inc}
@doc ~S"""
Checks if the context is valid.
"""
@spec valid?(t) :: boolean
def valid?(%Context{} = ctx), do: ctx.valid?
@doc ~S"""
Unwraps the Context into an :ok|:error tuple.
"""
@spec unwrap(t) :: {:ok, term} | {:error, [Zot.Issue.t(), ...]}
def unwrap(%Context{valid?: true} = ctx), do: {:ok, ctx.output}
def unwrap(%Context{valid?: false} = ctx), do: {:error, ctx.issues}
@doc ~S"""
Parses the given context.
"""
@spec parse(t) :: t
def parse(%Context{} = ctx) do
with {:ok, ctx} <- resolve_default(ctx),
{:ok, ctx} <- validate_required(ctx),
{:ok, ctx} <- parse_type(ctx),
{:ok, ctx} <- apply_effects(ctx) do
ctx
else
{:error, ctx} -> ctx
{:halt, ctx} -> ctx
end
end
#
# PRIVATE
#
defp resolve_default(%Context{type: %_{default: nil}, output: nil} = ctx), do: {:ok, ctx}
defp resolve_default(%Context{type: %_{default: default}, output: nil} = ctx), do: {:ok, %{ctx | output: resolve(default)}}
defp resolve_default(%Context{} = ctx), do: {:ok, ctx}
defp validate_required(%Context{type: %_{required: true}, output: nil} = ctx), do: {:error, append_issues(ctx, [issue(ctx.path, "is required")])}
defp validate_required(%Context{type: %_{required: false}, output: nil} = ctx), do: {:halt, ctx}
defp validate_required(%Context{} = ctx), do: {:ok, ctx}
defp parse_type(%Context{} = ctx) do
case Zot.Type.parse(ctx.type, ctx.output, ctx.opts) do
{:ok, output} -> {:ok, %{ctx | output: output}}
{:error, issues} -> {:error, append_issues(%{ctx | output: nil}, prepend_path(issues, ctx.path))}
{:error, issues, partial} -> {:error, append_issues(inc_score(%{ctx | output: partial}, score(partial)), prepend_path(issues, ctx.path))}
end
end
defp score(nil), do: 0
defp score(value) when is_struct(value), do: score(Map.from_struct(value))
defp score(value) when is_map(value), do: 1 + score(Map.values(value))
defp score([]), do: 1
defp score([head | tail]), do: score(head) + score(tail)
defp score({_, value}), do: score(value)
# ↑ when iterating over a keyword's k-v tuples
defp score(_), do: 1
defp apply_effects(%Context{type: %_{effects: []}} = ctx), do: {:ok, ctx}
defp apply_effects(%Context{type: %_{effects: effects}} = ctx) do
Enum.reduce_while(effects, {:ok, ctx}, fn effect, {:ok, acc} ->
case apply_effect(effect, acc) do
{:ok, new_ctx} -> {:cont, {:ok, new_ctx}}
{:error, new_ctx} -> {:halt, {:error, new_ctx}}
end
end)
end
defp apply_effect({:transform, fun}, ctx), do: {:ok, %{ctx | output: invoke_transform(fun, ctx)}}
defp apply_effect({:refine, %Zot.Parameterized{} = refinement}, ctx) do
case invoke_refine(refinement.value, ctx) do
true -> {:ok, ctx}
false -> {:error, append_issues(ctx, [issue(ctx.path, refinement.params.error, actual: ctx.output)])}
%Context{valid?: true} = new_ctx -> {:ok, new_ctx}
%Context{valid?: false} = new_ctx -> {:error, new_ctx}
:ok -> {:ok, ctx}
:error -> {:error, append_issues(ctx, [issue(ctx.path, refinement.params.error, actual: ctx.output)])}
{:error, <<_, _::binary>> = reason} -> {:error, append_issues(ctx, [issue(ctx.path, reason, actual: ctx.output)])}
{:error, %_{} = error} -> {:error, append_issues(ctx, [issue(ctx.path, Exception.message(error))])}
end
end
defp invoke_transform({m, f, a} = mfa, ctx) when is_mfa(mfa), do: apply(m, f, [ctx.output | a])
defp invoke_transform(fun, ctx) when is_function(fun, 1), do: fun.(ctx.output)
defp invoke_refine({m, f, a} = mfa, ctx) when is_mfa(mfa), do: apply(m, f, [ctx | a])
defp invoke_refine(fun, ctx) when is_function(fun, 1), do: fun.(ctx.output)
defp invoke_refine(fun, ctx) when is_function(fun, 2), do: fun.(ctx.output, ctx)
end