lib/cldr/number/rbnf/processor.ex

defmodule Cldr.Rbnf.Processor do
  @moduledoc """
  Macro to define the interpreter for the compiled RBNF rules specific to a rule group (Ordinal,
  Spellout, NumberingSystem)

  """
  defmacro __using__(opts) do
    backend = opts[:backend]
    ordinal_module = Module.concat(backend, Number.Ordinal)
    cardinal_module = Module.concat(backend, Number.Cardinal)
    spellout_module = Module.concat(backend, Rbnf.Spellout)

    quote location: :keep do
      alias Cldr.Number
      alias Cldr.Digits
      import Cldr.Rbnf.Processor
      Module.put_attribute(__MODULE__, :backend, unquote(backend))

      defp do_rule(number, locale, function, rule, parsed) do
        results =
          Enum.map(parsed, fn {operation, argument} ->
            do_operation(operation, number, locale, function, rule, argument)
          end)

        if Enum.any?(results, fn
             {:error, _} -> true
             _ -> false
           end) do
          {:error, collect_errors(results)}
        else
          :erlang.iolist_to_binary(results)
        end
      end

      defp collect_errors(results) do
        results
        |> Enum.map(fn
          {_, v} -> v
          other -> other
        end)
        |> Enum.join(", ")
      end

      defp do_operation(:literal, _number, _locale, _function, _rule, string) do
        string
      end

      defp do_operation(:modulo, number, locale, function, rule, nil)
           when is_number(number) and number < 0 do
        apply(__MODULE__, function, [abs(number), locale])
      end

      defp do_operation(:modulo, number, locale, function, rule, {:format, format})
           when is_number(number) and number < 0 do
        Cldr.Number.to_string!(abs(number), unquote(backend), locale: locale, format: format)
      end

      defp do_operation(:modulo, number, locale, function, rule, nil)
           when is_integer(number) do
        mod = number - div(number, rule.divisor) * rule.divisor
        apply(__MODULE__, function, [mod, locale])
      end

      # For Fractional rules we format the integral part
      defp do_operation(:modulo, number, locale, function, _rule, nil)
           when is_float(number) do
        format_fraction(number, locale)
      end

      defp do_operation(:modulo, number, locale, _function, rule, {:rule, rule_name}) do
        mod = number - div(number, rule.divisor) * rule.divisor
        apply(__MODULE__, rule_name, [mod, locale])
      end

      defp do_operation(:modulo, number, locale, function, rule, {:format, format}) do
        mod = number - div(number, rule.divisor) * rule.divisor
        Cldr.Number.to_string!(mod, unquote(backend), locale: locale, format: format)
      end

      # For Fractional rules we format the fraction as individual digits.
      defp do_operation(:quotient, number, locale, function, rule, nil)
           when is_float(number) do
        apply(__MODULE__, function, [trunc(number), locale])
      end

      defp do_operation(:quotient, number, locale, function, rule, nil) do
        divisor = div(number, rule.divisor)
        apply(__MODULE__, function, [divisor, locale])
      end

      defp do_operation(:quotient, number, locale, _function, rule, {:rule, rule_name}) do
        divisor = div(number, rule.divisor)
        apply(__MODULE__, rule_name, [divisor, locale])
      end

      defp do_operation(:call, number, locale, _function, _rule, {:format, format}) do
        Cldr.Number.to_string!(number, unquote(backend), locale: locale, format: format)
      end

      defp do_operation(:call, number, locale, _function, _rule, {:rule, rule_name}) do
        apply(__MODULE__, rule_name, [number, locale])
      end

      defp do_operation(:ordinal, number, locale, _function, _rule, plurals) do
        plural = unquote(ordinal_module).plural_rule(number, locale)
        Map.get(plurals, plural) || Map.get(plurals, :other)
      end

      defp do_operation(:cardinal, number, locale, _function, _rule, plurals) do
        plural = unquote(cardinal_module).plural_rule(number, locale)
        Map.get(plurals, plural) || Map.get(plurals, :other)
      end

      defp do_operation(:conditional, number, locale, function, rule, argument) do
        mod = number - div(number, rule.divisor) * rule.divisor

        if mod > 0 do
          do_rule(mod, locale, function, rule, argument)
        else
          ""
        end
      end

      defp format_fraction(number, locale) do
        fraction =
          number
          |> Digits.fraction_as_integer()
          |> Integer.to_string()
          |> String.split("", trim: true)
          |> Enum.map(&String.to_integer/1)
          |> Enum.map(&unquote(spellout_module).spellout_cardinal(&1, locale))
          |> Enum.join(" ")
      end

      @before_compile Cldr.Rbnf.Processor
    end
  end

  @public_rulesets :public_rulesets
  def define_rules(rule_group_name, backend, env) do
    Module.register_attribute(env.module, @public_rulesets, [])

    enumerate_rules(rule_group_name, backend, fn
      rule_group, locale, "public", :error ->
        define_rule(:error, nil, rule_group, locale, nil)
        |> Code.eval_quoted([], env)

      _rule_group, _locale, "private", :error ->
        nil

      rule_group, locale, "public", :redirect ->
        define_rule(:redirect, backend, rule_group, locale, nil)
        |> Code.eval_quoted([], env)

      _rule_group, _locale, "private", :redirect ->
        nil

      rule_group, locale, access, rule ->
        function_name = Cldr.Rbnf.force_valid_function_name(rule_group)
        {:ok, parsed} = Cldr.Rbnf.Rule.parse(rule.definition)

        function_body = rule_body(locale, rule_group, rule, parsed, backend)

        rule.base_value
        |> define_rule(rule.range, function_name, locale, function_body)
        |> add_function_to_exports(access, env.module, locale)
        |> Code.eval_quoted([], env)
    end)
  end

  defp enumerate_rules(rule_group_type, backend, fun) do
    all_rules = Cldr.Rbnf.for_all_locales(backend)[rule_group_type]

    unless is_nil(all_rules) do
      for {locale_name, _rule_group} <- all_rules do
        for {rule_group, %{access: access, rules: rules}} <- all_rules[locale_name] do
          for rule <- rules do
            fun.(rule_group, locale_name, access, rule)
          end

          fun.(rule_group, locale_name, access, :redirect)
          fun.(rule_group, locale_name, access, :error)
        end
      end
    end
  end

  # If we are provided with a Decimal integer then we can call the
  # equivalent integer function without loss of precision
  defp define_rule(:error, _range, rule_group, locale_name, _body) do
    quote location: :keep do
      def unquote(rule_group)(
            %Decimal{exp: 0, coef: number},
            %Cldr.LanguageTag{rbnf_locale_name: unquote(locale_name)} = locale
          ) do
        unquote(rule_group)(number, locale)
      end

      def unquote(rule_group)(number, %Cldr.LanguageTag{rbnf_locale_name: unquote(locale_name)}) do
        {:error, rbnf_rule_error(number, unquote(rule_group), unquote(locale_name))}
      end
    end
  end

  defp define_rule(:redirect, backend, rule_group, locale_name, _body) do
    quote location: :keep do
      def unquote(rule_group)(number, unquote(locale_name)) do
        with {:ok, locale} <- Module.concat(unquote(backend), Locale).new(unquote(locale_name)) do
          unquote(rule_group)(number, locale)
        end
      end
    end
  end

  defp define_rule("-x", _range, rule_group, locale_name, body) do
    quote location: :keep do
      def unquote(rule_group)(number, %Cldr.LanguageTag{rbnf_locale_name: unquote(locale_name)})
          when Kernel.and(is_number(number), number < 0),
          do: unquote(body)
    end
  end

  # Improper fraction rule
  defp define_rule("x.x", _range, rule_group, locale_name, body) do
    quote location: :keep do
      def unquote(rule_group)(number, %Cldr.LanguageTag{rbnf_locale_name: unquote(locale_name)})
          when is_float(number),
          do: unquote(body)
    end
  end

  defp define_rule("x,x", range, rule_group, locale, body) do
    define_rule("x.x", range, rule_group, locale, body)
  end

  defp define_rule(0, "undefined", rule_group, locale_name, body) do
    quote location: :keep do
      def unquote(rule_group)(number, %Cldr.LanguageTag{rbnf_locale_name: unquote(locale_name)})
          when is_integer(number),
          do: unquote(body)
    end
  end

  defp define_rule(base_value, "undefined", rule_group, locale_name, body)
       when is_integer(base_value) do
    quote location: :keep do
      def unquote(rule_group)(number, %Cldr.LanguageTag{rbnf_locale_name: unquote(locale_name)})
          when Kernel.and(is_integer(number), number >= unquote(base_value)),
          do: unquote(body)
    end
  end

  defp define_rule(base_value, range, rule_group, locale_name, body)
       when is_integer(range) and is_integer(base_value) do
    quote location: :keep do
      def unquote(rule_group)(number, %Cldr.LanguageTag{rbnf_locale_name: unquote(locale_name)})
          when Kernel.and(
                 is_integer(number),
                 Kernel.and(number >= unquote(base_value), number < unquote(range))
               ),
          do: unquote(body)
    end
  end

  defp define_rule("Inf", _range, _rule_group, _locale_name, _body) do
    {:error, "Infinite rule sets are not implemented"}
  end

  defp define_rule("NaN", _range, _rule_group, _locale_name, _body) do
    {:error, "NaN rule sets are not implemented"}
  end

  defp define_rule("0.x", _range, _rule_group, _locale_name, _body) do
    {:error, "Proper Fraction rule sets are not implemented"}
  end

  defp define_rule("x.0", _range, _rule_group, _locale_name, _body) do
    {:error, "Master rule sets are not implemented"}
  end

  # Get the AST of the rule body
  defp rule_body(locale_name, rule_group, rule, parsed, _backend) do
    locale =
      Cldr.Config.all_language_tags()
      |> Map.get(locale_name)

    quote location: :keep do
      do_rule(
        number,
        unquote(Macro.escape(locale)),
        unquote(rule_group),
        unquote(Macro.escape(rule)),
        unquote(Macro.escape(parsed))
      )
    end
  end

  # Keep track of the public rulesets per locale so we can introspect the
  # public interface
  defp add_function_to_exports(
         {:def, _aliases, [{:when, _, [{name, _, _} | _]} | _]} = function,
         "public",
         module,
         locale
       ) do
    public_rulesets = Module.get_attribute(module, @public_rulesets) || %{}
    locale_public_rulesets = [name | Map.get(public_rulesets, locale) || []]

    Module.put_attribute(
      module,
      @public_rulesets,
      Map.put(public_rulesets, locale, Enum.uniq(locale_public_rulesets))
    )

    function
  end

  defp add_function_to_exports(other, _access, _module, _locale) do
    other
  end

  def rbnf_rule_error(number, rule_group, locale_name) do
    {
      Cldr.Rbnf.NoRuleForNumber,
      "rule group #{inspect(rule_group)} for locale #{inspect(locale_name)} does not " <>
        "know how to process #{inspect(number)}"
    }
  end

  defmacro __before_compile__(env) do
    module = env.module

    backend =
      module
      |> Module.get_attribute(:backend)

    rule_sets =
      module
      |> Module.get_attribute(:public_rulesets)

    all_rule_sets =
      rule_sets
      |> Map.values()
      |> List.flatten()
      |> Enum.uniq()
      |> Enum.sort()

    rule_sets = Macro.escape(rule_sets)

    quote location: :keep,
          bind_quoted: [
            rule_sets: rule_sets,
            all_rule_sets: all_rule_sets,
            backend: backend,
            module: module
          ] do
      # A map of rule sets by locale
      def rule_sets do
        unquote(Macro.escape(rule_sets))
      end

      # All rule sets for a locale
      def rule_sets(%Cldr.LanguageTag{rbnf_locale_name: rbnf_locale_name}) do
        rule_sets(rbnf_locale_name)
      end

      def rule_sets(rbnf_locale_name) when is_atom(rbnf_locale_name) do
        Map.get(rule_sets(), rbnf_locale_name)
      end

      def rule_sets(rbnf_locale_name) when is_binary(rbnf_locale_name) do
        rbnf_locale_name
        |> String.to_existing_atom()
        |> rule_sets
      rescue
        ArgumentError ->
          nil
      end

      # All rule sets for all locales
      def all_rule_sets do
        unquote(all_rule_sets)
      end

      # Return an error for a valid rule set which
      # is not supported for either the locale or
      # the number

      for rule_group <- all_rule_sets do
        @dialyzer {:nowarn_function, [{rule_group, 2}]}
        def unquote(rule_group)(number, locale_name)
            when is_atom(locale_name) or is_binary(locale_name) do
          with {:ok, locale} <- unquote(backend).validate_locale(locale_name) do
            unquote(rule_group)(number, locale)
          end
        end

        def unquote(rule_group)(number, %Cldr.LanguageTag{rbnf_locale_name: rbnf_locale_name}) do
          {:error, rbnf_rule_error(number, unquote(rule_group), rbnf_locale_name)}
        end

        # NumberSystem rules are only in the root locale so
        # lets make it easier to use them by defaulting the locale
        if hd(Enum.reverse(Module.split(module))) == "NumberSystem" do
          def unquote(rule_group)(number) do
            unquote(rule_group)(number, unquote(Cldr.Config.root_locale_name()))
          end
        end
      end
    end
  end
end