defmodule Eyeon.TextParser.Number do @moduledoc false # Parses Ion numeric literals: integers (decimal, hex, binary), floats, and decimals. # Each function takes (binary, pos, opts) and returns {value, rest, pos} or {:error, reason}. alias Eyeon.TextDecoder.Timestamp alias Eyeon.TextDecoder.Validation @doc """ Parse a number starting with a digit or `-`. The sign (if negative) has already been consumed; `negative` indicates sign. `first_digit` is the first digit byte already consumed. """ @spec parse_number(byte(), binary(), non_neg_integer(), boolean()) :: {any(), binary(), non_neg_integer()} | {:error, String.t()} def parse_number(?0, rest, pos, negative) do case rest do <> -> parse_hex_int(rest2, pos + 1, negative) <> -> parse_hex_int(rest2, pos + 1, negative) <> -> parse_bin_int(rest3, pos + 1, negative) <> -> parse_bin_int(rest3, pos + 1, negative) _ -> # Could be 0, 0.xxx, 0eN, 0dN, or a timestamp starting with 0 parse_decimal_digits(rest, pos, negative, [?0], 1) end end def parse_number(digit, rest, pos, negative) when digit in ?1..?9 do parse_decimal_digits(rest, pos, negative, [digit], 1) end # Parse decimal integer digits, then disambiguate defp parse_decimal_digits(<>, pos, negative, acc, count) when digit in ?0..?9 do parse_decimal_digits(rest, pos + 1, negative, [digit | acc], count + 1) end defp parse_decimal_digits(<>, pos, negative, acc, count) when digit in ?0..?9 do # Underscore followed by digit parse_decimal_digits(rest, pos + 2, negative, [digit, ?_ | acc], count + 2) end # Underscore at end or followed by underscore - keep in acc for validation later defp parse_decimal_digits(<>, pos, negative, acc, count) do parse_decimal_digits(rest, pos + 1, negative, [?_ | acc], count + 1) end # After 4 digits, check for timestamp (hyphen after YYYY) defp parse_decimal_digits(<>, pos, negative, acc, _count) do digits_str = acc |> Enum.reverse() |> IO.iodata_to_binary() clean = String.replace(digits_str, "_", "") if String.length(clean) == 4 and not negative do # This is a timestamp: YYYY-... parse_timestamp_after_year(clean, rest, pos + 1) else # This is just an integer followed by a minus (separate tokens) finalize_integer(digits_str, negative, <>, pos) end end # Dot -> could be decimal or float defp parse_decimal_digits(<>, pos, negative, acc, _count) do int_str = acc |> Enum.reverse() |> IO.iodata_to_binary() parse_fractional(rest, pos + 1, negative, int_str) end # e/E -> float defp parse_decimal_digits(<>, pos, negative, acc, _count) when e in [?e, ?E] do int_str = acc |> Enum.reverse() |> IO.iodata_to_binary() parse_float_exponent(rest, pos + 1, negative, int_str, "") end # d/D -> decimal with exponent defp parse_decimal_digits(<>, pos, negative, acc, _count) when d in [?d, ?D] do int_str = acc |> Enum.reverse() |> IO.iodata_to_binary() parse_decimal_exponent(rest, pos + 1, negative, int_str, "") end # T after digits -> could be year-only timestamp (YYYYT) defp parse_decimal_digits(<>, pos, negative, acc, _count) do digits_str = acc |> Enum.reverse() |> IO.iodata_to_binary() clean = String.replace(digits_str, "_", "") if String.length(clean) == 4 and not negative do parse_timestamp_year_only(clean, rest, pos + 1) else finalize_integer(digits_str, negative, <>, pos) end end # End of digits -> integer defp parse_decimal_digits(rest, pos, negative, acc, _count) do digits_str = acc |> Enum.reverse() |> IO.iodata_to_binary() finalize_integer(digits_str, negative, rest, pos) end defp finalize_integer(digits_str, negative, rest, pos) do full_str = if negative, do: "-" <> digits_str, else: digits_str with :ok <- validate_underscores(full_str, :dec_int), :ok <- validate_no_leading_zeros(full_str), :ok <- expect_separator(rest, pos) do clean = String.replace(full_str, "_", "") {String.to_integer(clean), rest, pos} end end # Parse fractional part after "." defp parse_fractional(data, pos, negative, int_str) do {frac_str, rest, pos2} = scan_frac_digits(data, pos, []) case rest do <> when e in [?e, ?E] -> parse_float_exponent(rest2, pos2 + 1, negative, int_str, frac_str) <> when d in [?d, ?D] -> parse_decimal_exponent(rest2, pos2 + 1, negative, int_str, frac_str) _ -> # Decimal (no exponent) finalize_decimal(int_str, frac_str, "", negative, rest, pos2) end end defp scan_frac_digits(<>, pos, acc) when digit in ?0..?9 do scan_frac_digits(rest, pos + 1, [digit | acc]) end defp scan_frac_digits(<>, pos, acc) when digit in ?0..?9 do scan_frac_digits(rest, pos + 2, [digit, ?_ | acc]) end defp scan_frac_digits(<>, pos, acc) do scan_frac_digits(rest, pos + 1, [?_ | acc]) end defp scan_frac_digits(rest, pos, acc) do str = acc |> Enum.reverse() |> IO.iodata_to_binary() {str, rest, pos} end # Parse float exponent: [+-]?[0-9]+ defp parse_float_exponent(data, pos, negative, int_str, frac_str) do {sign, data2, pos2} = case data do <> -> {"+", rest, pos + 1} <> -> {"-", rest, pos + 1} _ -> {"", data, pos} end {exp_digits, rest, pos3} = scan_exp_digits(data2, pos2, []) if exp_digits == "" do {:error, "expected digits in float exponent"} else finalize_float(int_str, frac_str, sign <> exp_digits, negative, rest, pos3) end end # Parse decimal exponent: [+-]?[0-9]+ defp parse_decimal_exponent(data, pos, negative, int_str, frac_str) do {sign, data2, pos2} = case data do <> -> {"+", rest, pos + 1} <> -> {"-", rest, pos + 1} _ -> {"", data, pos} end {exp_digits, rest, pos3} = scan_exp_digits(data2, pos2, []) if exp_digits == "" do {:error, "expected digits in decimal exponent"} else finalize_decimal(int_str, frac_str, sign <> exp_digits, negative, rest, pos3) end end defp scan_exp_digits(<>, pos, acc) when digit in ?0..?9 do scan_exp_digits(rest, pos + 1, [digit | acc]) end defp scan_exp_digits(<>, pos, acc) when digit in ?0..?9 do scan_exp_digits(rest, pos + 2, [digit, ?_ | acc]) end defp scan_exp_digits(rest, pos, acc) do str = acc |> Enum.reverse() |> IO.iodata_to_binary() {str, rest, pos} end defp finalize_float(int_str, frac_str, exp_str, negative, rest, pos) do mantissa = if frac_str == "" do int_str else int_str <> "." <> frac_str end full = if negative, do: "-" <> mantissa <> "e" <> exp_str, else: mantissa <> "e" <> exp_str with :ok <- validate_underscores(full, :float), :ok <- validate_no_leading_zeros(full), :ok <- expect_separator(rest, pos) do clean = String.replace(full, "_", "") # Normalize for Elixir float parsing normalized = cond do String.match?(clean, ~r/\.[eE]/) -> String.replace(clean, ~r/\.([eE])/, ".0\\1") String.contains?(clean, ".") -> clean true -> String.replace(clean, ~r/([eE])/, ".0\\1") end case Float.parse(normalized) do {val, ""} -> {val, rest, pos} _ -> parse_float_overflow(normalized, rest, pos) end end end defp parse_float_overflow(str, rest, pos) do if String.starts_with?(str, "-") do {:neg_infinity, rest, pos} else {:infinity, rest, pos} end end defp finalize_decimal(int_str, frac_str, exp_str, negative, rest, pos) do mantissa = if frac_str == "" do int_str <> "." else int_str <> "." <> frac_str end full_for_validation = if exp_str == "" do if negative, do: "-" <> mantissa, else: mantissa else if negative, do: "-" <> mantissa <> "d" <> exp_str, else: mantissa <> "d" <> exp_str end with :ok <- validate_underscores(full_for_validation, :decimal), :ok <- validate_no_leading_zeros(full_for_validation), :ok <- expect_separator(rest, pos) do clean_mantissa = String.replace(if(negative, do: "-" <> mantissa, else: mantissa), "_", "") if exp_str == "" do dec = Decimal.new(clean_mantissa) {dec, rest, pos} else clean_exp = String.replace(exp_str, "_", "") explicit_exp = String.to_integer(clean_exp) mantissa_dec = Decimal.new( if(String.contains?(clean_mantissa, "."), do: clean_mantissa, else: clean_mantissa <> "." ) ) dec = %Decimal{mantissa_dec | exp: mantissa_dec.exp + explicit_exp} {dec, rest, pos} end end end # ---- Hex integer ---- defp parse_hex_int(data, pos, negative) do {hex_str, rest, pos2} = scan_hex_digits(data, pos, []) if hex_str == "" do {:error, "expected hex digits after 0x"} else full = if negative, do: "-0x" <> hex_str, else: "0x" <> hex_str with :ok <- validate_underscores(full, :hex_int), :ok <- expect_separator(rest, pos2) do clean = String.replace(hex_str, "_", "") result = String.to_integer(clean, 16) val = if negative, do: -result, else: result {val, rest, pos2} end end end defp scan_hex_digits(<>, pos, acc) when digit in ?0..?9 or digit in ?a..?f or digit in ?A..?F do scan_hex_digits(rest, pos + 1, [digit | acc]) end defp scan_hex_digits(<>, pos, acc) do scan_hex_digits(rest, pos + 1, [?_ | acc]) end defp scan_hex_digits(rest, pos, acc) do str = acc |> Enum.reverse() |> IO.iodata_to_binary() {str, rest, pos} end # ---- Binary integer ---- defp parse_bin_int(data, pos, negative) do {bin_str, rest, pos2} = scan_bin_digits(data, pos, []) if bin_str == "" do {:error, "expected binary digits after 0b"} else full = if negative, do: "-0b" <> bin_str, else: "0b" <> bin_str with :ok <- validate_underscores(full, :bin_int), :ok <- expect_separator(rest, pos2) do clean = String.replace(bin_str, "_", "") result = String.to_integer(clean, 2) val = if negative, do: -result, else: result {val, rest, pos2} end end end defp scan_bin_digits(<>, pos, acc) when digit in [?0, ?1] do scan_bin_digits(rest, pos + 1, [digit | acc]) end defp scan_bin_digits(<>, pos, acc) do scan_bin_digits(rest, pos + 1, [?_ | acc]) end defp scan_bin_digits(rest, pos, acc) do str = acc |> Enum.reverse() |> IO.iodata_to_binary() {str, rest, pos} end # ---- Timestamp parsing ---- # After YYYY- has been consumed defp parse_timestamp_after_year(year_str, data, pos) do # Expect MM case data do <> when m1 in ?0..?9 and m2 in ?0..?9 -> month_str = <> case rest do <> -> # YYYY-MMT (year-month) ts_str = year_str <> "-" <> month_str <> "T" validate_and_return_timestamp(ts_str, rest2, pos + 3) <> -> # YYYY-MM-DD... parse_timestamp_after_month(year_str, month_str, rest2, pos + 3) _ -> # Not a valid timestamp, treat YYYY as integer and - as separate # Actually this shouldn't happen since we only get here if we had YYYY- {:error, "invalid timestamp: expected day or 'T' after month"} end _ -> {:error, "invalid timestamp: expected month digits"} end end defp parse_timestamp_after_month(year_str, month_str, data, pos) do case data do <> when d1 in ?0..?9 and d2 in ?0..?9 -> day_str = <> case rest do <> -> # Could be YYYY-MM-DDT (date with T) or YYYY-MM-DDThh:mm... case rest2 do <> when h1 in ?0..?9 and h2 in ?0..?9 -> # Has time component parse_timestamp_time( year_str <> "-" <> month_str <> "-" <> day_str <> "T", rest2, pos + 3 ) _ -> # YYYY-MM-DDT (date only with trailing T) ts_str = year_str <> "-" <> month_str <> "-" <> day_str <> "T" validate_and_return_timestamp(ts_str, rest2, pos + 3) end _ -> # YYYY-MM-DD (date only, no T) ts_str = year_str <> "-" <> month_str <> "-" <> day_str validate_and_return_timestamp(ts_str, rest, pos + 2) end _ -> {:error, "invalid timestamp: expected day digits"} end end defp parse_timestamp_time(prefix, data, pos) do # Parse hh:mm case data do <> when h1 in ?0..?9 and h2 in ?0..?9 and m1 in ?0..?9 and m2 in ?0..?9 -> time_prefix = prefix <> <> case rest do <> when s1 in ?0..?9 and s2 in ?0..?9 -> # Has seconds seconds_str = <> case rest2 do <> -> # Fractional seconds {frac, rest4, pos4} = scan_frac_digits(rest3, pos + 9, []) ts_str = time_prefix <> ":" <> seconds_str <> "." <> frac parse_timestamp_offset(ts_str, rest4, pos4) _ -> ts_str = time_prefix <> ":" <> seconds_str parse_timestamp_offset(ts_str, rest2, pos + 8) end _ -> # hh:mm with no seconds parse_timestamp_offset(time_prefix, rest, pos + 5) end _ -> {:error, "invalid timestamp: expected time"} end end defp parse_timestamp_offset(prefix, data, pos) do case data do <> -> validate_and_return_timestamp(prefix <> "Z", rest, pos + 1) <> when sign in [?+, ?-] and oh1 in ?0..?9 and oh2 in ?0..?9 and om1 in ?0..?9 and om2 in ?0..?9 -> offset_str = <> validate_and_return_timestamp(prefix <> offset_str, rest, pos + 6) _ -> {:error, "invalid timestamp: expected timezone offset"} end end defp parse_timestamp_year_only(year_str, rest, pos) do ts_str = year_str <> "T" validate_and_return_timestamp(ts_str, rest, pos) end defp validate_and_return_timestamp(ts_str, rest, pos) do with :ok <- Timestamp.validate_timestamp(ts_str), :ok <- expect_separator(rest, pos) do {{:timestamp, ts_str}, rest, pos} end end # ---- Validation helpers (inline versions) ---- defp validate_underscores(str, type) do Validation.validate_underscores(str, type) end defp validate_no_leading_zeros(str) do Validation.validate_no_leading_zeros(str) end defp expect_separator(<<>>, _pos), do: :ok defp expect_separator(<>, _pos) when byte in [?\s, ?\t, ?\n, ?\r, ?\v, ?\f, ?,, ?:, ?), ?], ?}, ?(, ?[, ?{, ?", ?'] do :ok end defp expect_separator(<>, _pos) when next in [?/, ?*], do: :ok defp expect_separator(<>, pos) do {:error, "unexpected character '#{<>}' at position #{pos} after number"} end end