Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion Sources/Yams/Emitter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,28 @@ public final class Emitter {
case crln
}

/// Number format style to use when emitting YAML.
public enum NumberFormatStyle {
/// Use scientific notation.
case scientific
/// Use decimal notation.
case decimal
}

public struct NumberFormatStrategy {
public var style: NumberFormatStyle = .scientific
public var doubleMaximumSignificantDigits = 15
public var floatMaximumSignificantDigits = 7

public init(style: NumberFormatStyle = .scientific,
doubleMaximumSignificantDigits: Int = 15,
floatMaximumSignificantDigits: Int = 7) {
self.style = style
self.doubleMaximumSignificantDigits = doubleMaximumSignificantDigits
self.floatMaximumSignificantDigits = floatMaximumSignificantDigits
}
}

/// Retrieve this Emitter's binary output.
public internal(set) var data = Data()

Expand Down Expand Up @@ -281,6 +303,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 numberFormatStrategy: NumberFormatStrategy = NumberFormatStrategy()

/// Create `Emitter.Options` with the specified values.
///
/// - parameter canonical: Set if the output should be in the "canonical" format described in the YAML
Expand All @@ -297,6 +322,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,
Expand All @@ -306,7 +332,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,
numberFormatStrategy: NumberFormatStrategy = NumberFormatStrategy()) {
self.canonical = canonical
self.indent = indent
self.width = width
Expand All @@ -320,6 +347,7 @@ public final class Emitter {
self.mappingStyle = mappingStyle
self.newLineScalarStyle = newLineScalarStyle
self.redundancyAliasingStrategy = redundancyAliasingStrategy
self.numberFormatStrategy = numberFormatStrategy
}
}

Expand Down
23 changes: 17 additions & 6 deletions Sources/Yams/Encoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ public class YAMLEncoder {
let encoder = _Encoder(userInfo: finalUserInfo,
sequenceStyle: options.sequenceStyle,
mappingStyle: options.mappingStyle,
newlineScalarStyle: options.newLineScalarStyle)
newlineScalarStyle: options.newLineScalarStyle,
numberFormatStrategy: options.numberFormatStrategy)
var container = encoder.singleValueContainer()
try container.encode(value)
try options.redundancyAliasingStrategy?.releaseAnchorReferences()
Expand All @@ -55,13 +56,20 @@ 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] = [],
sequenceStyle: Node.Sequence.Style,
mappingStyle: Node.Mapping.Style,
newlineScalarStyle: Node.Scalar.Style,
numberFormatStrategy: Emitter.NumberFormatStrategy
) {
self.userInfo = userInfo
self.codingPath = codingPath
self.sequenceStyle = sequenceStyle
self.mappingStyle = mappingStyle
self.newlineScalarStyle = newlineScalarStyle
self.numberFormatStrategy = numberFormatStrategy
}

// MARK: - Swift.Encoder Methods
Expand All @@ -71,6 +79,7 @@ private class _Encoder: Swift.Encoder {
let sequenceStyle: Node.Sequence.Style
let mappingStyle: Node.Mapping.Style
let newlineScalarStyle: Node.Scalar.Style
let numberFormatStrategy: Emitter.NumberFormatStrategy

func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> {
if canEncodeNewValue {
Expand Down Expand Up @@ -136,7 +145,8 @@ private class _ReferencingEncoder: _Encoder {
codingPath: encoder.codingPath + [key],
sequenceStyle: encoder.sequenceStyle,
mappingStyle: encoder.mappingStyle,
newlineScalarStyle: encoder.newlineScalarStyle)
newlineScalarStyle: encoder.newlineScalarStyle,
numberFormatStrategy: encoder.numberFormatStrategy)
}

init(referencing encoder: _Encoder, at index: Int) {
Expand All @@ -146,7 +156,8 @@ private class _ReferencingEncoder: _Encoder {
codingPath: encoder.codingPath + [_YAMLCodingKey(index: index)],
sequenceStyle: encoder.sequenceStyle,
mappingStyle: encoder.mappingStyle,
newlineScalarStyle: encoder.newlineScalarStyle)
newlineScalarStyle: encoder.newlineScalarStyle,
numberFormatStrategy: encoder.numberFormatStrategy)
}

deinit {
Expand Down Expand Up @@ -245,7 +256,7 @@ extension _Encoder: SingleValueEncodingContainer {

private func encode(yamlEncodable encodable: YAMLEncodable) throws {
func encodeNode() {
node = encodable.box()
node = encodable.box(numberFormatStrategy: numberFormatStrategy)
if let stringValue = encodable as? (any StringProtocol), stringValue.contains("\n") {
node.scalar?.style = newlineScalarStyle
}
Expand Down
73 changes: 50 additions & 23 deletions Sources/Yams/Representer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,40 +62,40 @@
/// Type is representable as `Node.scalar`.
public protocol ScalarRepresentable: NodeRepresentable {
/// This value's `Node.scalar` representation.
func represented() -> Node.Scalar
func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar
}

extension ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() throws -> Node {
return .scalar(represented())
return .scalar(represented(numberFormatStrategy: .init()))
}
}

extension NSNull: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
return .init("null", Tag(.null))
}
}

extension Bool: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> 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(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
return .init(base64EncodedString(), Tag(.binary))
}
}

extension Date: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
return .init(iso8601String, Tag(.timestamp))
}

Expand Down Expand Up @@ -157,14 +157,16 @@

extension Double: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
doubleFormatter.maximumSignificantDigits = numberFormatStrategy.doubleMaximumSignificantDigits
return .init(doubleFormatter.string(for: self)!.replacingOccurrences(of: "+-", with: "-"), Tag(.float))
}
}

