Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
33 changes: 22 additions & 11 deletions lib/decimal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(<<digit, rest::binary>>, acc, count) when digit in ?0..?9 do
parse_digits_count(rest, [digit | acc], count + 1)
defp parse_digits_count(<<?0, rest::binary>>, 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(<<digit, rest::binary>>, 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))
Expand Down Expand Up @@ -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
<<?., after_dot::binary>> -> parse_digits_count(after_dot, int_rev, int_size)
_ -> {int_rev, int_size, after_int}
<<?., after_dot::binary>> ->
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 ->
Expand Down
15 changes: 15 additions & 0 deletions test/decimal/property_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 27 additions & 3 deletions test/decimal_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down