diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa1a67..940c0b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # CHANGELOG +## Unreleased + +### Bug fixes + +* Fix `Decimal.parse/2` and `Decimal.new/2` rejecting inspect output for + values at the context's full precision with negative exponents (e.g. + `Decimal.new("0.3162277660168379331998893544432719")`). The + `:max_digits` limit no longer counts non-significant leading zeros. + ## v3.1.0 (2026-05-08) ### Enhancements diff --git a/lib/decimal.ex b/lib/decimal.ex index 79d95ee..1494ab6 100644 --- a/lib/decimal.ex +++ b/lib/decimal.ex @@ -1617,9 +1617,9 @@ defmodule Decimal do The following options are supported: - * `:max_digits` - maximum number of decimal digits consumed from the input, - including leading and trailing zeros. Defaults to `#{@default_max_digits}`. - Pass `:infinity` to disable. + * `:max_digits` - maximum number of significant decimal digits in the parsed + coefficient. Leading zeros are not counted, but trailing zeros are. Defaults + to `#{@default_max_digits}`. Pass `:infinity` to disable. * `:max_exponent` - maximum absolute value of the parsed decimal exponent, after fractional digits are accounted for. Defaults to `#{@default_max_exponent}`. Pass `:infinity` to disable. @@ -2594,11 +2594,19 @@ defmodule Decimal do "#{inspect(key)} must be a non-negative integer or :infinity, got: #{inspect(value)}" end - defp parse_digits_count(<>, acc, count) when digit in ?0..?9 do - parse_digits_count(rest, [digit | acc], count + 1) + defp parse_digits_count(<>, acc, count, leading_zeros) + when count == leading_zeros do + parse_digits_count(rest, acc, count + 1, leading_zeros + 1) end - defp parse_digits_count(rest, acc, count), do: {acc, count, rest} + defp parse_digits_count(<>, acc, count, leading_zeros) + when digit in ?0..?9 do + parse_digits_count(rest, [digit | acc], count + 1, leading_zeros) + end + + defp parse_digits_count(rest, acc, count, leading_zeros) do + {acc, count, leading_zeros, rest} + end defp digits_acc_to_integer([], _size), do: 0 defp digits_acc_to_integer(acc, _size), do: :erlang.list_to_integer(:lists.reverse(acc)) @@ -2646,19 +2654,22 @@ defmodule Decimal do end defp parse_unsign(bin, limits) do - {int_rev, int_size, after_int} = parse_digits_count(bin, [], 0) + {int_rev, int_size, leading_zeros, after_int} = parse_digits_count(bin, [], 0, 0) - {coef_rev, total_size, after_float} = + {coef_rev, total_size, leading_zeros, after_float} = case after_int do - <> -> parse_digits_count(after_dot, int_rev, int_size) - _ -> {int_rev, int_size, after_int} + <> -> + parse_digits_count(after_dot, int_rev, int_size, leading_zeros) + + _ -> + {int_rev, int_size, leading_zeros, after_int} end cond do total_size == 0 -> :error - exceeds_limit?(total_size, limits.max_digits) -> + exceeds_limit?(total_size - leading_zeros, limits.max_digits) -> :error true -> diff --git a/test/decimal/property_test.exs b/test/decimal/property_test.exs index dea15ec..1e6857a 100644 --- a/test/decimal/property_test.exs +++ b/test/decimal/property_test.exs @@ -205,6 +205,21 @@ defmodule Decimal.PropertyTest do end end + property "inspect output parses back at default decimal128 limits" do + gen = + decimal( + coef_max: 9_999_999_999_999_999_999_999_999_999_999_999, + exp_min: -6144, + exp_max: 6144 + ) + + check all(a <- gen, max_runs: 200) do + s = Decimal.to_string(a, :scientific, max_digits: :infinity) + assert {parsed, ""} = Decimal.parse(s) + assert parsed == a + end + end + property "Decimal.new/1 of an integer round-trips through to_integer/1" do check all(n <- StreamData.integer(), max_runs: 100) do d = Decimal.new(n) diff --git a/test/decimal_test.exs b/test/decimal_test.exs index cae853e..d3d70be 100644 --- a/test/decimal_test.exs +++ b/test/decimal_test.exs @@ -91,6 +91,12 @@ defmodule DecimalTest do assert Decimal.parse("123", max_digits: 3) == {d(1, 123, 0), ""} assert Decimal.parse("123", max_digits: 2) == :error + assert Decimal.parse("0.123", max_digits: 3) == {d(1, 123, -3), ""} + assert Decimal.parse("00123", max_digits: 3) == {d(1, 123, 0), ""} + assert Decimal.parse("0.00123", max_digits: 3) == {d(1, 123, -5), ""} + assert Decimal.parse("123.000", max_digits: 6) == {d(1, 123_000, -3), ""} + assert Decimal.parse("123.000", max_digits: 5) == :error + assert Decimal.parse("1e10", max_exponent: 10) == {d(1, 1, 10), ""} assert Decimal.parse("1e10", max_exponent: 9) == :error @@ -117,6 +123,15 @@ defmodule DecimalTest do end) end + @tag timeout: @bounded_smoke_timeout + test "parse/2 rejects very long digit runs without materializing them" do + input = String.duplicate("9", 1_000_000) + + assert_runs_quickly("parse/2 bounded digit rejection", fn -> + assert Decimal.parse(input, max_digits: 34) == :error + end) + end + test "parse/2 with very long digit strings under explicit limits" do digits = String.duplicate("9", 50_000) coef = :erlang.binary_to_integer(digits) @@ -146,10 +161,19 @@ defmodule DecimalTest do fractional = "0." <> digits - assert Decimal.parse(fractional, max_digits: 50_001, max_exponent: :infinity) == - {%Decimal{coef: :erlang.binary_to_integer("0" <> digits), exp: -50_000}, ""} + assert Decimal.parse(fractional, max_digits: 50_000, max_exponent: :infinity) == + {%Decimal{coef: :erlang.binary_to_integer(digits), exp: -50_000}, ""} + + assert Decimal.parse(fractional, max_digits: 49_999, max_exponent: :infinity) == :error + end + + test "parse/1 round-trips inspect output at default precision" do + decimal = %Decimal{coef: 3_162_277_660_168_379_331_998_893_544_432_719, exp: -34} + string = Decimal.to_string(decimal, :scientific, max_digits: :infinity) - assert Decimal.parse(fractional, max_digits: 50_000, max_exponent: :infinity) == :error + assert string == "0.3162277660168379331998893544432719" + assert Decimal.parse(string) == {decimal, ""} + assert Decimal.new(string) == decimal end test "nan?/1" do