lib/transform.ex

defmodule Toml.Transform do
  @moduledoc """
  Defines the behavior for custom transformations of decoded TOML values.

  See the documentation for `c:transform/2` for more details.
  """

  @type t :: module
  @type key :: binary | atom | term
  @type keypath :: [binary] | [atom] | [term]
  @type value ::
          %{key => value}
          | [value]
          | number
          | binary
          | NaiveDateTime.t()
          | DateTime.t()
          | Date.t()
          | Time.t()

  def __using__(_) do
    quote do
      @behaviour unquote(__MODULE__)
    end
  end

  @doc """
  This function is invoked for every key/value pair in the document, in a depth-first,
  bottom-up, traversal of the document.

  The transformer must return one of two values:

    * `{:error, term}` - an error occurred in the transformer, and decoding should fail
    * `term` - replace the value for the given key with the value returned from the 
      transformer in the final document
    
  ## Example

  An example transformation would be the conversion of tables of a certain shape to a known struct value.

  The struct:

      defmodule Server do
        defstruct [:name, :ip, :ports]
      end
      

  TOML which contains a table of this shape:

      [servers.alpha]
      ip = "192.168.1.1"
      ports = [8080, 8081]
      
      [servers.beta]
      ip = "192.168.1.2"
      ports = [8082, 8083]
      

  And finally, the transforer implementation:

      defmodule ServerTransform do
        use Toml.Transform

        # Transform IP strings to Erlang address tuples
        def transform(:ip, ip) when is_binary(ip) do
          case :inet.parse_ipv4_address(String.to_charlist(ip)) do
            {:ok, result} ->
              result
            {:error, reason} ->
              {:error, {:invalid_ip, ip, reason}}
          end
        end

        # Non-binary values for IP addresses should return an error
        def transform(:ip, ip), do: {:error, {:invalid_ip, ip, :expected_string}}

        # Transform the server objects to Server structs
        def transform(:servers, servers) when is_map(servers) do
          for {name, server} <- servers do
            struct(Server, Map.put(server, :name, name))
          end
        end

        # Non-map values for servers should return an error
        def transform(:servers, s), do: {:error, {:invalid_server, s}}

        # Ignore all other values
        def transform(_key, v), do: v
      end
      
    Assuming we decode with the following options: 

        Toml.decode!(content, keys: :atoms, transforms: [ServerTransform])
      
    The result would be:

        %{
          servers: [
            %Server{name: :alpha, ip: {192,168,1,1}, ports: [8080, 8081]},
            %Server{name: :beta, ip: {192,168,1,2}, ports: [8082, 8083]}
          ]
         }

  """
  @callback transform(key, value) :: {:error, term} | term

  # Given a list of transform functions, compose them into a single transformation pass
  @doc false
  def compose(mods) when is_list(mods) do
    Enum.reduce(mods, nil, &compose_transforms/2)
  end

  # The first transform in the list does not require composition
  defp compose_transforms(mod, nil), do: &mod.transform/2
  # All subsequent transforms are composed with the previous
  defp compose_transforms(mod, acc) when is_atom(mod) and is_function(acc) do
    fn k, v ->
      case acc.(k, v) do
        {:error, _} = err ->
          throw(err)

        v2 ->
          mod.transform(k, v2)
      end
    end
  end
end