diff --git a/Package.swift b/Package.swift index 862bb2d..dbadbc8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.2 import PackageDescription let package: Package = .init( @@ -63,3 +63,20 @@ let package: Package = .init( ), ] ) +package.targets = package.targets.map { + switch $0.type { + case .plugin: return $0 + case .binary: return $0 + default: break + } + { + var settings: [SwiftSetting] = $0 ?? [] + + settings.append(.enableUpcomingFeature("ExistentialAny")) + settings.append(.treatWarning("ExistentialAny", as: .error)) + settings.append(.treatWarning("MutableGlobalVariable", as: .error)) + + $0 = settings + } (&$0.swiftSettings) + return $0 +} diff --git a/README.md b/README.md index 7972eb3..09e8c4a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A pure Swift JSON parsing and encoding library designed for high-performance, hi ## Requirements -The swift-json library requires Swift 5.9 or later. +The swift-json library requires Swift 6.2 or later. | Platform | Status | diff --git a/Sources/JSONAST/JSON.Array.swift b/Sources/JSONAST/JSON.Array.swift index 8b9efb3..0fffcd0 100644 --- a/Sources/JSONAST/JSON.Array.swift +++ b/Sources/JSONAST/JSON.Array.swift @@ -1,6 +1,6 @@ extension JSON { /// A JSON array, which can recursively contain instances of ``JSON``. - /// This type is a transparent wrapper around a native [`[JSON]`]() + /// This type is a transparent wrapper around a native `[JSON]` /// array. @frozen public struct Array { public var elements: [JSON.Node] diff --git a/Sources/JSONAST/JSON.Node.swift b/Sources/JSONAST/JSON.Node.swift index ff12564..5fce2d5 100644 --- a/Sources/JSONAST/JSON.Node.swift +++ b/Sources/JSONAST/JSON.Node.swift @@ -12,8 +12,6 @@ extension JSON { case string(Literal) /// A numerical value. case number(Number) - /// A numerical value that is not a finite number. - case numberExtension_(NumberExtension_) /// An array container. case array(Array) @@ -42,7 +40,7 @@ extension JSON.Node { /// /// - Parameters: /// - string: A string to escape. - /// - Returns: A string literal, which includes the [`""`]() delimiters. + /// - Returns: A string literal, which includes the `""` delimiters. /// /// This function escapes the following characters: `"`, `\`, `\b`, `\t`, `\n`, /// `\f`, and `\r`. It does not escape forward slashes (`/`). @@ -89,7 +87,6 @@ extension JSON.Node: CustomStringConvertible { case .bool(false): "false" case .string(let self): .init(self) case .number(let self): "\(self)" - case .numberExtension_(let self): "\(self)" case .array(let self): "\(self)" case .object(let self): "\(self)" } @@ -163,7 +160,7 @@ extension JSON.Node { /// matches ``number(_:) [case]``, but it could not be represented exactly by `T`. /// /// > Note: - /// This type conversion will fail if ``Number.places`` is non-zero, even if + /// This type conversion will fail if ``Number.Inline/places`` is non-zero, even if /// the fractional part is zero. For example, you can convert `5` to an /// integer, but not `5.0`. This matches the behavior of /// ``ExpressibleByIntegerLiteral``. @@ -192,7 +189,7 @@ extension JSON.Node { /// matches ``number(_:) [case]``, but it could not be represented exactly by `T`. /// /// > Note: - /// This type conversion will fail if ``Number.places`` is non-zero, even if + /// This type conversion will fail if ``Number.Inline/places`` is non-zero, even if /// the fractional part is zero. For example, you can convert `5` to an /// integer, but not `5.0`. This matches the behavior of /// ``ExpressibleByIntegerLiteral``. @@ -234,6 +231,16 @@ extension JSON.Node { @inlinable public func `as`(_: Float.Type) -> Float? { self.as(JSON.Number.self)?.as(Float.self) } + /// Attempts to load an instance of ``Float16`` from this variant. + /// + /// - Returns: + /// The closest value of ``Float16`` to the payload of this variant if it matches + /// ``number(_:) [case]``, `nil` otherwise. + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + public func `as`(_: Float16.Type) -> Float16? { + self.as(JSON.Number.self)?.as(Float16.self) + } + /// Attempts to load an instance of ``Number`` from this variant. /// /// - Returns: @@ -299,21 +306,12 @@ extension JSON.Node { /// source object. For more details about the payload, see the documentation /// for ``object(_:)``. /// - /// To facilitate interoperability with decimal types, this method will also - /// return a pseudo-object containing the values of ``Number.units`` and - /// ``Number.places``, if this variant is a ``number(_:) [case]``. This function - /// creates the pseudo-object by calling ``Object.init(encoding:)``. - /// /// > Complexity: /// O(1). This method does *not* perform any elementwise work. @inlinable public var object: JSON.Object? { switch self { - case .object(let items): - items - case .number(let number): - .init(encoding: number) - default: - nil + case .object(let items): items + default: nil } } } diff --git a/Sources/JSONAST/JSON.Number.Base10.Inverse.swift b/Sources/JSONAST/JSON.Number.Base10.Inverse.swift index b09afe5..cbf4286 100644 --- a/Sources/JSONAST/JSON.Number.Base10.Inverse.swift +++ b/Sources/JSONAST/JSON.Number.Base10.Inverse.swift @@ -1,10 +1,10 @@ extension JSON.Number.Base10 { - /// Negative powers of 10, down to [`1e-19`](). + /// Negative powers of 10, down to `1e-19`. enum Inverse { /// Returns the inverse of the given power of 10. /// - Parameters: - /// - x: A positive exponent. If `x` is [`2`](), this subscript - /// will return [`1e-2`](). + /// - x: A positive exponent. If `x` is `2`, this subscript + /// will return `1e-2`. /// - _: A ``BinaryFloatingPoint`` type. static subscript(x: Int, as _: T.Type) -> T where T: BinaryFloatingPoint { diff --git a/Sources/JSONAST/JSON.Number.Inline.swift b/Sources/JSONAST/JSON.Number.Inline.swift new file mode 100644 index 0000000..def65de --- /dev/null +++ b/Sources/JSONAST/JSON.Number.Inline.swift @@ -0,0 +1,132 @@ +extension JSON.Number { + /// This type is memory-efficient, and can store fixed-point numbers with + /// up to 64 bits of precision. It uses all 64 bits to encode its magnitude, + /// which enables it to round-trip integers of width up to ``UInt64``. + @frozen public struct Inline: Hashable, Equatable, Sendable { + // this layout should allow instances of `Number` to fit in 2 words. + // this is backed by an `Int`, but the swift compiler can optimize it + // into a `UInt8`-sized field + + /// The sign of this numeric literal. + public var sign: FloatingPointSign + // cannot have an inlinable property wrapper + @usableFromInline internal var _places: UInt32 + /// The number of decimal places this numeric literal has. + /// + /// > Note: + /// > This property has type ``UInt64`` to facilitate computations with + /// ``units``. It is backed by a ``UInt32`` and can therefore only store + /// 32 bits of precision. + @inlinable public var places: UInt64 { + .init(self._places) + } + /// The magnitude of this numeric literal, expressed in units of ``places``. + /// + /// If ``units`` is `123`, and ``places`` is `2`, that would represent + /// a magnitude of `1.23`. + public var units: UInt64 + /// Creates a numeric literal. + /// - Parameters: + /// - sign: The sign, positive or negative. + /// - units: The magnitude, in units of `places`. + /// - places: The number of decimal places. + @inlinable public init(sign: FloatingPointSign, units: UInt64, places: UInt32 = 0) { + self.sign = sign + self.units = units + self._places = places + } + } +} +extension JSON.Number.Inline { + @inlinable public init(_ signed: T) where T: SignedInteger { + self.init(sign: signed < 0 ? .minus : .plus, units: UInt64.init(signed.magnitude)) + } + @inlinable public init(_ unsigned: T) where T: UnsignedInteger { + self.init(sign: .plus, units: UInt64.init(unsigned)) + } +} +extension JSON.Number.Inline { + @inlinable public func `as`( + _: T.Type + ) -> T? where T: FixedWidthInteger & UnsignedInteger { + guard self.places == 0 else { + return nil + } + switch self.sign { + case .minus: + return self.units == 0 ? 0 : nil + case .plus: + return T.init(exactly: self.units) + } + } + + @inlinable public func `as`( + _: T.Type + ) -> T? where T: FixedWidthInteger & SignedInteger { + guard self.places == 0 else { + return nil + } + switch self.sign { + case .minus: + let negated: Int64 = .init(bitPattern: 0 &- self.units) + return negated <= 0 ? T.init(exactly: negated) : nil + case .plus: + return T.init(exactly: self.units) + } + } + + @inlinable public func `as`(_: (units: T, places: T).Type) -> (units: T, places: T)? + where T: FixedWidthInteger & SignedInteger { + guard let places: T = T.init(exactly: self.places) else { + return nil + } + switch self.sign { + case .minus: + let negated: Int64 = Int64.init(bitPattern: 0 &- self.units) + guard negated <= 0, + let units: T = T.init(exactly: negated) else { + return nil + } + return (units: units, places: places) + case .plus: + guard let units: T = T.init(exactly: self.units) else { + return nil + } + return (units: units, places: places) + } + } +} +extension JSON.Number.Inline: CustomStringConvertible { + /// Returns a zero-padded string representation of this numeric literal. + /// + /// This property always formats the number with full precision. + /// If ``units`` is `100` and ``places`` is `2`, this will return + /// `"1.00"`. + /// + /// This string is guaranteed to be round-trippable; reparsing it + /// will always return the same value. + /// + /// > Warning: + /// > This string is not necessarily identical to how this literal was + /// written in its original source file. In particular, if it was + /// written with an exponent, the exponent would have been normalized + /// into ``units`` and ``places``. + public var description: String { + guard self.places > 0 else { + switch self.sign { + case .plus: return "\(self.units)" + case .minus: return "-\(self.units)" + } + } + let places: Int = .init(self.places) + let unpadded: String = .init(self.units) + let string: String = """ + \(String.init(repeating: "0", count: Swift.max(0, 1 + places - unpadded.count)))\ + \(unpadded) + """ + switch self.sign { + case .plus: return "\(string.dropLast(places)).\(string.suffix(places))" + case .minus: return "-\(string.dropLast(places)).\(string.suffix(places))" + } + } +} diff --git a/Sources/JSONAST/JSON.Number.swift b/Sources/JSONAST/JSON.Number.swift index b8baa78..234dce3 100644 --- a/Sources/JSONAST/JSON.Number.swift +++ b/Sources/JSONAST/JSON.Number.swift @@ -1,50 +1,45 @@ extension JSON { /// A lossless representation of a numeric literal. - /// - /// This type is memory-efficient, and can store fixed-point numbers with - /// up to 64 bits of precision. It uses all 64 bits to encode its magnitude, - /// which enables it to round-trip integers of width up to ``UInt64``. - @frozen public struct Number: Hashable, Equatable, Sendable { - // this layout should allow instances of `Number` to fit in 2 words. - // this is backed by an `Int`, but the swift compiler can optimize it - // into a `UInt8`-sized field - - /// The sign of this numeric literal. - public var sign: FloatingPointSign - // cannot have an inlinable property wrapper - @usableFromInline internal var _places: UInt32 - /// The number of decimal places this numeric literal has. - /// - /// > Note: - /// > This property has type ``UInt64`` to facilitate computations with - /// ``units``. It is backed by a ``UInt32`` and can therefore only store - /// 32 bits of precision. - @inlinable public var places: UInt64 { - .init(self._places) - } - /// The magnitude of this numeric literal, expressed in units of ``places``. - /// - /// If ``units`` is [`123`](), and ``places`` is [`2`](), that would represent - /// a magnitude of [`1.23`](). - public var units: UInt64 - /// Creates a numeric literal. - /// - Parameters: - /// - sign: The sign, positive or negative. - /// - units: The magnitude, in units of `places`. - /// - places: The number of decimal places. - @inlinable public init(sign: FloatingPointSign, units: UInt64, places: UInt32 = 0) { - self.sign = sign - self.units = units - self._places = places + @frozen public enum Number: Hashable, Equatable, Sendable { + case fallback(String) + case infinity(FloatingPointSign) + case inline(Inline) + case nan + case snan + } +} +extension JSON.Number: CustomStringConvertible { + @inlinable public var description: String { + switch self { + case .fallback(let string): string + case .infinity(.plus): "inf" + case .infinity(.minus): "-inf" + case .inline(let self): "\(self)" + case .nan: "nan" + case .snan: "snan" } } } extension JSON.Number { - @inlinable public init(_ signed: T) where T: SignedInteger { - self.init(sign: signed < 0 ? .minus : .plus, units: UInt64.init(signed.magnitude)) + @inlinable public init(_ value: T) where T: SignedInteger { + self = .inline(.init(value)) + } + @inlinable public init(_ value: T) where T: UnsignedInteger { + self = .inline(.init(value)) } - @inlinable public init(_ unsigned: T) where T: UnsignedInteger { - self.init(sign: .plus, units: UInt64.init(unsigned)) + @inlinable public init( + _ value: T + ) where T: BinaryFloatingPoint & LosslessStringConvertible { + // must check this first, because `isNaN` is true for signaling NaN as well + if value.isSignalingNaN { + self = .snan + } else if value.isNaN { + self = .nan + } else if value.isInfinite { + self = .infinity(value.sign) + } else { + self = .fallback("\(value)") + } } } extension JSON.Number { @@ -53,49 +48,40 @@ extension JSON.Number { /// - Parameters: /// - _: A type conforming to ``UnsignedInteger`` (and ``FixedWidthInteger``). /// - Returns: - /// The value of this numeric literal as an instance of [`T`](), or - /// nil if it is negative, fractional, or would overflow [`T`](). + /// The value of this numeric literal as an instance of `T`, or + /// nil if it is negative, fractional, or would overflow `T`. /// > Note: - /// This type conversion will fail if ``places`` is non-zero, even if + /// This type conversion will fail if ``Inline/places`` is non-zero, even if /// the fractional part is zero. For example, you can convert - /// [`5`]() to an integer, but not [`5.0`](). This matches the behavior + /// `5` to an integer, but not `5.0`. This matches the behavior /// of ``ExpressibleByIntegerLiteral``. @inlinable public func `as`( _: T.Type ) -> T? where T: FixedWidthInteger & UnsignedInteger { - guard self.places == 0 else { + guard case .inline(let self) = self else { return nil } - switch self.sign { - case .minus: - return self.units == 0 ? 0 : nil - case .plus: - return T.init(exactly: self.units) - } + return self.as(T.self) } /// Converts this numeric literal to a signed integer, if it can be /// represented exactly. /// - Parameters: /// - _: A type conforming to ``SignedInteger`` (and ``FixedWidthInteger``). /// - Returns: - /// The value of this numeric literal as an instance of [`T`](), or - /// nil if it is fractional or would overflow [`T`](). + /// The value of this numeric literal as an instance of `T`, or + /// nil if it is fractional or would overflow `T`. /// > Note: - /// This type conversion will fail if ``places`` is non-zero, even if + /// This type conversion will fail if ``Inline/places`` is non-zero, even if /// the fractional part is zero. For example, you can convert - /// [`5`]() to an integer, but not [`5.0`](). This matches the behavior + /// `5` to an integer, but not `5.0`. This matches the behavior /// of ``ExpressibleByIntegerLiteral``. - @inlinable public func `as`(_: T.Type) -> T? where T: FixedWidthInteger & SignedInteger { - guard self.places == 0 else { + @inlinable public func `as`( + _: T.Type + ) -> T? where T: FixedWidthInteger & SignedInteger { + guard case .inline(let self) = self else { return nil } - switch self.sign { - case .minus: - let negated: Int64 = .init(bitPattern: 0 &- self.units) - return negated <= 0 ? T.init(exactly: negated) : nil - case .plus: - return T.init(exactly: self.units) - } + return self.as(T.self) } /// Converts this numeric literal to a fixed-point decimal, if it can be /// represented exactly. @@ -104,122 +90,69 @@ extension JSON.Number { /// (and ``FixedWidthInteger``). /// - Returns: /// The value of this numeric literal as an instance of - /// [`(units:T, places:T)`](), or nil if the value of either - /// field would overflow [`T`](). + /// `(units:T, places:T)`, or nil if the value of either + /// field would overflow `T`. /// > Note: /// It’s possible for the `places` field to overflow before `units` does. - /// For example, this will happen for the literal [`"0.0e-9999999999999999999"`](). + /// For example, this will happen for the literal `"0.0e-9999999999999999999"`. @inlinable public func `as`(_: (units: T, places: T).Type) -> (units: T, places: T)? where T: FixedWidthInteger & SignedInteger { - guard let places: T = T.init(exactly: self.places) else { + guard case .inline(let self) = self else { return nil } - switch self.sign { - case .minus: - let negated: Int64 = Int64.init(bitPattern: 0 &- self.units) - guard negated <= 0, - let units: T = T.init(exactly: negated) else { - return nil - } - return (units: units, places: places) - case .plus: - guard let units: T = T.init(exactly: self.units) else { - return nil - } - return (units: units, places: places) - } + return self.as((units: T, places: T).self) } - +} +extension JSON.Number { // Note: There is currently a compiler crash // // https://github.com/apple/swift/issues/63775 // - // that prevents ``nearest(_:)`` from being inlined into clients, + // that prevents ``parsed(as:)`` from being inlined into clients, // because it uses a lookup table for negative powers of ten. // Therefore, we provide manual specializations for ``Float80``, // ``Double``, and ``Float`` instead. On the bright side, this // means we don’t need to emit a giant conversion function into - // the client. (We just have three giant conversion function + // the client. (We just have four giant conversion function // specializations in the library.) #if (os(Linux) || os(macOS)) && arch(x86_64) /// Converts this numeric literal to a ``Float80`` value, or its closest /// floating-point representation. - public func `as`(_: Float80.Type) -> Float80 { - self.nearest(Float80.self) + public func `as`(_: Float80.Type) -> Float80? { + self.parsed(as: Float80.self) } #endif /// Converts this numeric literal to a ``Double`` value, or its closest /// floating-point representation. - public func `as`(_: Double.Type) -> Double { - self.nearest(Double.self) + public func `as`(_: Double.Type) -> Double? { + self.parsed(as: Double.self) } /// Converts this numeric literal to a ``Float`` value, or its closest /// floating-point representation. - public func `as`(_: Float.Type) -> Float { - self.nearest(Float.self) + public func `as`(_: Float.Type) -> Float? { + self.parsed(as: Float.self) } - /// Converts this numeric literal to a floating-point value, or its closest + /// Converts this numeric literal to a ``Float16`` value, or its closest /// floating-point representation. - /// - /// - Parameters: - /// - _: A type conforming to ``BinaryFloatingPoint``. - /// - Returns: - /// The value of this numeric literal as an instance of - /// [`T`](), or the value of [`T`]() closest to it. - private func nearest(_: T.Type) -> T where T: BinaryFloatingPoint { - var places: Int = .init(self.places), - units: UInt64 = self.units - // steve canon, feel free to submit a PR - while places > 0 { - guard case (let quotient, remainder: 0) = units.quotientAndRemainder( - dividingBy: 10 - ) else { - switch self.sign { - case .minus: return -T.init(units) * Base10.Inverse[places, as: T.self] - case .plus: return T.init(units) * Base10.Inverse[places, as: T.self] - } - } - units = quotient - places -= 1 - } - switch self.sign { - case .minus: return -T.init(units) - case .plus: return T.init(units) - } + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) + public func `as`(_: Float16.Type) -> Float16? { + self.parsed(as: Float16.self) } } -extension JSON.Number: CustomStringConvertible { - /// Returns a zero-padded string representation of this numeric literal. - /// - /// This property always formats the number with full precision. - /// If ``units`` is [`100`]() and ``places`` is [`2`](), this will return - /// [`"1.00"`](). - /// - /// This string is guaranteed to be round-trippable; reparsing it - /// will always return the same value. - /// - /// > Warning: - /// > This string is not necessarily identical to how this literal was - /// written in its original source file. In particular, if it was - /// written with an exponent, the exponent would have been normalized - /// into ``units`` and ``places``. - public var description: String { - guard self.places > 0 else { - switch self.sign { - case .plus: return "\(self.units)" - case .minus: return "-\(self.units)" - } - } - let places: Int = .init(self.places) - let unpadded: String = .init(self.units) - let string: String = """ - \(String.init(repeating: "0", count: Swift.max(0, 1 + places - unpadded.count)))\ - \(unpadded) - """ - switch self.sign { - case .plus: return "\(string.dropLast(places)).\(string.suffix(places))" - case .minus: return "-\(string.dropLast(places)).\(string.suffix(places))" +extension JSON.Number { + /// We want floating point types to roundtrip losslessly, the only way to guarantee that is + /// to render the number as a string and parse it using the standard library parser. + private func parsed( + as _: FloatingPoint.Type + ) -> FloatingPoint? where FloatingPoint: BinaryFloatingPoint & LosslessStringConvertible { + switch self { + case .fallback(let string): FloatingPoint.init(string) + case .infinity(.minus): -FloatingPoint.infinity + case .infinity(.plus): FloatingPoint.infinity + case .inline(let inline): FloatingPoint.init("\(inline)") + case .nan: FloatingPoint.nan + case .snan: FloatingPoint.signalingNaN } } } diff --git a/Sources/JSONAST/JSON.NumberExtension_.swift b/Sources/JSONAST/JSON.NumberExtension_.swift deleted file mode 100644 index 3ef412d..0000000 --- a/Sources/JSONAST/JSON.NumberExtension_.swift +++ /dev/null @@ -1,15 +0,0 @@ -extension JSON { - @frozen public enum NumberExtension_: Sendable { - case NaN - case infinity(FloatingPointSign) - } -} -extension JSON.NumberExtension_: CustomStringConvertible { - @inlinable public var description: String { - switch self { - case .NaN: "NaN" - case .infinity(.plus): "inf" - case .infinity(.minus): "-inf" - } - } -} diff --git a/Sources/JSONAST/JSON.Object.swift b/Sources/JSONAST/JSON.Object.swift index 980a5f3..9fe027d 100644 --- a/Sources/JSONAST/JSON.Object.swift +++ b/Sources/JSONAST/JSON.Object.swift @@ -1,7 +1,7 @@ extension JSON { /// A string-keyed JSON object, which can recursively contain instances of /// ``JSON``. This type is a transparent wrapper around a native - /// [`[(key:String, value:JSON)]`]() array. + /// `[(key: String, value: JSON)]` array. /// /// JSON objects are more closely-related to ``KeyValuePairs`` than to /// ``Dictionary``, since object keys can occur more than once in the same @@ -32,21 +32,6 @@ extension JSON { } } } -extension JSON.Object { - /// Creates a pseudo-object containing integral ``Number`` values taken - /// from the supplied `number`, keyed by `"units"` and `"places"` and - /// wrapped in containers of type `Self`. - /// - /// This pseudo-object is intended for consumption by compiler-generated - /// ``Codable`` implementations. Decoding it incurs a small but non-zero - /// overhead when compared with calling ``Number``’s numeric casting - /// methods directly. - public init(encoding number: JSON.Number) { - let units: JSON.Number = .init(sign: number.sign, units: number.units, places: 0), - places: JSON.Number = .init(sign: .plus, units: number.places, places: 0) - self.init([("units", .number(units)), ("places", .number(places))]) - } -} extension JSON.Object: CustomStringConvertible { /// Returns this object serialized as a minified string. /// diff --git a/Sources/JSONAST/JSON.TypecastError.swift b/Sources/JSONAST/JSON.TypecastError.swift index 67015e9..ecc2c1b 100644 --- a/Sources/JSONAST/JSON.TypecastError.swift +++ b/Sources/JSONAST/JSON.TypecastError.swift @@ -15,7 +15,6 @@ extension JSON.TypecastError { case .null: self = .null case .bool: self = .bool case .number: self = .number - case .numberExtension_: self = .number case .string: self = .string case .array: self = .array case .object: self = .object diff --git a/Sources/JSONDecoding/Conformances/Character (ext).swift b/Sources/JSONDecoding/Conformances/Character (ext).swift index d56b32c..dc83897 100644 --- a/Sources/JSONDecoding/Conformances/Character (ext).swift +++ b/Sources/JSONDecoding/Conformances/Character (ext).swift @@ -5,7 +5,7 @@ extension Character: JSONStringDecodable { /// /// This is needed because its ``LosslessStringConvertible.init(_:)`` /// witness traps on invalid input instead of returning nil, which - /// causes its default implementation (where [`Self:LosslessStringConvertible`]()) + /// causes its default implementation (where `Self: LosslessStringConvertible`) /// to do the same. @inlinable public init(json: JSON.Node) throws { let string: String = try .init(json: json) diff --git a/Sources/JSONDecoding/Conformances/Float16 (ext).swift b/Sources/JSONDecoding/Conformances/Float16 (ext).swift new file mode 100644 index 0000000..e05d061 --- /dev/null +++ b/Sources/JSONDecoding/Conformances/Float16 (ext).swift @@ -0,0 +1,6 @@ +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension Float16: JSONDecodable { + @inlinable public init(json: JSON.Node) throws { + self = try json.cast { $0.as(Self.self) } + } +} diff --git a/Sources/JSONDecoding/Decoding/JSON.DecodingError.swift b/Sources/JSONDecoding/Decoding/JSON.DecodingError.swift index 08091dd..c2bd236 100644 --- a/Sources/JSONDecoding/Decoding/JSON.DecodingError.swift +++ b/Sources/JSONDecoding/Decoding/JSON.DecodingError.swift @@ -16,8 +16,8 @@ extension JSON { } extension JSON.DecodingError: Equatable where Location: Equatable { /// Compares the ``location`` properties and the ``underlying`` - /// errors of the operands for equality, returning [`true`]() - /// if they are equal. Always returns [`false`]() if (any of) + /// errors of the operands for equality, returning `true` + /// if they are equal. Always returns `false` if (any of) /// the underlying ``Error`` existentials are not ``Equatable``. public static func == (lhs: Self, rhs: Self) -> Bool { lhs.location == rhs.location && @@ -26,7 +26,7 @@ extension JSON.DecodingError: Equatable where Location: Equatable { } extension JSON.DecodingError: TraceableError { /// Returns a single note that says - /// [`"while decoding value for field '_'"`](). + /// `"while decoding value for field '_'"`. public var notes: [String] { ["while decoding value for field '\(self.location)'"] } diff --git a/Sources/JSONEncoding/Conformances/Double (ext).swift b/Sources/JSONEncoding/Conformances/Double (ext).swift new file mode 100644 index 0000000..c507c9e --- /dev/null +++ b/Sources/JSONEncoding/Conformances/Double (ext).swift @@ -0,0 +1 @@ +extension Double: JSONEncodable {} diff --git a/Sources/JSONEncoding/Conformances/Float (ext).swift b/Sources/JSONEncoding/Conformances/Float (ext).swift new file mode 100644 index 0000000..88380b5 --- /dev/null +++ b/Sources/JSONEncoding/Conformances/Float (ext).swift @@ -0,0 +1 @@ +extension Float: JSONEncodable {} diff --git a/Sources/JSONEncoding/Conformances/Float16 (ext).swift b/Sources/JSONEncoding/Conformances/Float16 (ext).swift new file mode 100644 index 0000000..8e067c7 --- /dev/null +++ b/Sources/JSONEncoding/Conformances/Float16 (ext).swift @@ -0,0 +1,2 @@ +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension Float16: JSONEncodable {} diff --git a/Sources/JSONEncoding/Conformances/Float80 (ext).swift b/Sources/JSONEncoding/Conformances/Float80 (ext).swift new file mode 100644 index 0000000..aea40ce --- /dev/null +++ b/Sources/JSONEncoding/Conformances/Float80 (ext).swift @@ -0,0 +1,3 @@ +#if (os(Linux) || os(macOS)) && arch(x86_64) +extension Float80: JSONEncodable {} +#endif diff --git a/Sources/JSONEncoding/Encoders/JSON.Literal (ext).swift b/Sources/JSONEncoding/Encoders/JSON.Literal (ext).swift index 522c4fb..4c33a63 100644 --- a/Sources/JSONEncoding/Encoders/JSON.Literal (ext).swift +++ b/Sources/JSONEncoding/Encoders/JSON.Literal (ext).swift @@ -19,3 +19,21 @@ extension JSON.Literal where Value: BinaryInteger { json.utf8 += self.value.description.utf8 } } +extension JSON.Literal where Value: BinaryFloatingPoint & LosslessStringConvertible { + /// Encodes this literal’s floating-point ``value`` to the provided JSON stream. + @inlinable internal static func += (json: inout JSON, self: Self) { + if self.value.isSignalingNaN { + json.utf8 += "snan".utf8 + } else if self.value.isNaN { + json.utf8 += "nan".utf8 + } else if self.value.isInfinite { + if case .minus = self.value.sign { + json.utf8.append(0x2D) // "-" + } + + json.utf8 += "inf".utf8 + } else { + json.utf8 += self.value.description.utf8 + } + } +} diff --git a/Sources/JSONEncoding/JSONEncodable.swift b/Sources/JSONEncoding/JSONEncodable.swift index 07a49b5..dc9604c 100644 --- a/Sources/JSONEncoding/JSONEncodable.swift +++ b/Sources/JSONEncoding/JSONEncodable.swift @@ -14,6 +14,11 @@ extension JSONEncodable where Self: BinaryInteger { json += JSON.Literal.init(self) } } +extension JSONEncodable where Self: BinaryFloatingPoint & LosslessStringConvertible { + @inlinable public func encode(to json: inout JSON) { + json += JSON.Literal.init(self) + } +} extension JSONEncodable where Self: RawRepresentable, RawValue: JSONEncodable { @inlinable public func encode(to json: inout JSON) { self.rawValue.encode(to: &json) diff --git a/Sources/JSONParsing/Rules/JSON.NodeRule.NaN.swift b/Sources/JSONParsing/Rules/JSON.NodeRule.NaN.swift index df9cd8b..274b818 100644 --- a/Sources/JSONParsing/Rules/JSON.NodeRule.NaN.swift +++ b/Sources/JSONParsing/Rules/JSON.NodeRule.NaN.swift @@ -1,11 +1,11 @@ import Grammar extension JSON.NodeRule { - /// A literal `NaN` expression. + /// A literal `nan` expression, all lowercase. enum NaN: LiteralRule { typealias Terminal = UInt8 - /// The ASCII string `NaN`. - static var literal: [UInt8] { [0x4e, 0x61, 0x4e] } + /// The ASCII string `nan`. + static var literal: [UInt8] { [0x6e, 0x61, 0x6e] } } } diff --git a/Sources/JSONParsing/Rules/JSON.NodeRule.Infinity.swift b/Sources/JSONParsing/Rules/JSON.NodeRule.Nonfinite.swift similarity index 53% rename from Sources/JSONParsing/Rules/JSON.NodeRule.Infinity.swift rename to Sources/JSONParsing/Rules/JSON.NodeRule.Nonfinite.swift index 1688b7a..5a6f642 100644 --- a/Sources/JSONParsing/Rules/JSON.NodeRule.Infinity.swift +++ b/Sources/JSONParsing/Rules/JSON.NodeRule.Nonfinite.swift @@ -1,15 +1,24 @@ import Grammar extension JSON.NodeRule { - /// Matches `inf` or `-inf`. - enum Infinity: ParsingRule { + enum Nonfinite: ParsingRule { typealias Terminal = UInt8 static func parse( _ input: inout ParsingInput> - ) throws -> FloatingPointSign + ) throws -> JSON.Number where Source.Element == Terminal, Source.Index == Location { + if let _: Void = input.parse( + as: UnicodeEncoding.LowercaseS?.self + ) { + try input.parse(as: JSON.NodeRule.NaN.self) + return .snan + } else if + let _: Void = input.parse(as: JSON.NodeRule.NaN?.self) { + return .nan + } + let sign: FloatingPointSign if let _: Void = input.parse(as: UnicodeEncoding.Hyphen?.self) { sign = .minus @@ -18,7 +27,7 @@ extension JSON.NodeRule { } try input.parse(as: Inf.self) - return sign + return .infinity(sign) } } } diff --git a/Sources/JSONParsing/Rules/JSON.NodeRule.swift b/Sources/JSONParsing/Rules/JSON.NodeRule.swift index bd31e73..148fd22 100644 --- a/Sources/JSONParsing/Rules/JSON.NodeRule.swift +++ b/Sources/JSONParsing/Rules/JSON.NodeRule.swift @@ -34,11 +34,8 @@ extension JSON.NodeRule: ParsingRule { let _: Void = input.parse(as: False?.self) { return .bool(false) } else if - let _: Void = input.parse(as: JSON.NodeRule.NaN?.self) { - return .numberExtension_(.NaN) - } else if - let sign: FloatingPointSign = input.parse(as: JSON.NodeRule.Infinity?.self) { - return .numberExtension_(.infinity(sign)) + let number: JSON.Number = input.parse(as: JSON.NodeRule.Nonfinite?.self) { + return .number(number) } else { try input.parse(as: Null.self) return .null diff --git a/Sources/JSONParsing/Rules/JSON.NumberRule.swift b/Sources/JSONParsing/Rules/JSON.NumberRule.swift index adda493..826a38a 100644 --- a/Sources/JSONParsing/Rules/JSON.NumberRule.swift +++ b/Sources/JSONParsing/Rules/JSON.NumberRule.swift @@ -35,67 +35,110 @@ extension JSON.NumberRule: ParsingRule { /// ASCII terminals. typealias ASCII = UnicodeEncoding + let start: Source.Index = input.index // https://datatracker.ietf.org/doc/html/rfc8259#section-6 // JSON does not allow prefix '+' let sign: FloatingPointSign switch input.parse(as: ASCII.Hyphen?.self) { - case _?: sign = .minus - case nil: sign = .plus + case _?: sign = .minus + case nil: sign = .plus } - var units: UInt64 = - try input.parse(as: Pattern.UnsignedInteger>.self) - var places: UInt32 = 0 - if var (_, remainder): (Void, UInt64) = - try? input.parse(as: (ASCII.Period, DecimalDigit).self) { + /// parse integral component + var units: UInt64? = try input.parse(as: DecimalDigit.self) + while let remainder: UInt64 = input.parse(as: DecimalDigit?.self) { + guard + let value: UInt64 = units else { + continue + } + if case (let shifted, false) = value.multipliedReportingOverflow(by: 10), + case (let refined, false) = shifted.addingReportingOverflow(remainder) { + units = refined + } else { + units = nil + } + } + + /// parse fractional component, if present + var places: UInt32 = 0 + if var (_, remainder): (Void, UInt64) = try? input.parse( + as: (ASCII.Period, DecimalDigit).self + ) { while true { - if case (let shifted, false) = units.multipliedReportingOverflow(by: 10), - case (let refined, false) = shifted.addingReportingOverflow(remainder) { - places += 1 - units = refined - } else { - throw Pattern.IntegerOverflowError.init() + places += 1 + + if let value: UInt64 = units { + if case (let shifted, false) = value.multipliedReportingOverflow(by: 10), + case (let refined, false) = shifted.addingReportingOverflow(remainder) { + units = refined + } else { + units = nil + } } - if let next: UInt64 = input.parse(as: DecimalDigit?.self) { - remainder = next - } else { + guard + let next: UInt64 = input.parse(as: DecimalDigit?.self) else { break } + + remainder = next } } + + let exponent: (sign: FloatingPointSign, magnitude: UInt32)? if let _: Void = input.parse(as: ASCII.E?.self) { let sign: FloatingPointSign? = input.parse(as: PlusOrMinus?.self) - let exponent: UInt32 = try input.parse( + let magnitude: UInt32 = try input.parse( as: Pattern.UnsignedInteger>.self ) - // you too, can exploit the vulnerabilities below - switch sign { - case .minus?: - places += exponent - - case .plus?, nil: - guard places < exponent else { - places -= exponent + + exponent = magnitude > 0 ? (sign: sign ?? .plus, magnitude: magnitude) : nil + } else { + exponent = nil + } + + representable: + if let exponent: (sign: FloatingPointSign, magnitude: UInt32), + var units: UInt64 { + switch exponent.sign { + case .minus: + // note: potential crash if `exponent.magnitude` is absurdly large + places += exponent.magnitude + + case .plus: + guard places < exponent.magnitude else { + // note: see above + places -= exponent.magnitude break } - defer { + + let shift: Int + if units == 0 { places = 0 - } - guard units != 0 else { break + } else { + shift = .init(exponent.magnitude - places) + places = 0 } - let shift: Int = .init(exponent - places) + if shift < JSON.Number.Base10.Exp.endIndex, case (let shifted, false) = units.multipliedReportingOverflow( by: JSON.Number.Base10.Exp[shift] ) { units = shifted } else { - throw Pattern.IntegerOverflowError.init() + break representable } } + + return .inline(.init(sign: sign, units: units, places: places)) + } else if + let units: UInt64 { + return .inline(.init(sign: sign, units: units, places: places)) } - return .init(sign: sign, units: units, places: places) + + /// number is not representable in efficient format, fall back to string + let end: Location = input.index + return .fallback(String.init(decoding: input[start ..< end], as: Unicode.UTF8.self)) } } diff --git a/Sources/JSONParsing/Rules/JSON.RootRule.swift b/Sources/JSONParsing/Rules/JSON.RootRule.swift index d4103ca..6d1fd31 100644 --- a/Sources/JSONParsing/Rules/JSON.RootRule.swift +++ b/Sources/JSONParsing/Rules/JSON.RootRule.swift @@ -31,7 +31,7 @@ extension JSON { try JSON.RootRule.Index>.parse(utf8) ``` */ - /// The generic [`Location`]() + /// The generic `Location` /// parameter provides this flexibility as a zero-cost abstraction. /// /// > Tip: diff --git a/Sources/JSONTests/Parsing.swift b/Sources/JSONTests/Parsing.swift index 127d494..c522574 100644 --- a/Sources/JSONTests/Parsing.swift +++ b/Sources/JSONTests/Parsing.swift @@ -22,15 +22,6 @@ import Testing } } - @Test static func Number0() throws { - guard case JSON.Node.number(.init(sign: .plus, units: 0, places: 0))? = try .init( - parsingFragment: "0" - ) else { - Issue.record() - return - } - } - @Test static func String() throws { guard case JSON.Node.string(.init("a"))? = try .init(parsingFragment: "\"a\"") else { Issue.record() diff --git a/Sources/JSONTests/ParsingNumerics.swift b/Sources/JSONTests/ParsingNumerics.swift new file mode 100644 index 0000000..e873527 --- /dev/null +++ b/Sources/JSONTests/ParsingNumerics.swift @@ -0,0 +1,71 @@ +import JSON +import Testing + +/// i do not know why this has to be a separate test suite. there is some kind of macro bug in +/// the swift-testing framework... +@Suite enum ParsingNumerics { + struct Wrapper: JSONObjectEncodable, JSONObjectDecodable + where T: JSONEncodable & JSONDecodable { + enum CodingKey: String, Sendable { + case value + } + + let value: T + + init(value: T) { + self.value = value + } + init(json: borrowing JSON.ObjectDecoder) throws { + self.value = try json[.value].decode() + } + func encode(to json: inout JSON.ObjectEncoder) { + json[.value] = self.value + } + } + + @Test( + arguments: [ + ("0", .init(0)), + ("\(UInt64.max)", .init(UInt64.max)), + ("\(Int64.max)", .init(Int64.max)), + ("\(Int64.min)", .init(Int64.min)), + ("nan", .nan), + ("snan", .snan), + ("inf", .infinity(.plus)), + ("-inf", .infinity(.minus)), + ("1.0000000000000000000012345", .fallback("1.0000000000000000000012345")) + ] as [(String, JSON.Number)], + ) static func Number(_ expression: String, _ expected: JSON.Number) throws { + guard case JSON.Node.number(let number)? = try .init(parsingFragment: expression) else { + Issue.record() + return + } + + #expect(number == expected) + } + + @Test( + arguments: [ + (-123456789.123456789123456789123456789, "-123456789.12345679"), + (-1, "-1.0"), + (-0.5, "-0.5"), + (0, "0.0"), + (0.5, "0.5"), + (1, "1.0"), + (123.456e+50, "1.23456e+52"), + (123.456e-50, "1.23456e-48"), + (123456789.123456789123456789123456789, "123456789.12345679"), + (.nan, "nan"), + (.signalingNaN, "snan"), + (-.infinity, "-inf"), + (+.infinity, "inf") + ] as [(Double, String)], + ) static func NumberRoundtripping(_ value: Double, string: String) throws { + let encoded: Wrapper = .init(value: value) + let json: JSON = .encode(encoded) + let decoded: Wrapper = try json.decode() + + #expect("\(json)" == "{\"value\":\(string)}") + #expect(encoded.value.bitPattern == decoded.value.bitPattern) + } +}