diff --git a/Sources/SwiftlyCore/StringExtensions.swift b/Sources/SwiftlyCore/StringExtensions.swift new file mode 100644 index 00000000..3e01c67c --- /dev/null +++ b/Sources/SwiftlyCore/StringExtensions.swift @@ -0,0 +1,64 @@ +/// A description +extension String { + /// Wraps text to fit within specified column width + /// + /// This method reformats the string to ensure each line fits within the specified column width, + /// attempting to break at spaces when possible to avoid splitting words. + /// + /// - Parameters: + /// - columns: Maximum width (in characters) for each line + /// - wrappingIndent: Number of spaces to add at the beginning of each wrapped line (not the first line) + /// + /// - Returns: A new string with appropriate line breaks to maintain the specified column width + func wrapText(to columns: Int, wrappingIndent: Int = 0) -> String { + let effectiveColumns = columns - wrappingIndent + guard effectiveColumns > 0 else { return self } + + var result: [Substring] = [] + var currentIndex = self.startIndex + + while currentIndex < self.endIndex { + let nextChunk = self[currentIndex...].prefix(effectiveColumns) + + // Handle line breaks in the current chunk + if let lastLineBreak = nextChunk.lastIndex(of: "\n") { + result.append( + contentsOf: self[currentIndex.. Int { +#if os(macOS) || os(Linux) + var size = winsize() +#if os(OpenBSD) + // TIOCGWINSZ is a complex macro, so we need the flattened value. + let tiocgwinsz = UInt(0x4008_7468) + let result = ioctl(STDOUT_FILENO, tiocgwinsz, &size) +#else + let result = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &size) +#endif + + if result == 0 && Int(size.ws_col) > 0 { + return Int(size.ws_col) + } +#endif + return 80 // Default width if terminal size detection fails } public func readLine(prompt: String) async -> String? { @@ -75,10 +98,13 @@ public struct SwiftlyCoreContext: Sendable { } while true { - let answer = (await self.readLine(prompt: "Proceed? \(options)") ?? (defaultBehavior ? "y" : "n")).lowercased() + let answer = + (await self.readLine(prompt: "Proceed? \(options)") + ?? (defaultBehavior ? "y" : "n")).lowercased() guard ["y", "n", ""].contains(answer) else { - await self.print("Please input either \"y\" or \"n\", or press ENTER to use the default.") + await self.print( + "Please input either \"y\" or \"n\", or press ENTER to use the default.") continue } diff --git a/Tests/SwiftlyTests/StringExtensionsTests.swift b/Tests/SwiftlyTests/StringExtensionsTests.swift new file mode 100644 index 00000000..bba42db3 --- /dev/null +++ b/Tests/SwiftlyTests/StringExtensionsTests.swift @@ -0,0 +1,208 @@ +@testable import SwiftlyCore +import Testing +import XCTest + +@Suite struct StringExtensionsTests { + @Test("Basic text wrapping at column width") + func testBasicWrapping() { + let input = "This is a simple test string that should be wrapped at the specified width." + let expected = """ + This is a + simple test + string that + should be + wrapped at + the + specified + width. + """ + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Preserve existing line breaks") + func testPreserveLineBreaks() { + let input = "First line\nSecond line\nThird line" + let expected = "First line\nSecond line\nThird line" + + XCTAssertEqual(input.wrapText(to: 20), expected) + } + + @Test("Combine wrapping with existing line breaks") + func testCombineWrappingAndLineBreaks() { + let input = "Short line\nThis is a very long line that needs to be wrapped\nAnother short line" + let expected = """ + Short line + This is a very + long line that + needs to be + wrapped + Another short line + """ + + XCTAssertEqual(input.wrapText(to: 15), expected) + } + + @Test("Words longer than column width") + func testLongWords() { + let input = "This has a supercalifragilisticexpialidocious word" + let expected = """ + This has a + supercalifragilisticexpialidocious + word + """ + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Text with no spaces") + func testNoSpaces() { + let input = "ThisIsALongStringWithNoSpaces" + let expected = "ThisIsALongStringWithNoSpaces" + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Empty string") + func testEmptyString() { + let input = "" + let expected = "" + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Single character") + func testSingleCharacter() { + let input = "X" + let expected = "X" + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Single line not exceeding width") + func testSingleLineNoWrapping() { + let input = "Short text" + let expected = "Short text" + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Wrapping with indentation") + func testWrappingWithIndent() { + let input = "This is text that should be wrapped with indentation on new lines." + let expected = """ + This is + text that + should be + wrapped + with + indentation + on new + lines. + """ + + XCTAssertEqual(input.wrapText(to: 10, wrappingIndent: 2), expected) + } + + @Test("Zero or negative column width") + func testZeroOrNegativeWidth() { + let input = "This should not be wrapped" + + XCTAssertEqual(input.wrapText(to: 0), input) + XCTAssertEqual(input.wrapText(to: -5), input) + } + + @Test("Very narrow column width") + func testVeryNarrowWidth() { + let input = "A B C" + let expected = "A\nB\nC" + + XCTAssertEqual(input.wrapText(to: 1), expected) + } + + @Test("Special characters") + func testSpecialCharacters() { + let input = "Special !@#$%^&*() chars" + let expected = """ + Special + !@#$%^&*() + chars + """ + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Unicode characters") + func testUnicodeCharacters() { + let input = "Unicode: δ½ ε₯½δΈ–η•Œ πŸ˜€πŸš€πŸŒ" + let expected = """ + Unicode: δ½ ε₯½δΈ–η•Œ + πŸ˜€πŸš€πŸŒ + """ + + XCTAssertEqual(input.wrapText(to: 15), expected) + } + + @Test("Irregular spacing") + func testIrregularSpacing() { + let input = "Words with irregular spacing" + let expected = """ + Words with + irregular + spacing + """ + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Tab characters") + func testTabCharacters() { + let input = "Text\twith\ttabs" + let expected = """ + Text\twith + \ttabs + """ + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Trailing spaces") + func testTrailingSpaces() { + let input = "Text with trailing spaces " + let expected = """ + Text with + trailing + spaces + """ + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Leading spaces") + func testLeadingSpaces() { + let input = " Leading spaces with text" + let expected = """ + Leading + spaces with + text + """ + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Multiple consecutive newlines") + func testMultipleNewlines() { + let input = "First\n\nSecond\n\n\nThird" + let expected = "First\n\nSecond\n\n\nThird" + + XCTAssertEqual(input.wrapText(to: 10), expected) + } + + @Test("Edge case - exactly at column width") + func testExactColumnWidth() { + let input = "1234567890 abcdefghij" + let expected = "1234567890\nabcdefghij" + + XCTAssertEqual(input.wrapText(to: 10), expected) + } +}