defmodule Localize.Inputs.Number.Parser do
@moduledoc """
Front-door parser for locale-aware number form input.
Delegates to `Localize.Number.Parser.parse/2`. The input layer
does no parsing of its own — this module is a thin policy
layer that adds form-input tolerance (paste artefacts,
accounting parentheses, NBSP normalisation) before forwarding.
"""
@doc """
Parses a locale-formatted number from a user-typed string.
### Arguments
* `string` is the raw user input.
* `options` is a keyword list of options.
### Options
* `:locale` — the locale to interpret the string under.
Defaults to `Localize.get_locale/0`.
* `:integer` — when `true`, only integers are accepted.
### Returns
* `{:ok, Decimal.t()}` (or `{:ok, integer()}` when
`integer: true`).
* `{:ok, nil}` for blank input.
* `{:error, Exception.t() | {module(), String.t()}}` on parse
failure.
### Examples
iex> Localize.Inputs.Number.Parser.parse_number("1,234.56", locale: :en)
{:ok, Decimal.new("1234.56")}
iex> Localize.Inputs.Number.Parser.parse_number("1.234,56", locale: :de)
{:ok, Decimal.new("1234.56")}
iex> Localize.Inputs.Number.Parser.parse_number("", locale: :en)
{:ok, nil}
iex> Localize.Inputs.Number.Parser.parse_number("(1,234.56)", locale: :en)
{:ok, Decimal.new("-1234.56")}
"""
@spec parse_number(String.t() | nil, Keyword.t()) ::
{:ok, Decimal.t() | integer() | nil} | {:error, term()}
def parse_number(string, options \\ [])
def parse_number(nil, _options), do: {:ok, nil}
def parse_number("", _options), do: {:ok, nil}
def parse_number(string, options) when is_binary(string) do
integer? = Keyword.get(options, :integer, false)
parser_options = Keyword.take(options, [:locale, :number_system])
parser_options =
if integer?,
do: Keyword.put(parser_options, :number, :integer),
else: Keyword.put_new(parser_options, :number, :decimal)
case Localize.Number.Parser.parse(normalize(string), parser_options) do
{:ok, value} -> {:ok, value}
{:error, _} = error -> error
end
end
@doc """
Normalises a parsed value to its canonical period-decimal
string form — what a JS-driven form submission expects.
### Arguments
* `value` is a `Decimal`, integer, or `nil`.
### Returns
* A binary in canonical period-decimal form.
* `nil` when the input is `nil`.
### Examples
iex> Localize.Inputs.Number.Parser.to_canonical(Decimal.new("1234.56"))
"1234.56"
iex> Localize.Inputs.Number.Parser.to_canonical(nil)
nil
"""
@spec to_canonical(Decimal.t() | integer() | nil) :: String.t() | nil
def to_canonical(nil), do: nil
def to_canonical(value) when is_integer(value), do: Integer.to_string(value)
def to_canonical(%Decimal{} = value), do: Decimal.to_string(value, :normal)
@doc false
# Strip common paste artefacts. Pure, no locale awareness.
@spec normalize(String.t()) :: String.t()
def normalize(string) when is_binary(string) do
string
|> String.replace(" ", " ")
|> String.replace(" ", " ")
|> String.replace(" ", " ")
|> String.replace("−", "-")
|> String.replace("–", "-")
|> String.replace("—", "-")
|> String.trim()
|> strip_accounting_parens()
end
defp strip_accounting_parens(string) do
case Regex.run(~r/^\((.*)\)$/u, string) do
[_, inner] -> "-" <> String.trim(inner)
_ -> string
end
end
end