lib/expo/po.ex

defmodule Expo.PO do
  @moduledoc """
  File handling for PO (`.po`) and POT (`.pot`) files.
  """

  alias Expo.Messages
  alias Expo.PO.{DuplicateMessagesError, Parser, SyntaxError}

  @type parse_option :: {:file, Path.t()}

  @doc """
  Dumps a `Expo.Messages` struct as iodata.

  This function dumps a `Expo.Messages` struct (representing a PO file) as iodata,
  which can later be written to a file or converted to a string with
  `IO.iodata_to_binary/1`.

  ## Examples

  After running the following code:

      iodata =
        Expo.PO.compose(%Expo.Messages{
          headers: ["Last-Translator: Jane Doe"],
          messages: [
            %Expo.Message.Singular{msgid: ["foo"], msgstr: ["bar"], comments: "A comment"}
          ]
        })

      File.write!("/tmp/test.po", iodata)

  the `/tmp/test.po` file would look like this:

      msgid ""
      msgstr ""
      "Last-Translator: Jane Doe"

      # A comment
      msgid "foo"
      msgstr "bar"

  """
  @spec compose(Messages.t()) :: iodata()
  defdelegate compose(messages), to: Expo.PO.Composer

  @doc """
  Parses the given `string` into a `Expo.Messages` struct.

  It returns `{:ok, messages}` if there are no errors, otherwise
  `{:error, error}` where `error` is an exception struct.

  ## Examples

      iex> {:ok, po} = Expo.PO.parse_string(\"""
      ...> msgid "foo"
      ...> msgstr "bar"
      ...> \""")
      iex> [message] = po.messages
      iex> message.msgid
      ["foo"]
      iex> message.msgstr
      ["bar"]
      iex> po.headers
      []

      iex> Expo.PO.parse_string("foo")
      {:error, %Expo.PO.SyntaxError{line: 1, reason: "unknown keyword 'foo'"}}

  """
  @spec parse_string(String.t(), [parse_option()]) ::
          {:ok, Messages.t()} | {:error, SyntaxError.t() | DuplicateMessagesError.t()}
  def parse_string(string, options \\ []) when is_binary(string) and is_list(options) do
    Parser.parse(string, options)
  end

  @doc """
  Parses `string` into a `Expo.Messages` struct, raising an exception if there are
  any errors.

  Works exactly like `parse_string/1`, but returns a `Expo.Messages` struct
  if there are no errors or raises an exception if there are.

  ## Examples

      iex> po = Expo.PO.parse_string!(\"""
      ...> msgid "foo"
      ...> msgstr "bar"
      ...> \""")
      iex> [message] = po.messages
      iex> message.msgid
      ["foo"]
      iex> message.msgstr
      ["bar"]
      iex> po.headers
      []

      iex> Expo.PO.parse_string!("msgid")
      ** (Expo.PO.SyntaxError) 1: no space after 'msgid'

      iex> Expo.PO.parse_string!(\"""
      ...> msgid "test"
      ...> msgstr ""
      ...>
      ...> msgid "test"
      ...> msgstr ""
      ...> \""")
      ** (Expo.PO.DuplicateMessagesError) 4: found duplicate on line 4 for msgid: 'test'

  """
  @spec parse_string!(String.t(), [parse_option()]) :: Messages.t()
  def parse_string!(string, opts \\ []) do
    case parse_string(string, opts) do
      {:ok, parsed} -> parsed
      {:error, error} -> raise error
    end
  end

  @doc """
  Parses the contents of a file into a `Expo.Messages` struct.

  This function works similarly to `parse_string/1` except that it takes a file
  and parses the contents of that file. It can return:

    * `{:ok, po}` if the parsing is successful

    * `{:error, error}` if there is an error with the contents of the
      `.po` file (for example, a syntax error); `error` is an exception struct

    * `{:error, reason}` if there is an error with reading the file (this error
      is one of the errors that can be returned by `File.read/1`)

  ## Examples

      {:ok, po} = Expo.PO.parse_file("messages.po")
      po.file
      #=> "messages.po"

      Expo.PO.parse_file("nonexistent")
      #=> {:error, :enoent}

  """
  @spec parse_file(Path.t(), [parse_option()]) ::
          {:ok, Messages.t()}
          | {:error, SyntaxError.t() | DuplicateMessagesError.t() | File.posix()}
  def parse_file(path, options \\ []) when is_list(options) do
    with {:ok, contents} <- File.read(path) do
      parse_string(contents, Keyword.put_new(options, :file, path))
    end
  end

  @doc """
  Parses the contents of a file into a `Expo.Messages` struct, raising if there
  are any errors.

  Works like `parse_file/1`, except that it raises an exception
  if there are issues with the contents of the file or with reading the file.

  ## Examples

      Expo.PO.parse_file!("nonexistent.po")
      #=> ** (File.Error) could not parse "nonexistent.po": no such file or directory

  """
  @spec parse_file!(Path.t(), [parse_option()]) :: Messages.t()
  def parse_file!(path, opts \\ []) do
    case parse_file(path, opts) do
      {:ok, parsed} ->
        parsed

      {:error, %mod{} = error} when mod in [SyntaxError, DuplicateMessagesError] ->
        raise error

      {:error, reason} ->
        raise File.Error, reason: reason, action: "parse", path: Keyword.get(opts, :file, path)
    end
  end
end