lib/xml_builder_access.ex

defmodule XmlBuilder.Access do
  @moduledoc """
  Provides a function-based `Access` implementation. This allows to get an access
  to deeply nested elemetns via `Kernel.get_in/2`, `Kernel.pop_in/2`, `Kernel.put_in/3`,
  `Kernel.update_in/3`, and `Kernel.get_and_update_in/3`.

  **Example:**
      iex> get_in({:person, %{id: 1}, [{:data, %{}, [{:name, %{}, "John"}]}]},
      ...>   [XmlBuilder.Access.key(:data), XmlBuilder.Access.key(:name), XmlBuilder.Access.key()])
      "John"

      iex> update_in({:persons, %{}, [{:name, %{}, "John"}, {:name, %{}, "Jane"}]},
      ...>   [XmlBuilder.Access.key({:name, -1}), XmlBuilder.Access.key()], fn _ -> "Mary" end)
      {:persons, %{}, [{:name, %{}, "John"}, {:name, %{}, "Mary"}]}

  Negative indices are supported, `-1` for the last element, `-2` for next to the last etc.

  """

  @typedoc """
  Nested elements of any node are in general accessible by `{name, index}` tuple.
  When a single name atom passed as an argument, the implementation assumes
  index zero.
  """
  @type maybe_ordered_key ::
          nil | atom() | {atom(), integer()}

  @doc """
  Default `Access` function implementation accepting default values.

  **Examples:**

      iex> # simple value
      iex> get_in({:person, %{id: 1}, 42}, [XmlBuilder.Access.key()])
      42
      iex> put_in({:person, %{id: 1}, nil}, [XmlBuilder.Access.key()], 42)
      {:person, %{id: 1}, 42}
      iex> update_in({:person, %{id: 1}, nil},
      ...>   [XmlBuilder.Access.key()], fn _ -> 42 end)
      {:person, %{id: 1}, 42}
      iex> get_and_update_in({:person, %{id: 1}, nil},
      ...>   [XmlBuilder.Access.key()], fn old -> {old, 42} end)
      {nil, {:person, %{id: 1}, 42}}

      iex> # nested element
      iex> get_in({:person, %{id: 1}, [{:name, %{}, "John"}]},
      ...>   [XmlBuilder.Access.key(:name)])
      {:name, %{}, "John"}
      iex> put_in({:person, %{id: 1}, [{:name, %{}, "John"}]},
      ...>   [XmlBuilder.Access.key(:name)], "Mary")
      {:person, %{id: 1}, [{:name, %{}, "Mary"}]}
      iex> update_in({:person, %{id: 1}, [{:name, %{}, "John"}]},
      ...>   [XmlBuilder.Access.key(:name)], fn _ -> "Mary" end)
      {:person, %{id: 1}, [{:name, %{}, "Mary"}]}
      iex> get_and_update_in({:person, %{id: 1}, [{:name, %{}, "John"}]},
      ...>   [XmlBuilder.Access.key(:name)], fn {_, _, old} -> {old, "Mary"} end)
      {"John", {:person, %{id: 1}, [{:name, %{}, "Mary"}]}}

  """
  @spec key(key :: maybe_ordered_key()) ::
          Access.access_fun(data :: {atom(), map(), list() | any()}, get_value :: term)

  def key(key \\ nil)

  def key(nil) do
    fn
      :get, {_name, _attrs, value}, fun ->
        fun.(value)

      :get_and_update, {name, attrs, value}, fun ->
        case fun.(value) do
          :pop ->
            {value, {name, attrs, nil}}

          {old, updated} ->
            {old, {name, attrs, updated}}
        end
    end
  end

  def key(key) when is_atom(key), do: key({key, 0})

  def key({key, index}) when is_atom(key) and is_integer(index) do
    fn
      :get, {_name, _attrs, value}, fun when is_list(value) ->
        {value, idx} = if index < 0, do: {Enum.reverse(value), -index - 1}, else: {value, index}

        value
        |> Enum.reduce_while({0, nil}, fn
          {^key, _, _} = e, {^idx, nil} -> {:halt, {idx, e}}
          {^key, _, _}, {idx, nil} -> {:cont, {idx + 1, nil}}
          _, acc -> {:cont, acc}
        end)
        |> elem(1)
        |> case do
          {^key, attrs, value} ->
            value = if index < 0 and is_list(value), do: Enum.reverse(value), else: value

            fun.({key, attrs, value})

          nil ->
            fun.(nil)
        end

      :get_and_update, {name, attrs, value}, fun when is_list(value) ->
        {value, idx} = if index < 0, do: {Enum.reverse(value), -index - 1}, else: {value, index}

        {updated_at, {old, updated}} =
          Enum.reduce(value, {0, {nil, []}}, fn
            {^key, attrs, value}, {^idx, {nil, acc}} ->
              updated =
                case fun.({key, attrs, value}) do
                  :pop -> {{key, attrs, value}, acc}
                  {old, {key, attrs, updated}} -> {old, [{key, attrs, updated} | acc]}
                  {old, updated} -> {old, [{key, attrs, updated} | acc]}
                end

              {idx + 1, updated}

            {^key, _, _} = matched, {idx, {value, acc}} ->
              {idx + 1, {value, [matched | acc]}}

            any, {idx, {value, acc}} ->
              {idx, {value, [any | acc]}}
          end)

        updated = if index >= 0, do: Enum.reverse(updated), else: updated

        if updated_at > idx do
          {old, {name, attrs, updated}}
        else
          {old_value, updated_value} =
            case fun.({key, attrs, nil}) do
              :pop -> {{key, attrs, nil}, []}
              {old, {key, attrs, updated}} -> {old, [{key, attrs, updated}]}
              {old, updated} -> {old, [{key, attrs, updated}]}
            end

          {old_value, {name, attrs, updated ++ updated_value}}
        end
    end
  end
end