defmodule Funx.Validate.Dsl.Executor do @moduledoc false # Converts Step nodes into executable validator functions. # # ## Output # # Generates a function: `(value, opts) -> Either.t()` that: # - Runs all validators applicatively # - Accumulates all errors # - Returns Right(original_value) on success # - Returns Left(ValidationError) on failure import Funx.Monad, only: [map: 2] alias Funx.Monad.Effect alias Funx.Monad.Either alias Funx.Monad.Either.{Left, Right} alias Funx.Monad.Maybe.{Just, Nothing} alias Funx.Optics.{Lens, Prism, Traversal} alias Funx.Validate.Dsl.Step @doc """ Execute a list of Step nodes and generate a validator function. """ def execute_steps(steps, mode \\ :sequential) def execute_steps(steps, :sequential) do quote do validator_fns = unquote(compile_steps_to_validators(steps, :sequential)) # Return the validator function that always returns Either.t() fn value, opts -> Either.traverse_a(validator_fns, fn validator_fn -> validator_fn.(value, opts) end) |> map(fn _ -> value end) end end end def execute_steps(steps, :parallel) do quote do validator_fns = unquote(compile_steps_to_validators(steps, :parallel)) # Return the validator function that always returns Either.t() fn value, opts -> Effect.traverse_a(validator_fns, fn effect_fn -> effect_fn.(value, opts) end) |> Effect.run() |> map(fn _ -> value end) end end end # Compile steps into validator functions defp compile_steps_to_validators(steps, mode) do Enum.map(steps, &compile_step(&1, mode)) end # Compile a single step defp compile_step(%Step{optic: nil, validators: validators}, mode) do # Root validator - no projection compile_root_validators(validators, mode) end defp compile_step(%Step{optic: optic, validators: validators}, mode) do # Projected validator - apply optic then validate compile_projected_validators(optic, validators, mode) end # Compile root validators (no projection) defp compile_root_validators(validators, :sequential) do validator_calls = Enum.map(validators, &compile_validator_call/1) quote do fn value, opts -> validators = unquote(validator_calls) unquote(__MODULE__).apply_validators_return(value, value, validators, opts) end end end defp compile_root_validators(validators, :parallel) do validator_calls = Enum.map(validators, &compile_validator_call/1) quote do fn value, opts -> validators = unquote(validator_calls) Effect.lift_either(fn -> unquote(__MODULE__).apply_validators_return(value, value, validators, opts) end) end end end # Compile projected validators (with optic) defp compile_projected_validators(optic, validators, :sequential) do validator_calls = Enum.map(validators, &compile_validator_call/1) quote do fn value, opts -> optic = unquote(optic) projected = unquote(__MODULE__).project_optic(value, optic) unwrapped = unquote(__MODULE__).unwrap_maybe(projected) validators = unquote(validator_calls) unquote(__MODULE__).apply_validators_return(unwrapped, value, validators, opts) end end end defp compile_projected_validators(optic, validators, :parallel) do validator_calls = Enum.map(validators, &compile_validator_call/1) quote do fn value, opts -> optic = unquote(optic) projected = unquote(__MODULE__).project_optic(value, optic) unwrapped = unquote(__MODULE__).unwrap_maybe(projected) validators = unquote(validator_calls) Effect.lift_either(fn -> unquote(__MODULE__).apply_validators_return(unwrapped, value, validators, opts) end) end end end # Project to the value using the optic (public for use in quoted code) @doc false def project_optic(value, %Lens{} = optic) do # Lens = structural requirement, use view! to get raw value and raise on missing Lens.view!(value, optic) end def project_optic(value, %Prism{} = optic) do # Prism = optional field, returns Maybe (atoms are converted to Prism by parser) Prism.preview(value, optic) end def project_optic(value, %Traversal{} = optic) do Traversal.to_list(value, optic) end def project_optic(value, optic) when is_function(optic, 1) do # Plain projection function optic.(value) end # Unwrap Maybe values from Prism (public for use in quoted code) @doc false def unwrap_maybe(%Nothing{} = nothing), do: nothing def unwrap_maybe(%Just{value: v}), do: v def unwrap_maybe(value), do: value # Apply validators applicatively (public for use in quoted code) @doc false def apply_validators(value, validators, opts) do Either.traverse_a(validators, fn validator_fn -> validator_fn.(value, opts) end) end # Apply validators and return a specific value on success (public for use in quoted code) @doc false def apply_validators_return(validate_value, return_value, validators, opts) do apply_validators(validate_value, validators, opts) |> map(fn _ -> return_value end) end # Compile a single validator call # Handles: Module, {Module, opts}, or function validators defp compile_validator_call({validator_spec, validator_opts}) when is_list(validator_opts) do if module_spec?(validator_spec) do compile_module_validator_with_opts(validator_spec, validator_opts) else compile_function_validator_with_opts(validator_spec, validator_opts) end end defp compile_validator_call(validator_spec) do if module_spec?(validator_spec) do compile_module_validator(validator_spec) else compile_function_validator(validator_spec) end end # Check if validator_spec is a module (AST or atom) defp module_spec?({:__aliases__, _, _}), do: true defp module_spec?(spec) when is_atom(spec), do: true defp module_spec?(_), do: false # Compile module validator with options defp compile_module_validator_with_opts(module_spec, validator_opts) do quote do fn value, runtime_opts -> merged_opts = Keyword.merge(unquote(validator_opts), runtime_opts) env = Keyword.get(merged_opts, :env, %{}) result = unquote(module_spec).validate(value, merged_opts, env) unquote(__MODULE__).normalize_validator_result(result, value) end end end # Compile module validator without options defp compile_module_validator(module_spec) do quote do fn value, opts -> env = Keyword.get(opts, :env, %{}) result = unquote(module_spec).validate(value, opts, env) unquote(__MODULE__).normalize_validator_result(result, value) end end end # Compile function validator with options defp compile_function_validator_with_opts(validator_spec, validator_opts) do quote do fn value, runtime_opts -> merged_opts = Keyword.merge(unquote(validator_opts), runtime_opts) result = unquote(__MODULE__).call_validator_function(unquote(validator_spec), value, merged_opts) unquote(__MODULE__).normalize_validator_result(result, value) end end end # Compile function validator without options defp compile_function_validator(validator_spec) do quote do fn value, opts -> result = unquote(__MODULE__).call_validator_function(unquote(validator_spec), value, opts) unquote(__MODULE__).normalize_validator_result(result, value) end end end # Normalize validator return values to Either (public for use in quoted code) @doc false def normalize_validator_result(%Right{} = result, _value), do: result def normalize_validator_result(%Left{} = result, _value), do: result def normalize_validator_result(:ok, value), do: Either.right(value) def normalize_validator_result({:error, error}, _value), do: Either.left(error) # Call a function validator with appropriate arity (public for use in quoted code) @doc false def call_validator_function(validator, value, opts) do env = Keyword.get(opts, :env, %{}) cond do is_function(validator, 3) -> validator.(value, opts, env) is_function(validator, 2) -> validator.(value, opts) is_function(validator, 1) -> validator.(value) true -> raise ArgumentError, "Validator must be a function with arity 1, 2, or 3, or a module" end end end