From 5133870968a5cab32b827ec05e932419e34d10d2 Mon Sep 17 00:00:00 2001 From: Runar Bjarnason Date: Tue, 23 Sep 2014 22:18:00 -0400 Subject: [PATCH] Added JsonLong and JsonDouble cases for JsonNumber #116 Improved prettyprinting test Add BigDecimal and "lazy" Decimal JsonNumbers. This also switches the parser to just use the new JsonNumber.fromString method to obtain a JsonNumber. This method will return either a JsonLong if the value is a long or a JsonLazyDecimal otherwise. Add JsonNumber.fromString specs. Add some docs for JsonDecimal. Fix parser test to match new class of valid numbers. Allow semantic equality checks on JsonDecimals. Added a `normalized` method to JsonDecimal which allows us to compare JsonDecimals for *numeric* equality, even though we cannot represent them as BigDecimal, in general. Implemented a proper equals on JsonNumber. This is required to get the tests passing again. Ensure numeric equality is preserved in JsonNumber. Fix formatting in JsonNumberSpecification. Add BigInt and BigDecimal encoders/decoders. Better jNumber and friends impl for Strings. --- .gitignore | 2 + src/main/scala/argonaut/DecodeJson.scala | 16 +- src/main/scala/argonaut/EncodeJson.scala | 26 +- src/main/scala/argonaut/Json.scala | 127 ++++++- src/main/scala/argonaut/JsonNumber.scala | 322 ++++++++++++++++++ src/main/scala/argonaut/JsonParser.scala | 9 +- src/main/scala/argonaut/PrettyParams.scala | 9 +- .../argonaut/CodecNumberSpecification.scala | 5 +- .../scala/argonaut/CodecSpecification.scala | 2 + src/test/scala/argonaut/Data.scala | 130 ++++++- .../argonaut/JsonNumberSpecification.scala | 67 ++++ .../argonaut/JsonParserSpecification.scala | 2 +- .../scala/argonaut/JsonSpecification.scala | 14 +- src/test/scala/argonaut/KnownResults.scala | 8 +- .../argonaut/PrettyParamsSpecification.scala | 15 +- .../argonaut/StringWrapSpecification.scala | 4 +- 16 files changed, 705 insertions(+), 53 deletions(-) create mode 100644 src/main/scala/argonaut/JsonNumber.scala create mode 100644 src/test/scala/argonaut/JsonNumberSpecification.scala diff --git a/.gitignore b/.gitignore index 7417718e..412a1a8d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ target *.swp repl-port *.sublime-* +*~ +tags diff --git a/src/main/scala/argonaut/DecodeJson.scala b/src/main/scala/argonaut/DecodeJson.scala index 2dbdac60..a57d4b37 100644 --- a/src/main/scala/argonaut/DecodeJson.scala +++ b/src/main/scala/argonaut/DecodeJson.scala @@ -233,6 +233,16 @@ trait DecodeJsons extends GeneratedDecodeJsons with internal.MacrosCompat { (x.number map (_.toShort)).orElse( (x.string flatMap (s => tryTo(s.toShort)))), "Short") + implicit def BigIntDecodeJson: DecodeJson[BigInt] = + optionDecoder(x => + (x.number map (_.toBigDecimal.toBigInt)).orElse( + (x.string flatMap (s => tryTo(BigInt(s))))), "BigInt") + + implicit def BigDecimalDecodeJson: DecodeJson[BigDecimal] = + optionDecoder(x => + (x.number map (_.toBigDecimal)).orElse( + (x.string flatMap (s => tryTo(BigDecimal(s))))), "BigDecimal") + implicit def BooleanDecodeJson: DecodeJson[Boolean] = optionDecoder(_.bool, "Boolean") @@ -246,13 +256,13 @@ trait DecodeJsons extends GeneratedDecodeJsons with internal.MacrosCompat { optionDecoder(_.number map (_.toFloat), "java.lang.Float") implicit def JIntegerDecodeJson: DecodeJson[java.lang.Integer] = - optionDecoder(_.string flatMap (s => tryTo(s.toInt)), "java.lang.Integer") + optionDecoder(_.number flatMap (s => tryTo(s.toInt)), "java.lang.Integer") implicit def JLongDecodeJson: DecodeJson[java.lang.Long] = - optionDecoder(_.string flatMap (s => tryTo(s.toLong)), "java.lang.Long") + optionDecoder(_.number flatMap (s => tryTo(s.toLong)), "java.lang.Long") implicit def JShortDecodeJson: DecodeJson[java.lang.Short] = - optionDecoder(_.string flatMap (s => tryTo(s.toShort)), "java.lang.Short") + optionDecoder(_.number flatMap (s => tryTo(s.toShort)), "java.lang.Short") implicit def JBooleanDecodeJson: DecodeJson[java.lang.Boolean] = optionDecoder(_.bool map (q => q), "java.lang.Boolean") diff --git a/src/main/scala/argonaut/EncodeJson.scala b/src/main/scala/argonaut/EncodeJson.scala index 47fcb124..1812af3b 100644 --- a/src/main/scala/argonaut/EncodeJson.scala +++ b/src/main/scala/argonaut/EncodeJson.scala @@ -101,19 +101,25 @@ trait EncodeJsons extends GeneratedEncodeJsons with internal.MacrosCompat { EncodeJson(jString) implicit val DoubleEncodeJson: EncodeJson[Double] = - EncodeJson(jNumberOrNull) + EncodeJson(a => JsonDouble(a).asJsonOrNull) implicit val FloatEncodeJson: EncodeJson[Float] = - EncodeJson(a => jNumberOrNull(a)) + EncodeJson(a => JsonDouble(a.toDouble).asJsonOrNull) implicit val IntEncodeJson: EncodeJson[Int] = - EncodeJson(a => jNumberOrNull(a.toDouble)) + EncodeJson(a => JsonLong(a.toLong).asJsonOrNull) implicit val LongEncodeJson: EncodeJson[Long] = - EncodeJson(a => jString(a.toString)) + EncodeJson(a => JsonLong(a).asJsonOrNull) implicit val ShortEncodeJson: EncodeJson[Short] = - EncodeJson(a => jString(a.toString)) + EncodeJson(a => JsonLong(a.toLong).asJsonOrNull) + + implicit val BigDecimalEncodeJson: EncodeJson[BigDecimal] = + EncodeJson(a => JsonBigDecimal(a).asJsonOrNull) + + implicit val BigIntEncodeJson: EncodeJson[BigInt] = + EncodeJson(a => JsonBigDecimal(BigDecimal(a)).asJsonOrNull) implicit val BooleanEncodeJson: EncodeJson[Boolean] = EncodeJson(jBool) @@ -122,19 +128,19 @@ trait EncodeJsons extends GeneratedEncodeJsons with internal.MacrosCompat { EncodeJson(a => jString(a.toString)) implicit val JDoubleEncodeJson: EncodeJson[java.lang.Double] = - EncodeJson(a => jNumberOrNull(a.doubleValue)) + EncodeJson(a => JsonDouble(a.doubleValue).asJsonOrNull) implicit val JFloatEncodeJson: EncodeJson[java.lang.Float] = - EncodeJson(a => jNumberOrNull(a.floatValue.toDouble)) + EncodeJson(a => JsonDouble(a.floatValue.toDouble).asJsonOrNull) implicit val JIntegerEncodeJson: EncodeJson[java.lang.Integer] = - EncodeJson(a => jString(a.toString)) + EncodeJson(a => JsonLong(a.intValue.toLong).asJsonOrNull) implicit val JLongEncodeJson: EncodeJson[java.lang.Long] = - EncodeJson(a => jString(a.toString)) + EncodeJson(a => JsonLong(a.longValue).asJsonOrNull) implicit val JShortEncodeJson: EncodeJson[java.lang.Short] = - EncodeJson(a => jString(a.toString)) + EncodeJson(a => JsonLong(a.shortValue.toLong).asJsonOrNull) implicit val JBooleanEncodeJson: EncodeJson[java.lang.Boolean] = EncodeJson(a => jBool(a.booleanValue)) diff --git a/src/main/scala/argonaut/Json.scala b/src/main/scala/argonaut/Json.scala index 9350bf9f..c673eca2 100644 --- a/src/main/scala/argonaut/Json.scala +++ b/src/main/scala/argonaut/Json.scala @@ -263,7 +263,7 @@ sealed trait Json { * Returns this JSON number object or the value `0` if it is not a number. */ def numberOrZero: JsonNumber = - numberOr(0D) + numberOr(JsonLong(0L)) /** * Returns the string of this JSON value, or an empty string if this JSON value is not a string. @@ -498,6 +498,7 @@ trait Jsons { type JsonAssoc = (JsonField, Json) type JsonAssocList = List[JsonAssoc] type JsonNumber = Double + type JsonObjectMap = scalaz.InsertionMap[JsonField, Json] import PLens._, StoreT._ @@ -516,13 +517,27 @@ trait Jsons { * Note: It is an invalid Prism for NaN, +Infinity and -Infinity as they are not valid json. */ def jDoublePrism: SimplePrism[Json, Double] = - SimplePrism[Json, Double](d => JNumber(d), _.fold(None, _ => None, n => Some(n), _ => None, _ => None, _ => None)) + SimplePrism[Json, Double]( + d => JNumber(JsonDouble(d)), + _.fold(None, + _ => None, + n => Some(n.toDouble), + _ => None, + _ => None, + _ => None)) /** * A Prism for JSON integer values. */ def jIntPrism: SimplePrism[Json, Int] = - SimplePrism[Json, Int](i => JNumber(i.toDouble), _.fold(None, _ => None, n => safeCast[Double, Int].getOption(n), _ => None, _ => None, _ => None)) + SimplePrism[Json, Int]( + i => JNumber(JsonLong(i.toLong)), + _.fold(None, + _ => None, + n => n.safeInt, + _ => None, + _ => None, + _ => None)) /** * A Prism for JSON string values. @@ -626,9 +641,7 @@ trait Jsons { * * Note: NaN, +Infinity and -Infinity are not valid json. */ - val jNumber: JsonNumber => Option[Json] = - number => - (!number.isNaN && !number.isInfinity).option(JNumber(number)) + def jNumber(n: Int): Option[Json] = JsonLong(n).asJson /** * Construct a JSON value that is a number. Transforming @@ -636,8 +649,7 @@ trait Jsons { * the behaviour of most browsers, but is a lossy operation * as you can no longer distinguish between NaN and Infinity. */ - val jNumberOrNull: JsonNumber => Json = - number => jNumber(number).getOrElse(jNull) + def jNumberOrNull(n: Int): Json = JsonLong(n).asJsonOrNull /** * Construct a JSON value that is a number. Transforming @@ -648,8 +660,99 @@ trait Jsons { * interoperability is unlikely without custom handling of * these values. See also `jNumber` and `jNumberOrNull`. */ - val jNumberOrString: JsonNumber => Json = - number => jNumber(number).getOrElse(jString(number.toString)) + def jNumberOrString(n: Int): Json = JsonLong(n).asJsonOrString + + /** + * Construct a JSON value that is a number. + * + * Note: NaN, +Infinity and -Infinity are not valid json. + */ + def jNumber(n: Long): Option[Json] = JsonLong(n).asJson + + /** + * Construct a JSON value that is a number. Transforming + * NaN, +Infinity and -Infinity to jNull. This matches + * the behaviour of most browsers, but is a lossy operation + * as you can no longer distinguish between NaN and Infinity. + */ + def jNumberOrNull(n: Long): Json = JsonLong(n).asJsonOrNull + + /** + * Construct a JSON value that is a number. Transforming + * NaN, +Infinity and -Infinity to their string implementations. + * + * This is an argonaut specific transformation that allows all + * doubles to be encoded without losing information, but aware + * interoperability is unlikely without custom handling of + * these values. See also `jNumber` and `jNumberOrNull`. + */ + def jNumberOrString(n: Long): Json = JsonLong(n).asJsonOrString + + /** + * Construct a JSON value that is a number. + * + * Note: NaN, +Infinity and -Infinity are not valid json. + */ + def jNumber(n: Double): Option[Json] = JsonDouble(n).asJson + + /** + * Construct a JSON value that is a number. Transforming + * NaN, +Infinity and -Infinity to jNull. This matches + * the behaviour of most browsers, but is a lossy operation + * as you can no longer distinguish between NaN and Infinity. + */ + def jNumberOrNull(n: Double): Json = JsonDouble(n).asJsonOrNull + + /** + * Construct a JSON value that is a number. Transforming + * NaN, +Infinity and -Infinity to their string implementations. + * + * This is an argonaut specific transformation that allows all + * doubles to be encoded without losing information, but aware + * interoperability is unlikely without custom handling of + * these values. See also `jNumber` and `jNumberOrNull`. + */ + def jNumberOrString(n: Double): Json = JsonDouble(n).asJsonOrString + + /** + * Construct a JSON value that is a number. + */ + def jNumber(n: BigDecimal): Option[Json] = JsonBigDecimal(n).asJson + + /** + * Construct a JSON value that is a number. + */ + def jNumberOrNull(n: BigDecimal): Json = JsonBigDecimal(n).asJsonOrNull + + /** + * Construct a JSON value that is a number. + */ + def jNumber(n: String): Option[Json] = JsonNumber.fromString(n).flatMap(_.asJson) + + /** + * Construct a JSON value that is a number. Transforming the Strings "NaN", + * "Infinity", "+Infinity" and "-Infinity" to jNull. This matches the + * behaviour of most browsers, but is a lossy operation as you can no longer + * distinguish between NaN and Infinity. + */ + def jNumberOrNull(n: String): Option[Json] = n match { + case "NaN" | "Infinity" | "+Infinity" | "-Infinity" => Some(jNull) + case _ => JsonNumber.fromString(n).flatMap(_.asJson) + } + + /** + * Construct a JSON value that is a number. Transforming the Strings "NaN", + * "Infinity", "+Infinity" and "-Infinity" to their string implementations. + * + * This is an argonaut specific transformation that allows all + * doubles to be encoded without losing information, but aware + * interoperability is unlikely without custom handling of + * these values. See also `jNumber` and `jNumberOrNull`. + */ + def jNumberOrString(n: String): Option[Json] = n match { + case str @ ("NaN" | "Infinity" | "+Infinity" | "-Infinity") => Some(jString(str)) + case _ => JsonNumber.fromString(n).flatMap(_.asJson) + } /** * Construct a JSON value that is a string. @@ -685,7 +788,7 @@ trait Jsons { * A JSON value that is a zero number. */ val jZero: Json = - JNumber(0D) + JNumber(JsonLong(0L)) /** * A JSON value that is an empty string. @@ -743,7 +846,7 @@ trait Jsons { a1 match { case JNull => a2.isNull case JBool(b) => a2.bool exists (_ == b) - case JNumber(n) => a2.number exists (_ == n) + case JNumber(n) => a2.number exists (_ === n) case JString(s) => a2.string exists (_ == s) case JArray(a) => a2.array exists (_ === a) case JObject(o) => a2.obj exists (_ === o) diff --git a/src/main/scala/argonaut/JsonNumber.scala b/src/main/scala/argonaut/JsonNumber.scala new file mode 100644 index 00000000..24ac3354 --- /dev/null +++ b/src/main/scala/argonaut/JsonNumber.scala @@ -0,0 +1,322 @@ +package argonaut + +import scalaz.Equal +import scalaz.Scalaz._ +import monocle.function._ +import monocle.std._ + +/** + * JSON numbers with optimization by cases. + * Note: Javascript numbers are 64-bit decimals. + */ +sealed abstract class JsonNumber { + import Json._ + + def toBigDecimal: BigDecimal + def toDouble: Double + def toFloat: Float + def toLong: Long + def toInt: Int + def toShort: Short + + /** Safely coerce to an `Int` if this number fits in an `Int`, otherwise `None` */ + def safeInt: Option[Int] = safeCast[Double, Int].getOption(toDouble) + + /** Safely coerce to a `Long` if this number fits in a `Long`, otherwise `None` */ + def safeLong: Option[Long] = { + val n = toDouble + (n.floor == n) option toLong + } + + def isNaN: Boolean = false + def isInfinity: Boolean = false + + /** + * Construct a JSON value that is a number. + * + * Note: NaN, +Infinity and -Infinity are not valid json. + */ + def asJson: Option[Json] = + (!isNaN && !isInfinity).option(JNumber(this)) + + /** + * Construct a JSON value that is a number. Transforming + * NaN, +Infinity and -Infinity to jNull. This matches + * the behaviour of most browsers, but is a lossy operation + * as you can no longer distinguish between NaN and Infinity. + */ + def asJsonOrNull: Json = + asJson.getOrElse(jNull) + + /** + * Construct a JSON value that is a number. Transforming + * NaN, +Infinity and -Infinity to their string implementations. + * + * This is an argonaut specific transformation that allows all + * doubles to be encoded without losing information, but aware + * interoperability is unlikely without custom handling of + * these values. See also `jNumber` and `jNumberOrNull`. + */ + def asJsonOrString: Json = + asJson.getOrElse(jString(toString)) + + def toJsonDecimal: JsonDecimal = this match { + case n @ JsonDecimal(_) => n + case JsonBigDecimal(n) => JsonDecimal(n.toString) + case JsonLong(n) => JsonDecimal(n.toString) + case JsonDouble(n) => JsonDecimal(n.toString) + } + + override def hashCode: Int = toJsonDecimal.normalized.hashCode + override def equals(that: Any): Boolean = that match { + case (that: JsonNumber) => this === that + case _ => false + } +} + +/** + * A JsonDecimal represents and valid JSON number as a String. Unfortunately, + * there is no type in the Scala standard library which can represent all valid + * JSON decimal numbers, since the exponent may be larger than an `Int`. Such + * a number can still be round tripped (parser to printer). We lazily parse the + * string to a `BigDecimal` or a `Double` on demand. + */ +case class JsonDecimal private[argonaut] (value: String) extends JsonNumber { + lazy val toBigDecimal: BigDecimal = BigDecimal(value) + lazy val toDouble: Double = value.toDouble + + def toFloat = toDouble.toFloat + def toLong = toBigDecimal.toLong + def toInt = toDouble.toInt + def toShort = toDouble.toShort + + /** + * Returns a *normalized* version of this Decimal number. Since BigDecimal + * cannot represent all valid JSON value exactly, due to the exponent being + * limited to an `Int`, this method let's us get a normalized number that + * can be used to compare for equality. + * + * The 1st value (BigInt) is the exponent used to scale the 2nd value + * (BigDecimal) back to the original value represented by this number. The + * 2nd BigDecimal will always either be 0 or a number with exactly 1 decimal + * digit to the right of the decimal point. If the 2nd value is 0, then the + * exponent (1st value) will be 1 of 3 values: 1, 0, or -1 (the sign of the + * parsed exponent). This is so that we have 1 canonical value for 0, + * undetermined, and infinity, resp. + */ + def normalized: (BigInt, BigDecimal) = { + val JsonNumber.JsonNumberRegex(negative, intStr, decStr, expStr) = value + + def decScale(i: Int): Option[Int] = + if (i >= decStr.length) None + else if (decStr(i) == '0') decScale(i + 1) + else Some(- i - 1) + + val rescale = + if (intStr != "0") Some(intStr.length - 1) + else if (decStr != null) decScale(0) + else Some(0) + + val unscaledExponent = Option(expStr).map(BigInt(_)).getOrElse(BigInt(0)) + rescale match { + case Some(shift) => + val unscaledValue = + if (decStr == null) BigDecimal(intStr) + else BigDecimal(s"$intStr.$decStr") + val scaledValue = BigDecimal(unscaledValue.bigDecimal.movePointLeft(shift)) + (unscaledExponent + shift, if (negative != null) -scaledValue else scaledValue) + + case None => + val exp = + if (unscaledExponent < 0) -1 + else if (unscaledExponent > 0) 1 + else 0 + (BigInt(exp), BigDecimal(0)) + } + } +} + +case class JsonBigDecimal(value: BigDecimal) extends JsonNumber { + def toBigDecimal = value + def toDouble = value.toDouble + def toFloat = value.toFloat + def toInt = value.toInt + def toLong = value.toLong + def toShort = value.toShort + + override def safeInt: Option[Int] = + if (value.isValidInt) Some(toInt) else None + + override def safeLong: Option[Long] = + if (value.isValidLong) Some(toLong) else None +} + +case class JsonLong(value: Long) extends JsonNumber { + def toBigDecimal = BigDecimal(value) + def toDouble = value.toDouble + def toFloat = value.toFloat + def toInt = value.toInt + def toLong = value + def toShort = value.toShort + + override def safeInt: Option[Int] = { + if (value >= Int.MinValue && value <= Int.MaxValue) Some(value.toInt) + else None + } + + override def safeLong: Option[Long] = Some(value) +} + +case class JsonDouble(value: Double) extends JsonNumber { + def toBigDecimal = BigDecimal(value) + def toDouble = value + def toFloat = value.toFloat + def toInt = value.toInt + def toLong = value.toLong + def toShort = value.toShort + override def isNaN = value.isNaN + override def isInfinity = value.isInfinity +} + +object JsonNumber { + implicit val JsonNumberEqual: Equal[JsonNumber] = new Equal[JsonNumber] { + def equal(a: JsonNumber, b: JsonNumber) = (a, b) match { + case (a @ JsonDecimal(_), _) => a.normalized == b.toJsonDecimal.normalized + case (_, b @ JsonDecimal(_)) => a.toJsonDecimal.normalized == b.normalized + case (JsonLong(x), JsonLong(y)) => x == y + case (JsonDouble(x), JsonLong(y)) => x == y + case (JsonLong(x), JsonDouble(y)) => y == x + case (JsonDouble(x), JsonDouble(y)) => x == y + case _ => a.toBigDecimal == b.toBigDecimal + } + } + + /** + * Returns a `JsonNumber` whose value is the valid JSON number in `value`. + * This value is **not** verified to be a valid JSON string. It is assumed + * that `value` is a valid JSON number, according to the JSON specification. + * If the value is invalid then the results are undefined. This is provided + * for use in places like a Jawn parser facade, which provides its own + * verification of JSON numbers. + */ + def unsafeDecimal(value: String): JsonNumber = JsonDecimal(value) + + /** + * Parses a JSON number from a string. A String is valid if it conforms to + * the grammar in the JSON specification (RFC 4627 - + * http://www.ietf.org/rfc/rfc4627.txt), section 2.4. If it is valid, then + * the number is returned in a `Some`. Otherwise the number is invalid and + * `None` is returned. + * + * @param value a JSON number encoded as a string + * @return a JSON number if the string is valid + */ + def fromString(value: String): Option[JsonNumber] = { + + // Span over [0-9]* + def digits(index: Int): Int = { + if (index >= value.length) value.length + else { + val char = value(index) + if (char >= '0' && char <= '9') digits(index + 1) + else index + } + } + + // Verify [0-9]+ + def digits1(index: Int): Int = { + val end = digits(index) + if (end == index) -1 + else end + } + + // Verify 0 | [1-9][0-9]* + def natural(index: Int): Int = { + if (index >= value.length) -1 + else { + val char = value(index) + if (char == '0') index + 1 + else if (char >= '1' && char <= '9') digits(index + 1) + else index + } + } + + // Verify -?(0 | [1-9][0-9]*) + def integer(index: Int): Int = { + if (index >= value.length) -1 + else if (value(index) == '-') natural(index + 1) + else natural(index) + } + + // Span .[0-9]+ + def decimal(index: Int): Int = { + if (index < 0 || index >= value.length) index + else if (value(index) == '.') digits1(index + 1) + else index + } + + // Span e[-+]?[0-9]+ + def exponent(index: Int): Int = { + if (index < 0 || index >= value.length) index + else { + val e = value(index) + if (e == 'e' || e == 'E') { + val index0 = index + 1 + if (index0 < value.length) { + val sign = value(index0) + if (sign == '+' || sign == '-') digits1(index0 + 1) + else digits1(index0) + } else { + -1 + } + } else { + -1 + } + } + } + + val intIndex = integer(0) + val decIndex = decimal(intIndex) + val expIndex = exponent(decIndex) + + val invalid = + (expIndex != value.length) || + (intIndex == 0) || + (intIndex == -1) || + (decIndex == -1) + + // Assuming the number is an integer, does it fit in a Long? + def isLong: Boolean = { + val upperBound = if (value(0) == '-') MinLongString else MaxLongString + (value.length < upperBound.length) || + ((value.length == upperBound.length) && + value.compareTo(upperBound) <= 0) + } + + if (invalid) { + None + } else if (intIndex == expIndex && isLong) { + Some(JsonLong(value.toLong)) + } else { + Some(JsonDecimal(value)) + } + } + + private val MaxLongString = Long.MaxValue.toString + private val MinLongString = Long.MinValue.toString + + /** + * A regular expression that can match a valid JSON number. This has 4 match + * groups: + * + * 1. The optional negative sign. + * 2. The integer part. + * 3. The fractional part without the leading period. + * 4. The exponent part without the leading 'e', but with an optional leading '+' or '-'. + * + * The negative sign, fractional part and exponent part are optional matches + * and may be `null`. + */ + val JsonNumberRegex = + """(-)?((?:[1-9][0-9]*|0))(?:\.([0-9]+))?(?:[eE]([-+]?[0-9]+))?""".r +} diff --git a/src/main/scala/argonaut/JsonParser.scala b/src/main/scala/argonaut/JsonParser.scala index 6f93aaa0..5de4b29e 100644 --- a/src/main/scala/argonaut/JsonParser.scala +++ b/src/main/scala/argonaut/JsonParser.scala @@ -126,6 +126,7 @@ object JsonParser { @tailrec private[this] final def expectValue(stream: TokenSource, position: Int): String \/ (Int, Json) = { + @tailrec def safeNumberIndex(index: Int): Int = { if (index >= stream.length) stream.length @@ -150,10 +151,10 @@ object JsonParser { if (numberEndIndex == position) unexpectedContent(stream, position) else { val numberAsString = stream.substring(position, numberEndIndex) - numberAsString - .parseDouble - .fold(nfe => "Value [%s] cannot be parsed into a number.".format(numberAsString).left, - doubleValue => \/-((numberEndIndex, jNumberOrNull(doubleValue)))) + JsonNumber.fromString(numberAsString) match { + case Some(jn) => \/-((numberEndIndex, jn.asJsonOrNull)) + case None => "Value [%s] cannot be parsed into a number.".format(numberAsString).left + } } } } diff --git a/src/main/scala/argonaut/PrettyParams.scala b/src/main/scala/argonaut/PrettyParams.scala index 3fdb7f83..57375f9b 100644 --- a/src/main/scala/argonaut/PrettyParams.scala +++ b/src/main/scala/argonaut/PrettyParams.scala @@ -89,7 +89,7 @@ sealed trait PrettyParams { private[this] def vectorMemo() = { var vector: Vector[String] = Vector.empty - + val memoFunction: (Int => String) => Int => String = f => k => { val localVector = vector val adjustedK = if (k < 0) 0 else k @@ -166,7 +166,12 @@ sealed trait PrettyParams { k.fold[StringBuilder]( builder.append(nullText) , bool => builder.append(if (bool) trueText else falseText) - , n => builder.append(if (n == n.floor) BigDecimal(n).toBigInt.toString else n.toString) + , n => n match { + case JsonLong(x) => builder append x.toString + case JsonDouble(x) => builder append x.toString + case JsonDecimal(x) => builder append x + case JsonBigDecimal(x) => builder append x.toString + } , s => encloseJsonString(builder, s) , e => { rbracket(e.foldLeft((true, lbracket(builder))){case ((firstElement, builder), subElement) => diff --git a/src/test/scala/argonaut/CodecNumberSpecification.scala b/src/test/scala/argonaut/CodecNumberSpecification.scala index 2e5b7097..0e868f67 100644 --- a/src/test/scala/argonaut/CodecNumberSpecification.scala +++ b/src/test/scala/argonaut/CodecNumberSpecification.scala @@ -12,12 +12,13 @@ object CodecNumberSpecification extends Specification with ScalaCheck { Codec Numbers double that is not NaN or infinity encodes to number $double int always encodes to number $intToNumber - long always encodes to string $longToString + long always encodes to number $longToNumber """ def double = prop { (xs: List[Double]) => xs.filter(x => !x.isNaN && !x.isInfinity).asJson.array.forall(_.forall(_.isNumber)) } def intToNumber = prop { (xs: List[Int]) => xs.asJson.array.forall(_.forall(_.isNumber)) } - def longToString = prop { (xs: List[Long]) => xs.asJson.array.forall(_.forall(_.isString)) } + def longToNumber = prop { (xs: List[Long]) => xs.asJson.array.forall(_.forall(_.isNumber)) } + } diff --git a/src/test/scala/argonaut/CodecSpecification.scala b/src/test/scala/argonaut/CodecSpecification.scala index 9defa314..bbea5e28 100644 --- a/src/test/scala/argonaut/CodecSpecification.scala +++ b/src/test/scala/argonaut/CodecSpecification.scala @@ -55,6 +55,8 @@ object CodecSpecification extends Specification with ScalaCheck { Int encode/decode ${encodedecode[Int]} Long encode/decode ${encodedecode[Long]} Short encode/decode ${encodedecode[Short]} + BigInt encode/decode ${encodedecode[BigInt]} + BigDecimal encode/decode ${encodedecode[BigDecimal]} Boolean encode/decode ${encodedecode[Boolean]} Char encode/decode ${encodedecode[Char]} java.lang.Double encode/decode ${encodedecode[java.lang.Double]} diff --git a/src/test/scala/argonaut/Data.scala b/src/test/scala/argonaut/Data.scala index 921516dc..59b30f89 100644 --- a/src/test/scala/argonaut/Data.scala +++ b/src/test/scala/argonaut/Data.scala @@ -10,7 +10,126 @@ import scala.util.Random.shuffle object Data { val maxJsonStructureDepth = 3 - val jsonNumberGenerator: Gen[JNumber] = arbitrary[Double].map(number => JNumber(number)) + val jsonNumberRepGenerator: Gen[JsonNumber] = Gen.oneOf( + arbitrary[Double].map(JsonDouble(_)), + arbitrary[Long].map(JsonLong(_)) + ) + + val jsonNumberGenerator: Gen[JNumber] = + jsonNumberRepGenerator.map(number => JNumber(number)) + + case class EquivalentJsonNumberPair(_1: JsonNumber, _2: JsonNumber) + + val equivalentJsonNumberPair: Gen[EquivalentJsonNumberPair] = { + def wrapInt(n: Int): Gen[JsonNumber] = Gen.oneOf( + JsonDouble(n), + JsonLong(n), + JsonBigDecimal(n), + JsonDecimal(n.toString) + ) + + def wrapLong(n: Long): Gen[JsonNumber] = Gen.oneOf( + JsonLong(n), + JsonBigDecimal(n), + JsonDecimal(n.toString) + ) + + def wrapDouble(n: Double): Gen[JsonNumber] = Gen.oneOf( + JsonDouble(n), + JsonBigDecimal(BigDecimal(n)), + JsonDecimal(n.toString) + ) + + def wrapBigDecimal(n: BigDecimal): Gen[JsonNumber] = Gen.oneOf( + JsonBigDecimal(n), + JsonDecimal(n.toString) + ) + + def genPair[A](genValue: Gen[A])(wrap: A => Gen[JsonNumber]): Gen[EquivalentJsonNumberPair] = for { + n <- genValue + left <- wrap(n) + right <- wrap(n) + } yield EquivalentJsonNumberPair(left, right) + + case class Decimal(sign: String, unscaledValue: String, scale: BigInt) { + def placeDecimal(n: Int): String = { + val adjustedScale = scale + n + if (n <= 0) { + s"""${sign}${unscaledValue}${("0" * -n)}.0e$adjustedScale""" + } else if (n >= unscaledValue.length) { + s"""${sign}0.${"0" * (n - unscaledValue.length)}${unscaledValue}e$adjustedScale""" + } else { + val (int, frac) = unscaledValue.splitAt(unscaledValue.length - n) + s"""${sign}${int}.${frac}e$adjustedScale""" + } + } + } + + def genDecimal: Gen[Decimal] = for { + unscaledValue <- arbitrary[BigInt].map(_.abs) + if (unscaledValue != 0) + scale <- arbitrary[BigInt] + negative <- arbitrary[Boolean] + } yield Decimal(if (negative) "-" else "", unscaledValue.toString, scale) + + // This generates 2 equivalent JsonDecimals whose string representations + // are not necessarily equal, but whose numeric values are. + def jsonDecimalPair: Gen[EquivalentJsonNumberPair] = for { + decimal <- genDecimal + lshift <- arbitrary[Byte] + left = JsonNumber.unsafeDecimal(decimal.placeDecimal(lshift)) + rshift <- arbitrary[Byte] + right = JsonNumber.unsafeDecimal(decimal.placeDecimal(rshift)) + } yield EquivalentJsonNumberPair(left, right) + + Gen.oneOf( + genPair(arbitrary[Int])(wrapInt), + genPair(arbitrary[Long])(wrapLong), + genPair(arbitrary[Double])(wrapDouble), + genPair(arbitrary[BigDecimal])(wrapBigDecimal), + jsonDecimalPair + ) + } + + case class ValidJsonNumber(value: String) + + /** Generates a random, valid JSON number. */ + val validJsonNumber: Gen[ValidJsonNumber] = { + val digits: Gen[String] = Gen.listOf(Gen.numChar).map(_.mkString) + + val digits1: Gen[String] = for { + head <- Gen.numChar.map(_.toString) + tail <- digits + } yield s"$head$tail" + + val integer: Gen[String] = Gen.oneOf( + Gen.const("0"), + for { + head <- Gen.choose('1', '9').map(_.toString) + tail <- digits + } yield s"$head$tail" + ) + + val decimal: Gen[String] = Gen.oneOf( + Gen.const(""), + digits1.map("." + _) + ) + + val exponent: Gen[String] = Gen.oneOf( + Gen.const(""), + for { + exp <- Gen.oneOf("e", "E") + sgn <- Gen.oneOf("+", "-", "") + num <- digits1 + } yield s"$exp$sgn$num" + ) + + for { + int <- integer + dec <- decimal + exp <- exponent + } yield ValidJsonNumber(s"$int$dec$exp") + } def isValidJSONCharacter(char: Char): Boolean = !char.isControl && char != '\\' && char != '\"' @@ -76,6 +195,15 @@ object Data { implicit def ArbitraryJNumber: Arbitrary[JNumber] = Arbitrary(jsonNumberGenerator) + implicit def ArbitraryJsonNumber: Arbitrary[JsonNumber] = + Arbitrary(jsonNumberRepGenerator) + + implicit def ArbitraryValidJsonNumber: Arbitrary[ValidJsonNumber] = + Arbitrary(validJsonNumber) + + implicit def ArbitraryEquivalentJsonNumberPair: Arbitrary[EquivalentJsonNumberPair] = + Arbitrary(equivalentJsonNumberPair) + implicit def ArbitraryJArray: Arbitrary[JArray] = Arbitrary(jsonArrayGenerator()) implicit def ArbitraryJObject: Arbitrary[JObject] = Arbitrary(jsonObjectGenerator()) diff --git a/src/test/scala/argonaut/JsonNumberSpecification.scala b/src/test/scala/argonaut/JsonNumberSpecification.scala new file mode 100644 index 00000000..ded4bca9 --- /dev/null +++ b/src/test/scala/argonaut/JsonNumberSpecification.scala @@ -0,0 +1,67 @@ +package argonaut + +import org.scalacheck._, Prop._, Arbitrary._, Gen._ +import org.specs2._, org.specs2.specification._ +import org.specs2.matcher._ + +import Argonaut._ +import Data._ + +object JsonNumberSpecification extends Specification with ScalaCheck { + def is = s2""" + fromString should + Parse valid JSON number. $parseValidJsonNumbers + Parse to Long when value fits in a Long. $longValuesProduceLongs + Fail on empty string. $failOnEmptyString + Fail on missing integer part. $failOnMissingInteger + Fail on trailing decimal point. $failOnTrailingDecimal + Fail on leading zero. $failOnLeadingZero + Fail on trailing 'e'. $failOnTrailingE + + equals should + Equivalent numbers are equal. $equivalentNumbersAreEqual + """ + + def longValuesProduceLongs = prop { (value: Long) => + JsonNumber.fromString(value.toString) must_== Some(JsonLong(value)) + } + + def parseValidJsonNumbers = prop { (num: ValidJsonNumber) => + JsonNumber.fromString(num.value) must beSome.like{ + case JsonDecimal(value) => num.value must_== value + case JsonLong(value) => num.value must_== value.toString + } + } + + def equivalentNumbersAreEqual = prop { (pair: EquivalentJsonNumberPair) => + val EquivalentJsonNumberPair(lhs, rhs) = pair + lhs must_== rhs + } + + def failOnEmptyString = JsonNumber.fromString("") must beNone + + def failOnMissingInteger = { + JsonNumber.fromString(".012e100") must beNone + JsonNumber.fromString(".1234") must beNone + } + + def failOnTrailingDecimal = { + JsonNumber.fromString("0.") must beNone + JsonNumber.fromString("-12.") must beNone + JsonNumber.fromString("13.e-100") must beNone + } + + def failOnLeadingZero = { + JsonNumber.fromString("0123") must beNone + JsonNumber.fromString("-001") must beNone + } + + def failOnTrailingE = { + JsonNumber.fromString("1e") must beNone + JsonNumber.fromString("1e+") must beNone + JsonNumber.fromString("1e-") must beNone + JsonNumber.fromString("1E") must beNone + JsonNumber.fromString("1E+") must beNone + JsonNumber.fromString("1E-") must beNone + } +} diff --git a/src/test/scala/argonaut/JsonParserSpecification.scala b/src/test/scala/argonaut/JsonParserSpecification.scala index 71ac6a4a..2b9ffc17 100644 --- a/src/test/scala/argonaut/JsonParserSpecification.scala +++ b/src/test/scala/argonaut/JsonParserSpecification.scala @@ -20,7 +20,7 @@ object JsonParserSpecification extends Specification with DataTables with ScalaC val whitespaceGen: Gen[String] = listOf(Gen.oneOf(' ', '\n', '\r', '\t')).map(_.mkString) val whitespaceObjectGen: Gen[String] = whitespaceGen.map(whitespace => """#{#"field1"#:#12#,#"field2"#:#"test"#}#""".replace("#", whitespace)) - val whitespaceObject: Json = ("field1" := 12.0d) ->: ("field2" := "test") ->: jEmptyObject + val whitespaceObject: Json = ("field1" := 12) ->: ("field2" := "test") ->: jEmptyObject val whitespaceArrayGen: Gen[String] = whitespaceGen.map(whitespace => """#[#"value1"#,#12#]#""".replace("#", whitespace)) val whitespaceArray: Json = jArray(jString("value1") :: jNumberOrNull(12) :: Nil) diff --git a/src/test/scala/argonaut/JsonSpecification.scala b/src/test/scala/argonaut/JsonSpecification.scala index dd0bd3f6..a67efa5e 100644 --- a/src/test/scala/argonaut/JsonSpecification.scala +++ b/src/test/scala/argonaut/JsonSpecification.scala @@ -40,8 +40,12 @@ object JsonSpecification extends Specification with ScalaCheck { def sameValue = prop((j: Json) => j === j) def modString = prop((j: JString) => j.withString(_ + "test") /== j) - - def modNumber = prop((j: JNumber) => j.withNumber(number => if (number === 0.0d) number + 1 else number * 2) /== j) + + def modNumber = prop((j: JNumber) => j.withNumber { number => + JsonLong(number.safeInt.map(n => + if (n === 0) (n + 1) else (n * 2) + ).getOrElse(0).toLong) + } /== j) def modArray = prop((j: JArray) => j.withArray(jEmptyArray :: _) /== j) @@ -52,7 +56,7 @@ object JsonSpecification extends Specification with ScalaCheck { def notComposeNot = prop((j: Json) => j.not.not === j) def noEffect = prop((j: Json) => (j.not === j) !== j.isBool) - + def effectNotIsBool = prop((j: Json) => (j.not /== j) === j.isBool) def effectWithNumber = prop((j: Json, k: JsonNumber => JsonNumber) => ((j withNumber k) === j) || j.isNumber) @@ -61,13 +65,13 @@ object JsonSpecification extends Specification with ScalaCheck { def effectWithArray = prop((j: Json, k: List[Json] => List[Json]) => ((j withArray k) === j) || j.isArray) - def effectWithObject = prop((j: Json, k: JsonObject => JsonObject) => ((j withObject k) === j) || j.isObject) + def effectWithObject = prop((j: Json, k: JsonObject => JsonObject) => ((j withObject k) === j) || j.isObject) def arrayPrepend = prop((j: Json, e: Json) => !j.isArray || (e -->>: j).array.map(_.head) === e.some) def isBool = prop((b: Boolean) => jBool(b).isBool) - def isNumber = prop((n: JsonNumber) => !n.isNaN && !n.isInfinity ==> jNumberOrNull(n).isNumber) + def isNumber = prop((n: JsonNumber) => !n.isNaN && !n.isInfinity ==> n.asJsonOrNull.isNumber) def isString = prop((s: String) => jString(s).isString) diff --git a/src/test/scala/argonaut/KnownResults.scala b/src/test/scala/argonaut/KnownResults.scala index cceca298..0576f1b8 100644 --- a/src/test/scala/argonaut/KnownResults.scala +++ b/src/test/scala/argonaut/KnownResults.scala @@ -34,10 +34,10 @@ object KnownResults extends DataTables { "1" ! jNumberOrNull(1) | "-1" ! jNumberOrNull(-1) | "0" ! jNumberOrNull(0) | - "1E999" ! jNumberOrNull("1E999".toDouble) | - "1E+999" ! jNumberOrNull("1E+999".toDouble) | - "1E-999" ! jNumberOrNull("1E-999".toDouble) | - "158699798998941697" ! jNumberOrNull(158699798998941697D) + "1E999" ! jNumberOrNull("1E999") | + "1E+999" ! jNumberOrNull("1E+999") | + "1E-999" ! jNumberOrNull("1E-999") | + "158699798998941697" ! jNumberOrNull(158699798998941697L) def parseFailures = "JSON" | "parse result" | diff --git a/src/test/scala/argonaut/PrettyParamsSpecification.scala b/src/test/scala/argonaut/PrettyParamsSpecification.scala index 9d129d57..3f4e175d 100644 --- a/src/test/scala/argonaut/PrettyParamsSpecification.scala +++ b/src/test/scala/argonaut/PrettyParamsSpecification.scala @@ -195,11 +195,12 @@ object PrettyParamsSpecification extends Specification with ScalaCheck { case 4 => json.spaces4 } printedJson === jsonSpacesMap(secondIndex) - } - val numbersWhole = prop{(n: Long) => - jNumberOrNull(n).nospaces === "%.0f".format(n.toDouble) - } - val numbersFractional = forAll(arbitrary[(Double, Double)].filter{case (first, second) => second != 0}.map(pair => pair._1 / pair._2).filter(d => d != d.floor)){d => - jNumberOrNull(d).nospaces === d.toString - } + } ^ end + val numbers: Fragments = "number printing" ^ + "whole number pretty print" ! prop{(n: Long) => + jNumberOrNull(n).nospaces === n.toString + } ^ + "fractional number pretty print" ! prop{(d: Double) => + jNumberOrNull(d).nospaces === d.toString + } ^ end } diff --git a/src/test/scala/argonaut/StringWrapSpecification.scala b/src/test/scala/argonaut/StringWrapSpecification.scala index 01726383..c55ae2c3 100644 --- a/src/test/scala/argonaut/StringWrapSpecification.scala +++ b/src/test/scala/argonaut/StringWrapSpecification.scala @@ -24,7 +24,7 @@ object StringWrapSpecification extends Specification with ScalaCheck { parse Optional encode ${ (("optional" :?= (None: Option[String])) ->?: jEmptyObject) must_== jEmptyObject - } + } Optional encode alias ${ prop { (o: Option[Int]) => (("optional" :?= o) ->?: jEmptyObject) must_== (("optional" :?= o) ->?: jEmptyObject) @@ -85,7 +85,7 @@ object StringWrapSpecification extends Specification with ScalaCheck { json.decodeWith[Option[Person], Person](_ => None, _ => None, (_, _) => Person("Test", 5).some) === Person("Test", 5).some } } - + decodeOr[A, X: DecodeJson](X => A, => A): A returns the decoded and transformed Json for valid JSON ${ forAllNoShrink(alphaStr, arbitrary[Int]) { (name: String, age: Int) =>