lib/xmerl_xml_indent.ex

defmodule XmerlXmlIndent do
  @moduledoc """
  Erlang OTP's built-in `xmerl` library lacks functionality to print XML with indent.
  This module fills the gap by providing a custom callback to print XML with indent.

  This module is taken from https://github.com/erlang/otp/blob/master/lib/xmerl/src/xmerl_xml.erl,
  converted to Elixir and modified for indentation.

  This module is used in conjunction with Erlang's `xmerl` library. See the project documentation for details.
  """

  def unquote(:"#xml-inheritance#")() do
    []
  end

  def unquote(:"#text#")(text) do
    :xmerl_lib.export_text(text)
  end

  def unquote(:"#root#")(data, [%{name: _, value: v}], [], _e) do
    [v, data]
  end

  def unquote(:"#root#")(data, _attrs, [], _e) do
    ["<?xml version=\"1.0\"?>\n", data]
  end

  def unquote(:"#element#")(tag, [], attrs, _parents, _e) do
    :xmerl_lib.empty_tag(tag, attrs)
  end

  def unquote(:"#element#")(tag, data, attrs, parents, _e) do
    data =
      cond do
        is_a_tag?(data) ->
          level = Enum.count(parents)

          data
          |> clean_up_tag()
          |> indent_tag_lines(level)

        true ->
          data
      end

    :xmerl_lib.markup(tag, attrs, data)
  end

  @doc """
  This function distinguishes an XML tag from an XML value.

  Let's say there's an XML string `<Outer><Inner>Value</Inner></Outer>`,
  there will be two calls to this function:
  1. The first call has `data` parameter `['Value']`
  2. The second call has `data` parameter
     `[[['<', 'Inner', '>'], ['Value'], ['</', 'Inner', '>']]]`

  The first one is an XML value, not an XML tag.
  The second one is an XML tag.
  """
  defp is_a_tag?(data) do
    is_all_chars =
      Enum.reduce(
        data,
        true,
        fn d, acc ->
          is_char = is_integer(Enum.at(d, 0))
          acc && is_char
        end)

    !is_all_chars
  end

  @doc """
  This function cleans up a tag data contaminated by characters outside the tag.

  If the tag data is indented, this function removes the new lines
  ```
  [
    '\\n        ',
    [['<', 'Tag', '>'], ['Value'], ['</', 'Tag', '>']],
    '\\n      '
  ]
  ```

  After the cleanup, the tag data looks like this:
  ```
  [[['<', 'Tag', '>'], ['Value'], ['</', 'Tag', '>']]]
  ```
  """
  defp clean_up_tag(data) do
    Enum.filter(
      data,
      fn d -> !is_integer(Enum.at(d, 0)) end)
  end

  @doc """
  This function indents all tag lines in the data.

  Example clean tag data:
  ```
  [
    [['<', 'Tag1', '>'], ['Value 1'], ['</', 'Tag1', '>']],
    [['<', 'Tag2', '>'], ['Value 2'], ['</', 'Tag2', '>']],
  ]
  ```

  This function interleaves the tag data with indented new lines and appends
  a new line with one lower level indent:
  ```
  [
    ['\\n  ']
    ['<', 'Tag1', '>'],
    ['Value 1'],
    ['</', 'Tag1', '>'],
    ['\\n  ']
    ['<', 'Tag2', '>'],
    ['Value 2'],
    ['</', 'Tag2', '>'],
    ['\\n']
  ]
  ```
  """
  defp indent_tag_lines(data, level) do
    indented =
      Enum.reduce(
        data,
        [],
        fn d, acc ->
          acc ++ [prepend_indent(level + 1)] ++ d
        end)

    indented ++ [prepend_indent(level)]
  end

  defp prepend_indent(level) do
    ("\n" <> String.duplicate("  ", level)) |> to_charlist()
  end
end