From b8bd3454ebe280199c6fbecf7ad633c8e39cde34 Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 13 Aug 2020 19:18:05 -0400 Subject: [PATCH 1/9] round up 1's digit in the number formatter --- src/Data/Formatter/Number.purs | 14 ++++++++----- test/src/Number.purs | 37 +++++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Data/Formatter/Number.purs b/src/Data/Formatter/Number.purs index b7bfca3..ff6cc8d 100644 --- a/src/Data/Formatter/Number.purs +++ b/src/Data/Formatter/Number.purs @@ -112,17 +112,21 @@ format (Formatter f) num = let zeros = f.before - tens - one integer = Int.floor absed + roundedInt = Int.round absed leftover = absed - Int.toNumber integer - rounded = Int.round $ leftover * (Math.pow 10.0 (Int.toNumber f.after)) + factor = Math.pow 10.0 (Int.toNumber f.after) + rounded = Int.round $ leftover * factor + overflow = Int.toNumber rounded == factor roundedWithZeros = let roundedString = show rounded roundedLength = Str.length roundedString zeros' = repeat "0" (f.after - roundedLength) in zeros' <> roundedString + strInt = if overflow then show (integer + 1) else show integer shownNumber = if f.comma - then addCommas [] zero (Arr.reverse (CU.toCharArray (repeat "0" zeros <> show integer))) - else repeat "0" zeros <> show integer + then addCommas [] zero (Arr.reverse (CU.toCharArray (repeat "0" zeros <> strInt))) + else repeat "0" zeros <> strInt addCommas ∷ Array Char → Int → Array Char → String addCommas acc counter input = case Arr.uncons input of @@ -138,8 +142,8 @@ format (Formatter f) num = then "" else "." - <> (if rounded == 0 then repeat "0" f.after else "") - <> (if rounded > 0 then roundedWithZeros else "")) + <> (if rounded == 0 || overflow then repeat "0" f.after else "") + <> (if rounded > 0 && not overflow then roundedWithZeros else "")) unformat ∷ Formatter → String → Either String Number diff --git a/test/src/Number.purs b/test/src/Number.purs index 65e793c..d904428 100644 --- a/test/src/Number.purs +++ b/test/src/Number.purs @@ -2,10 +2,9 @@ module Test.Number (numberTest) where import Prelude -import Data.Formatter.Number (Formatter(..), printFormatter, parseFormatString, format, unformat) import Data.Either (Either(..)) - -import Test.Spec (describe, Spec) +import Data.Formatter.Number (Formatter(..), printFormatter, parseFormatString, format, unformat) +import Test.Spec (Spec, describe) import Test.Spec.Assertions (shouldEqual) import Test.Utils (forAll) @@ -36,6 +35,20 @@ numberTest = describe "Data.Formatter.Number" do ["+02.12", "+13.12", "-02.12", "-13.12"] (\n → (format fmt3 <$> (unformat fmt3 n)) `shouldEqual` (Right n)) + forAll (\{fmt: (Formatter fmt), input} -> "rounds up " <> show input <> " (" <> show fmt.after <> " digits)") + "rounding" + [ {fmt: fmt4, input: 1.99999, expected: "02"} + , {fmt: fmt1, input: 1.99999, expected: "002.00"} + , {fmt: fmt5, input: 1.99999, expected: "2.0000"} + , {fmt: fmt1, input: 1.89999, expected: "001.90"} + , {fmt: fmt5, input: 1.67899, expected: "1.6790"} + ] + (\{fmt, input, expected} -> do + format fmt input `shouldEqual` expected + format fmt (negate input) `shouldEqual` ("-" <> expected) + ) + + fmt1 ∷ Formatter fmt1 = Formatter { comma: false @@ -63,6 +76,24 @@ fmt3 = Formatter , sign: true } +fmt4 ∷ Formatter +fmt4 = Formatter + { comma: false + , before: 2 + , after: 0 + , abbreviations: false + , sign: false + } + +fmt5 ∷ Formatter +fmt5 = Formatter + { comma: true + , before: 1 + , after: 4 + , abbreviations: false + , sign: false + } + numberformatts ∷ Array { fmt ∷ Formatter, str ∷ String } numberformatts = [ { str: "000.00" From 11daf3acd1b2f8a5d793246385fd5356ead6419c Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 13 Aug 2020 19:21:07 -0400 Subject: [PATCH 2/9] remove an unused variable --- src/Data/Formatter/Number.purs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Data/Formatter/Number.purs b/src/Data/Formatter/Number.purs index ff6cc8d..a3ee783 100644 --- a/src/Data/Formatter/Number.purs +++ b/src/Data/Formatter/Number.purs @@ -112,7 +112,6 @@ format (Formatter f) num = let zeros = f.before - tens - one integer = Int.floor absed - roundedInt = Int.round absed leftover = absed - Int.toNumber integer factor = Math.pow 10.0 (Int.toNumber f.after) rounded = Int.round $ leftover * factor From 79fcedf1ff15fabddbf0cffdc81197c8f789b0d3 Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 13 Aug 2020 19:23:57 -0400 Subject: [PATCH 3/9] revert IDE auto-changes to the import statements --- test/src/Number.purs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/src/Number.purs b/test/src/Number.purs index d904428..6ad9588 100644 --- a/test/src/Number.purs +++ b/test/src/Number.purs @@ -2,8 +2,9 @@ module Test.Number (numberTest) where import Prelude -import Data.Either (Either(..)) import Data.Formatter.Number (Formatter(..), printFormatter, parseFormatString, format, unformat) +import Data.Either (Either(..)) + import Test.Spec (Spec, describe) import Test.Spec.Assertions (shouldEqual) import Test.Utils (forAll) From a5f3804f1fc46f91270a3e67020e6487b8a2f89e Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 13 Aug 2020 19:24:47 -0400 Subject: [PATCH 4/9] more IDE auto-changes to revert --- test/src/Number.purs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/src/Number.purs b/test/src/Number.purs index 6ad9588..ca8c2f8 100644 --- a/test/src/Number.purs +++ b/test/src/Number.purs @@ -5,7 +5,7 @@ import Prelude import Data.Formatter.Number (Formatter(..), printFormatter, parseFormatString, format, unformat) import Data.Either (Either(..)) -import Test.Spec (Spec, describe) +import Test.Spec (describe, Spec) import Test.Spec.Assertions (shouldEqual) import Test.Utils (forAll) From c0b332dd34cf8522163d3d204e6bed8a75ceb279 Mon Sep 17 00:00:00 2001 From: nathan Date: Mon, 17 Aug 2020 08:34:02 -0400 Subject: [PATCH 5/9] finalize the implementation (& add a test for a negative ) --- src/Data/Formatter/Number.purs | 33 ++++++++++++++++----------------- test/src/Number.purs | 10 ++++++++++ 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/src/Data/Formatter/Number.purs b/src/Data/Formatter/Number.purs index a3ee783..d23e3b4 100644 --- a/src/Data/Formatter/Number.purs +++ b/src/Data/Formatter/Number.purs @@ -111,21 +111,20 @@ format (Formatter f) num = else let zeros = f.before - tens - one - integer = Int.floor absed - leftover = absed - Int.toNumber integer - factor = Math.pow 10.0 (Int.toNumber f.after) - rounded = Int.round $ leftover * factor - overflow = Int.toNumber rounded == factor - roundedWithZeros = - let roundedString = show rounded - roundedLength = Str.length roundedString - zeros' = repeat "0" (f.after - roundedLength) - in zeros' <> roundedString - strInt = if overflow then show (integer + 1) else show integer - shownNumber = + factor = Math.pow 10.0 (Int.toNumber (max 0 f.after)) + rounded = Math.round (absed * factor) / factor + integer = Int.floor rounded + leftoverDecimal = rounded - Int.toNumber integer + leftover = Int.round $ leftoverDecimal * factor + leftoverWithZeros = + let leftoverString = show leftover + leftoverLength = Str.length leftoverString + zeros' = repeat "0" (f.after - leftoverLength) + in zeros' <> leftoverString + shownInt = if f.comma - then addCommas [] zero (Arr.reverse (CU.toCharArray (repeat "0" zeros <> strInt))) - else repeat "0" zeros <> strInt + then addCommas [] zero (Arr.reverse (CU.toCharArray (repeat "0" zeros <> show integer))) + else repeat "0" zeros <> show integer addCommas ∷ Array Char → Int → Array Char → String addCommas acc counter input = case Arr.uncons input of @@ -136,13 +135,13 @@ format (Formatter f) num = addCommas (Arr.cons ',' acc) zero input in (if num < zero then "-" else if num > zero && f.sign then "+" else "") - <> shownNumber + <> shownInt <> (if f.after < 1 then "" else "." - <> (if rounded == 0 || overflow then repeat "0" f.after else "") - <> (if rounded > 0 && not overflow then roundedWithZeros else "")) + <> (if leftover == 0 then repeat "0" f.after else "") + <> (if leftover > 0 then leftoverWithZeros else "")) unformat ∷ Formatter → String → Either String Number diff --git a/test/src/Number.purs b/test/src/Number.purs index ca8c2f8..bd2e377 100644 --- a/test/src/Number.purs +++ b/test/src/Number.purs @@ -43,6 +43,7 @@ numberTest = describe "Data.Formatter.Number" do , {fmt: fmt5, input: 1.99999, expected: "2.0000"} , {fmt: fmt1, input: 1.89999, expected: "001.90"} , {fmt: fmt5, input: 1.67899, expected: "1.6790"} + , {fmt: fmt6, input: 12.9, expected: "13"} ] (\{fmt, input, expected} -> do format fmt input `shouldEqual` expected @@ -95,6 +96,15 @@ fmt5 = Formatter , sign: false } +fmt6 ∷ Formatter +fmt6 = Formatter + { comma: true + , before: 1 + , after: -1 + , abbreviations: false + , sign: false + } + numberformatts ∷ Array { fmt ∷ Formatter, str ∷ String } numberformatts = [ { str: "000.00" From 274d76ad860d298947a87cc8cbd658989f824fcb Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 21 Aug 2020 17:49:18 -0400 Subject: [PATCH 6/9] switch to number rounding, which gives correct answers for a much wider range of numbers --- src/Data/Formatter/Number.purs | 18 ++++++++++-------- test/src/Number.purs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Data/Formatter/Number.purs b/src/Data/Formatter/Number.purs index d23e3b4..d81130a 100644 --- a/src/Data/Formatter/Number.purs +++ b/src/Data/Formatter/Number.purs @@ -110,21 +110,23 @@ format (Formatter f) num = format (Formatter f{abbreviations = false}) newNum <> abbr else let + period = Str.codePointFromChar '.' + showNumberAsInt = show >>> Str.takeWhile (_ /= period) zeros = f.before - tens - one factor = Math.pow 10.0 (Int.toNumber (max 0 f.after)) rounded = Math.round (absed * factor) / factor - integer = Int.floor rounded - leftoverDecimal = rounded - Int.toNumber integer - leftover = Int.round $ leftoverDecimal * factor + integer = Math.floor rounded + leftoverDecimal = rounded - integer + leftover = Math.round $ leftoverDecimal * factor leftoverWithZeros = - let leftoverString = show leftover + let leftoverString = showNumberAsInt leftover leftoverLength = Str.length leftoverString zeros' = repeat "0" (f.after - leftoverLength) in zeros' <> leftoverString shownInt = if f.comma - then addCommas [] zero (Arr.reverse (CU.toCharArray (repeat "0" zeros <> show integer))) - else repeat "0" zeros <> show integer + then addCommas [] zero (Arr.reverse (CU.toCharArray (repeat "0" zeros <> showNumberAsInt integer))) + else repeat "0" zeros <> showNumberAsInt integer addCommas ∷ Array Char → Int → Array Char → String addCommas acc counter input = case Arr.uncons input of @@ -140,8 +142,8 @@ format (Formatter f) num = then "" else "." - <> (if leftover == 0 then repeat "0" f.after else "") - <> (if leftover > 0 then leftoverWithZeros else "")) + <> (if leftover == 0.0 then repeat "0" f.after else "") + <> (if leftover > 0.0 then leftoverWithZeros else "")) unformat ∷ Formatter → String → Either String Number diff --git a/test/src/Number.purs b/test/src/Number.purs index bd2e377..97d27ee 100644 --- a/test/src/Number.purs +++ b/test/src/Number.purs @@ -44,6 +44,9 @@ numberTest = describe "Data.Formatter.Number" do , {fmt: fmt1, input: 1.89999, expected: "001.90"} , {fmt: fmt5, input: 1.67899, expected: "1.6790"} , {fmt: fmt6, input: 12.9, expected: "13"} + , {fmt: fmt7, input: 1.123456789012345678, expected: "1.1234567890123457"} + , {fmt: fmt6, input: 12345678901234567.8, expected: "12,345,678,901,234,568"} + , {fmt: fmt5, input: 123456789012.345678, expected: "123,456,789,012.3457"} ] (\{fmt, input, expected} -> do format fmt input `shouldEqual` expected @@ -105,6 +108,15 @@ fmt6 = Formatter , sign: false } +fmt7 ∷ Formatter +fmt7 = Formatter + { comma: true + , before: 1 + , after: 16 + , abbreviations: false + , sign: false + } + numberformatts ∷ Array { fmt ∷ Formatter, str ∷ String } numberformatts = [ { str: "000.00" From c519738f9845a749403bde1f957829822060cd4c Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 21 Aug 2020 18:21:49 -0400 Subject: [PATCH 7/9] adds documentation to the number formatter explaining the limitations --- src/Data/Formatter/Number.purs | 7 +++++++ test/src/Number.purs | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Data/Formatter/Number.purs b/src/Data/Formatter/Number.purs index d81130a..8b06fa5 100644 --- a/src/Data/Formatter/Number.purs +++ b/src/Data/Formatter/Number.purs @@ -83,6 +83,13 @@ formatParser = do } +-- | Formats a number according to the format object provided. +-- | Due to the nature of floating point numbers, may yield unpredictable results for extremely +-- | large or extremely small numbers, such as numbers whose absolute values are ≥ 1e21 or ≤ 1e-21, +-- | or when formatting with > 20 digits after the decimal place. +-- | See [purescript-decimals](https://pursuit.purescript.org/packages/purescript-decimals/4.0.0) +-- | for working with arbitrary precision decimals, which supports simple number +-- | formatting for numbers that go beyond the precision available with `Number`. format ∷ Formatter → Number → String format (Formatter f) num = let diff --git a/test/src/Number.purs b/test/src/Number.purs index 97d27ee..1a0bac9 100644 --- a/test/src/Number.purs +++ b/test/src/Number.purs @@ -44,9 +44,9 @@ numberTest = describe "Data.Formatter.Number" do , {fmt: fmt1, input: 1.89999, expected: "001.90"} , {fmt: fmt5, input: 1.67899, expected: "1.6790"} , {fmt: fmt6, input: 12.9, expected: "13"} - , {fmt: fmt7, input: 1.123456789012345678, expected: "1.1234567890123457"} - , {fmt: fmt6, input: 12345678901234567.8, expected: "12,345,678,901,234,568"} - , {fmt: fmt5, input: 123456789012.345678, expected: "123,456,789,012.3457"} + , {fmt: fmt7, input: 1.123456789012345678901234, expected: "1.1234567890123457"} + , {fmt: fmt6, input: 12345678901234567.8901234, expected: "12,345,678,901,234,568"} + , {fmt: fmt5, input: 123456789012.345678901234, expected: "123,456,789,012.3457"} ] (\{fmt, input, expected} -> do format fmt input `shouldEqual` expected From 3accec5ff67ddf214e3bdffbd45dfd889332b40f Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 21 Aug 2020 20:38:24 -0400 Subject: [PATCH 8/9] perf improvements --- src/Data/Formatter/Number.js | 2 ++ src/Data/Formatter/Number.purs | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 src/Data/Formatter/Number.js diff --git a/src/Data/Formatter/Number.js b/src/Data/Formatter/Number.js new file mode 100644 index 0000000..2e7f762 --- /dev/null +++ b/src/Data/Formatter/Number.js @@ -0,0 +1,2 @@ + +exports.showNumberAsInt = function (n) { return Math.round(n).toString(); } diff --git a/src/Data/Formatter/Number.purs b/src/Data/Formatter/Number.purs index 8b06fa5..3d64a37 100644 --- a/src/Data/Formatter/Number.purs +++ b/src/Data/Formatter/Number.purs @@ -82,6 +82,7 @@ formatParser = do , abbreviations: isJust abbreviations } +foreign import showNumberAsInt :: Number -> String -- | Formats a number according to the format object provided. -- | Due to the nature of floating point numbers, may yield unpredictable results for extremely @@ -117,8 +118,6 @@ format (Formatter f) num = format (Formatter f{abbreviations = false}) newNum <> abbr else let - period = Str.codePointFromChar '.' - showNumberAsInt = show >>> Str.takeWhile (_ /= period) zeros = f.before - tens - one factor = Math.pow 10.0 (Int.toNumber (max 0 f.after)) rounded = Math.round (absed * factor) / factor From 9545498b378ce9e60146744b9adb7f980d2d98ef Mon Sep 17 00:00:00 2001 From: nathan Date: Fri, 21 Aug 2020 21:08:39 -0400 Subject: [PATCH 9/9] document why the showNumberAsInt function exists --- src/Data/Formatter/Number.js | 3 +++ src/Data/Formatter/Number.purs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/Data/Formatter/Number.js b/src/Data/Formatter/Number.js index 2e7f762..c70fe6b 100644 --- a/src/Data/Formatter/Number.js +++ b/src/Data/Formatter/Number.js @@ -1,2 +1,5 @@ +// converts a number to a string of the nearest integer _without_ appending ".0" (like `show` for `Number`) or +// clamping to +/- 2 billion (like when working with `Int`). This is important for performance compared to other +// means of showing an integer potentially larger than +/- 2 billion. exports.showNumberAsInt = function (n) { return Math.round(n).toString(); } diff --git a/src/Data/Formatter/Number.purs b/src/Data/Formatter/Number.purs index 3d64a37..3187da1 100644 --- a/src/Data/Formatter/Number.purs +++ b/src/Data/Formatter/Number.purs @@ -82,6 +82,9 @@ formatParser = do , abbreviations: isJust abbreviations } +-- converts a number to a string of the nearest integer _without_ appending ".0" (like `show` for `Number`) or +-- clamping to +/- 2 billion (like when working with `Int`). This is important for performance compared to other +-- means of showing an integer potentially larger than +/- 2 billion. foreign import showNumberAsInt :: Number -> String -- | Formats a number according to the format object provided.