Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: E notation BigDecimal parser #9581

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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
48 changes: 15 additions & 33 deletions spec/std/big/big_decimal_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -73,41 +73,23 @@ describe BigDecimal do
.should eq(BigDecimal.new(BigInt.new(5), 1))
end

it "raises InvalidBigDecimalException when initializing from invalid input" do
expect_raises(InvalidBigDecimalException) do
BigDecimal.new("derp")
it "raises when initializing from invalid input" do
[
"derp", "1.2.3", "..2", "1..2",
"a1.2", "1a.2", "1.a2", "1.2a",
"1ee1", "e+e1",
"1 e1", "1e 1",
"..e1", "-..e1",
"e1", "e+5", ".e1", ".e+1", "-.e1",
"1e.", "1e0.1",
].each do |s|
expect_raises(ArgumentError, /^Unexpected '/) { BigDecimal.new(s) }
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("")
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("1.2.3")
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("..2")
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("1..2")
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("a1.2")
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("1a.2")
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("1.a2")
end

expect_raises(InvalidBigDecimalException) do
BigDecimal.new("1.2a")
[
"", ".", "1e+", "1.1e-", "1.0e",
].each do |s|
expect_raises(ArgumentError, "Unexpected end of number string") { BigDecimal.new(s) }
end
end

Expand Down
179 changes: 118 additions & 61 deletions src/big/big_decimal.cr
Original file line number Diff line number Diff line change
Expand Up @@ -68,83 +68,140 @@ struct BigDecimal < Number
# Strip '_' to make it compatible with int literals like "1_000_000"
str = str.delete('_')

raise InvalidBigDecimalException.new(str, "Zero size") if str.bytesize == 0

# Check str's validity and find index of '.'
decimal_index = nil
# Check str's validity and find index of 'e'
exponent_index = nil

str.each_char_with_index do |char, index|
case char
when '-'
unless index == 0 || exponent_index == index - 1
raise InvalidBigDecimalException.new(str, "Unexpected '-' character")
end
when '+'
unless exponent_index == index - 1
raise InvalidBigDecimalException.new(str, "Unexpected '+' character")
end
when '.'
if decimal_index
raise InvalidBigDecimalException.new(str, "Unexpected '.' character")
end
decimal_index = index
when 'e', 'E'
if exponent_index
raise InvalidBigDecimalException.new(str, "Unexpected #{char.inspect} character")
end
exponent_index = index
when '0'..'9'
# Pass
else
raise InvalidBigDecimalException.new(str, "Unexpected #{char.inspect} character")
end
end
value_str, value_negative, fraction_str, exponent_str, exponent_negative = parse_e_notation(str.each_char.with_index)

decimal_end_index = (exponent_index || str.bytesize) - 1
if decimal_index
decimal_count = (decimal_end_index - decimal_index).to_u64
decimal_count = fraction_str ? fraction_str.size.to_u64 : 0_u64
unscaled_string = fraction_str ? value_str + fraction_str : value_str
@value = (value_negative ? "-" + unscaled_string : unscaled_string).to_big_i

value_str = String.build do |builder|
# We know this is ASCII, so we can slice by index
builder.write(str.to_slice[0, decimal_index])
builder.write(str.to_slice[decimal_index + 1, decimal_count])
end
@value = value_str.to_big_i
else
decimal_count = 0_u64
@value = str[0..decimal_end_index].to_big_i
end

if exponent_index
exponent_postfix = str[exponent_index + 1]
case exponent_postfix
when '+', '-'
exponent_positive = exponent_postfix == '+'
exponent = str[(exponent_index + 2)..-1].to_u64
if exponent_str
# TODO wrap error
@scale = exponent_str.to_u64
if exponent_negative
@scale += decimal_count
else
exponent_positive = true
exponent = str[(exponent_index + 1)..-1].to_u64
end

@scale = exponent
if exponent_positive
if @scale < decimal_count
@scale = decimal_count - @scale
else
@scale -= decimal_count
@value *= 10.to_big_i ** @scale
@scale = 0_u64
end
else
@scale += decimal_count
end
else
@scale = decimal_count
end
end

private def parse_e_notation(iterator)
token = take_next_character(iterator)
value_negative = false
if token_sign?(token)
token, value_negative = parse_sign_symbol(token, iterator)
elsif !(token_digit?(token) || token_decimal?(token))
raise_parse_error(token)
end
next_token, value_str, fraction_str, exponent_str, exponent_negative = parse_numerical_part(token, iterator)
parse_end(next_token)
{value_str, value_negative, fraction_str, exponent_str, exponent_negative}
end

private def parse_sign_symbol(token, iterator)
{take_next_character(iterator), token[0] == '-'}
end

private def parse_numerical_part(token, iterator)
value_str = ""
fraction_str = nil
if token_digit?(token)
token, value_str = parse_digits(token, iterator)
token, fraction_str = parse_fractional_part(token, iterator) if token_decimal?(token)
elsif token_decimal?(token)
token, fraction_str = parse_fractional_part(token, iterator)
raise_parse_error(token) if fraction_str.empty?
else
raise_parse_error(token)
end
next_token, exponent_str, exponent_negative = token_e?(token) ? parse_exponent_part(token, iterator) : {token, nil, false}
{next_token, value_str, fraction_str, exponent_str, exponent_negative}
end

private def parse_digits(token, iterator)
val = String.build do |io|
while token_digit?(token)
io << token[0]
token = take_next_character(iterator, true)
end
end
{token, val}
end

private def parse_fractional_part(token, iterator)
token = take_next_character(iterator, true) # consume '.'
parse_digits(token, iterator)
end

private def parse_exponent_part(token, iterator)
token = take_next_character(iterator) # consume 'e'
if token_sign?(token)
next_token, exponent_negative = parse_sign_symbol(token, iterator)
token, val = parse_digits(next_token, iterator)
{token, val, exponent_negative}
elsif token_digit?(token)
token, val = parse_digits(token, iterator)
{token, val, false}
else
raise_parse_error(token)
end
end

private def parse_end(token)
raise_parse_error(token) unless token_end?(token)
end

private def take_next_character(iterator, allow_end = false) : Tuple(Char | Nil, Int32)
next_c = iterator.next
if next_c == Iterator::Stop::INSTANCE
raise_parse_eos_error unless allow_end
{nil, -1}
else
next_c.as(Tuple(Char, Int32))
end
end

private def token_digit?(token)
c = token[0]
c && c >= '0' && c <= '9'
end

private def token_sign?(token)
c = token[0]
c && (c == '-' || c == '+')
end

private def token_decimal?(token)
c = token[0]
c && c == '.'
end

private def token_e?(token)
c = token[0]
c && (c == 'e' || c == 'E')
end

private def token_end?(token)
token[0] == nil && token[1] == -1
end

private def raise_parse_error(token)
raise_parse_eos_error if token_end?(token)
raise ArgumentError.new("Unexpected '#{token[0]}' at character #{token[1]}")
end

private def raise_parse_eos_error
raise ArgumentError.new("Unexpected end of number string")
end

def - : BigDecimal
BigDecimal.new(-@value, @scale)
end
Expand Down