diff --git a/Sources/Yams/Emitter.swift b/Sources/Yams/Emitter.swift index 7e6358d1..ec3333b8 100644 --- a/Sources/Yams/Emitter.swift +++ b/Sources/Yams/Emitter.swift @@ -243,6 +243,14 @@ public final class Emitter { case crln } + /// Floating point number format style to use when emitting YAML. + public enum FloatingPointNumberFormatStrategy { + /// Use scientific notation. + case scientific + /// Use decimal notation. + case decimal + } + /// Retrieve this Emitter's binary output. public internal(set) var data = Data() @@ -281,6 +289,9 @@ public final class Emitter { /// Redundancy aliasing strategy to use when encoding. Defaults to nil public var redundancyAliasingStrategy: RedundancyAliasingStrategy? + /// Set the number format strategy to use when emitting YAML. + public var floatingPointNumberFormatStrategy: FloatingPointNumberFormatStrategy = .scientific + /// Create `Emitter.Options` with the specified values. /// /// - parameter canonical: Set if the output should be in the "canonical" format described in the YAML @@ -297,6 +308,7 @@ public final class Emitter { /// - parameter mappingStyle: Set the style for mappings (dictionaries) /// - parameter newLineScalarStyle: Set the style for newline-containing scalars /// - parameter redundancyAliasingStrategy: Set the strategy for identifying + /// - parameter numberFormatStrategy: Set the number format strategy to use when emitting YAML. /// redundant structures and automatically aliasing them public init(canonical: Bool = false, indent: Int = 0, width: Int = 0, allowUnicode: Bool = false, lineBreak: Emitter.LineBreak = .ln, @@ -306,7 +318,8 @@ public final class Emitter { sortKeys: Bool = false, sequenceStyle: Node.Sequence.Style = .any, mappingStyle: Node.Mapping.Style = .any, newLineScalarStyle: Node.Scalar.Style = .any, - redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil) { + redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil, + floatingPointNumberFormatStrategy: FloatingPointNumberFormatStrategy = .scientific) { self.canonical = canonical self.indent = indent self.width = width @@ -320,6 +333,7 @@ public final class Emitter { self.mappingStyle = mappingStyle self.newLineScalarStyle = newLineScalarStyle self.redundancyAliasingStrategy = redundancyAliasingStrategy + self.floatingPointNumberFormatStrategy = floatingPointNumberFormatStrategy } } @@ -346,6 +360,7 @@ public final class Emitter { /// - parameter mappingStyle: Set the style for mappings (dictionaries) /// - parameter newLineScalarStyle: Set the style for newline-containing scalars /// - parameter redundancyAliasingStrategy: Set the strategy for identifying redundant + /// - parameter numberFormatStrategy: Set the number format strategy to use when emitting YAML. /// structures and automatically aliasing them public init(canonical: Bool = false, indent: Int = 0, @@ -359,7 +374,8 @@ public final class Emitter { sequenceStyle: Node.Sequence.Style = .any, mappingStyle: Node.Mapping.Style = .any, newLineScalarStyle: Node.Scalar.Style = .any, - redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil) { + redundancyAliasingStrategy: RedundancyAliasingStrategy? = nil, + floatingPointNumberFormatStrategy: FloatingPointNumberFormatStrategy = .scientific) { options = Options(canonical: canonical, indent: indent, width: width, @@ -372,7 +388,8 @@ public final class Emitter { sequenceStyle: sequenceStyle, mappingStyle: mappingStyle, newLineScalarStyle: newLineScalarStyle, - redundancyAliasingStrategy: redundancyAliasingStrategy) + redundancyAliasingStrategy: redundancyAliasingStrategy, + floatingPointNumberFormatStrategy: floatingPointNumberFormatStrategy) // configure emitter yaml_emitter_initialize(&emitter) yaml_emitter_set_output(&self.emitter, { pointer, buffer, size in diff --git a/Sources/Yams/Encoder.swift b/Sources/Yams/Encoder.swift index 195b3998..9098c873 100644 --- a/Sources/Yams/Encoder.swift +++ b/Sources/Yams/Encoder.swift @@ -32,10 +32,7 @@ public class YAMLEncoder { if let aliasingStrategy = options.redundancyAliasingStrategy { finalUserInfo[.redundancyAliasingStrategyKey] = aliasingStrategy } - let encoder = _Encoder(userInfo: finalUserInfo, - sequenceStyle: options.sequenceStyle, - mappingStyle: options.mappingStyle, - newlineScalarStyle: options.newLineScalarStyle) + let encoder = _Encoder(userInfo: finalUserInfo, options: options) var container = encoder.singleValueContainer() try container.encode(value) try options.redundancyAliasingStrategy?.releaseAnchorReferences() @@ -55,26 +52,25 @@ public class YAMLEncoder { private class _Encoder: Swift.Encoder { var node: Node = .unused - init(userInfo: [CodingUserInfoKey: Any] = [:], codingPath: [CodingKey] = [], sequenceStyle: Node.Sequence.Style, - mappingStyle: Node.Mapping.Style, newlineScalarStyle: Node.Scalar.Style) { + init( + userInfo: [CodingUserInfoKey: Any] = [:], + codingPath: [CodingKey] = [], + options: YAMLEncoder.Options + ) { self.userInfo = userInfo self.codingPath = codingPath - self.sequenceStyle = sequenceStyle - self.mappingStyle = mappingStyle - self.newlineScalarStyle = newlineScalarStyle + self.options = options } // MARK: - Swift.Encoder Methods let codingPath: [CodingKey] let userInfo: [CodingUserInfoKey: Any] - let sequenceStyle: Node.Sequence.Style - let mappingStyle: Node.Mapping.Style - let newlineScalarStyle: Node.Scalar.Style + let options: YAMLEncoder.Options func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { if canEncodeNewValue { - node = Node([(Node, Node)](), .implicit, mappingStyle) + node = Node([(Node, Node)](), .implicit, options.mappingStyle) } else { precondition( node.isMapping, @@ -86,7 +82,7 @@ private class _Encoder: Swift.Encoder { func unkeyedContainer() -> UnkeyedEncodingContainer { if canEncodeNewValue { - node = Node([], .implicit, sequenceStyle) + node = Node([], .implicit, options.sequenceStyle) } else { precondition( node.isSequence, @@ -134,9 +130,7 @@ private class _ReferencingEncoder: _Encoder { reference = .mapping(key.stringValue) super.init(userInfo: encoder.userInfo, codingPath: encoder.codingPath + [key], - sequenceStyle: encoder.sequenceStyle, - mappingStyle: encoder.mappingStyle, - newlineScalarStyle: encoder.newlineScalarStyle) + options: encoder.options) } init(referencing encoder: _Encoder, at index: Int) { @@ -144,9 +138,7 @@ private class _ReferencingEncoder: _Encoder { reference = .sequence(index) super.init(userInfo: encoder.userInfo, codingPath: encoder.codingPath + [_YAMLCodingKey(index: index)], - sequenceStyle: encoder.sequenceStyle, - mappingStyle: encoder.mappingStyle, - newlineScalarStyle: encoder.newlineScalarStyle) + options: encoder.options) } deinit { @@ -245,9 +237,9 @@ extension _Encoder: SingleValueEncodingContainer { private func encode(yamlEncodable encodable: YAMLEncodable) throws { func encodeNode() { - node = encodable.box() + node = encodable.box(options: options) if let stringValue = encodable as? (any StringProtocol), stringValue.contains("\n") { - node.scalar?.style = newlineScalarStyle + node.scalar?.style = options.newLineScalarStyle } } try resolveAlias(for: encodable, encode: encodeNode) diff --git a/Sources/Yams/Representer.swift b/Sources/Yams/Representer.swift index 76f9842b..ae7cb52d 100644 --- a/Sources/Yams/Representer.swift +++ b/Sources/Yams/Representer.swift @@ -62,40 +62,40 @@ private func represent(_ value: Any) throws -> Node { /// Type is representable as `Node.scalar`. public protocol ScalarRepresentable: NodeRepresentable { /// This value's `Node.scalar` representation. - func represented() -> Node.Scalar + func represented(options: Emitter.Options) -> Node.Scalar } extension ScalarRepresentable { /// This value's `Node.scalar` representation. public func represented() throws -> Node { - return .scalar(represented()) + return .scalar(represented(options: .init())) } } extension NSNull: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init("null", Tag(.null)) } } extension Bool: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init(self ? "true" : "false", Tag(.bool)) } } extension Data: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init(base64EncodedString(), Tag(.binary)) } } extension Date: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init(iso8601String, Tag(.timestamp)) } @@ -157,15 +157,25 @@ private let iso8601WithoutZFormatter: DateFormatter = { extension Double: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { - return .init(doubleFormatter.string(for: self)!.replacingOccurrences(of: "+-", with: "-"), Tag(.float)) + public func represented(options: Emitter.Options) -> Node.Scalar { + let formattedString: String = formattedStringForCodable( + value: self, + floatingPointNumberFormatStrategy: options.floatingPointNumberFormatStrategy, + formatter: doubleFormatter + ) + return .init(formattedString.replacingOccurrences(of: "+-", with: "-"), Tag(.float)) } } extension Float: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { - return .init(floatFormatter.string(for: self)!.replacingOccurrences(of: "+-", with: "-"), Tag(.float)) + public func represented(options: Emitter.Options) -> Node.Scalar { + let formattedString: String = formattedStringForCodable( + value: self, + floatingPointNumberFormatStrategy: options.floatingPointNumberFormatStrategy, + formatter: floatFormatter + ) + return .init(formattedString.replacingOccurrences(of: "+-", with: "-"), Tag(.float)) } } @@ -190,7 +200,7 @@ private let floatFormatter = numberFormatter(with: 7) extension BinaryInteger { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init(String(describing: self), Tag(.int)) } } @@ -220,21 +230,21 @@ extension Optional: NodeRepresentable { extension Decimal: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init(description) } } extension URL: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init(absoluteString) } } extension String: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { let scalar = Node.Scalar(self) return scalar.resolvedTag.name == .str ? scalar : .init(self, Tag(.str), .singleQuoted) } @@ -242,7 +252,7 @@ extension String: ScalarRepresentable { extension UUID: ScalarRepresentable { /// This value's `Node.scalar` representation. - public func represented() -> Node.Scalar { + public func represented(options: Emitter.Options) -> Node.Scalar { return .init(uuidString) } } @@ -252,13 +262,13 @@ extension UUID: ScalarRepresentable { /// Types conforming to this protocol can be encoded by `YamlEncoder`. public protocol YAMLEncodable: Encodable { /// Returns this value wrapped in a `Node`. - func box() -> Node + func box(options: Emitter.Options) -> Node } extension YAMLEncodable where Self: ScalarRepresentable { /// Returns this value wrapped in a `Node.scalar`. - public func box() -> Node { - return .scalar(represented()) + public func box(options: Emitter.Options) -> Node { + return .scalar(represented(options: options)) } } @@ -281,35 +291,61 @@ extension UUID: YAMLEncodable {} extension Date: YAMLEncodable { /// Returns this value wrapped in a `Node.scalar`. - public func box() -> Node { + public func box(options: Emitter.Options) -> Node { return Node(iso8601StringWithFullNanosecond, Tag(.timestamp)) } } extension Double: YAMLEncodable { /// Returns this value wrapped in a `Node.scalar`. - public func box() -> Node { - return Node(formattedStringForCodable, Tag(.float)) + public func box(options: Emitter.Options) -> Node { + let formattedString: String = formattedStringForCodable( + value: self, + floatingPointNumberFormatStrategy: options.floatingPointNumberFormatStrategy, + formatter: doubleFormatter + ) + return Node(formattedString, Tag(.float)) } } extension Float: YAMLEncodable { /// Returns this value wrapped in a `Node.scalar`. - public func box() -> Node { - return Node(formattedStringForCodable, Tag(.float)) + public func box(options: Emitter.Options) -> Node { + let formattedString: String = formattedStringForCodable( + value: self, + floatingPointNumberFormatStrategy: options.floatingPointNumberFormatStrategy, + formatter: floatFormatter + ) + return Node(formattedString, Tag(.float)) } } -private extension FloatingPoint where Self: CVarArg { - var formattedStringForCodable: String { - // Since `NumberFormatter` creates a string with insufficient precision for Decode, - // it uses with `String(format:...)` - let string = String(format: "%.*g", DBL_DECIMAL_DIG, self) - // "%*.g" does not use scientific notation if the exponent is less than –4. - // So fallback to using `NumberFormatter` if string does not uses scientific notation. - guard string.lazy.suffix(5).contains("e") else { - return doubleFormatter.string(for: self)!.replacingOccurrences(of: "+-", with: "-") +private func formattedStringForCodable( + value: T, + floatingPointNumberFormatStrategy: Emitter.FloatingPointNumberFormatStrategy, + formatter: NumberFormatter +) -> String { + if floatingPointNumberFormatStrategy == .decimal { + switch value { + case .infinity: + return ".inf" + case -.infinity: + return "-.inf" + case .nan: + return ".nan" + default: + return value.description } - return string } + + // Since `NumberFormatter` creates a string with insufficient precision for Decode, + // it uses with `String(format:...)` + let string = String(format: "%.*g", DBL_DECIMAL_DIG, value) + // "%*.g" does not use scientific notation if the exponent is less than –4. + // So fallback to using `NumberFormatter` if string does not uses scientific notation. + guard string.lazy.suffix(5).contains("e") else { + formatter.numberStyle = .scientific + return formatter.string(for: value)!.replacingOccurrences(of: "+-", with: "-") + } + return string } diff --git a/Tests/YamsTests/EncoderTests.swift b/Tests/YamsTests/EncoderTests.swift index c0ef06d8..3156b5e0 100644 --- a/Tests/YamsTests/EncoderTests.swift +++ b/Tests/YamsTests/EncoderTests.swift @@ -35,6 +35,60 @@ final class EncoderTests: XCTestCase, @unchecked Sendable { // swiftlint:disable _testRoundTrip(of: Timestamp(3141592653), expectedYAML: "3.141592653e+9\n") } + func testEncodingTopLevelSingleValueStructDecimalDouble() { + _testRoundTrip(of: Double(3.141592653), + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "3.141592653\n") + } + + func testDecimalDoubleStyle() throws { + _testRoundTrip(of: Double(6.8), + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "6.8\n") + } + + func testDecimalFloatStyle() throws { + _testRoundTrip(of: Float(6.8), + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "6.8\n") + } + + func testMinimumFractionDigits() throws { + _testRoundTrip(of: Double(6.0), + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "6.0\n") + } + + func testDecimalDoubleGreatestFiniteMagnitude() throws { + _testRoundTrip(of: Double.greatestFiniteMagnitude, + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "1.7976931348623157e+308\n") + } + + func testDecimalDoubleNegativeGreatestFiniteMagnitude() throws { + _testRoundTrip(of: -Double.greatestFiniteMagnitude, + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "-1.7976931348623157e+308\n") + } + + func testDecimalFloatGreatestFiniteMagnitude() throws { + _testRoundTrip(of: Float.greatestFiniteMagnitude, + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "3.4028235e+38\n") + } + + func testDecimalDoubleNegativeInfinite() throws { + _testRoundTrip(of: -Double.infinity, + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: "-.inf\n") + } + + func testDecimalDoublePositiveInfinite() throws { + _testRoundTrip(of: Double.infinity, + with: YAMLEncoder.Options(floatingPointNumberFormatStrategy: .decimal), + expectedYAML: ".inf\n") + } + func testEncodingTopLevelSingleValueClass() { _testRoundTrip(of: Counter(), expectedYAML: "0\n") } @@ -478,6 +532,20 @@ where T: Codable, T: Equatable { } // MARK: - Helper Global Functions + +private func numberDecimalFormatter(with significantDigits: Int = 7) -> NumberFormatter { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US") + formatter.numberStyle = .decimal + formatter.usesSignificantDigits = true + formatter.maximumSignificantDigits = significantDigits + formatter.positiveInfinitySymbol = ".inf" + formatter.negativeInfinitySymbol = "-.inf" + formatter.notANumberSymbol = ".nan" + formatter.exponentSymbol = "e+" + return formatter +} + public func expectEqual( _ expected: T, _ actual: T, _ message: @autoclosure () -> String = "",