diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 5dbdb4f..3dce814 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -9,10 +9,12 @@ on: jobs: build: - runs-on: macos-latest + runs-on: macos-15 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Show Swift version + run: swift --version - name: Build run: swift build -v - name: Run tests diff --git a/.gitignore b/.gitignore index 95c4320..9cd6f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ /Packages /*.xcodeproj xcuserdata/ +DerivedData/ +*.swiftpm/ +.swiftpm/xcode/ +Package.resolved diff --git a/Package.swift b/Package.swift index a6dd9b5..1afc4dd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,16 @@ -// swift-tools-version:5.1 +// swift-tools-version:6.1 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "Table", + platforms: [ + .macOS(.v11), + .iOS(.v14), + .tvOS(.v14), + .watchOS(.v7) + ], products: [ // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( @@ -20,9 +26,15 @@ let package = Package( // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( name: "Table", - dependencies: []), + dependencies: [], + swiftSettings: [ + .swiftLanguageMode(.v6) + ]), .testTarget( name: "TableTests", - dependencies: ["Table"]), + dependencies: ["Table"], + swiftSettings: [ + .swiftLanguageMode(.v6) + ]), ] ) diff --git a/README.md b/README.md index 8a62963..a8e16ea 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ # Welcome to Table -The table is a helper function to print the table. +The table is a helper function to print the table. -You can print the table bypassing the Any data! +You can print the table bypassing the Any data! [e.g., `1d array`,` 2d array`, and `dictionary`] -It inspired by `javascript` `console.table`. +It inspired by `javascript` `console.table`. I'm sure if you practice coding interviews, it helps you a lot. You don't need to struggle for checking results using a build-in print function! +## Requirements + +- **Swift 6.1+** (Xcode 16+ or Swift 6.1 toolchain on Linux) +- macOS 11+ / iOS 14+ / tvOS 14+ / watchOS 7+ + ## Usage ```swift @@ -154,13 +159,142 @@ You can use it on your iPad Playground 😎 +## Table Style + +Choose between ASCII (default) or Unicode box-drawing characters: + +### ASCII Style (Default) +```swift +print(table: [["1", "Hello"], ["2", "World"]], header: ["ID", "Word"]) +``` +``` ++--+-----+ +|ID|Word | ++--+-----+ +|1 |Hello| ++--+-----+ +|2 |World| ++--+-----+ +``` + +### Unicode Style (MySQL-like) +```swift +print(table: [["1", "Hello"], ["2", "World"]], header: ["ID", "Word"], style: .unicode) +``` +``` +β”Œβ”€β”€β”¬β”€β”€β”€β”€β”€β” +β”‚IDβ”‚Word β”‚ +β”œβ”€β”€β”Όβ”€β”€β”€β”€β”€β”€ +β”‚1 β”‚Helloβ”‚ +β”œβ”€β”€β”Όβ”€β”€β”€β”€β”€β”€ +β”‚2 β”‚Worldβ”‚ +β””β”€β”€β”΄β”€β”€β”€β”€β”€β”˜ +``` + +## Multi-Language Support + +Table correctly handles CJK characters, Korean Hangul, Japanese Kana, Arabic, and emoji with proper column alignment: + +```swift +// Mixed language table with Unicode style +print(table: [ + ["Hello", "δ½ ε₯½"], + ["World", "δΈ–η•Œ"] +], header: ["EN", "CN"], style: .unicode) +``` +``` +β”Œβ”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β” +β”‚EN β”‚CN β”‚ +β”œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€ +β”‚Helloβ”‚δ½ ε₯½β”‚ +β”œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€ +β”‚Worldβ”‚δΈ–η•Œβ”‚ +β””β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”˜ +``` + +### Emoji Support + +```swift +print(table: ["πŸ‘‹", "πŸŽ‰", "πŸš€"], style: .unicode) +``` +``` +β”Œβ”€β”€β”¬β”€β”€β”¬β”€β”€β” +β”‚πŸ‘‹β”‚πŸŽ‰β”‚πŸš€β”‚ +β””β”€β”€β”΄β”€β”€β”΄β”€β”€β”˜ +``` + +## Control Character Handling + +Control characters are automatically escaped to prevent layout corruption: + +```swift +print(table: ["Line1\nLine2", "Tab\there"]) +// Displays as: |Line1\nLine2|Tab\there| +``` + +## Logger Integration + +Table extends Apple's [os.Logger](https://developer.apple.com/documentation/os/logger) with a `table()` method for easy logging: + +```swift +import os +import Table + +let logger = Logger(subsystem: "com.myapp", category: "DataDisplay") + +// 1D Array of String with Header +logger.table(["Good", "Very Good", "Happy", "Cool!"], header: ["Wed", "Thu", "Fri", "Sat"]) + +// 1D Array of Int +logger.table([2, 94231, 241245125125]) + +// 1D Array of Double +logger.table([2.0, 931, 214.24124]) + +// 2D Array of String +logger.table([["1", "HELLOW"], ["2", "WOLLEH"]], header: ["Index", "Words"]) + +// 2D Array with unequal columns +logger.table([["1", "b2"], ["Hellow", "Great!"], ["sdjfklsjdfklsadf", "dsf", "1"]]) + +// 2D Array of Int +logger.table([[1, 2, 3], [4, 5, 6], [7, 8, 9, 10]]) + +// Dictionary +logger.table(["name": "Alice", "age": 30] as [AnyHashable: Any], header: ["key", "value"]) +``` + +### Logger Options + +```swift +// Choose table style (default: .unicode) +logger.table(data, style: .ascii) // +--+--+ +logger.table(data, style: .unicode) // β”Œβ”€β”€β”¬β”€β”€β” + +// Choose log level (default: .info) +logger.table(data, level: .debug) +logger.table(data, level: .error) + +// Choose distribution +logger.table(data, distribution: .fillEqually) +``` + +Output in Console.app: +``` +β”Œβ”€β”€β”€β”€β”€β”¬β”€β”€β”€β” +β”‚Name β”‚Ageβ”‚ +β”œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€ +β”‚Aliceβ”‚30 β”‚ +β”œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€ +β”‚Bob β”‚25 β”‚ +β””β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”˜ +``` + ## What's the next step?! I'm going to support more types! - tuple - decodable / encodable - custom data type -- emoticon / unicode - Table Style - ascii table @@ -168,15 +302,11 @@ Table Style - and more ## Unit Tests -I'm going to add testCases for dictionary next time. +The library uses Swift Testing framework with 25+ test cases covering Unicode handling, performance, and edge cases. ## Contributing to Table Contributions to the Table are welcomed and encouraged! -## Next Step -- Export CSV from Table -- Unicode - ## Contact Me If you have any questions about `Table`, please email me at shawn@shawnbaek.com diff --git a/Sources/Table/Table.swift b/Sources/Table/Table.swift index d57606a..525b791 100644 --- a/Sources/Table/Table.swift +++ b/Sources/Table/Table.swift @@ -6,7 +6,32 @@ // Copyright Β© 2020 BaekSungwook. All rights reserved. // -// Extension to handle Unicode character width properly +import os + +// MARK: - Control Character Escaping + +/// Escape control characters to prevent table layout corruption +private func escapeControlCharacters(_ string: String) -> String { + var result = "" + for char in string { + switch char { + case "\n": + result += "\\n" + case "\t": + result += "\\t" + case "\r": + result += "\\r" + case "\0": + result += "\\0" + default: + result.append(char) + } + } + return result +} + +// MARK: - Unicode Width Extensions + extension String { /// Calculate the display width of the string /// CJK characters and other full-width characters count as 2 spaces @@ -16,47 +41,202 @@ extension String { } extension Character { - /// Calculate the display width of a character - /// CJK characters and other full-width characters count as 2 spaces + /// Calculate the display width of a character following UAX #11 + /// CJK characters, emoji, and other full-width characters count as 2 spaces var displayWidth: Int { // ASCII characters (most English letters, numbers, basic symbols) if self.isASCII { return 1 } - - // Check for CJK and other full-width characters + let unicodeScalars = self.unicodeScalars - let value = unicodeScalars[unicodeScalars.startIndex].value - - // CJK character ranges and full-width character ranges - if (0x1100...0x11FF).contains(value) || // Hangul Jamo - (0x2E80...0x9FFF).contains(value) || // CJK Unified Ideographs - (0xAC00...0xD7AF).contains(value) || // Hangul Syllables + guard let firstScalar = unicodeScalars.first else { + return 1 + } + + // Check for emoji first (emoji should be width 2) + // Emoji with presentation selector (U+FE0F) or emoji presentation property + if firstScalar.properties.isEmoji { + // Check for emoji presentation or variation selector + if firstScalar.properties.isEmojiPresentation || + unicodeScalars.contains(where: { $0.value == 0xFE0F }) { + return 2 + } + // Common emoji ranges that should be width 2 + let value = firstScalar.value + if (0x1F300...0x1F9FF).contains(value) || // Misc Symbols, Emoticons, etc. + (0x1FA00...0x1FAFF).contains(value) || // Extended-A + (0x2600...0x26FF).contains(value) || // Misc Symbols + (0x2700...0x27BF).contains(value) { // Dingbats + return 2 + } + } + + let value = firstScalar.value + + // Wide (W) and Fullwidth (F) characters per UAX #11 + // These all have display width 2 + if (0x1100...0x115F).contains(value) || // Hangul Jamo (initial consonants) + (0x231A...0x231B).contains(value) || // Watch, Hourglass + (0x2329...0x232A).contains(value) || // Angle brackets + (0x23E9...0x23F3).contains(value) || // Various symbols + (0x23F8...0x23FA).contains(value) || // Various symbols + (0x25FD...0x25FE).contains(value) || // Medium squares + (0x2614...0x2615).contains(value) || // Umbrella, Hot beverage + (0x2648...0x2653).contains(value) || // Zodiac symbols + (0x26AA...0x26AB).contains(value) || // Circles + (0x26BD...0x26BE).contains(value) || // Soccer, Baseball + (0x26C4...0x26C5).contains(value) || // Snowman, Sun + (0x26F2...0x26F3).contains(value) || // Fountain, Golf + (0x2708...0x270D).contains(value) || // Various symbols + (0x2733...0x2734).contains(value) || // Eight spoked asterisk + (0x2753...0x2755).contains(value) || // Question marks + (0x2763...0x2764).contains(value) || // Hearts + (0x2795...0x2797).contains(value) || // Math symbols + (0x2934...0x2935).contains(value) || // Arrows + (0x2B05...0x2B07).contains(value) || // Arrows + (0x2B1B...0x2B1C).contains(value) || // Squares + (0x2E80...0x2EFF).contains(value) || // CJK Radicals Supplement + (0x2F00...0x2FDF).contains(value) || // Kangxi Radicals + (0x2FF0...0x2FFF).contains(value) || // Ideographic Description + (0x3000...0x303E).contains(value) || // CJK Symbols and Punctuation + (0x3041...0x3096).contains(value) || // Hiragana + (0x3099...0x30FF).contains(value) || // Hiragana/Katakana + (0x3105...0x312F).contains(value) || // Bopomofo + (0x3131...0x318E).contains(value) || // Hangul Compatibility Jamo + (0x3190...0x31FF).contains(value) || // Kanbun, Bopomofo Extended, etc. + (0x3200...0x321E).contains(value) || // Enclosed CJK Letters + (0x3220...0x3247).contains(value) || // Enclosed CJK Letters continued + (0x3250...0x4DBF).contains(value) || // CJK Extension A and more + (0x4E00...0x9FFF).contains(value) || // CJK Unified Ideographs + (0xA960...0xA97F).contains(value) || // Hangul Jamo Extended-A + (0xAC00...0xD7A3).contains(value) || // Hangul Syllables + (0xD7B0...0xD7FF).contains(value) || // Hangul Jamo Extended-B (0xF900...0xFAFF).contains(value) || // CJK Compatibility Ideographs - (0xFF01...0xFF60).contains(value) { // Full-width ASCII variants + (0xFE10...0xFE1F).contains(value) || // Vertical Forms + (0xFE30...0xFE6F).contains(value) || // CJK Compatibility Forms + (0xFF01...0xFF60).contains(value) || // Fullwidth ASCII variants + (0xFFE0...0xFFE6).contains(value) { // Fullwidth symbols + return 2 + } + + // Supplementary planes (CJK Extension B and beyond) + if (0x20000...0x2FFFF).contains(value) || // Supplementary Ideographic Plane + (0x30000...0x3FFFF).contains(value) { // Tertiary Ideographic Plane return 2 } - - // Other Unicode characters default to width 1 + + // All other characters (Arabic, Cyrillic, Latin Extended, etc.) default to width 1 return 1 } } -public enum TableSpacing { +public enum TableSpacing: Sendable { case fillProportionally case fillEqually } +/// Table border style +public enum TableStyle: Sendable { + /// ASCII characters: + - | + case ascii + /// Unicode box-drawing characters: β”Œ ─ ┐ β”‚ β”” β”˜ β”œ ─ ┬ β”΄ β”Ό + case unicode +} + +/// Position of a horizontal line in the table +private enum LinePosition { + case top + case middle + case bottom +} + +/// Box-drawing characters for table rendering +private struct BoxCharacters { + let horizontal: Character + let vertical: Character + let topLeft: Character + let topRight: Character + let bottomLeft: Character + let bottomRight: Character + let leftT: Character + let rightT: Character + let topT: Character + let bottomT: Character + let cross: Character + + static let ascii = BoxCharacters( + horizontal: "-", + vertical: "|", + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + leftT: "+", + rightT: "+", + topT: "+", + bottomT: "+", + cross: "+" + ) + + static let unicode = BoxCharacters( + horizontal: "─", + vertical: "β”‚", + topLeft: "β”Œ", + topRight: "┐", + bottomLeft: "β””", + bottomRight: "β”˜", + leftT: "β”œ", + rightT: "─", + topT: "┬", + bottomT: "β”΄", + cross: "β”Ό" + ) + + static func characters(for style: TableStyle) -> BoxCharacters { + switch style { + case .ascii: return .ascii + case .unicode: return .unicode + } + } + + func leftCorner(for position: LinePosition) -> Character { + switch position { + case .top: return topLeft + case .middle: return leftT + case .bottom: return bottomLeft + } + } + + func rightCorner(for position: LinePosition) -> Character { + switch position { + case .top: return topRight + case .middle: return rightT + case .bottom: return bottomRight + } + } + + func junction(for position: LinePosition) -> Character { + switch position { + case .top: return topT + case .middle: return cross + case .bottom: return bottomT + } + } +} + /// Print data in table format /// - Parameters: /// - table: Zero or more items to print. /// - header: A string to print header on table. -/// - terminator: A string to print end of function. /// - distribution: A spacing for item +/// - style: Table border style (.ascii or .unicode) +/// - terminator: A string to print end of function. @discardableResult public func print( table data: Any, header: [String]? = nil, distribution: TableSpacing = .fillProportionally, + style: TableStyle = .ascii, terminator: String = "" ) -> String { struct DefaultStream: TextOutputStream { @@ -64,13 +244,14 @@ public enum TableSpacing { print(string, terminator: "") } } - + var defaultStream = DefaultStream() - + return print( table: data, header: header, distribution: distribution, + style: style, terminator: terminator, stream: &defaultStream ) @@ -80,59 +261,65 @@ public enum TableSpacing { table data: Any, header: [String]? = nil, distribution: TableSpacing = .fillProportionally, + style: TableStyle = .ascii, terminator: String = "", stream: inout Stream ) -> String { var result = "" let mirrorObj = Mirror(reflecting: data) + let box = BoxCharacters.characters(for: style) if mirrorObj.subjectType == [String].self { let inputData = data as! [String] var info = tableInfo(data: inputData) + let hasHeader = header != nil if let header = header { assert(header.count == inputData.count, "header should be equal items") for (index, title) in header.enumerated() { let infoWidth = info.widthInfo[index]! info.widthInfo[index] = max(infoWidth, title.displayWidth) } - result.append(print(header: header, info: info, distribution: distribution, stream: &stream)) + result.append(print(header: header, info: info, distribution: distribution, box: box, stream: &stream)) } - result.append(printTable(data: inputData, info: info, distribution: distribution, stream: &stream)) + result.append(printTable(data: inputData, info: info, distribution: distribution, box: box, hasHeader: hasHeader, stream: &stream)) print(terminator, to: &stream) result.append(terminator) } else if mirrorObj.subjectType == [Int].self { let inputData = data as! [Int] var info = tableInfo(data: inputData) + let hasHeader = header != nil if let header = header { assert(header.count == inputData.count, "header should be equal items") for (index, title) in header.enumerated() { let infoWidth = info.widthInfo[index]! info.widthInfo[index] = max(infoWidth, title.displayWidth) } - result.append(print(header: header, info: info, distribution: distribution, stream: &stream)) + result.append(print(header: header, info: info, distribution: distribution, box: box, stream: &stream)) } - result.append(printTable(data: inputData, info: info, distribution: distribution, stream: &stream)) + result.append(printTable(data: inputData, info: info, distribution: distribution, box: box, hasHeader: hasHeader, stream: &stream)) print(terminator, to: &stream) result.append(terminator) } else if mirrorObj.subjectType == [Double].self { let inputData = data as! [Double] var info = tableInfo(data: inputData) + let hasHeader = header != nil if let header = header { assert(header.count == inputData.count, "header should be equal items") for (index, title) in header.enumerated() { let infoWidth = info.widthInfo[index]! info.widthInfo[index] = max(infoWidth, title.displayWidth) } - result.append(print(header: header, info: info, distribution: distribution, stream: &stream)) + result.append(print(header: header, info: info, distribution: distribution, box: box, stream: &stream)) } - result.append(printTable(data: inputData, info: info, distribution: distribution, stream: &stream)) + result.append(printTable(data: inputData, info: info, distribution: distribution, box: box, hasHeader: hasHeader, stream: &stream)) print(terminator, to: &stream) result.append(terminator) } else if mirrorObj.subjectType == [AnyHashable: Any].self { let inputData = data as! [AnyHashable: Any] var info = tableInfo(data: inputData) + let hasHeader = header != nil if let header = header { assert(header.count == 2, "header should be key, value for dictionary") for (index, title) in header.enumerated() { @@ -143,54 +330,57 @@ public enum TableSpacing { info.maxValueWidth = max(info.maxValueWidth, title.displayWidth) } } - result.append(print(header: header, info: info, distribution: distribution, stream: &stream)) + result.append(print(header: header, info: info, distribution: distribution, box: box, stream: &stream)) } - result.append(printTable(data: inputData, info: info, distribution: distribution, stream: &stream)) + result.append(printTable(data: inputData, info: info, distribution: distribution, box: box, hasHeader: hasHeader, stream: &stream)) print(terminator, to: &stream) result.append(terminator) } else if mirrorObj.subjectType == [[String]].self { let inputData = data as! [[String]] var info = tableInfo(data: inputData) + let hasHeader = header != nil if let header = header { assert(inputData.map({ $0.count }).max() == header.count, "header should be equal items") for (index, title) in header.enumerated() { let infoWidth = info.widthInfo[index]! info.widthInfo[index] = max(infoWidth, title.displayWidth) } - result.append(print(header: header, info: info, distribution: distribution, stream: &stream)) + result.append(print(header: header, info: info, distribution: distribution, box: box, stream: &stream)) } - result.append(printTable(data: inputData, info: info, distribution: distribution, stream: &stream)) + result.append(printTable(data: inputData, info: info, distribution: distribution, box: box, hasHeader: hasHeader, stream: &stream)) print(terminator, to: &stream) result.append(terminator) } else if mirrorObj.subjectType == [[Int]].self { let inputData = data as! [[Int]] var info = tableInfo(data: inputData) + let hasHeader = header != nil if let header = header { assert(inputData.map({ $0.count }).max() == header.count, "header should be equal items") for (index, title) in header.enumerated() { let infoWidth = info.widthInfo[index]! info.widthInfo[index] = max(infoWidth, title.displayWidth) } - result.append(print(header: header, info: info, distribution: distribution, stream: &stream)) + result.append(print(header: header, info: info, distribution: distribution, box: box, stream: &stream)) } - result.append(printTable(data: inputData, info: info, distribution: distribution, stream: &stream)) + result.append(printTable(data: inputData, info: info, distribution: distribution, box: box, hasHeader: hasHeader, stream: &stream)) print(terminator, to: &stream) result.append(terminator) } else if mirrorObj.subjectType == [[Double]].self { let inputData = data as! [[Double]] var info = tableInfo(data: inputData) + let hasHeader = header != nil if let header = header { assert(inputData.map({ $0.count }).max() == header.count, "header should be equal items") for (index, title) in header.enumerated() { let infoWidth = info.widthInfo[index]! info.widthInfo[index] = max(infoWidth, title.displayWidth) } - result.append(print(header: header, info: info, distribution: distribution, stream: &stream)) + result.append(print(header: header, info: info, distribution: distribution, box: box, stream: &stream)) } - result.append(printTable(data: inputData, info: info, distribution: distribution, stream: &stream)) + result.append(printTable(data: inputData, info: info, distribution: distribution, box: box, hasHeader: hasHeader, stream: &stream)) print(terminator, to: &stream) result.append(terminator) } @@ -201,18 +391,19 @@ public enum TableSpacing { header: [String], info: (numberOfItem: Int, maxWidth: Int, widthInfo: [Int: Int]), distribution: TableSpacing, + box: BoxCharacters, stream: inout Stream ) -> String { var result = "" let fullWidth = distribution == .fillProportionally ? info.widthInfo.reduce(0, { $0 + $1.value }) : info.maxWidth * info.numberOfItem - let horizontalLine = horizontal(numberOfItems: info.numberOfItem, width: info.widthInfo, length: fullWidth, distribution: distribution) + let horizontalLine = horizontal(numberOfItems: info.numberOfItem, width: info.widthInfo, length: fullWidth, distribution: distribution, box: box, position: .top) print(horizontalLine, to: &stream) result.append("\(horizontalLine)\n") - var row = "|" + var row = "\(box.vertical)" for i in 0.. String { var result = "" - let horizontalLine = horizontal(numberOfItems: info.numberOfItem, keyWidth: info.maxKeyWidth, valueWidth: info.maxValueWidth, distribution: distribution) + let horizontalLine = horizontal(numberOfItems: info.numberOfItem, keyWidth: info.maxKeyWidth, valueWidth: info.maxValueWidth, distribution: distribution, box: box, position: .top) print(horizontalLine, to: &stream) result.append("\(horizontalLine)\n") - var row = "|" + var row = "\(box.vertical)" for i in 0.. String { + guard !data.isEmpty else { return "" } var result = "" - let horizontalLine = horizontal( - numberOfItems: info.numberOfItem, - keyWidth: info.maxKeyWidth, - valueWidth: info.maxValueWidth, distribution: distribution - ) - print(horizontalLine, to: &stream) - result.append("\(horizontalLine)\n") + let keys = Array(data.keys) let maxWidth = max(info.maxKeyWidth, info.maxValueWidth) - for key in data.keys { - var row = "|" - let keyValue = String(describing: key) + + for (index, key) in keys.enumerated() { + let isFirst = index == 0 + let isLast = index == keys.count - 1 + + // Top line (only if no header and first row) + if isFirst && !hasHeader { + let topLine = horizontal(numberOfItems: info.numberOfItem, keyWidth: info.maxKeyWidth, valueWidth: info.maxValueWidth, distribution: distribution, box: box, position: .top) + print(topLine, to: &stream) + result.append("\(topLine)\n") + } else if isFirst && hasHeader { + // Separator after header + let midLine = horizontal(numberOfItems: info.numberOfItem, keyWidth: info.maxKeyWidth, valueWidth: info.maxValueWidth, distribution: distribution, box: box, position: .middle) + print(midLine, to: &stream) + result.append("\(midLine)\n") + } + + var row = "\(box.vertical)" + let keyValue = escapeControlCharacters(String(describing: key)) let keyWidth = distribution == .fillProportionally ? info.maxKeyWidth : maxWidth - let keySpace = String(repeating: " ", count: keyWidth - keyValue.displayWidth) - let keyItem = "\(keyValue)\(keySpace)|" + let keySpace = String(repeating: " ", count: max(0, keyWidth - keyValue.displayWidth)) + let keyItem = "\(keyValue)\(keySpace)\(box.vertical)" row += keyItem - let value = String(describing: data[key] ?? "") + let value = escapeControlCharacters(String(describing: data[key] ?? "")) let valueWidth = distribution == .fillProportionally ? info.maxValueWidth : maxWidth - let space = String(repeating: " ", count: valueWidth - value.displayWidth) - let item = "\(value)\(space)|" + let space = String(repeating: " ", count: max(0, valueWidth - value.displayWidth)) + let item = "\(value)\(space)\(box.vertical)" row += item print(row, to: &stream) - print(horizontalLine, to: &stream) result.append("\(row)\n") + + // Bottom or middle line + let linePosition: LinePosition = isLast ? .bottom : .middle + let horizontalLine = horizontal(numberOfItems: info.numberOfItem, keyWidth: info.maxKeyWidth, valueWidth: info.maxValueWidth, distribution: distribution, box: box, position: linePosition) + print(horizontalLine, to: &stream) result.append("\(horizontalLine)\n") } return result @@ -287,25 +496,35 @@ public enum TableSpacing { data: [Item], info: (numberOfItem: Int, maxWidth: Int, widthInfo: [Int: Int]), distribution: TableSpacing, + box: BoxCharacters, + hasHeader: Bool, stream: inout Stream ) -> String { + guard info.numberOfItem > 0 else { return "" } var result = "" let fullWidth = distribution == .fillProportionally ? info.widthInfo.reduce(0, { $0 + $1.value }) : info.maxWidth * info.numberOfItem - let horizontalLine = horizontal(numberOfItems: info.numberOfItem, width: info.widthInfo, length: fullWidth, distribution: distribution) - print(horizontalLine, to: &stream) - result.append("\(horizontalLine)\n") - var row = "|" + + // Top line or separator after header + let topPosition: LinePosition = hasHeader ? .middle : .top + let topLine = horizontal(numberOfItems: info.numberOfItem, width: info.widthInfo, length: fullWidth, distribution: distribution, box: box, position: topPosition) + print(topLine, to: &stream) + result.append("\(topLine)\n") + + var row = "\(box.vertical)" for i in 0.. String { + guard info.numberOfItem > 0, !data.isEmpty else { return "" } var result = "" let fullWidth = distribution == .fillProportionally ? info.widthInfo.reduce(0, { $0 + $1.value }) : info.maxWidth * info.numberOfItem - let horizontalLine = horizontal(numberOfItems: info.numberOfItem, width: info.widthInfo, length: fullWidth, distribution: distribution) - print(horizontalLine, to: &stream) - result.append("\(horizontalLine)\n") + for i in 0.. ( maxValueWidth: Int, widthInfo: [String: Int] ) { + guard !data.isEmpty else { + return (numberOfItem: 2, maxKeyWidth: 0, maxValueWidth: 0, widthInfo: [:]) + } let valueData = data.compactMap { String(describing: $0.value) } let keyData = data.compactMap { String(describing: $0.key) } - let maxValueWidth = valueData.sorted { $0.displayWidth > $1.displayWidth }.first!.displayWidth - let maxKeyWidth = keyData.sorted { $0.displayWidth > $1.displayWidth }.first!.displayWidth + let maxValueWidth = valueData.map { $0.displayWidth }.max() ?? 0 + let maxKeyWidth = keyData.map { $0.displayWidth }.max() ?? 0 var maxValueWidthDict: [String: Int] = [:] for key in keyData { maxValueWidthDict[key] = String(describing: data[key] ?? "").displayWidth @@ -368,8 +610,11 @@ private func tableInfo(data: [Item]) -> ( maxWidth: Int, widthInfo: [Int: Int] ) { + guard !data.isEmpty else { + return (numberOfItem: 0, maxWidth: 0, widthInfo: [:]) + } let stringData = data.map { String($0) } - let maxWidth = stringData.sorted { $0.displayWidth > $1.displayWidth }.first!.displayWidth + let maxWidth = stringData.map { $0.displayWidth }.max() ?? 0 var maxWidthDict: [Int: Int] = [:] for (index, item) in stringData.enumerated() { maxWidthDict[index] = item.displayWidth @@ -382,15 +627,21 @@ private func tableInfo(data: [[Item]]) -> ( maxWidth: Int, widthInfo: [Int: Int] ) { + guard !data.isEmpty else { + return (numberOfItem: 0, maxWidth: 0, widthInfo: [:]) + } let flattened = Array(data.joined()) - let maxWidth = String(flattened.sorted { String($0).displayWidth > String($1).displayWidth }.first!).displayWidth - let itemCount = data.sorted{ $0.count > $1.count }.first!.count + guard !flattened.isEmpty else { + return (numberOfItem: 0, maxWidth: 0, widthInfo: [:]) + } + let maxWidth = flattened.map { String($0).displayWidth }.max() ?? 0 + let itemCount = data.map { $0.count }.max() ?? 0 var maxWidthDict: [Int: Int] = [:] for i in 0.. $1.displayWidth }.first!.displayWidth + let stringData = items.map { String(describing: $0) } + let maxCount = stringData.map { $0.displayWidth }.max() ?? 0 maxWidthDict[i] = maxCount } } @@ -401,18 +652,25 @@ private func tableInfo(data: [[Item]]) -> ( numberOfItems: Int, width: [Int: Int], length: Int, - distribution: TableSpacing + distribution: TableSpacing, + box: BoxCharacters, + position: LinePosition ) -> String { let maxWidth = Int(width.values.sorted(by: > ).first ?? 0) - var line = String(repeating: "-", count: length) - line.insert("+", at: line.startIndex) + let horizontal = box.horizontal + let leftCorner = box.leftCorner(for: position) + let rightCorner = box.rightCorner(for: position) + let junction = box.junction(for: position) + + var line = "\(leftCorner)" for i in 0..(data: [[Item]]) -> ( numberOfItems: Int, keyWidth: Int, valueWidth: Int, - distribution: TableSpacing + distribution: TableSpacing, + box: BoxCharacters, + position: LinePosition ) -> String { let maxWidth = max(keyWidth, valueWidth) - var line = distribution == .fillProportionally ? - String(repeating: "-", count: keyWidth + valueWidth) : - String(repeating: "-", count: maxWidth * 2) + let horizontal = box.horizontal + let leftCorner = box.leftCorner(for: position) + let rightCorner = box.rightCorner(for: position) + let junction = box.junction(for: position) - line.insert("+", at: line.startIndex) + var line = "\(leftCorner)" for i in 0.. [XCTestCaseEntry] { - return [ - testCase(TableTests.allTests), - ] -} -#endif