Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions Sources/SwiftlyCore/StringExtensions.swift
Original file line number Diff line number Diff line change
@@ -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..<lastLineBreak].split(
separator: "\n", omittingEmptySubsequences: false
))
currentIndex = self.index(after: lastLineBreak)
continue
}

// We've reached the end of the string
if nextChunk.endIndex == self.endIndex {
result.append(self[currentIndex...])
break
}

// Try to break at the last space within the column limit
if let lastSpace = nextChunk.lastIndex(of: " ") {
result.append(self[currentIndex..<lastSpace])
currentIndex = self.index(after: lastSpace)
continue
}

// If no space in the chunk, find the next space after column limit
if let nextSpace = self[currentIndex...].firstIndex(of: " ") {
result.append(self[currentIndex..<nextSpace])
currentIndex = self.index(after: nextSpace)
continue
}

// No spaces left in the string - add the rest and finish
result.append(self[currentIndex...])
break
}

// Apply indentation to wrapped lines and join them
return
result
.map { $0.isEmpty ? $0 : String(repeating: " ", count: wrappingIndent) + $0 }
.joined(separator: "\n")
}
}
36 changes: 31 additions & 5 deletions Sources/SwiftlyCore/SwiftlyCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,38 @@ public struct SwiftlyCoreContext: Sendable {
/// Pass the provided string to the set output handler if any.
/// If no output handler has been set, just print to stdout.
public func print(_ string: String = "", terminator: String? = nil) async {
// Get terminal size or use default width
let terminalWidth = self.getTerminalWidth()
let wrappedString = string.isEmpty ? string : string.wrapText(to: terminalWidth)

guard let handler = self.outputHandler else {
if let terminator {
Swift.print(string, terminator: terminator)
Swift.print(wrappedString, terminator: terminator)
} else {
Swift.print(string)
Swift.print(wrappedString)
}
return
}
await handler.handleOutputLine(string + (terminator ?? ""))
await handler.handleOutputLine(wrappedString + (terminator ?? ""))
}

/// Detects the terminal width in columns
private func getTerminalWidth() -> 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? {
Expand All @@ -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
}

Expand Down
208 changes: 208 additions & 0 deletions Tests/SwiftlyTests/StringExtensionsTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}