Skip to content

Commit

Permalink
Handle min and max values for JavaScript target (#83)
Browse files Browse the repository at this point in the history
  • Loading branch information
JosephTLyons authored Dec 7, 2024
1 parent c7546ae commit 2a06117
Show file tree
Hide file tree
Showing 11 changed files with 326 additions and 30 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## v2.0.0 - 2024-12-07

- Handled values that exceed either the `Number.MIN_SAFE_INTEGER` and `Number.MAX_SAFE_INTEGER` limits on the JavaScript target.
- Handled values that cannot fit within the float type, on both Erlang and JavaScript targets.

This is a breaking change, as we introduced new errors to `ParseError`, which will affect users who are matching on the `ParseError` type.
- `OutOfIntRange`
- `OutOfFloatRange`

## v1.3.5 - 2024-11-20

- Fixed a bug where base prefix substrings would be recognized as such later in the string.
Expand Down
2 changes: 1 addition & 1 deletion src/lenient_parse.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ pub fn to_int_with_base(

text
|> tokenizer.tokenize_int
|> parser.parse_int_tokens(base:)
|> parser.parse_int_tokens(base)
}
116 changes: 102 additions & 14 deletions src/lenient_parse/internal/build.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,37 @@ import bigi
import gleam/bool
import gleam/deque.{type Deque}
import gleam/int
import gleam/list
import gleam/string
import lenient_parse/internal/base_constants.{base_10}
import lenient_parse/internal/convert
import lenient_parse/internal/pilkku/pilkku
import lenient_parse/internal/scale
import parse_error.{type ParseError, OutOfFloatRange, OutOfIntRange}

pub fn float_value(
is_positive is_positive: Bool,
whole_digits whole_digits: Deque(Int),
fractional_digits fractional_digits: Deque(Int),
scale_factor scale_factor: Int,
) -> Float {
) -> Result(Float, ParseError) {
let #(whole_digits, fractional_digits) =
scale.deques(whole_digits, fractional_digits, scale_factor)
let exponent = fractional_digits |> deque.length
let #(digits, _) = scale.deques(whole_digits, fractional_digits, exponent)

// `bigi.undigits` documentation says it can fail if:
// - the base is less than 2: We are hardcoding base 10, so this doesn't apply
// - the base is less than 2: We are hardcoding base 10, so this doesn't
// apply.
// - if the digits are out of range for the given base: For float parsing, the
// tokenizer has already marked these digits as `Unknown` tokens and the
// parser has already raised an error. Therefore, the error case here should
// be unreachable. We do not want to `let assert Ok()`, just in case there
// is some bug in the prior code. Using the fallback will result in some
// precision loss, but it is better than crashing.
let float_value = case digits |> deque.to_list |> bigi.undigits(base_10) {
// precision loss, but it is better than crashing. We may want to raise an
// actual error in the future.
let digits_list = digits |> deque.to_list
case digits_list |> bigi.undigits(base_10) {
Ok(coefficient) -> {
let sign =
case is_positive {
Expand All @@ -43,26 +49,108 @@ pub fn float_value(
)

case decimal |> pilkku.to_float {
// `pilkku.to_float` returns 0.0 for both 0.0 and -0.0
Ok(float_value) if float_value == 0.0 && !is_positive -> Ok(-0.0)
Ok(float_value) -> Ok(float_value)
// TODO: Add tests and return an error for this case
Error(_) -> Error(Nil)
Error(_) -> {
let float_string =
float_string(
whole_digits: whole_digits |> deque.to_list,
fractional_digits: fractional_digits |> deque.to_list,
is_positive: is_positive,
)
Error(OutOfFloatRange(float_string))
}
}
}
Error(_) -> Error(Nil)
}

case float_value {
Ok(float_value) -> float_value
// Fallback to logic that can result in slight rounding issues
// Should be unreachable.
// If we hit this case, it is an error and we will see rounding issues in
// some cases.
Error(_) -> {
let float_value =
digits
|> convert.digits_to_int
|> int.to_float
|> scale.float(-exponent)
use <- bool.guard(is_positive, float_value)
float_value *. -1.0
use <- bool.guard(is_positive, Ok(float_value))
Ok(float_value *. -1.0)
}
}
}

pub fn integer_value(
digits digits: Deque(Int),
base base: Int,
is_positive is_positive: Bool,
) -> Result(Int, ParseError) {
// `bigi.undigits` documentation says it can fail if:
// - the base is less than 2: We've already ensured that the user has picked
// a base >= 2 and <= 36, so this doesn't apply.
// - if the digits are out of range for the given base: For integer parsing,
// the tokenizer has already marked these digits as `Unknown` tokens and the
// parser has already raised an error. Therefore, the error case here should
// be unreachable. We do not want to `let assert Ok()`, just in case there
// is some bug in the prior code. If the fallback is hit, issues may arise
// on JavaScript. We may want to raise an actual error in the future.
let digits_list = digits |> deque.to_list
case digits_list |> bigi.undigits(base) {
Ok(big_int) ->
case big_int |> bigi.to_int {
Ok(value) -> {
let value = case is_positive {
True -> value
False -> -value
}
Ok(value)
}
Error(_) -> {
let integer_string = digits_list |> integer_string(is_positive)
Error(OutOfIntRange(integer_string))
}
}
// Should be unreachable.
// If we hit this case, we will see potentially invalid integer values on
// JavaScript target, when exceeding the min or max safe integer values.
Error(_) -> {
let value = digits |> convert.digits_to_int_with_base(base)
let value = case is_positive {
True -> value
False -> -value
}
Ok(value)
}
}
}

fn float_string(
whole_digits whole_digits: List(Int),
fractional_digits fractional_digits: List(Int),
is_positive is_positive: Bool,
) {
let whole_string = case whole_digits {
[] -> "0"
_ -> whole_digits |> list.map(int.to_string) |> string.join("")
}

let fractional_string = case fractional_digits {
[] -> "0"
_ -> fractional_digits |> list.map(int.to_string) |> string.join("")
}

case is_positive {
True -> whole_string <> "." <> fractional_string
False -> "-" <> whole_string <> "." <> fractional_string
}
}

fn integer_string(
digits_list digits_list: List(Int),
is_positive is_positive: Bool,
) {
let integer_string = digits_list |> list.map(int.to_string) |> string.join("")

case is_positive {
True -> integer_string
False -> "-" <> integer_string
}
}
13 changes: 4 additions & 9 deletions src/lenient_parse/internal/parser.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import lenient_parse/internal/base_constants.{
base_0, base_10, base_16, base_2, base_8,
}
import lenient_parse/internal/build
import lenient_parse/internal/convert.{digits_to_int, digits_to_int_with_base}
import lenient_parse/internal/convert.{digits_to_int}
import lenient_parse/internal/token.{
type Token, DecimalPoint, Digit, ExponentSymbol, Sign, Underscore, Unknown,
Whitespace,
Expand Down Expand Up @@ -103,12 +103,12 @@ pub fn parse_float_tokens(
None, True -> Error(EmptyString)
Some(_), True -> Error(WhitespaceOnlyString)
_, _ ->
Ok(build.float_value(
build.float_value(
is_positive:,
whole_digits:,
fractional_digits:,
scale_factor: exponent,
))
)
}
}

Expand Down Expand Up @@ -173,12 +173,7 @@ pub fn parse_int_tokens(
Error(BasePrefixOnly(index_range, prefix))
Some(_), _, True -> Error(WhitespaceOnlyString)
_, _, _ -> {
let value = digits |> digits_to_int_with_base(base:)
let value = case is_positive {
True -> value
False -> -value
}
Ok(value)
build.integer_value(digits:, base:, is_positive:)
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions src/parse_error.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,20 @@ pub type ParseError {
/// - `base`: The invalid base as an `Int`. The base must be between 2 and 36
/// inclusive.
InvalidBaseValue(base: Int)

/// Represents an error when the parsed number is outside the safe integer
/// range when ran on the JavaScript target.
///
/// - [MDN: Number.MAX_SAFE_INTEGER](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER)
/// - [MDN: Number.MIN_SAFE_INTEGER](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MIN_SAFE_INTEGER)
///
/// This also implies that numbers that parse to `Infinity` and `-Infinity`,
/// on the JavaScript target, will emit this error.
///
/// Note that Erlang's max and min integer limits are not handled.
OutOfIntRange(integer_string: String)

/// Represents an error when the parsed number cannot be fit within the float
/// type.
OutOfFloatRange(float_string: String)
}
51 changes: 47 additions & 4 deletions test/build_test.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,36 @@ import gleam/deque
import lenient_parse/internal/build
import startest/expect

// ------------------ float

pub fn build_float_empty_test() {
build.float_value(
is_positive: True,
whole_digits: deque.from_list([]),
fractional_digits: deque.from_list([]),
scale_factor: 0,
)
|> expect.to_equal(Ok(0.0))
}

pub fn build_float_explicit_0_both_test() {
build.float_value(
is_positive: True,
whole_digits: deque.from_list([0]),
fractional_digits: deque.from_list([0]),
scale_factor: 0,
)
|> expect.to_equal(Ok(0.0))
}

pub fn build_float_empty_fractional_test() {
build.float_value(
is_positive: True,
whole_digits: deque.from_list([1]),
fractional_digits: deque.from_list([]),
scale_factor: 0,
)
|> expect.to_equal(1.0)
|> expect.to_equal(Ok(1.0))
}

pub fn build_float_explicit_0_fractional_test() {
Expand All @@ -19,7 +41,7 @@ pub fn build_float_explicit_0_fractional_test() {
fractional_digits: deque.from_list([0]),
scale_factor: 0,
)
|> expect.to_equal(1.0)
|> expect.to_equal(Ok(1.0))
}

pub fn build_float_empty_whole_test() {
Expand All @@ -29,7 +51,7 @@ pub fn build_float_empty_whole_test() {
fractional_digits: deque.from_list([1]),
scale_factor: 0,
)
|> expect.to_equal(0.1)
|> expect.to_equal(Ok(0.1))
}

pub fn build_float_explicit_0_whole_test() {
Expand All @@ -39,5 +61,26 @@ pub fn build_float_explicit_0_whole_test() {
fractional_digits: deque.from_list([1]),
scale_factor: 0,
)
|> expect.to_equal(0.1)
|> expect.to_equal(Ok(0.1))
}

// ------------------ int

pub fn build_int_empty_test() {
build.integer_value(digits: deque.from_list([]), base: 10, is_positive: True)
|> expect.to_equal(Ok(0))
}

pub fn build_int_explicit_0_test() {
build.integer_value(digits: deque.from_list([0]), base: 10, is_positive: True)
|> expect.to_equal(Ok(0))
}

pub fn build_int_test() {
build.integer_value(
digits: deque.from_list([1, 2, 3]),
base: 10,
is_positive: True,
)
|> expect.to_equal(Ok(123))
}
8 changes: 7 additions & 1 deletion test/helpers.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import parse_error.{
type ParseError, BasePrefixOnly, EmptyString, InvalidBaseValue,
InvalidDecimalPosition, InvalidDigitPosition, InvalidExponentSymbolPosition,
InvalidSignPosition, InvalidUnderscorePosition, OutOfBaseRange,
UnknownCharacter, WhitespaceOnlyString,
OutOfFloatRange, OutOfIntRange, UnknownCharacter, WhitespaceOnlyString,
}

pub fn to_printable_text(text: String) -> String {
Expand Down Expand Up @@ -84,5 +84,11 @@ pub fn error_to_string(error: ParseError) -> String {
<> "\" at index: "
<> index |> int.to_string
InvalidBaseValue(base) -> "invalid base value: " <> base |> int.to_string
OutOfIntRange(integer_string) ->
"integer value \""
<> integer_string
<> "\" cannot safely be represented on the JavaScript target"
OutOfFloatRange(float_string) ->
"float value \"" <> float_string <> "\" cannot safely be represented"
}
}
35 changes: 35 additions & 0 deletions test/javascript_constants.gleam
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import gleam/int

const safe_integer = 9_007_199_254_740_991

pub fn min_safe_integer() -> Int {
-safe_integer
}

pub fn min_safe_integer_string() -> String {
min_safe_integer() |> int.to_string
}

pub fn min_safe_integer_minus_1() -> Int {
min_safe_integer() - 1
}

pub fn min_safe_integer_minus_1_string() -> String {
min_safe_integer_minus_1() |> int.to_string
}

pub fn max_safe_integer() -> Int {
safe_integer
}

pub fn max_safe_integer_string() -> String {
max_safe_integer() |> int.to_string
}

pub fn max_safe_integer_plus_1() -> Int {
safe_integer + 1
}

pub fn max_safe_integer_plus_1_string() -> String {
max_safe_integer_plus_1() |> int.to_string
}
Loading

0 comments on commit 2a06117

Please sign in to comment.