Current section

Files

Jump to
eyeon lib eyeon text_parser number.ex
Raw

lib/eyeon/text_parser/number.ex

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
<<?x, rest2::binary>> ->
parse_hex_int(rest2, pos + 1, negative)
<<?X, rest2::binary>> ->
parse_hex_int(rest2, pos + 1, negative)
<<?b, rest3::binary>> ->
parse_bin_int(rest3, pos + 1, negative)
<<?B, rest3::binary>> ->
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(<<digit, rest::binary>>, 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(<<?_, digit, rest::binary>>, 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(<<?_, rest::binary>>, 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(<<?-, rest::binary>>, 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, <<?-, rest::binary>>, pos)
end
end
# Dot -> could be decimal or float
defp parse_decimal_digits(<<?., rest::binary>>, 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(<<e, rest::binary>>, 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(<<d, rest::binary>>, 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(<<?T, rest::binary>>, 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, <<?T, rest::binary>>, 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
<<e, rest2::binary>> when e in [?e, ?E] ->
parse_float_exponent(rest2, pos2 + 1, negative, int_str, frac_str)
<<d, rest2::binary>> 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(<<digit, rest::binary>>, pos, acc) when digit in ?0..?9 do
scan_frac_digits(rest, pos + 1, [digit | acc])
end
defp scan_frac_digits(<<?_, digit, rest::binary>>, pos, acc) when digit in ?0..?9 do
scan_frac_digits(rest, pos + 2, [digit, ?_ | acc])
end
defp scan_frac_digits(<<?_, rest::binary>>, 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::binary>> -> {"+", rest, pos + 1}
<<?-, rest::binary>> -> {"-", 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::binary>> -> {"+", rest, pos + 1}
<<?-, rest::binary>> -> {"-", 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(<<digit, rest::binary>>, pos, acc) when digit in ?0..?9 do
scan_exp_digits(rest, pos + 1, [digit | acc])
end
defp scan_exp_digits(<<?_, digit, rest::binary>>, 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(<<digit, rest::binary>>, 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(<<?_, rest::binary>>, 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(<<digit, rest::binary>>, pos, acc) when digit in [?0, ?1] do
scan_bin_digits(rest, pos + 1, [digit | acc])
end
defp scan_bin_digits(<<?_, rest::binary>>, 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
<<m1, m2, rest::binary>> when m1 in ?0..?9 and m2 in ?0..?9 ->
month_str = <<m1, m2>>
case rest do
<<?T, rest2::binary>> ->
# YYYY-MMT (year-month)
ts_str = year_str <> "-" <> month_str <> "T"
validate_and_return_timestamp(ts_str, rest2, pos + 3)
<<?-, rest2::binary>> ->
# 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
<<d1, d2, rest::binary>> when d1 in ?0..?9 and d2 in ?0..?9 ->
day_str = <<d1, d2>>
case rest do
<<?T, rest2::binary>> ->
# Could be YYYY-MM-DDT (date with T) or YYYY-MM-DDThh:mm...
case rest2 do
<<h1, h2, ?:, _::binary>> 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
<<h1, h2, ?:, m1, m2, rest::binary>>
when h1 in ?0..?9 and h2 in ?0..?9 and m1 in ?0..?9 and m2 in ?0..?9 ->
time_prefix = prefix <> <<h1, h2, ?:, m1, m2>>
case rest do
<<?:, s1, s2, rest2::binary>> when s1 in ?0..?9 and s2 in ?0..?9 ->
# Has seconds
seconds_str = <<s1, s2>>
case rest2 do
<<?., rest3::binary>> ->
# 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
<<?Z, rest::binary>> ->
validate_and_return_timestamp(prefix <> "Z", rest, pos + 1)
<<sign, oh1, oh2, ?:, om1, om2, rest::binary>>
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 = <<sign, oh1, oh2, ?:, om1, om2>>
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(<<byte, _::binary>>, _pos)
when byte in [?\s, ?\t, ?\n, ?\r, ?\v, ?\f, ?,, ?:, ?), ?], ?}, ?(, ?[, ?{, ?", ?'] do
:ok
end
defp expect_separator(<<?/, next, _::binary>>, _pos) when next in [?/, ?*], do: :ok
defp expect_separator(<<byte, _::binary>>, pos) do
{:error, "unexpected character '#{<<byte>>}' at position #{pos} after number"}
end
end