lib/ymlr/encoder.ex

defprotocol Ymlr.Encoder do
  @moduledoc ~S"""
  Protocol controlling how a value is encoded to YAML.

  ## Deriving

  The protocol allows leveraging the Elixir's `@derive` feature to simplify
  protocol implementation in trivial cases. Accepted options are:

    * `:only` - encodes only values of specified keys.
    * `:except` - If set to a list of fields, all struct fields except specified
      keys are encoded. If set to `:defaults`, all fields are encoded, except
      the ones equaling their default value as defined in the struct.

  By default all keys except the `:__struct__` key are encoded.

  ## Example

  Let's assume a presence of the following struct:

  ```
  defmodule Test do
    defstruct [:foo, :bar, :baz]
  end
  ```

  If we were to call `@derive Ymlr.Encoder` just before `defstruct`, an
  implementation similar to the following implementation would be generated:

  ```
  defimpl Ymlr.Encoder, for: Test do
    def encode(data, idnent_level) do
      data
      |> Map.take(unquote([:foo, :bar, :baz]))
      |> Ymlr.Encode.map(idnent_level)
    end
  end
  ```

  ### Limit fields using the `only` Option

  We can limit the fields being encoded using the `:only` option:

  ```
  defmodule Test do
    @derive {Ymlr.Encoder, only: [:foo]}
    defstruct [:foo, :bar, :baz]
  end
  ```

  This would generate an implementation similar to the following:

  ```
  defimpl Ymlr.Encoder, for: Test do
    def encode(data, idnent_level) do
      data
      |> Map.take(unquote([:foo]))
      |> Ymlr.Encode.map(idnent_level)
    end
  end
  ```

  ### Exclude fields using the `except` Option

  We can exclude the fields being encoded using the `:except` option:

  ```
  defmodule Test do
    @derive {Ymlr.Encoder, except: [:foo]}
    defstruct [:foo, :bar, :baz]
  end
  ```

  This would generate an implementation similar to the following:

  ```
  defimpl Ymlr.Encoder, for: Test do
    def encode(data, idnent_level) do
      data
      |> Map.take(unquote([:bar, :baz]))
      |> Ymlr.Encode.map(idnent_level)
    end
  end
  ```

  We can exclude the fields being left at their defaults by passing `except:
  :defaults`:

  ```
  defmodule TestExceptDefaults do
    @derive {Ymlr.Encoder, except: :defaults}
    defstruct [:foo, bar: 1, baz: :ok]
  end
  ```

  This would generate an implementation similar to the following:

  ```
  iex> Ymlr.document!(%TestExceptDefaults{foo: 1, bar: 1, baz: :error})
  "baz: error\nfoo: 1"
  ```
  """

  @fallback_to_any true

  @type opts :: keyword()

  @doc """
  Encodes the given data to YAML.
  """
  @spec encode(data :: term(), idnent_level :: integer(), opts :: opts()) :: iodata()
  def encode(data, idnent_level, opts)
end

defimpl Ymlr.Encoder, for: Any do
  defmacro __deriving__(module, struct, opts) do
    if opts[:except] === :defaults do
      defaults =
        struct
        |> Map.from_struct()
        |> MapSet.new()
        |> Macro.escape()

      quote do
        defimpl Ymlr.Encoder, for: unquote(module) do
          def encode(data, idnent_level, opts) do
            data
            |> Map.from_struct()
            |> MapSet.new()
            |> MapSet.difference(unquote(defaults))
            |> Map.new()
            |> Ymlr.Encode.map(idnent_level, opts)
          end
        end
      end
    else
      fields = fields_to_encode(struct, opts)

      quote do
        defimpl Ymlr.Encoder, for: unquote(module) do
          def encode(data, idnent_level, opts) do
            data
            |> Map.take(unquote(fields))
            |> Ymlr.Encode.map(idnent_level, opts)
          end
        end
      end
    end
  end

  def encode(%_{} = struct, _level, _opts) do
    raise Protocol.UndefinedError,
      protocol: @protocol,
      value: struct,
      description: """
      Ymlr.Encoder protocol must always be explicitly implemented.
      If you own the struct, you can derive the implementation specifying \
      which fields should be encoded to YAML:
          @derive {Ymlr.Encoder, only: [....]}
          defstruct ...
      It is also possible to encode all fields, although this should be \
      used carefully to avoid accidentally leaking private information \
      when new fields are added:
          @derive Ymlr.Encoder
          defstruct ...
      Finally, if you don't own the struct you want to encode to YAML, \
      you may use Protocol.derive/3 placed outside of any module:
          Protocol.derive(Ymlr.Encoder, NameOfTheStruct, only: [...])
          Protocol.derive(Ymlr.Encoder, NameOfTheStruct)
      """
  end

  def encode(value, _level, _opts) do
    raise Protocol.UndefinedError,
      protocol: @protocol,
      value: value,
      description: "Ymlr.Encoder protocol must always be explicitly implemented"
  end

  defp fields_to_encode(struct, opts) do
    fields = Map.keys(struct)

    cond do
      only = Keyword.get(opts, :only) ->
        case only -- fields do
          [] ->
            only

          error_keys ->
            raise ArgumentError,
                  "`:only` specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <>
                    "#{inspect(fields -- [:__struct__])}"
        end

      except = Keyword.get(opts, :except) ->
        case except -- fields do
          [] ->
            fields -- [:__struct__ | except]

          error_keys ->
            raise ArgumentError,
                  "`:except` specified keys (#{inspect(error_keys)}) that are not defined in defstruct: " <>
                    "#{inspect(fields -- [:__struct__])}"
        end

      true ->
        fields -- [:__struct__]
    end
  end
end

defimpl Ymlr.Encoder, for: Map do
  def encode(data, idnent_level, opts), do: Ymlr.Encode.map(data, idnent_level, opts)
end

defimpl Ymlr.Encoder, for: [Date, Time, NaiveDateTime] do
  def encode(data, _level, _opts), do: @for.to_iso8601(data)
end

defimpl Ymlr.Encoder, for: DateTime do
  def encode(data, _level, _opts) do
    data |> DateTime.shift_zone!("Etc/UTC") |> DateTime.to_iso8601()
  end
end

defimpl Ymlr.Encoder, for: List do
  def encode(data, idnent_level, opts), do: Ymlr.Encode.list(data, idnent_level, opts)
end

defimpl Ymlr.Encoder, for: Tuple do
  def encode(data, idnent_level, opts) do
    Ymlr.Encode.list(Tuple.to_list(data), idnent_level, opts)
  end
end

defimpl Ymlr.Encoder, for: Atom do
  def encode(data, _idnent_level, _opts), do: Ymlr.Encode.atom(data)
end

defimpl Ymlr.Encoder, for: BitString do
  def encode(binary, indent_level, _opts) when is_binary(binary) do
    Ymlr.Encode.string(binary, indent_level)
  end

  def encode(bitstring, _indent_level, _opts) do
    raise Protocol.UndefinedError,
      protocol: @protocol,
      value: bitstring,
      description: "cannot encode a bitstring to YAML"
  end
end

defimpl Ymlr.Encoder, for: Integer do
  def encode(data, _idnent_level, _opts), do: Ymlr.Encode.number(data)
end

defimpl Ymlr.Encoder, for: Float do
  def encode(data, _idnent_level, _opts), do: Ymlr.Encode.number(data)
end

defimpl Ymlr.Encoder, for: Decimal do
  def encode(data, _indent_level, _opts) do
    # silence the xref warning
    decimal = Decimal
    decimal.to_string(data)
  end
end