extension Float: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
floatFormatter.maximumSignificantDigits = numberFormatStrategy.floatMaximumSignificantDigits
return .init(floatFormatter.string(for: self)!.replacingOccurrences(of: "+-", with: "-"), Tag(.float))
}
}
Expand All @@ -190,7 +192,7 @@

extension BinaryInteger {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
return .init(String(describing: self), Tag(.int))
}
}
Expand Down Expand Up @@ -220,29 +222,29 @@

extension Decimal: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
return .init(description)
}
}

extension URL: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
return .init(absoluteString)
}
}

extension String: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
let scalar = Node.Scalar(self)
return scalar.resolvedTag.name == .str ? scalar : .init(self, Tag(.str), .singleQuoted)
}
}

extension UUID: ScalarRepresentable {
/// This value's `Node.scalar` representation.
public func represented() -> Node.Scalar {
public func represented(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node.Scalar {
return .init(uuidString)
}
}
Expand All @@ -252,13 +254,13 @@
/// 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(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node
}

extension YAMLEncodable where Self: ScalarRepresentable {
/// Returns this value wrapped in a `Node.scalar`.
public func box() -> Node {
return .scalar(represented())
public func box(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node {
return .scalar(represented(numberFormatStrategy: numberFormatStrategy))
}
}

Expand All @@ -281,35 +283,60 @@

extension Date: YAMLEncodable {
/// Returns this value wrapped in a `Node.scalar`.
public func box() -> Node {
public func box(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node {
return Node(iso8601StringWithFullNanosecond, Tag(.timestamp))
}
}

let encodableFloatingPointFormatter = numberFormatter(with: 7)

extension Double: YAMLEncodable {
/// Returns this value wrapped in a `Node.scalar`.
public func box() -> Node {
return Node(formattedStringForCodable, Tag(.float))
public func box(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node {
encodableFloatingPointFormatter.maximumSignificantDigits = numberFormatStrategy.doubleMaximumSignificantDigits
let formattedString = formattedStringForCodable(numberFormatStyle: numberFormatStrategy.style,
formatter: encodableFloatingPointFormatter)
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(numberFormatStrategy: Emitter.NumberFormatStrategy) -> Node {
encodableFloatingPointFormatter.maximumSignificantDigits = numberFormatStrategy.floatMaximumSignificantDigits
let formattedString = formattedStringForCodable(numberFormatStyle: numberFormatStrategy.style,
formatter: encodableFloatingPointFormatter)
return Node(formattedString, Tag(.float))
}
}

private extension FloatingPoint where Self: CVarArg {
var formattedStringForCodable: String {
func formattedStringForCodable(numberFormatStyle: Emitter.NumberFormatStyle,
formatter: NumberFormatter) -> String {
if numberFormatStyle == .decimal && self != .greatestFiniteMagnitude && self != -.greatestFiniteMagnitude {
formatter.numberStyle = .decimal
return formatter.string(for: self)!
}
// 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: "-")
formatter.numberStyle = .scientific
return formatter.string(for: self)!.replacingOccurrences(of: "+-", with: "-")
}
return string
}
}

private extension Emitter.NumberFormatStrategy {
var numberFormatStyle: NumberFormatter.Style {

Check failure on line 334 in Sources/Yams/Representer.swift

View workflow job for this annotation

GitHub Actions / Analyze

Declarations should be referenced at least once within all files linted (unused_declaration)
switch self.style {
case .scientific:
return .scientific
case .decimal:
return .decimal
}
}
}
42 changes: 42 additions & 0 deletions Tests/YamsTests/EncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,48 @@ 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(numberFormatStrategy: .init(style: .decimal)),
expectedYAML: "3.141592653\n")
}

func testDecimalDoubleStyle() throws {
_testRoundTrip(of: Double(6.8),
with: YAMLEncoder.Options(numberFormatStrategy: .init(style: .decimal)),
expectedYAML: "6.8\n")
}

func testDecimalFloatStyle() throws {
_testRoundTrip(of: Float(6.8),
with: YAMLEncoder.Options(numberFormatStrategy: .init(style: .decimal)),
expectedYAML: "6.8\n")
}

func testMinimumFractionDigits() throws {
_testRoundTrip(of: Double(6.0),
with: YAMLEncoder.Options(numberFormatStrategy: .init(style: .decimal, doubleMaximumSignificantDigits: 1)),
expectedYAML: "6\n")
}

func testDecimalDoubleGreatestFiniteMagnitude() throws {
_testRoundTrip(of: Double.greatestFiniteMagnitude,
with: YAMLEncoder.Options(numberFormatStrategy: .init(style: .decimal)),
expectedYAML: "1.7976931348623157e+308\n")
}

func testDecimalDoubleNegativeGreatestFiniteMagnitude() throws {
_testRoundTrip(of: -Double.greatestFiniteMagnitude,
with: YAMLEncoder.Options(numberFormatStrategy: .init(style: .decimal)),
expectedYAML: "-1.7976931348623157e+308\n")
}

func testDecimalFloatGreatestFiniteMagnitude() throws {
_testRoundTrip(of: Float.greatestFiniteMagnitude,
with: YAMLEncoder.Options(numberFormatStrategy: .init(style: .decimal)),
expectedYAML: "3.4028234663852886e+38\n")
}

func testEncodingTopLevelSingleValueClass() {
_testRoundTrip(of: Counter(), expectedYAML: "0\n")
}
Expand Down
Loading