defmodule Serum.Project.ElixirValidator do @moduledoc false _moduledocp = "A module for validation of Serum project definition data." @type result() :: :ok | {:invalid, binary()} | {:invalid, [binary()]} @all_keys [ :site_name, :site_description, :author, :author_email, :server_root, :base_url, :date_format, :list_title_all, :list_title_tag, :pagination, :posts_per_page, :preview_length, :posts_source, :posts_path, :tags_path, :plugins, :theme, :pretty_urls ] @required_keys [ :site_name, :site_description, :author, :author_email, :base_url ] @spec validate(map()) :: result() def validate(term) def validate(%{} = map) do keys = map |> Map.keys() |> MapSet.new() with {:missing, []} <- check_missing_keys(keys), {:extra, []} <- check_extra_keys(keys), :ok <- check_constraints(map) do :ok else {:missing, [x]} -> {:invalid, "missing required property: #{x}"} {:missing, xs} -> props_str = Enum.join(xs, ", ") {:invalid, "missing required properties: #{props_str}"} {:extra, [x]} -> {:invalid, "unknown property: #{x}"} {:extra, xs} -> props_str = Enum.join(xs, ", ") {:invalid, "unknown properties: #{props_str}"} {:error, messages} -> {:invalid, messages} end end def validate(term) do {:invalid, "expected a map, got: #{inspect(term)}"} end @spec check_missing_keys(MapSet.t()) :: {:missing, [atom()]} defp check_missing_keys(keys) do missing = @required_keys |> MapSet.new() |> MapSet.difference(keys) |> MapSet.to_list() {:missing, missing} end @spec check_extra_keys(MapSet.t()) :: {:extra, [atom()]} defp check_extra_keys(keys) do extra = keys |> MapSet.difference(MapSet.new(@all_keys)) |> MapSet.to_list() {:extra, extra} end @spec check_constraints(map()) :: :ok | {:error, [binary()]} defp check_constraints(map) do map |> Enum.map(fn {k, v} -> {k, validate_field(k, v)} end) |> Enum.filter(&(elem(&1, 1) != :ok)) |> case do [] -> :ok errors -> messages = Enum.map(errors, fn {k, {:fail, s}} -> [ "the property ", [:bright, :yellow, to_string(k), :reset], " violates the constraint ", [:bright, :yellow, s, :reset] ] |> IO.ANSI.format() |> IO.iodata_to_binary() end) {:error, messages} end end rules = quote do [ site_name: [is_binary: []], site_description: [is_binary: []], author: [is_binary: []], author_email: [is_binary: []], server_root: [is_binary: [], =~: [~r[^https?://.+]]], base_url: [is_binary: [], =~: [~r[(^/$|^/.*/$)]]], date_format: [is_binary: []], list_title_all: [is_binary: []], list_title_tag: [is_binary: []], pagination: [is_boolean: []], posts_per_page: [is_integer: [], >=: [1]], preview_length: [valid_preview_length?: []], posts_source: [is_binary: []], posts_path: [is_binary: []], tags_path: [is_binary: []], plugins: [is_list: []], theme: [is_atom: []], pretty_urls: [in: [[true, false, :posts]]] ] end @spec validate_field(atom(), term()) :: :ok | {:fail, binary()} defp validate_field(key, value) Enum.each(rules, fn {key, exprs} -> [x | xs] = Enum.map(exprs, fn {func, args} -> quote(do: unquote(func)(var!(value), unquote_splicing(args))) end) check_expr = Enum.reduce(xs, x, "e(do: unquote(&2) and unquote(&1))) [y | ys] = Enum.map(exprs, fn {func, args} -> quote(do: unquote(func)(value, unquote_splicing(args))) end) check_str = ys |> Enum.reduce(y, "e(do: unquote(&2) and unquote(&1))) |> Macro.to_string() defp validate_field(unquote(key), value) do if unquote(check_expr) do :ok else {:fail, unquote(check_str)} end end end) @spec valid_preview_length?(term()) :: boolean() defp valid_preview_length?(value) defp valid_preview_length?(n) when is_integer(n) and n >= 0, do: true defp valid_preview_length?({:chars, n}) when is_integer(n) and n >= 0, do: true defp valid_preview_length?({:words, n}) when is_integer(n) and n >= 0, do: true defp valid_preview_length?({:paragraphs, n}) when is_integer(n) and n >= 0, do: true defp valid_preview_length?(_), do: false end