From 76ae019e9180acafb901c1b3c37baf0408b5de48 Mon Sep 17 00:00:00 2001 From: Michael Ilseman Date: Sun, 14 Mar 2021 17:43:14 -0600 Subject: [PATCH 1/2] WIP: Swifty numeric formatting --- Package.swift | 10 +- .../FormattersModule/CollectionPadding.swift | 93 ++++++ .../FormattersModule/FloatFormatting.swift | 200 +++++++++++ Sources/FormattersModule/Formatting.swift | 54 +++ Sources/FormattersModule/IntFormatting.swift | 311 ++++++++++++++++++ Sources/FormattersModule/Interpolations.swift | 110 +++++++ .../FormattersModule/StringAlignment.swift | 94 ++++++ Sources/RealModule/Formatters.swift | 13 + Tests/FormattersTests/FormattersTests.swift | 15 + 9 files changed, 896 insertions(+), 4 deletions(-) create mode 100644 Sources/FormattersModule/CollectionPadding.swift create mode 100644 Sources/FormattersModule/FloatFormatting.swift create mode 100644 Sources/FormattersModule/Formatting.swift create mode 100644 Sources/FormattersModule/IntFormatting.swift create mode 100644 Sources/FormattersModule/Interpolations.swift create mode 100644 Sources/FormattersModule/StringAlignment.swift create mode 100644 Sources/RealModule/Formatters.swift create mode 100644 Tests/FormattersTests/FormattersTests.swift diff --git a/Package.swift b/Package.swift index 565d5d60..4c1cc499 100644 --- a/Package.swift +++ b/Package.swift @@ -25,16 +25,18 @@ let package = Package( // User-facing modules .target(name: "ComplexModule", dependencies: ["RealModule"]), .target(name: "Numerics", dependencies: ["ComplexModule", "RealModule"]), - .target(name: "RealModule", dependencies: ["_NumericsShims"]), - + .target(name: "RealModule", dependencies: ["_NumericsShims", "FormattersModule"]), + .target(name: "FormattersModule", dependencies: []), + // Implementation details .target(name: "_NumericsShims", dependencies: []), .target(name: "_TestSupport", dependencies: ["Numerics"]), - + // Unit test bundles .testTarget(name: "ComplexTests", dependencies: ["_TestSupport"]), .testTarget(name: "RealTests", dependencies: ["_TestSupport"]), - + .testTarget(name: "FormattersTests", dependencies: ["_TestSupport"]), + // Test executables .target(name: "ComplexLog", dependencies: ["Numerics", "_TestSupport"], path: "Tests/Executable/ComplexLog"), .target(name: "ComplexLog1p", dependencies: ["Numerics", "_TestSupport"], path: "Tests/Executable/ComplexLog1p") diff --git a/Sources/FormattersModule/CollectionPadding.swift b/Sources/FormattersModule/CollectionPadding.swift new file mode 100644 index 00000000..b691470a --- /dev/null +++ b/Sources/FormattersModule/CollectionPadding.swift @@ -0,0 +1,93 @@ +//===--- CollectionPadding.swift ------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2019 - 2020 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +public enum CollectionBound { + case start + case end +} +extension CollectionBound { + internal var inverted: CollectionBound { self == .start ? .end : .start } +} + +extension RangeReplaceableCollection { + internal mutating func pad( + to newCount: Int, using fill: Self.Element, at bound: CollectionBound = .end + ) { + guard newCount > 0 else { return } + + let currentCount = self.count + guard newCount > currentCount else { return } + + let filler = repeatElement(fill, count: newCount &- currentCount) + let insertIdx = bound == .start ? self.startIndex : self.endIndex + self.insert(contentsOf: filler, at: insertIdx) + } + // TODO: Align/justify version, which just swaps the bound? +} + + +// Intersperse +extension Collection where SubSequence == Self { + fileprivate mutating func _eat(_ n: Int = 1) -> SubSequence { + defer { self = self.dropFirst(n) } + return self.prefix(n) + } +} + +// NOTE: The below would be more efficient with RRC method variants +// that returned the new valid indices. Instead, we have to create a new +// collection and reassign self. Similarly, we could benefit from a slide +// operation that can leave temporarily uninitialized spaces inside the +// collection. +extension RangeReplaceableCollection { + internal mutating func intersperse( + _ newElement: Element, every n: Int, startingFrom bound: CollectionBound + ) { + self.intersperse( + contentsOf: CollectionOfOne(newElement), every: n, startingFrom: bound) + } + + internal mutating func intersperse( + contentsOf newElements : C, every n: Int, startingFrom bound: CollectionBound + ) where C.Element == Element { + precondition(n > 0) + + let currentCount = self.count + guard currentCount > n else { return } + + let remainder = currentCount % n + + var result = Self() + let interspersedCount = newElements.count + let insertCount = (currentCount / n) - (remainder == 0 ? 0 : 1) + let newCount = currentCount + interspersedCount * insertCount + defer { assert(result.count == newCount) } + result.reserveCapacity(newCount) + + var selfConsumer = self[...] + + // When we start from the end, any remainder will appear as a prefix. + // Otherwise, the remainder will fall out naturally from the main loop. + if remainder != 0 && bound == .end { + result.append(contentsOf: selfConsumer._eat(remainder)) + assert(!selfConsumer.isEmpty, "Guarded count above") + result.append(contentsOf: newElements) + } + + while !selfConsumer.isEmpty { + result.append(contentsOf: selfConsumer._eat(n)) + if !selfConsumer.isEmpty { + result.append(contentsOf: newElements) + } + } + self = result + } +} diff --git a/Sources/FormattersModule/FloatFormatting.swift b/Sources/FormattersModule/FloatFormatting.swift new file mode 100644 index 00000000..aa8578b7 --- /dev/null +++ b/Sources/FormattersModule/FloatFormatting.swift @@ -0,0 +1,200 @@ +//===--- FloatFormatting.swift --------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2019 - 2020 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + + +/// Specifies how a float should be formatted. +/// +/// The output of alignment is not meant for end-user consumption, use a +/// locale-rich formatter for that. This is meant for machine and programmer +/// use (e.g. log files, textual formats, or anywhere `printf` is used). +public struct FloatFormatting: Hashable { + // NOTE: fprintf will read from C locale. Swift print uses dot. + // We could consider a global var for the c locale's character. + + /// The radix character to use. + public var radixPoint: Character + + /// Whether the include an explicit positive sign, if positive. + public var explicitPositiveSign: Bool + + /// Whether to use uppercase (TODO: hex and/or exponent characters?) + public var uppercase: Bool + + // Note: no includePrefix for FloatFormatting; it doesn't exist for + // fprintf (%a always prints a prefix, %efg don't need one), so why + // introduce it here. + + public enum Notation: Hashable { + /// Swift's String(floating-point) formatting. + case decimal + + /// Hexadecimal formatting. Only permitted for BinaryFloatingPoint types. + case hex + + /// Prints all digits before the radix point, and `precision` digits following + /// the radix point. If `precision` is zero, the radix point is omitted. + /// + /// Note that very large floating-point values may print quite a lot of digits + /// when using this format, even if `precision` is zero--up to hundreds for + /// `Double`, and thousands for `Float80`. Note also that this format is + /// very likely to print non-zero values as all-zero. If either of these is a concern + /// for your use, consider using `.optimal` or `.hybrid` instead. + /// + /// Systems may impose an upper bound on the number of digits that are + /// supported following the radix point. + /// + /// This corresponds to C's `%f` formatting used with `fprintf`. + case fixed(precision: Int32 = 6) + + /// Prints the number in the form [-]d.ddd...dde±dd, with `precision` significant + /// digits following the radix point. Systems may impose an upper bound on the number + /// of digits that are supported. + /// + /// This corresponds to C's `%e` formatting used with `fprintf`. + case exponential(precision: Int32 = 6) + + /// Behaves like `.fixed` when the number is scaled close to 1.0, and like + /// `.exponential` if it has a very large or small exponent. + /// + /// The corresponds to C's `%g` formatting used with `fprintf`. + case hybrid(precision: Int32 = 6) + } + + /// The notation to use. Swift's default formatting behavior corresponds to `.decimal`. + public var notation: Notation + + /// The separator formatting options to use. + public var separator: SeparatorFormatting + + public init( + radixPoint: Character = ".", + explicitPositiveSign: Bool = false, + uppercase: Bool = false, + notation: Notation = .decimal, + separator: SeparatorFormatting = .none + ) { + self.radixPoint = radixPoint + self.explicitPositiveSign = explicitPositiveSign + self.uppercase = uppercase + self.notation = notation + self.separator = separator + } + + /// Format as a decimal (Swift's default printing format). + public static var decimal: FloatFormatting { .decimal() } + + /// Format as a decimal (Swift's default printing format). + public static func decimal( + radixPoint: Character = ".", + explicitPositiveSign: Bool = false, + uppercase: Bool = false, + separator: SeparatorFormatting = .none + ) -> FloatFormatting { + return FloatFormatting( + radixPoint: radixPoint, + explicitPositiveSign: explicitPositiveSign, + uppercase: uppercase, + notation: .decimal, + separator: separator + ) + } + + /// Format as a hex float. + public static var hex: FloatFormatting { .hex() } + + /// Format as a hex float. + public static func hex( + radixPoint: Character = ".", + explicitPositiveSign: Bool = false, + uppercase: Bool = false, + separator: SeparatorFormatting = .none + ) -> FloatFormatting { + return FloatFormatting( + radixPoint: radixPoint, + explicitPositiveSign: explicitPositiveSign, + uppercase: uppercase, + notation: .hex, + separator: separator + ) + } +} + +extension FloatFormatting { + // Returns a fprintf-compatible length modifier for a given argument type + private static func _formatStringLengthModifier( + _ type: I.Type + ) -> String? { + switch type { + // fprintf formatters promote Float to Double + case is Float.Type: return "" + case is Double.Type: return "" + // fprintf formatters use L for Float80 + case is Float80.Type: return "L" + default: return nil + } + } + + // TODO: Are we making these public yet? + public func toFormatString( + _ align: String.Alignment = .none, for type: I.Type + ) -> String? { + + // No separators supported + guard separator == SeparatorFormatting.none else { return nil } + + // Radix character simply comes from C locale, so require it be + // default. + guard radixPoint == "." else { return nil } + + // Make sure this is a type that fprintf supports. + guard let lengthMod = FloatFormatting._formatStringLengthModifier(type) else { return nil } + + var specification = "%" + + // 1. Flags + // IEEE: `+` The result of a signed conversion shall always begin with a sign ( '+' or '-' ) + if explicitPositiveSign { + specification += "+" + } + + // IEEE: `-` The result of the conversion shall be left-justified within the field. The + // conversion is right-justified if this flag is not specified. + if align.anchor == .start { + specification += "-" + } + + // Padding has to be space + guard align.fill == " " else { + return nil + } + + if align.minimumColumnWidth > 0 { + specification += "\(align.minimumColumnWidth)" + } + + // 3. Precision and conversion specifier. + switch notation { + case let .fixed(p): + specification += "\(p)" + lengthMod + (uppercase ? "F" : "f") + case let .exponential(p): + specification += "\(p)" + lengthMod + (uppercase ? "E" : "e") + case let .hybrid(p): + specification += "\(p)" + lengthMod + (uppercase ? "G" : "g") + case .hex: + guard type.radix == 2 else { return nil } + specification += lengthMod + (uppercase ? "A" : "a") + default: + return nil + } + + return specification + } +} diff --git a/Sources/FormattersModule/Formatting.swift b/Sources/FormattersModule/Formatting.swift new file mode 100644 index 00000000..2bfcf44f --- /dev/null +++ b/Sources/FormattersModule/Formatting.swift @@ -0,0 +1,54 @@ +//===--- Formatting.swift -------------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2019 - 2020 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +/// Specify separators to insert during formatting. +public struct SeparatorFormatting: Hashable { + /// The separator character to use. + public var separator: Character? + + /// The spacing between separators. + public var spacing: Int + + public init(separator: Character? = nil, spacing: Int = 3) { + self.separator = separator + self.spacing = spacing + } + + // TODO: Consider modeling `none` as `nil` separator formatting... + + /// No separators. + public static var none: SeparatorFormatting { + SeparatorFormatting() + } + + /// Insert `separator` every `n`characters. + public static func every( + _ n: Int, separator: Character + ) -> SeparatorFormatting { + SeparatorFormatting(separator: separator, spacing: n) + } + + /// Insert `separator` every thousands. + public static func thousands(separator: Character) -> SeparatorFormatting { + .every(3, separator: separator) + } +} + +public protocol FixedWidthIntegerFormatter { + func format(_: I, into: inout OS) +} +extension FixedWidthIntegerFormatter { + public func format(_ x: I) -> String { + var result = "" + self.format(x, into: &result) + return result + } +} diff --git a/Sources/FormattersModule/IntFormatting.swift b/Sources/FormattersModule/IntFormatting.swift new file mode 100644 index 00000000..916e29b1 --- /dev/null +++ b/Sources/FormattersModule/IntFormatting.swift @@ -0,0 +1,311 @@ +//===--- IntFormatting.swift ----------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2019 - 2020 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +/// Specifies how an integer should be formatted. +/// +/// The output of alignment is not meant for end-user consumption, use a +/// locale-rich formatter for that. This is meant for machine and programmer +/// use (e.g. log files, textual formats, or anywhere `printf` is used). +public struct IntegerFormatting: Hashable { + /// The base to use for the string representation. `radix` must be at least 2 and at most 36. + /// The default is 10. + public var radix: Int + + /// Explicitly print a positive sign.TODO + public var explicitPositiveSign: Bool + + /// Include the integer literal prefix for binary, octal, or hexadecimal bases. + public var includePrefix: Bool + + /// Whether to use uppercase letters to represent numerals + /// greater than 9 (default is to use lowercase) + public var uppercase: Bool + + /// TODO: docs + public var minDigits: Int + + /// The separator formatting options to use. + public var separator: SeparatorFormatting + + public init( + radix: Int = 10, + explicitPositiveSign: Bool = false, + includePrefix: Bool = true, + uppercase: Bool = false, + minDigits: Int = 1, + separator: SeparatorFormatting = .none + ) { + precondition(radix >= 2 && radix <= 36) + + self.radix = radix + self.explicitPositiveSign = explicitPositiveSign + self.includePrefix = includePrefix + self.uppercase = uppercase + self.minDigits = minDigits + self.separator = separator + } + + /// Format as a decimal integer. + public static func decimal( + explicitPositiveSign: Bool = false, + minDigits: Int = 1, + separator: SeparatorFormatting = .none + ) -> IntegerFormatting { + return IntegerFormatting( + radix: 10, + explicitPositiveSign: explicitPositiveSign, + minDigits: minDigits, + separator: separator) + } + + /// Format as a decimal integer. + public static var decimal: IntegerFormatting { .decimal() } + + /// Format as a hexadecimal integer. + public static func hex( + explicitPositiveSign: Bool = false, + includePrefix: Bool = true, + uppercase: Bool = false, + minDigits: Int = 1, + separator: SeparatorFormatting = .none + ) -> IntegerFormatting { + return IntegerFormatting( + radix: 16, + explicitPositiveSign: explicitPositiveSign, + includePrefix: includePrefix, + uppercase: uppercase, + minDigits: minDigits, + separator: separator) + } + + /// Format as a hexadecimal integer. + public static var hex: IntegerFormatting { .hex() } + + /// Format as an octal integer. + public static func octal( + explicitPositiveSign: Bool = false, + includePrefix: Bool = true, + uppercase: Bool = false, + minDigits: Int = 1, // TODO: document if prefix is zero! + separator: SeparatorFormatting = .none + ) -> IntegerFormatting { + IntegerFormatting( + radix: 8, + explicitPositiveSign: explicitPositiveSign, + includePrefix: includePrefix, + uppercase: uppercase, + minDigits: minDigits, + separator: separator) + } + + /// Format as an octal integer. + public static var octal: IntegerFormatting { .octal() } + + /// TODO: binary + +} + +extension IntegerFormatting { + // On Prefixes + // + // `fprintf` has oddball prefix behaviors. + // * We want signed and unsigned prefixes (former cannot be easily emulated) + // * The precision-adjusting octal prefix won't be missed. + // * Nor the special case for minDigits == 0 + // * We want a hexadecimal prefix to be printed if requested, even for + // the value 0. + // * We don't want to conflate prefix capitalization with hex-digit + // capitalization. + // * A binary prefix for radix 2 is nice to have + // + // Instead, we go with Swift literal syntax. If a prefix is requested, + // and radix is: + // 2: "0b1010" + // 8: "0o1234" + // 16: "0x89ab" + // + // This can be sensibly emulated using `fprintf` for unsigned types by just + // adding it before the specifier. + fileprivate var _prefix: String { + guard includePrefix else { return "" } + switch radix { + case 2: return "0b" + case 8: return "0o" + case 16: return "0x" + default: return "" + } + } +} + +extension IntegerFormatting: FixedWidthIntegerFormatter { + public func format( + _ i: I, into os: inout OS + ) { + if i == 0 && self.minDigits == 0 { + return + } + + // Sign + if I.isSigned { + if i < 0 { + os.write("-") + } else if self.explicitPositiveSign { + os.write("+") + } + } + + // Prefix + os.write(self._prefix) + + // Digits + let number = String( + i.magnitude, radix: self.radix, uppercase: self.uppercase + ).aligned(.right(columns: self.minDigits, fill: "0")) + if let separator = self.separator.separator { + var num = number + num.intersperse( + separator, every: self.separator.spacing, startingFrom: .end) + os.write(num) + } else { + os.write(number) + } + } +} + +extension IntegerFormatting { + + // Returns a fprintf-compatible length modifier for a given argument type + private static func _formatStringLengthModifier( + _ type: I.Type + ) -> String? { + // IEEE Std 1003.1-2017, length modifiers: + + switch type { + // hh - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) char + case is CChar.Type: return "hh" + case is CUnsignedChar.Type: return "hh" + + // h - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) short + case is CShort.Type: return "h" + case is CUnsignedShort.Type: return "h" + + case is CInt.Type: return "" + case is CUnsignedInt.Type: return "" + + // l - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) long + case is CLong.Type: return "l" + case is CUnsignedLong.Type: return "l" + + // ll - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) long long + case is CLongLong.Type: return "ll" + case is CUnsignedLongLong.Type: return "ll" + + default: return nil + } + } + + // TODO: Are we making these public yet? + public func toFormatString( + _ align: String.Alignment = .none, for type: I.Type + ) -> String? { + // Based on IEEE Std 1003.1-2017 + + // No separators supported + guard separator == SeparatorFormatting.none else { return nil } + + // `d`/`i` is the only signed integral conversions allowed + guard !type.isSigned || radix == 10 else { return nil } + + // IEEE: Each conversion specification is introduced by the '%' character + // after which the following appear in sequence: + // 1. Zero or more flags (in any order), which modify the meaning of + // the conversion specification. + // 2. An optional minimum field width. If the converted value has fewer + // bytes than the field width, it shall be padded with + // characters by default on the left; it shall be padded on the right + // if the left-adjustment flag ( '-' ), is given to the field width. + // 3. An optional precision that gives the minimum number of digits to + // appear for the d, i, o, u, x, and X conversion specifiers ... + // 4. An optional length modifier that specifies the size of the argument. + // 5. A conversion specifier character that indicates the type of + // conversion to be applied. + + // Use Swift style prefixes rather than fprintf style prefixes + var specification = "\(_prefix)%" + + // + // 1. Flags + // + + // Use `+` flag if signed, otherwise prefix a literal `+` for unsigned + if explicitPositiveSign { + // IEEE: `+` The result of a signed conversion shall always begin with a sign ( '+' or '-' ) + if type.isSigned { + specification += "+" + } else { + specification.insert("+", at: specification.startIndex) + } + } + + // IEEE: `-` The result of the conversion shall be left-justified within the field. The + // conversion is right-justified if this flag is not specified. + if align.anchor == .start { + specification += "-" + } + + // 2. Minimumn field width + + // Padding has to be space + guard align.fill == " " else { + // IEEE: `0` Leading zeros (following any indication of sign or base) are used to pad to + // the field width rather than performing space padding. If the '0' and '-' flags + // both appear, the '0' flag is ignored. If a precision is specified, the '0' flag + // shall be ignored. + // + // Commentary: `0` is when the user doesn't want to use precision (minDigits). This allows + // sign and prefix characters to be counted towards field width (they wouldn't be + // counted towards precision). This is more useful for floats, where precision is + // digits after the radix. We're already handling prefix ourselves; we choose not + // to support this functionality. + // + // TODO: consider providing a static function to emulate the behavior... (not everything). + return nil + } + + if align.minimumColumnWidth > 0 { + specification += "\(align.minimumColumnWidth)" + } + + // 3. Precision + + // Default precision for integers is 1, otherwise use the requested precision + if minDigits != 1 { + specification += ".\(minDigits)" + } + + // 4. Length modifier + guard let lengthMod = IntegerFormatting._formatStringLengthModifier(type) else { return nil } + specification += lengthMod + + // 5. The conversion specifier + switch radix { + case 10: + specification += "d" + case 8: + specification += "o" + case 16: + specification += uppercase ? "X" : "x" + default: return nil + } + + return specification + } +} diff --git a/Sources/FormattersModule/Interpolations.swift b/Sources/FormattersModule/Interpolations.swift new file mode 100644 index 00000000..a0f7ed1b --- /dev/null +++ b/Sources/FormattersModule/Interpolations.swift @@ -0,0 +1,110 @@ +//===--- Interpolations.swift ---------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2019 - 2020 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +/// Conform to this protocol to customize the behavior of Swifty `printf`-style interpolations. +public protocol SwiftyStringFormatting { + // %s, but general over anything that can be printed + mutating func appendInterpolation( + _ s: S, + maxPrefixLength: Int, // Int.max by default + align: String.Alignment // .none + ) where S.Element: CustomStringConvertible + + // %x, %X, %o, %d, %i + // TODO: %u? + mutating func appendInterpolation( + _ value: I, + format: IntegerFormatting, // .decimal(minDigits: 1) by default + align: String.Alignment // .none + ) + + // %f, %F + // TODO: FloatFormatting struct + mutating func appendInterpolation( + _ value: F, + explicitRadix: Bool, // false by default + precision: Int?, // nil by default + uppercase: Bool, // false by default + zeroFillFinite: Bool, // false by default + minDigits: Int, // 1 by default + explicitPositiveSign: Character?, // nil by default + align: String.Alignment) // .none + +} + +extension DefaultStringInterpolation: SwiftyStringFormatting { + + public mutating func appendInterpolation( + _ seq: S, + maxPrefixLength: Int = Int.max, + align: String.Alignment = .none + ) where S.Element: CustomStringConvertible { + var str = "" + var iter = seq.makeIterator() + var count = 0 + while let next = iter.next(), count < maxPrefixLength { + str.append(next.description) + count += 1 + } + appendInterpolation(str.aligned(align)) + } + + public mutating func appendInterpolation( + _ value: I, + format: IntegerFormatting = .decimal(minDigits: 1), + align: String.Alignment = .none + ) { + appendInterpolation(format.format(value).aligned(align)) + } + + + // %f, %F + public mutating func appendInterpolation( + _ value: F, + explicitRadix: Bool = false, + precision: Int? = nil, + uppercase: Bool = false, + zeroFillFinite: Bool = false, + minDigits: Int = 1, + explicitPositiveSign: Character? = nil, + align: String.Alignment = .none + ) { + + // TODO: body should be extracted into a format method, can be invoked + // outside of interpolation context + + let valueStr: String + if value.isNaN { + valueStr = uppercase ? "NAN" : "nan" + } else if value.isInfinite { + valueStr = uppercase ? "INF" : "inf" + } else { + if let dValue = value as? Double { + valueStr = String(dValue) + } else if let fValue = value as? Float { + valueStr = String(fValue) + } else { + fatalError("TODO") + } + + // FIXME: Precision, minDigits, radix, zeroFillFinite, ... + guard explicitRadix == false else { fatalError() } + guard precision == nil else { fatalError() } + guard uppercase == false else { fatalError() } + guard minDigits == 1 else { fatalError() } + guard zeroFillFinite == false else { fatalError() } + guard explicitPositiveSign == nil else { fatalError() } + } + + appendInterpolation(valueStr.aligned(align)) + } + +} diff --git a/Sources/FormattersModule/StringAlignment.swift b/Sources/FormattersModule/StringAlignment.swift new file mode 100644 index 00000000..39397091 --- /dev/null +++ b/Sources/FormattersModule/StringAlignment.swift @@ -0,0 +1,94 @@ +//===--- StringAlignment.swift --------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2019 - 2020 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +extension String { + /// Specify the alignment of a string, for machine-formatting purposes + /// + /// The output of alignment is not meant for end-user consumption, use a + /// locale-rich formatter for that. This is meant for machine and programmer + /// use (e.g. log files, textual formats, or anywhere `printf` is used). + /// + /// NOTE: One `Character` is currently considered one column, though they + /// may commonly be printed out differently. What is considered one or + /// two columns is application-specific. The Unicode standard does not dictate this. + /// + /// TODO: We can consider adding a half-sensible approximation function, + /// or even use a user-supplied function here. + public struct Alignment: Hashable { + /// The minimum number of characters + public var minimumColumnWidth: Int + + /// Where to align + public var anchor: CollectionBound + + /// The Character to use to reach `minimumColumnWidth` + public var fill: Character + + public init( + minimumColumnWidth: Int = 0, + anchor: CollectionBound = .end, + fill: Character = " " + ) { + self.minimumColumnWidth = minimumColumnWidth + self.anchor = anchor + self.fill = fill + } + + /// Specify a right-aligned string. + public static var right: Alignment { Alignment(anchor: .end) } + + /// Specify a left-aligned string. + public static var left: Alignment { Alignment(anchor: .start) } + + /// No aligment requirements + public static var none: Alignment { .right } + + /// Specify a right-aligned string. + public static func right( + columns: Int = 0, fill: Character = " " + ) -> Alignment { + Alignment.right.columns(columns).fill(fill) + } + /// Specify a left-aligned string. + public static func left( + columns: Int = 0, fill: Character = " " + ) -> Alignment { + Alignment.left.columns(columns).fill(fill) + } + + /// Specify the minimum number of columns. + public func columns(_ i: Int) -> Alignment { + var result = self + result.minimumColumnWidth = i + return result + } + + public func fill(_ c: Character) -> Alignment { + var result = self + result.fill = c + return result + } + } +} + +extension StringProtocol { + /// Align `self`, according to `align`. + public func aligned(_ align: String.Alignment) -> String { + var copy = String(self) + copy.pad(to: align.minimumColumnWidth, using: align.fill, at: align.anchor.inverted) + return copy + } + + /// Indent `self` by `columns`, using `fill` (default space). + public func indented(_ columns: Int, fill: Character = " ") -> String { + String(repeating: fill, count: columns) + self + } +} diff --git a/Sources/RealModule/Formatters.swift b/Sources/RealModule/Formatters.swift new file mode 100644 index 00000000..e6626878 --- /dev/null +++ b/Sources/RealModule/Formatters.swift @@ -0,0 +1,13 @@ +//===--- Formatters.swift -------------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2021 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +@_exported import FormattersModule + diff --git a/Tests/FormattersTests/FormattersTests.swift b/Tests/FormattersTests/FormattersTests.swift new file mode 100644 index 00000000..d93d8f5e --- /dev/null +++ b/Tests/FormattersTests/FormattersTests.swift @@ -0,0 +1,15 @@ +//===--- FormattersTest.swift ---------------------------------*- swift -*-===// +// +// This source file is part of the Swift Numerics open source project +// +// Copyright (c) 2019 - 2020 Apple Inc. and the Swift Numerics project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +import XCTest +import FormattersModule + + From a2e874777119ac07b866f77a5d6add143e3c324a Mon Sep 17 00:00:00 2001 From: Michael Ilseman Date: Mon, 29 Mar 2021 21:25:46 -0600 Subject: [PATCH 2/2] Add in some quick ad-hoc tests --- .../FormattersModule/CollectionPadding.swift | 6 +- Sources/FormattersModule/IntFormatting.swift | 130 ------------------ Tests/FormattersTests/FormattersTests.swift | 57 ++++++++ 3 files changed, 61 insertions(+), 132 deletions(-) diff --git a/Sources/FormattersModule/CollectionPadding.swift b/Sources/FormattersModule/CollectionPadding.swift index b691470a..d1d3d645 100644 --- a/Sources/FormattersModule/CollectionPadding.swift +++ b/Sources/FormattersModule/CollectionPadding.swift @@ -67,9 +67,11 @@ extension RangeReplaceableCollection { var result = Self() let interspersedCount = newElements.count - let insertCount = (currentCount / n) - (remainder == 0 ? 0 : 1) + let insertCount = (currentCount / n) - (remainder == 0 ? 1 : 0) let newCount = currentCount + interspersedCount * insertCount - defer { assert(result.count == newCount) } + defer { + assert(result.count == newCount) + } result.reserveCapacity(newCount) var selfConsumer = self[...] diff --git a/Sources/FormattersModule/IntFormatting.swift b/Sources/FormattersModule/IntFormatting.swift index 916e29b1..f5a8af6d 100644 --- a/Sources/FormattersModule/IntFormatting.swift +++ b/Sources/FormattersModule/IntFormatting.swift @@ -179,133 +179,3 @@ extension IntegerFormatting: FixedWidthIntegerFormatter { } } } - -extension IntegerFormatting { - - // Returns a fprintf-compatible length modifier for a given argument type - private static func _formatStringLengthModifier( - _ type: I.Type - ) -> String? { - // IEEE Std 1003.1-2017, length modifiers: - - switch type { - // hh - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) char - case is CChar.Type: return "hh" - case is CUnsignedChar.Type: return "hh" - - // h - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) short - case is CShort.Type: return "h" - case is CUnsignedShort.Type: return "h" - - case is CInt.Type: return "" - case is CUnsignedInt.Type: return "" - - // l - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) long - case is CLong.Type: return "l" - case is CUnsignedLong.Type: return "l" - - // ll - d, i, o, u, x, or X conversion specifier applies to (signed|unsigned) long long - case is CLongLong.Type: return "ll" - case is CUnsignedLongLong.Type: return "ll" - - default: return nil - } - } - - // TODO: Are we making these public yet? - public func toFormatString( - _ align: String.Alignment = .none, for type: I.Type - ) -> String? { - // Based on IEEE Std 1003.1-2017 - - // No separators supported - guard separator == SeparatorFormatting.none else { return nil } - - // `d`/`i` is the only signed integral conversions allowed - guard !type.isSigned || radix == 10 else { return nil } - - // IEEE: Each conversion specification is introduced by the '%' character - // after which the following appear in sequence: - // 1. Zero or more flags (in any order), which modify the meaning of - // the conversion specification. - // 2. An optional minimum field width. If the converted value has fewer - // bytes than the field width, it shall be padded with - // characters by default on the left; it shall be padded on the right - // if the left-adjustment flag ( '-' ), is given to the field width. - // 3. An optional precision that gives the minimum number of digits to - // appear for the d, i, o, u, x, and X conversion specifiers ... - // 4. An optional length modifier that specifies the size of the argument. - // 5. A conversion specifier character that indicates the type of - // conversion to be applied. - - // Use Swift style prefixes rather than fprintf style prefixes - var specification = "\(_prefix)%" - - // - // 1. Flags - // - - // Use `+` flag if signed, otherwise prefix a literal `+` for unsigned - if explicitPositiveSign { - // IEEE: `+` The result of a signed conversion shall always begin with a sign ( '+' or '-' ) - if type.isSigned { - specification += "+" - } else { - specification.insert("+", at: specification.startIndex) - } - } - - // IEEE: `-` The result of the conversion shall be left-justified within the field. The - // conversion is right-justified if this flag is not specified. - if align.anchor == .start { - specification += "-" - } - - // 2. Minimumn field width - - // Padding has to be space - guard align.fill == " " else { - // IEEE: `0` Leading zeros (following any indication of sign or base) are used to pad to - // the field width rather than performing space padding. If the '0' and '-' flags - // both appear, the '0' flag is ignored. If a precision is specified, the '0' flag - // shall be ignored. - // - // Commentary: `0` is when the user doesn't want to use precision (minDigits). This allows - // sign and prefix characters to be counted towards field width (they wouldn't be - // counted towards precision). This is more useful for floats, where precision is - // digits after the radix. We're already handling prefix ourselves; we choose not - // to support this functionality. - // - // TODO: consider providing a static function to emulate the behavior... (not everything). - return nil - } - - if align.minimumColumnWidth > 0 { - specification += "\(align.minimumColumnWidth)" - } - - // 3. Precision - - // Default precision for integers is 1, otherwise use the requested precision - if minDigits != 1 { - specification += ".\(minDigits)" - } - - // 4. Length modifier - guard let lengthMod = IntegerFormatting._formatStringLengthModifier(type) else { return nil } - specification += lengthMod - - // 5. The conversion specifier - switch radix { - case 10: - specification += "d" - case 8: - specification += "o" - case 16: - specification += uppercase ? "X" : "x" - default: return nil - } - - return specification - } -} diff --git a/Tests/FormattersTests/FormattersTests.swift b/Tests/FormattersTests/FormattersTests.swift index d93d8f5e..b6c178e4 100644 --- a/Tests/FormattersTests/FormattersTests.swift +++ b/Tests/FormattersTests/FormattersTests.swift @@ -13,3 +13,60 @@ import XCTest import FormattersModule +final class FrmattersTest: XCTestCase { + public func testIntegerFormatting() { + // TODO: More exhaustive programmatic tests using the formatter structs + } + + // Some quick ad-hoc tests + public func testIntegerFormattingAdHoc() { + var buffer = "" + func put(_ s: String) { + buffer += s + } + func expect(_ s: String) { + XCTAssertEqual(buffer, s) + buffer = "" + } + + put(""" + \(12345678, format: .decimal(minDigits: 2), align: .right(columns: 9, fill: " ")) + """) + expect(" 12345678") + + put("\(54321, format: .hex)") + expect("0xd431") + + put("\(54321, format: .hex(includePrefix: false, uppercase: true))") + expect("D431") + + put("\(1234567890, format: .hex(includePrefix: true, minDigits: 12), align: .right(columns: 20))") + expect(" 0x0000499602d2") + + put("\(9876543210, format: .hex(explicitPositiveSign: true), align: .right(columns: 20, fill: "-"))") + expect("--------+0x24cb016ea") + + put("\("Hi there", align: .left(columns: 20))!") + expect("Hi there !") + + put("\(-1234567890, format: .hex(includePrefix: true, minDigits: 12), align: .right(columns: 20))") + expect(" -0x0000499602d2") + + put("\(-1234567890, format: .hex(minDigits: 12, separator: .every(2, separator: "_")), align: .right(columns: 22))") + expect(" -0x00_00_49_96_02_d2") + + put("\(-1234567890, format: .hex(minDigits: 10, separator: .every(4, separator: "_")), align: .right(columns: 22))") + expect(" -0x00_4996_02d2") + + put("\(1234567890, format: .decimal(separator: .thousands(separator: "⌟")))") + expect("1⌟234⌟567⌟890") + + put("\(98765, format: .hex(includePrefix: true, minDigits: 8, separator: .every(2, separator: "_")))") + expect("0x00_01_81_cd") + + put("\(12345, format: .hex(minDigits: 5))") + expect("0x03039") + + } + +}