lib/xmerl_xml_indent_base.ex

defmodule XmerlXmlIndentBase 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.
  """

  defmacro __using__(_opts) do
    quote do
      @doc """
      This function specifies the end of line (EOL) character. The default is `\n`.
      """
      def new_line() do
        "\n"
      end

      @doc """
      This function specifies the indent character. The default is `  ` (two spaces).
      """
      def indent() do
        "  "
      end

      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\"?>#{new_line()}", 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

      # 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

      # 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

      # 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
        (new_line() <> String.duplicate(indent(), level)) |> to_charlist()
      end

      defoverridable new_line: 0, indent: 0
    end
  end
end