lib/expo/messages.ex

defmodule Expo.Messages do
  @moduledoc """
  A struct that represents lists of `Expo.Message.Singular` and `Expo.Message.Plural`
  structs for MO and PO files.

  All fields in the struct are public. See `t:t/0`.
  """

  alias Expo.{Message, Util}

  @type t :: %__MODULE__{
          headers: [String.t()],
          top_comments: [[String.t()]],
          messages: [Message.t()],
          file: nil | Path.t()
        }

  @enforce_keys [:messages]
  defstruct headers: [], messages: [], top_comments: [], file: nil

  @doc """
  Rebalances all strings.

    * All headers (see `Expo.Message.Singular.rebalance/1` and `Expo.Message.Plural.rebalance/1`)
    * Put one string per newline of `headers` and add one empty line at start

  ### Examples

      iex> Expo.Messages.rebalance(%Expo.Messages{
      ...>   headers: ["", "hello", "\\n", "", "world", ""],
      ...>   messages: [%Expo.Message.Singular{
      ...>     msgid: ["", "hello", "\\n", "", "world", ""],
      ...>     msgstr: ["", "hello", "\\n", "", "world", ""]
      ...>   }]
      ...> })
      %Expo.Messages{
        headers: ["", "hello\\n", "world"],
        messages: [%Expo.Message.Singular{
          msgid: ["hello\\n", "world"],
          msgstr: ["hello\\n", "world"]
        }]
      }

  """
  @spec rebalance(t()) :: t()
  def rebalance(
        %__MODULE__{headers: headers, messages: all_messages, top_comments: top_comments} =
          messages
      ) do
    {headers, top_comments, all_messages} =
      headers
      |> Util.inject_meta_headers(top_comments, all_messages)
      |> Enum.map(fn %struct{} = message -> struct.rebalance(message) end)
      |> Util.extract_meta_headers()

    headers =
      case headers do
        [] -> []
        headers -> ["" | headers]
      end

    %__MODULE__{
      messages
      | headers: headers,
        top_comments: top_comments,
        messages: all_messages
    }
  end

  @doc """
  Get header by name (case insensitive).

  ### Examples

      iex> messages = %Expo.Messages{headers: ["Language: en_US\\n"], messages: []}
      iex> Expo.Messages.get_header(messages, "language")
      ["en_US"]

      iex> messages = %Expo.Messages{headers: ["Language: en_US\\n"], messages: []}
      iex> Expo.Messages.get_header(messages, "invalid")
      []

  """
  @spec get_header(t(), String.t()) :: [String.t()]
  def get_header(%__MODULE__{headers: headers}, header_name) when is_binary(header_name) do
    header_name_match = Regex.escape(header_name)
    escaped_newline = Regex.escape("\\\n")

    ~r/
      # Start of line
      ^
      # Header Name
      (?<header>
        #{header_name_match}
      ):
      # Ignore Whitespace
      \s
      (?<content>
        (
          # Allow an escaped newline in content
          #{escaped_newline}
          |
          # Allow everything except a newline in content
          [^\n]
        )*
      )
      # Header must end with newline or end of string
      (\n|\z)
    /imx
    |> Regex.scan(IO.iodata_to_binary(headers), capture: ["content"])
    |> Enum.map(fn [content] -> content end)
  end

  @doc """
  Finds a given `message_to_find` in a list of `messages`.

  Equality between messages is checked using `Expo.Message.same?/2`.

  Returns `nil` if `message_to_find` is not found.
  """
  @spec find([Message.t()] | t(), Message.t()) :: Message.t() | nil
  def find(%__MODULE__{messages: messages} = _messages, message_to_find) do
    Enum.find(messages, &Message.same?(&1, message_to_find))
  end
end