lib/localize/translate.ex

defmodule Localize.Translate do
  @moduledoc """
  Manage translations embedded into translation structs.

  Although it can be used with any struct **`Localize.Translate` shines when paired with an
  `Ecto.Schema`**. It allows you to keep the translations into a field of the schema and avoids
  requiring extra tables for translation storage and complex _joins_ when retrieving translations
  from the database.

  The runtime API lives on `Localize.Translate` itself:

  * `translate/1`, `translate/2` return a translated copy of an entire struct, or a single
    field when the second argument is a field name.

  * `translate/3` and `translate!/3` return a single translated field with an explicit
    locale or locale fallback chain.

  * `translatable?/2` reports whether a field is declared as translatable.

  For `Ecto.Query` support, `Localize.Translate.QueryBuilder` provides the `translated/3`
  and `translated_as/3` macros (requires `Ecto.SQL`).

  ### Locale handling

  `Localize.Translate` builds on [`:localize`](https://hex.pm/packages/localize) for
  CLDR-aware locale handling. Three properties follow:

  * **All locale inputs validate.** Atoms (`:en`), strings (`"en"`), and
    `%Localize.LanguageTag{}` structs are accepted anywhere a locale is expected — in
    `:locales`, `translate/N`, and `QueryBuilder.translated/3`. Each value is run
    through `Localize.validate_locale/1` and unwrapped to its `:cldr_locale_id` atom.
    Invalid locale names raise.

  * **Fallbacks walk the CLDR parent chain.** A locale expands into a fallback chain
    by walking parents (e.g. `:"en-AU"` → `:"en-001"` → `:en`, stopping before the
    `:und` root). For `QueryBuilder`, the chain is filtered to the locales declared in
    `:locales` so unsupported parents don't generate dead branches.

  * **The current locale is `Localize.get_locale/0`.** `translate/1` and `translate/2`
    (with a field name) default the locale from `Localize.get_locale/0` and walk its
    parents.

  Mixed `:locales` lists like `[:en, %Localize.LanguageTag{cldr_locale_id: :fr}]` are
  supported. After validation each entry is reduced to an atom, deduplicated, and
  sorted.

  When used, `Localize.Translate` accepts the following options:

  * `:translates` (required) - list of the fields that will be translated.

  * `:locales` (optional) - the list of locales for which translations will be stored. Required
    if you use the `translations/1` macro to auto-generate the embedded schema. Locales are
    atoms (for example `:en`, `:"en-AU"`).

  * `:default_locale` (optional) - declares the locale of the base untranslated columns. The
    fields in the main schema are considered to be in this locale, and no separate translation
    embed is generated for it.

  * `:container` (optional) - the name of the field that holds the translations. Defaults to
    `:translations`.

  ### Structured translations

  Structured translations are the preferred and recommended way of using `Localize.Translate`. To
  use structured translations, use the `translations/1` macro when defining a schema. The
  `translations/1` macro will define an embedded schema with the name being the parameter passed
  to the `translations/1` macro. Within the `:translations` field that is generated, an
  `embeds_one/2` call is added for each non-default locale in `:locales`. Here's an example
  schema configuration:

      defmodule MyApp.Article do
        use Ecto.Schema
        use Localize.Translate,
          translates: [:title, :body],
          locales: [:en, :es, :fr],
          default_locale: :en

        schema "articles" do
          field :title, :string
          field :body, :string
          translations :translations
        end
      end

  When expanded by the Elixir compiler, the example above will look like the following code. It
  is provided here only as an example to show what the `translations/1` macro compiles to.

      defmodule MyApp.Article do
        use Ecto.Schema

        schema "articles" do
          field :title, :string
          field :body, :string

          embeds_one :translations, Translations, on_replace: :update, primary_key: false do
            embeds_one :es, MyApp.Article.Translations.Fields
            embeds_one :fr, MyApp.Article.Translations.Fields
          end
        end
      end

      defmodule MyApp.Article.Translations.Fields do
        use Ecto.Schema

        @primary_key false
        embedded_schema do
          field :title, :string
          field :body, :string
        end
      end

  ### Reflection

  Any module that uses `Localize.Translate` will have an autogenerated `__trans__` function that
  can be used for runtime introspection of the translation metadata.

  * `__trans__(:fields)` - Returns the list of translatable fields.

  * `__trans__(:container)` - Returns the name of the translation container.

  * `__trans__(:locales)` - Returns the configured locales (or `nil` if not given).

  * `__trans__(:default_locale)` - Returns the default locale. The fields in the main schema are
    considered to be in this locale.

  """

  @typedoc """
  A translatable struct that uses `Localize.Translate`.

  """
  @type translatable() :: struct()

  @typedoc """
  A struct field as an atom.

  """
  @type field :: atom()

  @typedoc """
  A locale identifier as an atom (for example `:en`, `:"en-AU"`) or a string.

  """
  @type locale :: atom() | String.t()

  @typedoc """
  When translating or querying either a single locale or a list of locales can be provided.

  A list of locales acts as a fallback chain: each locale is tried in order until a translation
  is found.

  """
  @type locale_list :: locale() | [locale(), ...]

  @doc false
  defmacro using(opts) do
    quote do
      import Localize.Translate,
        only: [trans_fields: 1, trans_container: 1, trans_locales: 1, trans_default_locale: 1]

      Module.put_attribute(__MODULE__, :trans_fields, trans_fields(unquote(opts)))
      Module.put_attribute(__MODULE__, :trans_container, trans_container(unquote(opts)))
      Module.put_attribute(__MODULE__, :trans_locales, trans_locales(unquote(opts)))
      Module.put_attribute(__MODULE__, :trans_default_locale, trans_default_locale(unquote(opts)))

      @after_compile {Localize.Translate, :__validate_translatable_fields__}
      @after_compile {Localize.Translate, :__validate_translation_container__}

      @spec __trans__(:fields) :: list(atom)
      def __trans__(:fields), do: @trans_fields

      @spec __trans__(:container) :: atom
      def __trans__(:container), do: @trans_container

      @spec __trans__(:locales) :: [atom(), ...] | nil
      def __trans__(:locales), do: @trans_locales

      @spec __trans__(:default_locale) :: atom() | nil
      def __trans__(:default_locale), do: @trans_default_locale
    end
  end

  defmacro __using__(opts) do
    quote do
      require Localize.Translate

      Localize.Translate.using(unquote(opts))
      import Localize.Translate, only: :macros
    end
  end

  @doc false
  def default_trans_options do
    [build_field_schema: true]
  end

  @doc """
  Defines the embedded translation container field on an `Ecto.Schema`.

  Within an `Ecto.Schema` block, expands into an `embeds_one/3` declaration for the named
  container field plus auto-generated `Translations` and `Translations.Fields` embedded
  schemas — one `embeds_one` per non-default locale, each holding the translatable string
  fields declared in `:translates`.

  ### Arguments

  * `field_name` is the name of the container field on the parent schema, given as an atom
    (commonly `:translations`).

  * `translation_module` (optional) is the alias name of the generated translation schema.
    Defaults to `Translations`. Pass an alias to use a different module name under the parent.

  * `locales_or_options` (optional) is either an explicit list of locale atoms, or a keyword
    list of options. When omitted, the locales configured via the `:locales` option on
    `use Localize.Translate` are used.

  ### Options

  * `:build_field_schema` - when `true` (the default), generates the inner
    `Translations.Fields` module. Set to `false` if you want to define that module yourself.

  ### Examples

      defmodule MyApp.Article do
        use Ecto.Schema
        use Localize.Translate,
          translates: [:title, :body],
          locales: [:en, :es, :fr],
          default_locale: :en

        schema "articles" do
          field :title, :string
          field :body, :string
          translations :translations
        end
      end

  """
  defmacro translations(field_name, translation_module \\ nil, locales_or_options \\ []) do
    module = __CALLER__.module
    translation_module = trans_module(translation_module)

    if Keyword.keyword?(locales_or_options) do
      quote do
        translations(
          unquote(field_name),
          unquote(translation_module),
          Module.get_attribute(unquote(module), :trans_locales),
          unquote(locales_or_options)
        )
      end
    else
      quote do
        translations(
          unquote(field_name),
          unquote(translation_module),
          unquote(locales_or_options),
          []
        )
      end
    end
  end

  @doc """
  Defines the embedded translation container field with explicit locales.

  Four-arity form of `translations/3`. Use this when you want to override the configured
  `:locales` option for a single schema, or to pass an options keyword list alongside an
  explicit locale list.

  ### Arguments

  * `field_name` is the name of the container field on the parent schema, as an atom.

  * `translation_module` is the alias name of the generated translation schema.

  * `locales` is the explicit list of locale atoms for which translation embeds are
    generated. The schema's `:default_locale` is excluded.

  * `options` is a keyword list of options, the same as accepted by `translations/3`.

  """
  defmacro translations(field_name, translation_module, locales, options) do
    caller = __CALLER__.module
    options = Keyword.merge(Localize.Translate.default_trans_options(), options)
    {build_field_schema, _options} = Keyword.pop(options, :build_field_schema)

    quote do
      if unquote(translation_module) && unquote(build_field_schema) do
        @before_compile {Localize.Translate, :__build_embedded_schema__}
      end

      @translation_module Module.concat(unquote(caller), unquote(translation_module))
      @locales unquote(locales)

      embeds_one(unquote(field_name), @translation_module, on_replace: :update)
    end
  end

  @doc false
  defmacro __build_embedded_schema__(env) do
    translation_module = Module.get_attribute(env.module, :translation_module)
    fields = Module.get_attribute(env.module, :trans_fields)
    locales = Module.get_attribute(env.module, :locales)
    default_locale = Module.get_attribute(env.module, :trans_default_locale)

    quote do
      defmodule unquote(translation_module) do
        @moduledoc false

        use Ecto.Schema

        @primary_key false
        embedded_schema do
          for locale_name <- List.wrap(unquote(locales)),
              locale_name != unquote(default_locale) do
            embeds_one(locale_name, Module.concat(__MODULE__, Fields), on_replace: :update)
          end
        end
      end

      defmodule Module.concat(unquote(translation_module), Fields) do
        @moduledoc false

        use Ecto.Schema
        import Ecto.Changeset

        @primary_key false
        embedded_schema do
          for a_field <- unquote(fields) do
            field(a_field, :string)
          end
        end

        def changeset(fields, params) do
          fields
          |> cast(params, unquote(fields))
          |> validate_required(unquote(fields))
        end
      end
    end
  end

  @doc """
  Checks whether the given field is translatable or not.

  Returns true if the given field is translatable. Raises if the given module or struct does not
  use `Localize.Translate`.

  ### Arguments

  * `module_or_translatable` is either a module that uses `Localize.Translate` or a struct of
    that module.

  * `field` is the field name as an atom or string.

  ### Returns

  * `true` if the given field is translatable.

  * `false` if the given field is not translatable.

  ### Examples

  Assuming the Article schema defined in [Structured translations](#module-structured-translations).

      iex> Localize.Translate.translatable?(Article, :title)
      true

      iex> article = %Article{}
      iex> Localize.Translate.translatable?(article, :not_existing)
      false

      iex> Localize.Translate.translatable?(Date, :day)
      ** (RuntimeError) Elixir.Date must use `Localize.Translate` in order to be translated

  """
  def translatable?(module_or_translatable, field)

  @spec translatable?(module | translatable(), String.t() | atom) :: boolean
  def translatable?(%{__struct__: module}, field), do: translatable?(module, field)

  def translatable?(module, field) when is_atom(module) and is_binary(field) do
    translatable?(module, String.to_atom(field))
  end

  def translatable?(module, field) when is_atom(module) and is_atom(field) do
    if Keyword.has_key?(module.__info__(:functions), :__trans__) do
      Enum.member?(module.__trans__(:fields), field)
    else
      raise "#{module} must use `Localize.Translate` in order to be translated"
    end
  end

  @doc """
  Translates a whole struct into the current locale.

  Equivalent to `translate(translatable, locale)` with `locale` resolved from
  `Localize.get_locale/0` when the optional `:localize` dependency is loaded, falling back
  to the schema's `:default_locale` otherwise.

  ### Arguments

  * `translatable` is a struct that uses `Localize.Translate`.

  ### Returns

  * The translated struct.

  """
  @spec translate(translatable()) :: translatable()
  def translate(translatable), do: translate(translatable, default_locale_for(translatable))

  @doc """
  Translates a whole struct (or a single field) into the given locale.

  Returns the struct with every translatable field — and every translatable association or
  embed — replaced by the value for the requested locale, falling back to the default
  value when the locale has no entry. If the second argument is the name of a translatable
  field, returns that single translated field using the current locale (see `translate/3`
  for an explicit locale).

  ### Arguments

  * `translatable` is a struct that uses `Localize.Translate`.

  * `locale_or_field` is one of:

    * an atom or string locale (e.g. `:es`, `"fr"`),

    * a list of locales acting as a fallback chain,

    * a `%Localize.LanguageTag{}` — when the optional `:localize` dependency is loaded,
      expanded into a fallback chain by walking the CLDR parent chain, or

    * an atom field name — translates a single field using the current locale (see
      `translate/3`).

  ### Returns

  * The translated struct, or the translated value of a single field.

  ### Examples

      # Translate the entire article into Spanish
      Localize.Translate.translate(article, :es)

      # Fallback chain — Deutsch missing, Spanish wins
      Localize.Translate.translate(article, [:de, :es])

  """
  @spec translate(translatable(), locale_list() | field() | struct()) ::
          translatable() | any()
  def translate(translatable, locale_or_field) do
    case classify_locale_or_field(translatable, locale_or_field) do
      {:field, field} -> translate(translatable, field, default_locale_for(translatable))
      {:locale, chain} -> do_translate(translatable, chain)
    end
  end

  defp do_translate(%{__struct__: module} = translatable, locale) do
    if Keyword.has_key?(module.__info__(:functions), :__trans__) do
      default_locale = module.__trans__(:default_locale)

      translatable
      |> translate_fields(locale, default_locale)
      |> translate_assocs(locale)
    else
      translatable
    end
  end

  @doc """
  Translates a single field into the given locale.

  Looks up the field's translation in the given locale (or first match in a fallback
  chain) and returns it. Returns the base value if no translation is available.

  ### Arguments

  * `translatable` is a struct that uses `Localize.Translate`.

  * `field` is the name of the translatable field as an atom.

  * `locale` is either a single locale or a list of locales acting as a fallback chain.

  ### Returns

  * The translated value, or the value from the base column when no translation exists
    for any locale in the chain.

  ### Examples

      # Spanish title
      Localize.Translate.translate(article, :title, :es)

      # Unknown locale falls back to the base column
      Localize.Translate.translate(article, :title, :de)

      # Fallback chain
      Localize.Translate.translate(article, :title, [:de, :es])

  Raises if the field isn't declared as translatable:

      Localize.Translate.translate(article, :fake_attr, :es)
      ** (RuntimeError) 'Article' module must declare 'fake_attr' as translatable

  """
  @spec translate(translatable(), field(), locale_list() | struct()) :: any()
  def translate(%{__struct__: module} = translatable, field, locale) when is_atom(field) do
    chain = expand_locale(locale)
    default_locale = module.__trans__(:default_locale)

    unless translatable?(translatable, field) do
      raise not_translatable_error(module, field)
    end

    case translate_field(translatable, chain, field, default_locale) do
      :error -> Map.fetch!(translatable, field)
      nil -> Map.fetch!(translatable, field)
      translation -> translation
    end
  end

  @doc """
  Translates a single field into the given locale, raising if no translation is available.

  Strict variant of `translate/3`. Where `translate/3` falls back to the base value when a
  translation is missing, `translate!/3` raises so callers can distinguish "no
  translation" from "translation equals the default".

  ### Arguments

  * `translatable` is a struct that uses `Localize.Translate`.

  * `field` is the name of the translatable field as an atom.

  * `locale` is either a single locale or a list of locales acting as a fallback chain.

  ### Returns

  * The translated value.

  * Raises a `RuntimeError` if no translation exists for any locale in the chain.

  ### Examples

      Localize.Translate.translate!(article, :title, :de)
      ** (RuntimeError) translation doesn't exist for field ':title' in locale :de

  """
  @spec translate!(translatable(), field(), locale_list() | struct()) :: any()
  def translate!(%{__struct__: module} = translatable, field, locale) when is_atom(field) do
    chain = expand_locale(locale)
    default_locale = module.__trans__(:default_locale)

    unless translatable?(translatable, field) do
      raise not_translatable_error(module, field)
    end

    case translate_field(translatable, chain, field, default_locale) do
      :error -> raise no_translation_error(field, locale)
      translation -> translation
    end
  end

  defp classify_locale_or_field(%{__struct__: module}, value) do
    cond do
      is_atom(value) and not is_nil(value) and not is_boolean(value) and
        Keyword.has_key?(module.__info__(:functions), :__trans__) and
          value in module.__trans__(:fields) ->
        {:field, value}

      true ->
        {:locale, expand_locale(value)}
    end
  end

  defp expand_locale(locale), do: Localize.Translate.Locale.expand(locale)

  # With `:localize` always present, `current/0` reliably returns a `LanguageTag`.
  # `translate/N` then expands it through the CLDR parent chain, so a missing
  # translation degrades through parents to the schema's `:default_locale` value
  # in the base columns.
  defp default_locale_for(_struct), do: Localize.Translate.Locale.current()

  defp translate_field(%{__struct__: _module} = struct, locales, field, default_locale)
       when is_list(locales) do
    Enum.reduce_while(locales, :error, fn locale, translated_field ->
      case translate_field(struct, locale, field, default_locale) do
        :error -> {:cont, translated_field}
        nil -> {:cont, translated_field}
        translation -> {:halt, translation}
      end
    end)
  end

  defp translate_field(%{__struct__: _module} = struct, default_locale, field, default_locale) do
    Map.fetch!(struct, field)
  end

  defp translate_field(%{__struct__: module} = struct, locale, field, _default_locale) do
    with {:ok, all_translations} <- Map.fetch(struct, module.__trans__(:container)),
         {:ok, translations_for_locale} <- get_translations_for_locale(all_translations, locale),
         {:ok, translated_field} <- get_translated_field(translations_for_locale, field) do
      translated_field || Map.fetch!(struct, field)
    end
  end

  defp translate_fields(%{__struct__: module} = struct, locale, default_locale) do
    fields = module.__trans__(:fields)

    Enum.reduce(fields, struct, fn field, struct ->
      case translate_field(struct, locale, field, default_locale) do
        :error -> struct
        nil -> struct
        translation -> Map.put(struct, field, translation)
      end
    end)
  end

  defp translate_assocs(%{__struct__: module} = struct, locale) do
    associations = module.__schema__(:associations)
    embeds = module.__schema__(:embeds)

    Enum.reduce(associations ++ embeds, struct, fn assoc_name, struct ->
      Map.update(struct, assoc_name, nil, fn
        %Ecto.Association.NotLoaded{} = item ->
          item

        items when is_list(items) ->
          Enum.map(items, &translate(&1, locale))

        %{} = item ->
          translate(item, locale)

        item ->
          item
      end)
    end)
  end

  defp get_translations_for_locale(%{__struct__: _} = all_translations, locale)
       when is_binary(locale) do
    get_translations_for_locale(all_translations, String.to_existing_atom(locale))
  end

  defp get_translations_for_locale(%{__struct__: _} = all_translations, locale)
       when is_atom(locale) do
    Map.fetch(all_translations, locale)
  end

  defp get_translations_for_locale(all_translations, locale) do
    Map.fetch(all_translations, to_string(locale))
  end

  defp get_translated_field(nil, _field), do: nil

  defp get_translated_field(%{__struct__: _} = translations_for_locale, field)
       when is_binary(field) do
    get_translated_field(translations_for_locale, String.to_existing_atom(field))
  end

  defp get_translated_field(%{__struct__: _} = translations_for_locale, field)
       when is_atom(field) do
    Map.fetch(translations_for_locale, field)
  end

  defp get_translated_field(translations_for_locale, field) do
    Map.fetch(translations_for_locale, to_string(field))
  end

  defp no_translation_error(field, locales) when is_list(locales) do
    "translation doesn't exist for field '#{inspect(field)}' in locales #{inspect(locales)}"
  end

  defp no_translation_error(field, locale) do
    "translation doesn't exist for field '#{inspect(field)}' in locale #{inspect(locale)}"
  end

  defp not_translatable_error(module, field) do
    "'#{inspect(module)}' module must declare '#{inspect(field)}' as translatable"
  end

  @doc false
  def __validate_translatable_fields__(%{module: module}, _bytecode) do
    struct_fields = Map.keys(module.__struct__())
    translatable_fields = module.__trans__(:fields)
    invalid_fields = translatable_fields -- struct_fields

    case invalid_fields do
      [] ->
        nil

      [_] ->
        raise ArgumentError,
          message:
            "#{module} declares '#{invalid_fields}' as translatable but it is not defined in the module's struct"

      _ ->
        raise ArgumentError,
          message:
            "#{module} declares '#{invalid_fields}' as translatable but they are not defined in the module's struct"
    end
  end

  @doc false
  def __validate_translation_container__(%{module: module}, _bytecode) do
    container = module.__trans__(:container)

    unless Enum.member?(Map.keys(module.__struct__()), container) do
      raise ArgumentError,
        message:
          "The field #{container} used as the translation container is not defined in #{inspect(module)} struct"
    end
  end

  @doc false
  def trans_fields(options) do
    case Keyword.fetch(options, :translates) do
      {:ok, fields} when is_list(fields) ->
        fields

      _ ->
        raise ArgumentError,
          message:
            "Localize.Translate requires a 'translates' option that contains the list of translatable field names"
    end
  end

  @doc false
  def trans_container(options) do
    case Keyword.fetch(options, :container) do
      :error -> :translations
      {:ok, container} -> container
    end
  end

  @doc false
  def trans_locales(options) do
    case Keyword.fetch(options, :locales) do
      :error ->
        nil

      {:ok, locales} ->
        locales
        |> Enum.map(&Localize.Translate.Locale.normalise!/1)
        |> Enum.uniq()
        |> Enum.sort()
    end
  end

  @doc false
  def trans_default_locale(options) do
    case Keyword.fetch(options, :default_locale) do
      :error -> nil
      {:ok, default_locale} -> default_locale
    end
  end

  @doc false
  def trans_module(nil) do
    {:__aliases__, [], [:Translations]}
  end

  def trans_module(translation_module) do
    translation_module
  end
end