lib/expo/po.ex

defmodule Expo.Po do
  @moduledoc """
  `.po` / `.pot` file handler
  """

  alias Expo.Po.DuplicateTranslationsError
  alias Expo.Po.Parser
  alias Expo.Po.SyntaxError
  alias Expo.Translations

  @type parse_options :: [{:file, Path.t()}]

  @type parse_error ::
          {:error,
           {:parse_error, message :: String.t(), context :: String.t(), line :: pos_integer()}}
  @type duplicate_translations_error ::
          {:error,
           {:duplicate_translations,
            [{message :: String.t(), new_line :: pos_integer(), old_line :: pos_integer()}]}}
  @type file_error :: {:error, File.posix()}

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

  This function dumps a `Expo.Translations` 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.Translations{
        headers: ["Last-Translator: Jane Doe"],
        translations: [
          %Expo.Translation.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(translations :: Translations.t()) :: iodata()
  defdelegate compose(content), to: Expo.Po.Composer

  @doc """
  Parses a string into a `Expo.Translations` struct.

  This function parses a given `str` into a `Expo.Translations` struct.
  It returns `{:ok, po}` if there are no errors,
  otherwise `{:error, line, reason}`.

  ## Examples

      iex> {:ok, po} = Expo.Po.parse_string \"""
      ...> msgid "foo"
      ...> msgstr "bar"
      ...> \"""
      iex> [t] = po.translations
      iex> t.msgid
      ["foo"]
      iex> t.msgstr
      ["bar"]
      iex> po.headers
      []

      iex> Expo.Po.parse_string "foo"
      {:error, {:parse_error, "expected msgid followed by strings while processing plural translation inside singular translation or plural translation", "foo", 1}}

  """
  @spec parse_string(content :: binary(), opts :: parse_options()) ::
          {:ok, Translations.t()}
          | parse_error()
          | duplicate_translations_error()
  def parse_string(content, opts \\ []) do
    Parser.parse(content, opts)
  end

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

  Works exactly like `parse_string/1`, but returns a `Expo.Translations` struct
  if there are no errors or raises a `Expo.Po.SyntaxError` error if there
  are.

  ## Examples

      iex> po = Expo.Po.parse_string! \"""
      ...> msgid "foo"
      ...> msgstr "bar"
      ...> \"""
      iex> [t] = po.translations
      iex> t.msgid
      ["foo"]
      iex> t.msgstr
      ["bar"]
      iex> po.headers
      []

      iex> Expo.Po.parse_string!("msgid")
      ** (Expo.Po.SyntaxError) 1: expected whitespace while processing msgid followed by strings inside plural translation inside singular translation or plural translation

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

  """
  @spec parse_string!(content :: String.t(), opts :: parse_options()) ::
          Translations.t() | no_return
  def parse_string!(str, opts \\ []) do
    case parse_string(str, opts) do
      {:ok, parsed} ->
        parsed

      {:error, {:parse_error, reason, context, line}} ->
        options = [line: line, reason: reason, context: context]

        options =
          case opts[:file] do
            nil -> options
            path -> [{:file, path} | options]
          end

        raise SyntaxError, options

      {:error, {:duplicate_translations, duplicates}} ->
        options = [duplicates: duplicates]

        options =
          case opts[:file] do
            nil -> options
            path -> [{:file, path} | options]
          end

        raise DuplicateTranslationsError, options
    end
  end

  @doc """
  Parses the contents of a file into a `Expo.Translations` 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}`
    * `{:error, line, reason}` if there is an error with the contents of the
      `.po` file (for example, a syntax error)
    * `{: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 "translations.po"
      po.file
      #=> "translations.po"

      Expo.Po.parse_file "nonexistent"
      #=> {:error, :enoent}

  """
  @spec parse_file(path :: Path.t(), opts :: parse_options()) ::
          {:ok, Translations.t()}
          | parse_error()
          | duplicate_translations_error()
          | file_error()
  def parse_file(path, opts \\ []) do
    with {:ok, contents} <- File.read(path) do
      Parser.parse(contents, Keyword.put_new(opts, :file, path))
    end
  end

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

  Works like `parse_file/1`, except that it raises a `Expo.Po.SyntaxError`
  exception if there's a syntax error in the file or a `File.Error` error if
  there's an error 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(), opts :: parse_options()) :: Translations.t() | no_return
  def parse_file!(path, opts \\ []) do
    case parse_file(path, opts) do
      {:ok, parsed} ->
        parsed

      {:error, {:parse_error, reason, context, line}} ->
        raise SyntaxError, line: line, reason: reason, file: path, context: context

      {:error, {:duplicate_translations, duplicates}} ->
        raise DuplicateTranslationsError,
          duplicates: duplicates,
          file: Keyword.get(opts, :file, path)

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