lib/gettext_check.ex

defmodule GettextCheck do
  @moduledoc """
  `GettextCheck` module allows to check for missing translations
  in [gettext](https://www.gnu.org/software/gettext/) `po` and `pot` files.

  It is made to work with the [elixir-gettext/gettext](https://github.com/elixir-gettext/gettext/blob/main/lib/gettext.ex)
  library.

  ## Basic Overview

  Using [elixir-gettext/gettext](https://github.com/elixir-gettext/gettext/blob/main/lib/gettext.ex)
  on you elixir project will create a `priv/gettext`
  directory with the following structure:

      priv/gettext
      ├── en
      │   └── LC_MESSAGES
      │       └── default.po
      └── en_US
          └── LC_MESSAGES
              └── default.po

  These files might be missing translations under `msgstr`
  (or `msgstr[0]` and `msgstr[1]` for plural translations).
  In a typical dev flow you would add the translations manually
  after extracting them from your code.

  Runing `GettextCheck` before pushing your code to a CI/CD
  pipeline can help prevent pushing mising translations to
  production.

  > This library uses [expo](https://hexdocs.pm/expo/readme.html)
  internally to parse the `po`/`pot` files.

  ## Usage

  Call `mix gettext_check` from the root of your project.

  Any missing translations will be listed in the output with the respective line number

  ```bash
        Missing translations:

        msgid: 'Online'
        /app/priv/locales/ja/LC_MESSAGES/default.po:7364
  ```

  ## Configuration

  You need to specify the locale but the priv directory is optional
  (default to `priv/gettext`).

  `GettextCheck` can be configured in two ways:

  #### 1. Command line options

  ```bash
    mix gettext_check --locale ja --priv priv/gettext
  ```

  #### 2. Mix config

  ```elixir
    config :gettext_check,
      locale: "ja",
      priv: "priv/gettext"
  ```

  """

  @root_path Path.dirname(__DIR__)

  alias Expo.Messages
  alias Expo.Message

  @doc """
  Checks a file for missing translation and returns a list of formatted errors.

  ## Examples

      iex> check("priv/locales/ja/LC_MESSAGES/default.po")
      [
        [_, _ANSI_reset, "msgid: Hello'", _, _ANSI_bright, _ANSI_red, "/app/priv/locales/ja/LC_MESSAGES/default.po:12", _],
        [_, _ANSI_reset, "msgid: World'", _, _ANSI_bright, _ANSI_red, "/app/priv/locales/ja/LC_MESSAGES/default.po:15", _]
      ]

  """
  @spec check(String.t()) :: [String.t()]
  def check(file_path) do
    result = Expo.PO.parse_file!(file_path)
    %Messages{messages: messages} = result

    Enum.reduce(messages, [], fn msg, errors ->
      get_errors(msg, file_path) ++ errors
    end)
  end

  @doc """
  Gets any missing translation errors from a message.

  ## Examples

      iex> get_errors(%Message.Singular{msgid: ["foo"], msgstr: [""]}, "priv/locales/ja/LC_MESSAGES/default.po")
      [
        [_, _ANSI_reset, "msgid: foo'", _, _ANSI_bright, _ANSI_red, "/app/priv/locales/ja/LC_MESSAGES/default.po:", _]
      ]

      iex> get_errors(%Message.Singular{msgid: ["bar"], msgstr: ["bar"]}, "priv/locales/ja/LC_MESSAGES/default.po")
      []

      iex> get_errors(%Message.Plural{msgid: ["bar"], msgid_plural: ["bars"], msgstr: %{0 => [""], 1 => [""]}}, "priv/locales/ja/LC_MESSAGES/default.po")
      [
        [_, _ANSI_reset, "msgid: bars'", _, _ANSI_bright, _ANSI_red, "/app/priv/locales/ja/LC_MESSAGES/default.po:", _],
        [_, _ANSI_reset, "msgid: bar'", _, _ANSI_bright, _ANSI_red, "/app/priv/locales/ja/LC_MESSAGES/default.po:", _]
      ]

  """
  @spec get_errors(Message.t(), String.t()) :: [String.t()] | nil
  def get_errors(%Message.Singular{} = message, file_path) do
    %Message.Singular{msgid: msgid, msgstr: msgstr} = Message.Singular.rebalance(message)

    if missing_msg?(msgstr) do
      line = Message.Singular.source_line_number(message, :msgstr)

      [format_error(msgid, file_path, line)]
    else
      []
    end
  end

  def get_errors(%Message.Plural{} = message, file_path) do
    %Message.Plural{msgid: msgid, msgid_plural: msgid_plural, msgstr: msgstr} =
      Message.Plural.rebalance(message)

    Enum.reduce(msgstr, [], fn {index, msg}, errors ->
      id = if index > 0, do: msgid_plural, else: msgid

      if missing_msg?(msg) do
        line = Message.Plural.source_line_number(message, {:msgstr, index})

        [format_error(id, file_path, line) | errors]
      else
        errors
      end
    end)
  end

  @spec missing_msg?([String.t()]) :: boolean
  defp missing_msg?(msgstr) do
    Enum.any?(msgstr, &(&1 == ""))
  end

  defp format_error(msgid, file_path, line) do
    [
      "\n",
      IO.ANSI.reset(),
      "msgid: '#{msgid}'",
      "\n",
      IO.ANSI.bright(),
      IO.ANSI.red(),
      "#{@root_path}/#{file_path}:#{line}",
      "\n"
    ]
  end
end