lib/mecto.ex

defmodule Mecto do
  @moduledoc """
  "Mail merging" with Ecto structs.

  A parser to interpolate MediaWiki-like `[[foo.bar]]` markup using data from Ecto schemas.
  """

  @doc """
  Extracts nodes from some markup, returning a map where the end values are the count
  of how many times that node was in the markup.

  Note: this doesn't validate the nodes, just extracts them or errors if the markup is
  invalid.

  ## Examples

      iex> Mecto.extract_nodes("some text [[blog_post.title]] and [[blog_post.comments[0].content]]")
      %{blog_post: %{title: 1, comments: %{0 => %{content: 1}}}}

      iex> Mecto.extract_nodes("some text that [[is|invalid]]")
      {:error, "invalid markup"}

  """
  @spec extract_nodes(String.t()) :: map() | {:error, String.t()}
  defdelegate extract_nodes(text), to: Mecto.MarkupParser

  @doc """
  Builds a map from an Ecto schema, including following associations.

  Note: this will not recurse if a struct is repeated deeper in the tree.

  ## Examples

      iex> Mecto.parse_schema(Mecto.User)
      %{id: :id, username: :string}

      iex> Mecto.parse_schema(Mecto.Foo)
      {:error, :missing_schema}

  """
  @spec parse_schema(module()) :: map() | {:error, atom()}
  defdelegate parse_schema(module), to: Mecto.SchemaExtractor, as: :convert_from

  @doc """
  Validates markup fields exist on the supplied schema.

  ## Examples

      iex> Mecto.validate("Title for post #[[blog_post.id]]", Mecto.BlogPost)
      %{blog_post: %{id: :id}}

      iex> Mecto.validate("[[blog_post.invalid_field]]", Mecto.BlogPost)
      {:error, ["blog_post.invalid_field does not exist"]}

  """
  @spec validate(String.t(), module()) :: map() | {:error, [String.t()]}
  def validate(text, module) do
    schema = parse_schema(module)
    keyed_schema = keyed_map(module, schema)

    case extract_nodes(text) do
      {:error, e} -> {:error, [e]}
      nodes -> Mecto.SchemaValidator.check(keyed_schema, nodes)
    end
  end

  @doc """
  Interpolates markup with the values in the given Ecto structs.

  Note: this does _not_ validate the markup (beyond checking it can be parsed) - it is
  expected that any text passed to this has already been validated with `Mecto.validate/2`

  ## Examples

      iex> Mecto.interpolate("Title for post [[blog_post.title]]", %Mecto.BlogPost{title: "some title"})
      {:ok, "Title for post some title"}

      iex> Mecto.interpolate("Some [[[invalid markup]]", %Mecto.BlogPost{})
      {:error, "invalid markup"}

      iex> Mecto.interpolate("Some [[blog_post.invalid_field]]", %Mecto.BlogPost{})
      {:ok, "Some "}

  """
  @spec interpolate(String.t(), map()) :: {:ok, String.t()} | {:error, String.t()}
  def interpolate(text, data) do
    %module{} = data
    keyed_data = keyed_map(module, data)

    case Mecto.MarkupParser.parse(text) do
      {:error, _e, _, _, _, _} ->
        {:error, "invalid markup"}

      {:ok, nodes, _, _, _, _} ->
        nodes
        |> Enum.map(fn
          node when is_binary(node) ->
            node

          {:field, path} ->
            path =
              Enum.map(path, fn
                path_entry when is_integer(path_entry) ->
                  Access.at(path_entry)

                path_entry ->
                  path_entry
                  |> String.to_existing_atom()
                  |> Access.key()
              end)

            get_in(keyed_data, path)
        end)
        |> Enum.join()
        |> then(&{:ok, &1})
    end
  end

  @spec keyed_map(module(), map()) :: map()
  defp keyed_map(module, map) do
    module_key =
      Atom.to_string(module)
      |> String.split(".")
      |> List.last()
      |> Macro.underscore()
      |> String.to_atom()

    %{module_key => map}
  end
end