Last active
September 11, 2019 18:23
-
-
Save milseman/63eecdbca831adcfe9aeb76b681d72e6 to your computer and use it in GitHub Desktop.
String Formatters Through Interpolation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
struct StringFormatters { | |
var text = "Hello, World!" | |
} | |
/* | |
This is the master template for fprintf format string functionality. | |
For development purposes only | |
``` | |
func appendInterpolation( | |
/* for decimal */ thousandsSeparator: Character = "", // Or, do we want (safe) non-monetary grouping? | |
leftJustify: Bool = false, | |
/* for numbers, maybe only signed? */ explicitPositiveSign: String = "", // or Character? | |
/* for float */ explicitRadix: Bool = false, | |
/* for float, a shortestRepresentation decimal or scientific modifier */ | |
/* for hex, include the 0x. for octal, include a 0 */ includePrefix: Bool = false, | |
/* for integers */ minDigits: Int = 0, | |
/* for float */ minPrecision: Int = 6, // what is the right default? | |
/* for shortestRepresentation */ maxSignificantDigits: Int = Int.max, | |
// OMIT /* for string or Sequence<UInt8> or CChar* */ maxUTF8Bytes: Int = Int.max, | |
padding: Character = "" | |
) { } | |
``` | |
*/ | |
extension String { | |
} | |
extension Collection where SubSequence == Self { | |
mutating func _eat(_ n: Int = 1) -> SubSequence { | |
defer { self = self.dropFirst(n) } | |
return self.prefix(n) | |
} | |
} | |
extension String { | |
public struct Alignment { | |
public enum Anchor { | |
case left | |
case right | |
case center | |
} | |
public var minimumColumnWidth = 0 | |
public var anchor = Anchor.right | |
public var fill: Character = " " | |
public init( | |
minimumColumnWidth: Int = 0, | |
anchor: Anchor = Anchor.right, | |
fill: Character = " " | |
) { | |
self.minimumColumnWidth = minimumColumnWidth | |
self.anchor = anchor | |
self.fill = fill | |
} | |
public static var right: Alignment { | |
Alignment(anchor: .right) | |
} | |
public static var left: Alignment { | |
Alignment(anchor: .left) | |
} | |
public static var center: Alignment { | |
Alignment(anchor: .center) | |
} | |
public static func right( | |
columns: Int = 0, fill: Character = " " | |
) -> Alignment { | |
Alignment.right.columns(columns).fill(fill) | |
} | |
public static func left( | |
columns: Int = 0, fill: Character = " " | |
) -> Alignment { | |
Alignment.left.columns(columns).fill(fill) | |
} | |
public static func center( | |
columns: Int = 0, fill: Character = " " | |
) -> Alignment { | |
Alignment.center.columns(columns).fill(fill) | |
} | |
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 | |
} | |
} | |
// TODO: Numeric formatting options | |
} | |
extension StringProtocol { | |
public func aligned(_ align: String.Alignment) -> String { | |
guard align.minimumColumnWidth > 0 else { return String(self) } | |
let segmentLength = self.count | |
let fillerCount = align.minimumColumnWidth - segmentLength | |
guard fillerCount > 0 else { return String(self) } | |
var filler = String(repeating: align.fill, count: fillerCount) | |
let insertIdx: String.Index | |
switch align.anchor { | |
case .left: insertIdx = filler.startIndex | |
case .right: insertIdx = filler.endIndex | |
case .center: | |
insertIdx = filler.index(filler.startIndex, offsetBy: fillerCount / 2) | |
} | |
filler.insert(contentsOf: self, at: insertIdx) | |
return filler | |
} | |
public func indented(_ columns: Int, fill: Character = " ") -> String { | |
String(repeating: fill, count: columns) + self | |
} | |
} | |
public protocol SwiftyStringFormatting { | |
// %s | |
mutating func appendInterpolation<S: StringProtocol>( | |
_ s: S, | |
maxPrefixLength: Int, // Int.max by default | |
align: String.Alignment) // .right(columns: 0, fill: " ") by default | |
// %x and %X | |
mutating func appendInterpolation<I: FixedWidthInteger>( | |
hex: I, | |
uppercase: Bool, // false by default | |
includePrefix: Bool, // false by default | |
minDigits: Int, // 1 by default | |
explicitPositiveSign: Character?, // nil by default | |
align: String.Alignment) // .right(columns: 0, fill: " ") by default | |
// %o | |
mutating func appendInterpolation<I: FixedWidthInteger>( | |
octal: I, | |
includePrefix: Bool, // false by default | |
minDigits: Int, // 1 by default | |
explicitPositiveSign: Character?, // nil by default | |
align: String.Alignment) // .right(columns: 0, fill: " ") by default | |
// %d, %i | |
mutating func appendInterpolation<I: FixedWidthInteger>( | |
_: I, | |
thousandsSeparator: Character?, // nil by default | |
minDigits: Int, // 1 by default | |
explicitPositiveSign: Character?, // nil by default | |
align: String.Alignment) // .right(columns: 0, fill: " ") by default | |
// TODO: Consider removing this one... | |
// %u | |
mutating func appendInterpolation<I: FixedWidthInteger>( | |
asUnsigned: I, | |
thousandsSeparator: Character?, // nil by default | |
minDigits: Int, // 1 by default | |
align: String.Alignment) // .right(columns: 0, fill: " ") by default | |
// %f, %F | |
mutating func appendInterpolation<F: FloatingPoint>( | |
_ 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) // .right(columns: 0, fill: " ") by default | |
} | |
// Default argument values | |
extension SwiftyStringFormatting { | |
mutating func appendInterpolation<S: StringProtocol>( | |
_ s: S, | |
maxPrefixLength: Int = Int.max, | |
align: String.Alignment = String.Alignment() | |
) { | |
appendInterpolation(s, maxPrefixLength: maxPrefixLength, align: align) | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
hex: I, | |
uppercase: Bool = false, | |
includePrefix: Bool = false, | |
minDigits: Int = 1, | |
explicitPositiveSign: Character? = nil, | |
align: String.Alignment = String.Alignment() | |
) { | |
appendInterpolation( | |
hex: hex, | |
uppercase: uppercase, | |
includePrefix: includePrefix, | |
minDigits: minDigits, | |
explicitPositiveSign: explicitPositiveSign, | |
align: align) | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
octal: I, | |
includePrefix: Bool = false, | |
minDigits: Int = 1, | |
explicitPositiveSign: Character? = nil, | |
align: String.Alignment = String.Alignment() | |
) { | |
appendInterpolation( | |
octal: octal, | |
includePrefix: includePrefix, | |
minDigits: minDigits, | |
explicitPositiveSign: explicitPositiveSign, | |
align: align) | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
_ value: I, | |
thousandsSeparator: Character? = nil, | |
minDigits: Int = 1, | |
explicitPositiveSign: Character? = nil, | |
align: String.Alignment = String.Alignment() | |
) { | |
appendInterpolation( | |
value, | |
thousandsSeparator: thousandsSeparator, | |
minDigits: minDigits, | |
explicitPositiveSign: explicitPositiveSign, | |
align: align) | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
asUnsigned: I, | |
thousandsSeparator: Character? = nil, | |
minDigits: Int = 1, | |
align: String.Alignment = String.Alignment() | |
) { | |
appendInterpolation( | |
asUnsigned: asUnsigned, | |
thousandsSeparator: thousandsSeparator, | |
minDigits: minDigits, | |
align: align) | |
} | |
// %f, %F | |
public mutating func appendInterpolation<F: FloatingPoint>( | |
_ value: F, | |
explicitRadix: Bool = false, | |
precision: Int? = nil, | |
uppercase: Bool = false, | |
zeroFillFinite: Bool = false, | |
minDigits: Int = 1, | |
explicitPositiveSign: Character? = nil, | |
align: String.Alignment = String.Alignment() | |
) { | |
appendInterpolation( | |
value, | |
explicitRadix: explicitRadix, | |
precision: precision, | |
uppercase: uppercase, | |
zeroFillFinite: zeroFillFinite, | |
minDigits: minDigits, | |
explicitPositiveSign: explicitPositiveSign, | |
align: align) | |
} | |
} | |
// Default implementations | |
extension SwiftyStringFormatting { | |
// mutating func appendInterpolation<I: FixedWidthInteger>( | |
// asUnsigned: I, | |
// thousandsSeparator: Character?, | |
// minDigits: Int, | |
// align: String.Alignment | |
// ) { | |
// asUnsigned.words.reversed() | |
// } | |
} | |
extension String { | |
fileprivate func zeroExtend(minDigits: Int) -> String { | |
return "\(self, align: .right(columns: minDigits, fill: "0"))" | |
} | |
fileprivate init<I: FixedWidthInteger>( | |
_ value: I, radix: Int, uppercase: Bool, minDigits: Int | |
) { | |
let number = String(value, radix: radix, uppercase: uppercase) | |
// Handle the minus sign | |
let align = String.Alignment.right(columns: minDigits, fill: "0") | |
if value < 0 { | |
self = "-\(number.dropFirst(), align: align)" | |
} else { | |
self = number.aligned(align) | |
} | |
} | |
} | |
extension DefaultStringInterpolation: SwiftyStringFormatting { | |
public mutating func appendInterpolation<S: StringProtocol>( | |
_ str: S, | |
maxPrefixLength: Int, | |
align: String.Alignment = String.Alignment() | |
) { | |
appendInterpolation(str.prefix(maxPrefixLength).aligned(align)) | |
} | |
private func signed<I: FixedWidthInteger>( | |
_ value: I, | |
radix: Int, | |
minDigits: Int, | |
explicitPositiveSign: Character?, | |
addPrefix: String?, | |
uppercase: Bool | |
) -> String { | |
if value == 0 && minDigits == 0 { return "" } | |
let valueStr = String( | |
value, radix: radix, uppercase: uppercase, minDigits: minDigits) | |
if value >= 0 { | |
var result = "" | |
if let sign = explicitPositiveSign { | |
result.append(sign) | |
} | |
if let prefix = addPrefix { | |
result += prefix | |
} | |
result += valueStr | |
return result | |
} | |
if let prefix = addPrefix { | |
var result = "-" | |
result += prefix | |
result.append(contentsOf: valueStr.dropFirst()) | |
return result | |
} | |
return valueStr | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
hex: I, | |
uppercase: Bool, | |
includePrefix: Bool, | |
minDigits: Int, | |
explicitPositiveSign: Character?, | |
align: String.Alignment = String.Alignment() | |
) { | |
let addPrefix: String? = includePrefix ? (uppercase ? "0X" : "0x") : nil | |
let result = signed( | |
hex, | |
radix: 16, | |
minDigits: minDigits, | |
explicitPositiveSign: explicitPositiveSign, | |
addPrefix: hex == 0 ? nil : addPrefix, | |
uppercase: uppercase) | |
self.appendInterpolation(result.aligned(align)) | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
octal: I, | |
includePrefix: Bool, | |
minDigits: Int, | |
explicitPositiveSign: Character?, | |
align: String.Alignment = String.Alignment() | |
) { | |
let addPrefix: String? = includePrefix ? "0" : nil | |
let result: String | |
if octal == 0 && (includePrefix && minDigits == 0 || minDigits == 1) { | |
result = "0" | |
} else { | |
result = signed( | |
octal, | |
radix: 8, | |
minDigits: addPrefix == nil ? minDigits : minDigits - 1, | |
explicitPositiveSign: explicitPositiveSign, | |
addPrefix: addPrefix, | |
uppercase: false) | |
} | |
self.appendInterpolation(result.aligned(align)) | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
_ value: I, | |
thousandsSeparator: Character?, | |
minDigits: Int, | |
explicitPositiveSign: Character?, | |
align: String.Alignment | |
) { | |
let valueStr = signed( | |
value, | |
radix: 10, | |
minDigits: minDigits, | |
explicitPositiveSign: explicitPositiveSign, | |
addPrefix: nil, | |
uppercase: false) | |
guard let thousands = thousandsSeparator else { | |
appendInterpolation(valueStr.aligned(align)) | |
return | |
} | |
let hasSign = value < 0 || explicitPositiveSign != nil | |
let numLength = valueStr.count - (hasSign ? 1 : 0) | |
var result = "" | |
var scanner = valueStr[...] | |
if hasSign { | |
result.append(contentsOf: scanner._eat()) | |
} | |
if numLength % 3 != 0 { | |
result.append(contentsOf: scanner._eat(numLength % 3)) | |
if !scanner.isEmpty { | |
result.append(thousands) | |
} | |
} | |
while !scanner.isEmpty { | |
result.append(contentsOf: scanner._eat(3)) | |
if !scanner.isEmpty { | |
result.append(thousands) | |
} | |
} | |
appendInterpolation(result.aligned(align)) | |
} | |
public mutating func appendInterpolation<I: FixedWidthInteger>( | |
asUnsigned: I, | |
thousandsSeparator: Character?, | |
minDigits: Int, | |
align: String.Alignment | |
) { | |
fatalError() | |
} | |
// %f, %F | |
public mutating func appendInterpolation<F: FloatingPoint>( | |
_ value: F, | |
explicitRadix: Bool, | |
precision: Int?, | |
uppercase: Bool, | |
zeroFillFinite: Bool, | |
minDigits: Int, | |
explicitPositiveSign: Character?, | |
align: String.Alignment | |
) { | |
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)) | |
} | |
} | |
/// | |
/// Examples | |
/// | |
func p<C: Collection>( | |
_ s: C, line: Int = #line, indent: Int = 2 | |
) where C.Element == Character { | |
print("\(line): \(s)".indented(indent)) | |
} | |
print("Examples:") | |
p("\(hex: 54321)") | |
// "d431" | |
p("\(hex: 54321, uppercase: true)") | |
// "D431" | |
p("\(hex: 1234567890, includePrefix: true, minDigits: 12, align: .right(columns: 20))") | |
// " 0x0000499602d2" | |
p("\(hex: 9876543210, explicitPositiveSign: "π", align: .center(columns: 20, fill: "-"))") | |
// "-----π24cb016ea-----" | |
p("\("Hi there", align: .left(columns: 20))!") | |
// "Hi there !" | |
p("\(hex: -1234567890, includePrefix: true, minDigits: 12, align: .right(columns: 20))") | |
// " -0x0000499602d2" | |
p("\(1234567890, thousandsSeparator: "β")") | |
// "1β234β567β890" | |
p("\(123.4567)") | |
// "123.4567" | |
/// | |
/// Test Harness | |
/// | |
var testsPassed = true | |
defer { | |
if testsPassed { | |
print("[OK] Tests Passed") | |
} else { | |
print("[FAIL] Tests Failed") | |
} | |
} | |
func checkExpect( | |
_ condition: @autoclosure () -> Bool, | |
expected: @autoclosure () -> String, saw: @autoclosure () -> String, | |
file: StaticString = #file, line: UInt = #line | |
) { | |
if !condition() { | |
print(""" | |
[FAIL] \(file):\(line) | |
expected: \(expected()) | |
saw: \(saw()) | |
""") | |
testsPassed = false | |
} | |
} | |
func expectEqual<C: Equatable>( | |
_ lhs: C, _ rhs: C, file: StaticString = #file, line: UInt = #line | |
) { | |
checkExpect( | |
lhs == rhs, expected: "\(lhs)", saw: "\(rhs)", file: file, line: line) | |
} | |
func expectNotEqual<C: Equatable>( | |
_ lhs: C, _ rhs: C, file: StaticString = #file, line: UInt = #line | |
) { | |
checkExpect( | |
lhs != rhs, expected: "not \(lhs)", saw: "\(rhs)", file: file, line: line) | |
} | |
func expectNil<T>( | |
_ t: T?, file: StaticString = #file, line: UInt = #line | |
) { | |
checkExpect(t == nil, expected: "nil", saw: "\(t!)", file: file, line: line) | |
} | |
func expectTrue( | |
_ t: Bool, file: StaticString = #file, line: UInt = #line | |
) { | |
checkExpect(t, expected: "true", saw: "\(t)", file: file, line: line) | |
} | |
func expectEqualSequence<S1: Sequence, S2: Sequence>( | |
_ lhs: S1, _ rhs: S2, file: StaticString = #file, line: UInt = #line | |
) where S1.Element == S2.Element, S1.Element: Equatable { | |
checkExpect(lhs.elementsEqual(rhs), expected: "\(lhs)", saw: "\(rhs)", | |
file: file, line: line) | |
} | |
var allTests: [(name: String, run: () -> ())] = [] | |
struct TestSuite { | |
let name: String | |
init(_ s: String) { | |
self.name = s | |
} | |
func test(_ name: String, _ f: @escaping () -> ()) { | |
allTests.append((name, f)) | |
} | |
} | |
func runAllTests() { | |
for (test, run) in allTests { | |
print("Running test \(test)") | |
run() | |
} | |
} | |
defer { runAllTests() } | |
/// | |
/// Tests | |
/// | |
extension FixedWidthInteger { | |
var negated: Self? { | |
guard case (let value, false) = Self(0).subtractingReportingOverflow(self) else { return nil } | |
return value | |
} | |
} | |
var FormatTests = TestSuite("Format Tests") | |
let positiveValues = [ | |
Int.min, | |
Int(Int8.min), | |
Int(Int16.min), | |
Int(Int32.min), | |
0, | |
1, | |
16, | |
341, | |
12345, | |
Int(Int8.max), | |
Int(Int16.max), | |
Int(Int32.max), | |
Int.max, | |
] | |
let values = positiveValues + positiveValues.compactMap { $0.negated } | |
let uint32BitpatternValues: [UInt32] = values.map { | |
UInt32(bitPattern: Int32(truncatingIfNeeded: $0)) | |
} | |
import Foundation | |
FormatTests.test("fprintf equivalency") { | |
func equivalent<T: CVarArg>(_ t: T, format: String, | |
file: StaticString = #file, line: UInt = #line, | |
_ f: (T) -> String | |
) { | |
if String(format: format, t) == f(t) { return } | |
print(""" | |
Formatting \(t) with \(format) | |
""") | |
expectEqual(String(format: format, t), f(t), file: file, line: line) | |
} | |
for value in uint32BitpatternValues { | |
for precision in (0..<11) { | |
for width in (0..<15) { | |
for align in [String.Alignment.left(columns: width), .right(columns: width)] { | |
let justify = align.anchor == .left ? "-" : "" | |
for (includePrefix) in [false, true] { | |
let hash = includePrefix ? "#" : "" | |
// Hex | |
for (specifier, uppercase) in [("x", false), ("X", true)] { | |
let format = "%\(justify)\(hash)\(width).\(precision)\(specifier)" | |
// Note: hex is considered unsigned, so no positive sign tests | |
equivalent(value, format: format) { """ | |
\(hex: $0, uppercase: uppercase, includePrefix: includePrefix, | |
minDigits: precision, | |
align: align) | |
""" | |
} | |
// Special zero-fill | |
if align.anchor == .right && precision == 1 && width != 0 { | |
// It seems like a 0 width, even expressed as `%00x` is | |
// interpreted as just the 0 flag. | |
let format = "%0\(hash)\(width)\(specifier)" | |
// Note: hex is considered unsigned, so no positive sign tests | |
equivalent(value, format: format) { """ | |
\(hex: $0, uppercase: uppercase, includePrefix: includePrefix, | |
minDigits: (value != 0 && includePrefix) ? width - 2 : width, | |
align: align.fill("0")) | |
""" | |
} | |
} | |
} | |
// Octal | |
let format = "%\(justify)\(hash)\(width).\(precision)o" | |
// Note: octal is considered unsigned, so no positive sign tests | |
equivalent(value, format: format) { """ | |
\(octal: $0, includePrefix: includePrefix, | |
minDigits: precision, | |
align: align) | |
""" | |
} | |
// Special zero-fill | |
if align.anchor == .right && precision == 1 && width != 0 { | |
// It seems like a 0 width, even expressed as `%00x` is | |
// interpreted as just the 0 flag. | |
let format = "%0\(hash)\(width)o" | |
// Note: hex is considered unsigned, so no positive sign tests | |
equivalent(value, format: format) { """ | |
\(octal: $0, includePrefix: includePrefix, | |
minDigits: width, | |
align: align.fill("0")) | |
""" | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
FormatTests.test("negative values") { | |
for value in values { | |
expectEqual(value < 0 ? "-" : "π", "\(hex: value, explicitPositiveSign: "π")".first!) | |
// TODO: check moar | |
} | |
} | |
FormatTests.test("Ad-hoc tests") { | |
// Some simple ad-hoc sanity checks | |
expectEqual("d431", "\(hex: 54321)") | |
expectEqual("D431", "\(hex: 54321, uppercase: true)") | |
expectEqual("________d431", "\(hex: 54321, align: .right(columns: 12, fill: "_"))") | |
expectEqual("-----π24cb016ea-----", | |
"\(hex: 9876543210, explicitPositiveSign: "π", align: .center(columns: 20, fill: "-"))") | |
expectEqual("0", "\(hex: 0)") | |
expectEqual("", "\(hex: 0, minDigits: 0)") | |
expectEqual("Hi there !", "\("Hi there", align: .left(columns: 20))!") | |
expectEqual(" -0x0000499602d2", | |
"\(hex: -1234567890, includePrefix: true, minDigits: 12, align: .right(columns: 20))") | |
expectEqual(" -011145401322", | |
"\(octal: -1234567890, includePrefix: true, minDigits: 12, align: .right(columns: 20))") | |
expectEqual("---π111454013352----", | |
"\(octal: 9876543210, explicitPositiveSign: "π", align: .center(columns: 20, fill: "-"))") | |
expectEqual("---0111454013352----", | |
"\(octal: 9876543210, includePrefix: true, align: .center(columns: 20, fill: "-"))") | |
expectEqual("1β234β567β890", | |
"\(1234567890, thousandsSeparator: "β")") | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment