lib/cldr_html_locale.ex

if match?({:module, _}, Code.ensure_compiled(Cldr.LocaleDisplay)) do
  defmodule Cldr.HTML.Locale do
    @moduledoc """
    Implements `Phoenix.HTML.Form.select/4` specifically for
    localised locale display.

    """

    alias Cldr.Locale

    @type select_options :: [
            {:locales, [atom() | binary(), ...]}
            | {:locale, Cldr.Locale.locale_name() | Cldr.LanguageTag.t()}
            | {:collator, function()}
            | {:mapper, function()}
            | {:backend, module()}
            | {:selected, atom() | binary()}
            | {atom(), any()}
          ]

    @type locale :: %{
            locale: String.t(),
            display_name: String.t(),
            language_tag: Cldr.LanguageTag.t()
          }

    @type mapper :: (locale() -> String.t())

    @identity :identity

    # All configurations include these locales
    # but they shouldn't be presented for
    # display
    @dont_include_default [:"en-001", :root, :und]

    @doc """
    Generate an HTML select tag for a locale list
    that can be used with a `Phoenix.HTML.Form.t`.

    ## Arguments

    * A `Phoenix.HTML.Form.t()` form

    * A `Phoenix.HTML.Form.field()` field

    * A `Keyword.t()` list of options

    ## Options

    For select options see `Phoenix.HTML.Form.select/4`

    * `:locales` defines the list of locales to be
      displayed in the the `select` tag.  The list defaults to
      `Cldr.known_locale_names/0`. If `:backend` is specified
      then the list of locales known to that backend
      is returned. If no `:backend` is specified the
      locales known to `Cldr.default_backend!/0` is
      returned.

    * `:locale` defines the locale to be used to localise the
      description of the list of locales.  The default is the locale
      returned by `Cldr.get_locale/1` If set to `:identity` then
      each locale in the `:locales` list will be rendered in its
      own locale.

    * `:backend` is any backend module. The default is
      `Cldr.default_backend!/0`

    * `:collator` is a function used to sort the locales
      in the selection list. It is passed a list of maps where
      each map represents a locale. The default collator
      sorts by `locale_1.display_name < locale_2.display_name`.
      As a result, default collation sorts by code point
      which will not return expected results
      for scripts other than Latin.

    * `:mapper` is a function that creates the text to be
      displayed in the select tag for each locale.  It is
      passed a map with three fields: `:display_name`, `:locale`
      and `:language_tag`. The default mapper is
      `&{&1.display_name, &1.locale}`. See `t:locale`.

    * `:selected` identifies the locale that is to be selected
      by default in the `select` tag.  The default is `nil`. This
      is passed to `Phoenix.HTML.Form.select/4`

    * `:prompt` is a prompt displayed at the top of the select
       box. This is passed unmodified to `Phoenix.HTML.Form.select/4`

    ## Notes

    If `:locale` is set to `:identity` then each locale in
    `:locales` will be used to render its own display name. In
    this case each locale in `:locales` must also be configured
    in the `:backend` or an error will be returned.

    ## Examples

         Cldr.HTML.Currency.select(:my_form, :locale_list, selected: "en")

         Cldr.HTML.Currency.select(:my_form, :locale_list,
           locales: ["zh-Hant", "ar", "fr"],
           mapper: &({&1.display_name, &1.locale}))

    """
    @spec select(
            form :: Phoenix.HTML.Form.t(),
            field :: Phoenix.HTML.Form.field(),
            select_options
          ) ::
            Phoenix.HTML.safe()
            | {:error, {Cldr.UnknownCurrencyError, binary()}}
            | {:error, {Cldr.UnknownLocaleError, binary()}}

    def select(form, field, options \\ [])

    def select(form, field, options) when is_list(options) do
      select(form, field, validate_options(options), options[:selected])
    end

    @doc """
    Generate a list of options for a locale list
    that can be used with `Phoenix.HTML.Form.select/4`,
    `Phoenix.HTML.Form.options_for_select/2` or
    to create a <datalist>.

    ## Arguments

    * A `Keyword.t()` list of options.

    ## Options

    See `Cldr.HTML.Locale.select/3` for options.

    """
    @spec locale_options(select_options) ::
            list(tuple())
            | {:error, {Cldr.UnknownLocaleError, binary()}}

    def locale_options(options \\ [])

    def locale_options(options) when is_list(options) do
      options
      |> validate_options()
      |> build_locale_options()
    end

    # Invalid options
    defp select(_form, _field, {:error, reason}, _selected) do
      {:error, reason}
    end

    # Selected currency
    @omit_from_select_options [
      :locales,
      :locale,
      :mapper,
      :collator,
      :backend,
      :add_likely_subtags,
      :prefer,
      :compound_locale
    ]

    defp select(form, field, %{locale: locale} = options, _selected) do
      select_options =
        options
        |> Map.drop(@omit_from_select_options)
        |> Map.to_list()

      options = build_locale_options(options)
      {options, select_options} = add_lang_attribute(locale, options, select_options)

      Phoenix.HTML.Form.select(form, field, options, select_options)
    end

    # For the :identity case, add a :lang attribute to each select option
    defp add_lang_attribute(@identity, options, select_options) do
      options = Enum.map(options, fn {key, value} -> [key: key, value: value, lang: value] end)
      {options, select_options}
    end

    # For the non-identity case, add one :lang attribute to the whole select
    defp add_lang_attribute(locale, options, select_options) do
      {options, Keyword.put(select_options, :lang, locale)}
    end

    defp validate_options(options) do
      options = Map.new(options)

      with options <- Map.merge(default_options(), options),
           {:ok, options} <- validate_locale(options.locale, options),
           {:ok, options} <- validate_selected(options.selected, options),
           {:ok, options} <- validate_locales(options.locales, options),
           {:ok, options} <- validate_identity_locales(options.locale, options) do
        options
      end
    end

    defp default_options do
      Map.new(
        locales: nil,
        locale: Cldr.get_locale(),
        backend: nil,
        collator: &default_collator/1,
        mapper: &{&1.display_name, &1.locale},
        selected: nil,
        add_likely_subtags: false,
        compound_locale: false,
        prefer: :default
      )
    end

    defp default_collator(locales) do
      Enum.sort(locales, &default_comparator/2)
    end

    # Note that this is not a unicode aware comparison
    defp default_comparator(locale_1, locale_2) do
      locale_1.display_name < locale_2.display_name
    end

    defp validate_selected(nil, options) do
      {:ok, options}
    end

    defp validate_selected(selected, options) do
      list_options =
        options
        |> Map.take([:add_likely_subtags])
        |> Map.to_list()

      backend = options[:backend]

      with {:ok, locale} <- Locale.canonical_language_tag(selected, backend, list_options) do
        {:ok, Map.put(options, :selected, locale)}
      end
    end

    # Return a list of validated locales or an error
    defp validate_locales(nil, options) do
      default_locales = Cldr.known_locale_names(options[:backend]) -- @dont_include_default
      validate_locales(default_locales, options)
    end

    defp validate_locales(locales, options) when is_list(locales) do
      list_options =
        options
        |> Map.take([:add_likely_subtags])
        |> Map.to_list()

      backend = options[:backend]

      Enum.reduce_while(locales, [], fn locale, acc ->
        case Locale.canonical_language_tag(to_string(locale), backend, list_options) do
          {:ok, locale} -> {:cont, [locale | acc]}
          {:error, reason} -> {:halt, {:error, reason}}
        end
      end)
      |> case do
        {:error, reason} -> {:error, reason}
        locales -> {:ok, Map.put(options, :locales, locales)}
      end
    end

    defp validate_identity_locales(@identity, options) do
      Enum.reduce_while(options.locales, {:ok, options}, fn locale, acc ->
        case Cldr.validate_locale(locale, options.backend) do
          {:ok, _locale} -> {:cont, acc}
          {:error, reason} -> {:halt, {:error, reason}}
        end
      end)
    end

    defp validate_identity_locales(_locale, options) do
      options
    end

    defp validate_locale(:identity, options) do
      {_locale, backend} = Cldr.locale_and_backend_from(nil, options[:backend])
      {:ok, Map.put(options, :backend, backend)}
    end

    defp validate_locale(locale, options) do
      {locale, backend} = Cldr.locale_and_backend_from(locale, options.backend)

      with {:ok, locale} <- Cldr.validate_locale(locale, backend) do
        options
        |> Map.put(:locale, locale)
        |> Map.put(:backend, locale.backend)
        |> wrap(:ok)
      end
    end

    defp wrap(term, atom) do
      {atom, term}
    end

    defp maybe_include_selected_locale(%{selected: nil} = options) do
      options
    end

    defp maybe_include_selected_locale(%{locales: locales, selected: selected} = options) do
      if Enum.any?(locales, &(&1.canonical_locale_name == selected.canonical_locale_name)) do
        options
      else
        Map.put(options, :locales, [selected | locales])
      end
    end

    defp build_locale_options(options) when is_map(options) do
      options = maybe_include_selected_locale(options)

      locales = Map.fetch!(options, :locales)
      locale = Map.fetch!(options, :locale)
      collator = Map.fetch!(options, :collator)
      mapper = Map.fetch!(options, :mapper)
      display_options = Map.take(options, [:prefer, :compound_locale]) |> Map.to_list()

      locales
      |> Enum.map(&display_name(&1, locale, display_options))
      |> collator.()
      |> Enum.map(&mapper.(&1))
    end

    defp display_name(locale, @identity, options) do
      if is_nil(locale.cldr_locale_name) do
        raise Cldr.UnknownLocaleError, "The locale #{locale.canonical_locale_name} is not known"
      end

      options = Keyword.put(options, :locale, locale)
      display_name = Cldr.LocaleDisplay.display_name!(locale, options)
      %{locale: locale.canonical_locale_name, display_name: display_name, language_tag: locale}
    end

    defp display_name(locale, _in_locale, options) do
      display_name = Cldr.LocaleDisplay.display_name!(locale, options)
      %{locale: locale.canonical_locale_name, display_name: display_name, language_tag: locale}
    end

    defimpl Phoenix.HTML.Safe, for: Cldr.LanguageTag do
      def to_iodata(language_tag) do
        language_tag.canonical_locale_name
      end
    end
  end
end