Skip to content

Instantly share code, notes, and snippets.

@milseman
Last active September 11, 2019 18:23
Show Gist options
  • Save milseman/63eecdbca831adcfe9aeb76b681d72e6 to your computer and use it in GitHub Desktop.
Save milseman/63eecdbca831adcfe9aeb76b681d72e6 to your computer and use it in GitHub Desktop.
String Formatters Through Interpolation
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