lib/kalevala/output.ex

defmodule Kalevala.Output.Context do
  @moduledoc """
  Context struct for an output callback module
  """

  defstruct data: [], meta: %{}, opts: %{}
end

defmodule Kalevala.Output do
  @moduledoc """
  An output post processor for text from Kalevala

  Allow the game to modify text leaving Kalevala, such as to insert
  ANSI codes for color.
  """

  alias Kalevala.Output.Context

  @callback init(Keyword.t()) :: Context.t()

  @callback parse(String.t(), Context.t()) :: Context.t()

  @callback post_parse(Context.t()) :: Context.t()

  defmacro __using__(_opts) do
    quote do
      @behaviour Kalevala.Output

      alias Kalevala.Output.Context

      @impl true
      def init(opts) do
        %Context{
          data: [],
          opts: opts,
          meta: %{}
        }
      end

      @impl true
      def post_parse(context), do: context

      @impl true
      def parse(datum, context) do
        Map.put(context, :data, context.data ++ [datum])
      end

      defoverridable init: 1, parse: 2, post_parse: 1
    end
  end

  def process(text_data, callback_module, opts \\ []) do
    text_data = List.wrap(text_data)
    opts = Enum.into(opts, %{})

    context = callback_module.init(opts)

    context =
      Enum.reduce(text_data, context, fn datum, context ->
        parse(datum, callback_module, context)
      end)

    case callback_module.post_parse(context) do
      :error ->
        text_data

      %Context{data: data} ->
        data
    end
  end

  def parse(data, callback_module, context) when is_list(data) do
    Enum.reduce(data, context, &parse(&1, callback_module, &2))
  end

  def parse(data, callback_module, context) do
    callback_module.parse(data, context)
  end
end

defmodule Kalevala.Output.Tags do
  @moduledoc """
  Output processor that parses tags
  """

  use Kalevala.Output

  @doc """
  Escape special tag characters in a string

  To prevent characters from using these tags in commands
  """
  def escape(string) do
    string
    |> String.replace(~r/{/, "\\{")
    |> String.replace(~r/}/, "\\}")
  end

  @impl true
  def init(opts) do
    %Context{
      data: [],
      opts: opts,
      meta: %{
        current_tag: <<>>,
        current_string: <<>>
      }
    }
  end

  @impl true
  def post_parse(context) do
    context = Map.put(context, :data, context.data ++ [context.meta.current_string])

    case matching_tags?(context) do
      true ->
        context

      false ->
        :error
    end
  end

  defp matching_tags?(context) do
    stack = Enum.reduce(context.data, [], &match_closing_tags/2)
    stack == []
  end

  defp match_closing_tags(datum, stack) do
    case datum do
      :error ->
        :error

      {:open, tag_name, _attributes} ->
        [tag_name | stack]

      {:close, tag_name} ->
        case stack do
          [^tag_name | stack] ->
            stack

          _ ->
            :error
        end

      _ ->
        stack
    end
  end

  @impl true
  def parse(string, context) do
    {current_tag, current_string, processed} =
      parse_string(string, context.meta.current_tag, context.meta.current_string, [])

    meta =
      context.meta
      |> Map.put(:current_tag, current_tag)
      |> Map.put(:current_string, current_string)

    context
    |> Map.put(:data, context.data ++ processed)
    |> Map.put(:meta, meta)
  end

  def parse_string(<<>>, current_tag, current_string, processed),
    do: {current_tag, current_string, processed}

  def parse_string(<<"\\{"::utf8, string::binary>>, <<>>, current_string, processed) do
    parse_string(string, <<>>, current_string <> "{", processed)
  end

  def parse_string(<<"\\{"::utf8, string::binary>>, current_tag, current_string, processed) do
    parse_string(string, current_tag <> "{", current_string, processed)
  end

  def parse_string(<<"\\}"::utf8, string::binary>>, <<>>, current_string, processed) do
    parse_string(string, <<>>, current_string <> "}", processed)
  end

  def parse_string(<<"\\}"::utf8, string::binary>>, current_tag, current_string, processed) do
    parse_string(string, current_tag <> "}", current_string, processed)
  end

  def parse_string(<<"/"::utf8, string::binary>>, <<>>, current_string, processed) do
    parse_string(string, <<>>, current_string <> "/", processed)
  end

  def parse_string(<<"/"::utf8, string::binary>>, current_tag, current_string, processed) do
    parse_string(string, current_tag <> "/", current_string, processed)
  end

  def parse_string(<<"{"::utf8, string::binary>>, _current_tag, current_string, processed) do
    parse_string(string, "{", <<>>, processed ++ [current_string])
  end

  def parse_string(<<"}"::utf8, string::binary>>, current_tag, current_string, processed) do
    tag = parse_tag(current_tag <> "}")

    parse_string(string, <<>>, current_string, processed ++ [tag])
  end

  def parse_string(<<character::utf8, string::binary>>, <<>>, current_string, processed) do
    parse_string(string, <<>>, current_string <> <<character::utf8>>, processed)
  end

  def parse_string(<<character::utf8, string::binary>>, current_tag, current_string, processed) do
    parse_string(string, current_tag <> <<character::utf8>>, current_string, processed)
  end

  def parse_tag("{/" <> name) do
    {:close, String.replace(name, "}", "")}
  end

  def parse_tag(tag) do
    [tag_name | attributes] = Enum.reverse(parse_tag_attributes(tag))

    tag_name =
      tag_name
      |> String.replace(~r/[{}]/, "")
      |> String.trim()

    {:open, tag_name, Enum.into(attributes, %{})}
  end

  def parse_tag_attributes(tag) do
    case Regex.run(~r/(?<name>[\w-]+)="(?<value>[^"]+)"/, tag) do
      [string, name, value] ->
        [{name, value} | parse_tag_attributes(String.replace(tag, string, ""))]

      _ ->
        [tag]
    end
  end
end

defmodule Kalevala.Output.StripTags do
  @moduledoc """
  Remove any open and close tag tuples from the data
  """

  use Kalevala.Output

  @impl true
  def parse({:open, _tag_name, _attributes}, context) do
    context
  end

  def parse({:close, _tag_name}, context) do
    context
  end

  def parse(datum, context) do
    Map.put(context, :data, context.data ++ [datum])
  end
end

defmodule Kalevala.Output.TagColors do
  @moduledoc """
  Process `color` tags into ANSI escape codes
  """

  use Kalevala.Output

  @impl true
  def init(opts) do
    %Context{
      data: [],
      opts: opts,
      meta: %{
        tag_stack: []
      }
    }
  end

  @impl true
  def parse({:open, "color", attributes}, context) do
    tag_stack = [attributes | context.meta.tag_stack]
    meta = Map.put(context.meta, :tag_stack, tag_stack)

    context
    |> Map.put(:data, context.data ++ process_tag(attributes))
    |> Map.put(:meta, meta)
  end

  def parse({:close, "color"}, context) do
    [_attributes | tag_stack] = context.meta.tag_stack
    meta = Map.put(context.meta, :tag_stack, tag_stack)

    context
    |> Map.put(:data, context.data ++ process_close_tag(tag_stack))
    |> Map.put(:meta, meta)
  end

  def parse(datum, context) do
    Map.put(context, :data, context.data ++ [datum])
  end

  def background_color("black"), do: IO.ANSI.black_background()

  def background_color("red"), do: IO.ANSI.red_background()

  def background_color("green"), do: IO.ANSI.green_background()

  def background_color("yellow"), do: IO.ANSI.yellow_background()

  def background_color("blue"), do: IO.ANSI.blue_background()

  def background_color("magenta"), do: IO.ANSI.magenta_background()

  def background_color("cyan"), do: IO.ANSI.cyan_background()

  def background_color("white"), do: IO.ANSI.white_background()

  def background_color(nil), do: nil

  def background_color("256:" <> color) do
    IO.ANSI.color_background(String.to_integer(color))
  end

  def background_color(triplet) do
    case String.split(triplet, ",") do
      [r, g, b] ->
        "\e[48;2;#{r};#{g};#{b}m"

      _ ->
        nil
    end
  end

  def foreground_color("black"), do: IO.ANSI.black()

  def foreground_color("red"), do: IO.ANSI.red()

  def foreground_color("green"), do: IO.ANSI.green()

  def foreground_color("yellow"), do: IO.ANSI.yellow()

  def foreground_color("blue"), do: IO.ANSI.blue()

  def foreground_color("magenta"), do: IO.ANSI.magenta()

  def foreground_color("cyan"), do: IO.ANSI.cyan()

  def foreground_color("white"), do: IO.ANSI.white()

  def foreground_color(nil), do: nil

  def foreground_color("256:" <> color) do
    IO.ANSI.color(String.to_integer(color))
  end

  def foreground_color(triplet) do
    case String.split(triplet, ",") do
      [r, g, b] ->
        "\e[38;2;#{r};#{g};#{b}m"

      _ ->
        nil
    end
  end

  def underline("true"), do: IO.ANSI.underline()

  def underline(_), do: nil

  def process_tag(attributes) do
    foreground = Map.get(attributes, "foreground")
    background = Map.get(attributes, "background")

    attributes = [
      foreground_color(foreground),
      background_color(background),
      underline(Map.get(attributes, "underline"))
    ]

    Enum.reject(attributes, &is_nil/1)
  end

  def process_close_tag([]), do: [IO.ANSI.reset()]

  def process_close_tag([attributes | _stack]) do
    process_tag(attributes)
  end
end