defmodule Localize.Number.Format.Compiler do @moduledoc """ Compiles number format patterns into metadata for fast runtime interpretation. Number format patterns like `"#,##0.###"` or `"¤#,##0.00"` are parsed using a leex/yecc lexer-parser and then analysed to extract formatting metadata (digit counts, grouping, rounding, etc.) into a `Localize.Number.Format.Meta` struct. """ import Kernel, except: [length: 1] alias Localize.Number.Format.Meta @decimal_separator "." @grouping_separator "," @significant_digit "@" @digit_omit_zeroes "#" @digits "[0-9]" @default_pad_char " " @default_round_nearest 0 @max_integer_digits 0 @min_integer_digits 1 @min_fraction_digits 0 @rounding_pattern "[" <> @digit_omit_zeroes <> @significant_digit <> @grouping_separator <> "]" # ── Placeholder symbols ────────────────────────────────────── @doc false def placeholder(:decimal), do: "." def placeholder(:group), do: "," def placeholder(:exponent), do: "E" def placeholder(:plus), do: "+" def placeholder(:minus), do: "-" def placeholder(:currency), do: "¤" def placeholder(:exponent_sign), do: "+" # ── Tokenize and parse ────────────────────────────────────── @doc """ Tokenizes a number format definition string. ### Arguments * `definition` is a number format pattern string. ### Returns * `{:ok, tokens, end_line}` or an error tuple. """ @spec tokenize(String.t()) :: {:ok, list(), integer()} | {:error, term(), integer()} def tokenize(definition) when is_binary(definition) do definition |> String.to_charlist() |> :decimal_formats_lexer.string() end @doc """ Parses a number format definition into a keyword list of positive and negative format elements. ### Arguments * `definition` is a number format pattern string or a list of tokens from `tokenize/1`. ### Returns * `{:ok, format}` where `format` is a keyword list with `:positive` and `:negative` keys. * `{:error, reason}` if parsing fails. ### Examples iex> {:ok, parsed} = Localize.Number.Format.Compiler.parse("#,##0.###") iex> parsed[:positive] [format: "#,##0.###"] """ @spec parse(String.t() | list()) :: {:ok, Keyword.t()} | {:error, term()} def parse(tokens) when is_list(tokens) do :decimal_formats_parser.parse(tokens) end def parse("") do {:error, "empty format string cannot be compiled"} end def parse(definition) when is_binary(definition) do {:ok, tokens, _end_line} = tokenize(definition) :decimal_formats_parser.parse(tokens) end def parse(nil) do {:error, "no format string or token list provided"} end # ── Compile ────────────────────────────────────────────────── @doc """ Compiles a number format definition into metadata. Parses the format string, analyses it, and returns the metadata struct used to drive number formatting. ### Arguments * `definition` is a number format pattern string. ### Returns * `{:ok, meta}` where `meta` is a `Localize.Number.Format.Meta.t()`. * `{:error, reason}` if parsing fails. """ @spec compile(String.t()) :: {:ok, Meta.t()} | {:error, String.t()} def compile(definition) when is_binary(definition) do with {:ok, format} <- parse(definition), {:ok, meta_data} <- format_to_metadata(format) do {:ok, meta_data} else {:error, {_line, _parser, [message, context]}} -> {:error, "Decimal format compiler: #{message}#{Enum.join(context)}"} {:error, message} -> {:error, message} end end @doc """ Extracts metadata from a parsed format. ### Arguments * `format` is either a format pattern string or a parsed keyword list from `parse/1`. ### Returns * `{:ok, meta}` where `meta` is a `Localize.Number.Format.Meta.t()`. """ @spec format_to_metadata(String.t() | Keyword.t()) :: {:ok, Meta.t()} | {:error, String.t()} def format_to_metadata(format) when is_binary(format) do with {:ok, parsed} <- parse(format) do format_to_metadata(parsed) else {:error, {_line, _parser, [message, context]}} -> {:error, "Decimal format compiler: #{message}#{Enum.join(context)}"} {:error, reason} when is_binary(reason) -> {:error, reason} end end def format_to_metadata(format) when is_list(format) do metadata = analyse(format, format[:positive][:format]) case validate_scientific_constraints(metadata) do :ok -> {:ok, metadata} {:error, _} = error -> error end end @doc """ Same as `format_to_metadata/1` but raises on error. ### Arguments * `format` is either a format pattern string or a parsed keyword list. ### Returns * A `Localize.Number.Format.Meta.t()` struct. ### Raises * Raises `ArgumentError` if the format cannot be parsed. """ @spec format_to_metadata!(String.t() | Keyword.t()) :: Meta.t() def format_to_metadata!(format) do case format_to_metadata(format) do {:ok, metadata} -> metadata {:error, reason} -> raise ArgumentError, reason end end @doc """ Returns a regex that can be used to split a number format or number string into integer, fraction, and exponent parts. """ @integer_digits "(?[@#0-9,]+)" @fraction_digits "([.](?[#0-9,]+))?" @exponent "([Ee](?[+-])?(?[0-9]+))?" @format_regex @integer_digits <> @fraction_digits <> @exponent def number_match_regex do ~r/#{@format_regex}/ end # ── Analysis ───────────────────────────────────────────────── defp analyse(format, positive_format) do format_parts = split_format(positive_format) min_int = required_integer_digits(format_parts) max_int = max_integer_digits(format_parts) meta = %Meta{ integer_digits: %{min: min_int, max: max_int}, fractional_digits: %{ min: required_fraction_digits(format_parts), max: optional_fraction_digits(format_parts) + required_fraction_digits(format_parts) }, significant_digits: significant_digits(format_parts), exponent_digits: exponent_digits(format_parts), exponent_sign: exponent_sign(format_parts), engineering_grouping: engineering_grouping(format_parts, min_int, max_int), scientific_rounding: scientific_rounding(format_parts), grouping: grouping(format_parts), round_nearest: round_nearest(format_parts), padding_length: padding_length(format[:positive][:pad], format), padding_char: padding_char(format), multiplier: multiplier(format), currency: currency_location(format[:positive]), format: format } reconcile_significant_and_scientific_digits(meta) end # ── Format splitting ───────────────────────────────────────── defp split_format(nil), do: %{} defp split_format(format) do parts = Regex.named_captures(~r/#{@format_regex}/, format) parts |> Map.put("compact_integer", String.replace(parts["integer"], @grouping_separator, "")) |> Map.put("compact_fraction", String.replace(parts["fraction"], @grouping_separator, "")) end # ── Integer digit extraction ──────────────────────────────── @digits_match "(?" <> @digits <> "+)" defp required_integer_digits(%{"compact_integer" => integer_format}) do if captures = Regex.named_captures(~r/#{@digits_match}/, integer_format) do String.length(captures["digits"]) else @min_integer_digits end end defp required_integer_digits(_), do: @min_integer_digits # For non-scientific patterns we leave `max_integer_digits` at 0 — the # sentinel meaning "no upper limit", consumed by the formatter's # `set_max_integer_digits/2` (see `Localize.Number.Formatter.Decimal`). # # For scientific patterns (`E` present) TR35 uses the count of integer # placeholders in the pattern to drive both the mantissa width and the # engineering grouping. For `0.###E0` it is 1 (scientific); for # `##0.###E0` it is 3 (engineering with exponent ≡ 0 mod 3); for # `00.###E0` it is 2 (fixed-width mantissa, exponent shift = min - 1). # # Significant-digit patterns (`@@##E0`) are handled by # `reconcile_significant_and_scientific_digits/1` after analysis; we do # not count `@` placeholders here because TR35 forces those to a # 1-integer-digit mantissa regardless of the `@` count. defp max_integer_digits(format_parts) do cond do exponent_digits(format_parts) == 0 -> @max_integer_digits has_significant_placeholder?(format_parts) -> @max_integer_digits true -> pattern_integer_placeholder_count(format_parts) end end defp has_significant_placeholder?(%{"compact_integer" => integer_format}) when is_binary(integer_format) do String.contains?(integer_format, @significant_digit) end defp has_significant_placeholder?(_), do: false defp pattern_integer_placeholder_count(%{"compact_integer" => integer_format}) when is_binary(integer_format) do String.length(integer_format) end defp pattern_integer_placeholder_count(_), do: 0 # TR35 engineering rule (one sentence): when `maxIntegerDigits > # minIntegerDigits`, the exponent is forced to a multiple of # `maxIntegerDigits`. Otherwise the format is pure scientific (mantissa # has exactly `minIntegerDigits` integer digits) and no grouping # constraint applies, so we return 0. defp engineering_grouping(format_parts, min_int, max_int) do cond do exponent_digits(format_parts) == 0 -> 0 max_int > min_int -> max_int true -> 0 end end # ── Fraction digit extraction ─────────────────────────────── defp required_fraction_digits(%{"compact_fraction" => nil}), do: 0 defp required_fraction_digits(%{"compact_fraction" => fraction_format}) do if captures = Regex.named_captures(~r/#{@digits_match}/, fraction_format) do String.length(captures["digits"]) else @min_fraction_digits end end defp required_fraction_digits(_), do: @min_fraction_digits @hashes_match "(?[" <> @digit_omit_zeroes <> "]+)" defp optional_fraction_digits(%{"compact_fraction" => ""}), do: 0 defp optional_fraction_digits(%{"compact_fraction" => fraction_format}) do if captures = Regex.named_captures(~r/#{@hashes_match}/, fraction_format) do String.length(captures["hashes"]) else 0 end end defp optional_fraction_digits(_), do: 0 # ── Exponent extraction ──────────────────────────────────── defp exponent_digits(%{"exponent_digits" => ""}), do: 0 defp exponent_digits(%{"exponent_digits" => exp}), do: String.length(exp) defp exponent_digits(_), do: 0 @doc false def exponent_sign(%{"exponent_sign" => ""}), do: false def exponent_sign(%{"exponent_sign" => _}), do: true def exponent_sign(_), do: false # ── Scientific rounding ──────────────────────────────────── @scientific_match "(?0[0#]*)?" defp scientific_rounding(%{"exponent_digits" => ""}), do: 0 defp scientific_rounding(%{ "compact_integer" => integer_format, "compact_fraction" => fraction_format }) do format = integer_format <> fraction_format if captures = Regex.named_captures(~r/#{@scientific_match}/, format) do String.length(captures["scientific_rounding"]) else 0 end end defp scientific_rounding(_), do: 0 # ── Grouping extraction ──────────────────────────────────── defp grouping(%{"integer" => integer_format, "fraction" => fraction_format}) do %{integer: integer_grouping(integer_format), fraction: fraction_grouping(fraction_format)} end defp grouping(_) do %{ integer: %{first: @max_integer_digits, rest: @max_integer_digits}, fraction: %{first: @max_integer_digits, rest: @max_integer_digits} } end defp integer_grouping(format) do [_drop | groups] = String.split(format, @grouping_separator) grouping = groups |> Enum.reverse() |> Enum.slice(0..1) |> Enum.map(&String.length/1) case grouping do [first, rest] -> %{first: first, rest: rest} [first] -> %{first: first, rest: first} _ -> %{first: @max_integer_digits, rest: @max_integer_digits} end end defp fraction_grouping(format) do case String.split(format, @grouping_separator) do [_] -> %{first: @max_integer_digits, rest: @max_integer_digits} [group | _] -> %{first: String.length(group), rest: String.length(group)} end end # ── Significant digits ───────────────────────────────────── @min_significant_digits "(?" <> @significant_digit <> "+)" @max_significant_digits "(?" <> @digit_omit_zeroes <> "*)?" @leading_digits "([" <> @digit_omit_zeroes <> @grouping_separator <> "]*)?" @significant_digits_match @leading_digits <> @min_significant_digits <> @max_significant_digits defp significant_digits(%{ "compact_integer" => integer_format, "compact_fraction" => fraction_format }) do format = integer_format <> fraction_format if captures = Regex.named_captures(~r/#{@significant_digits_match}/, format) do minimum = String.length(captures["ats"]) maximum = minimum + String.length(captures["hashes"]) %{min: minimum, max: maximum} else %{min: 0, max: 0} end end defp significant_digits(_), do: %{min: 0, max: 0} # ── Rounding ─────────────────────────────────────────────── defp round_nearest(%{"integer" => integer_format, "fraction" => fraction_format}) do format = (integer_format <> @decimal_separator <> fraction_format) |> String.replace(~r/#{@rounding_pattern}/, "") |> String.trim_trailing(@decimal_separator) case Float.parse(format) do :error -> @default_round_nearest {rounding, ""} -> rounding end end defp round_nearest(_), do: @default_round_nearest # ── Padding ──────────────────────────────────────────────── defp padding_length(nil, _format), do: 0 defp padding_length(_pad, format) do String.length(format[:positive][:format]) end @doc false def padding_char(format) do format[:positive][:pad] || @default_pad_char end # ── Multiplier ───────────────────────────────────────────── defp multiplier(format) do cond do Keyword.has_key?(format[:positive], :percent) -> 100 Keyword.has_key?(format[:positive], :permille) -> 1000 true -> 1 end end # ── Currency location ────────────────────────────────────── defp currency_location([{:currency, count} | _rest]) do %{location: :first, symbol_count: count} end defp currency_location(parts) do location = Enum.reduce_while(parts, 0, fn {:currency, count}, offset -> {:halt, %{location: offset, symbol_count: count}} _other, offset -> {:cont, offset + 1} end) if location == 0 do nil else adjust_location(location, Kernel.length(parts)) end end defp adjust_location(%{location: offset} = location, count) when count == offset + 1 do %{location | location: :last} end defp adjust_location(location, _count), do: location # ── Scientific-pattern validation ────────────────────────── # TR35 forbids grouping separators in scientific patterns: # `#,##0.###E0` is a malformed format. The grouping decision is # made on the mantissa (which has only `min..max` integer digits, # at most), so the comma is silently ignored at output time and the # result is misleading. Reject these patterns at compile time with a # clear error so the caller can fix the pattern rather than # debugging a wrong value at runtime. # # **Breaking change in 0.41.0.** Patterns like `#,##0.###E0` that # previously silently round-tripped to the no-grouping form will now # return `{:error, …}` from `Localize.Number.to_string/2`. defp validate_scientific_constraints(%Meta{exponent_digits: 0}), do: :ok defp validate_scientific_constraints(%Meta{ exponent_digits: e, grouping: %{integer: %{first: first}} }) when e > 0 and first > 0 do {:error, "Scientific number patterns must not contain a grouping separator. " <> "TR35 disallows grouping in scientific patterns because the mantissa " <> "never has enough integer digits to trigger a group; the separator is " <> "silently ignored at output time and the result is misleading. " <> "Remove the comma from the pattern, or use a non-scientific pattern."} end defp validate_scientific_constraints(_meta), do: :ok # ── Reconciliation ────────────────────────────────────────── # TR35 significant-digit scientific patterns. Per the spec, `@@###E0` # is equivalent to `0.0###E0` with the integer part fixed at 1 digit # and the fraction width = (max_sig - 1). Apply the canonical # transformation here so the runtime formatter — which already # handles `integer_digits`, `fractional_digits`, and # `scientific_rounding` — does not need a separate `@`-aware branch. # # integer_digits → %{min: 1, max: 1} # fractional_digits → %{min: min_sig - 1, max: max_sig - 1} # significant_digits → cleared (so `round_to_significant_digits/2` # is a no-op; mantissa rounding flows via # `scientific_rounding`) # engineering_grouping → 0 (max == min, no engineering shift) # scientific_rounding → max_sig (the TR35 "n+1 significant digits" # rule, where n is max fraction = max_sig - 1) defp reconcile_significant_and_scientific_digits(%Meta{} = meta) do if meta.significant_digits[:min] > 0 && meta.exponent_digits > 0 do min_sig = meta.significant_digits[:min] max_sig = meta.significant_digits[:max] %{ meta | integer_digits: %{min: 1, max: 1}, fractional_digits: %{min: max(min_sig - 1, 0), max: max(max_sig - 1, 0)}, significant_digits: %{min: 0, max: 0}, engineering_grouping: 0, scientific_rounding: max_sig } else meta end end end