Skip to content

Refactor text wrapping implementation with improved logic #374

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 5, 2025
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
139 changes: 89 additions & 50 deletions Sources/SwiftlyCore/StringExtensions.swift
Original file line number Diff line number Diff line change
@@ -1,64 +1,103 @@
/// A description
import Foundation

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 }
func wrapText(to columns: Int) -> String {
guard columns > 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)
var current = startIndex

while current < endIndex {
if self[current] == "\n" {
result.append("\n")
current = index(after: current)
continue
}

// We've reached the end of the string
if nextChunk.endIndex == self.endIndex {
result.append(self[currentIndex...])
break
}
let remainingText: String.SubSequence = self[current...]
let nextNewlineRange = remainingText.range(of: "\n")
let lineEnd = nextNewlineRange?.lowerBound ?? endIndex

// 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
}
var lineStart = current

// 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
while lineStart < lineEnd {
let remainingLength = distance(from: lineStart, to: lineEnd)

if remainingLength <= columns {
result.append(self[lineStart..<lineEnd])
lineStart = lineEnd
continue
}

let chunkEnd = index(lineStart, offsetBy: columns + 1, limitedBy: lineEnd) ?? lineEnd
let chunkLength = distance(from: lineStart, to: chunkEnd)

if chunkLength <= columns {
result.append(self[lineStart..<chunkEnd])
lineStart = chunkEnd
continue
}

let nextCharIndex = index(lineStart, offsetBy: columns)

if self[nextCharIndex].isWhitespace && self[nextCharIndex] != "\n" {
result.append(self[lineStart..<nextCharIndex])
result.append("\n")
lineStart = self.skipWhitespace(from: index(after: nextCharIndex))
} else {
var lastWhitespace: String.Index?
var searchIndex = nextCharIndex

while searchIndex > lineStart {
let prevIndex = index(before: searchIndex)
if self[prevIndex].isWhitespace && self[prevIndex] != "\n" {
lastWhitespace = prevIndex
break
}
searchIndex = prevIndex
}

if let lastWS = lastWhitespace {
result.append(self[lineStart..<lastWS])
result.append("\n")
lineStart = self.skipWhitespace(from: index(after: lastWS))
} else {
let wordEndRange = self[lineStart...].rangeOfCharacter(from: .whitespacesAndNewlines)
let wordEnd = wordEndRange?.lowerBound ?? lineEnd

result.append(self[lineStart..<wordEnd])
if wordEnd < lineEnd && self[wordEnd] != "\n" {
result.append("\n")
lineStart = self.skipWhitespace(from: index(after: wordEnd))
} else {
lineStart = wordEnd
}
}
}
}

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

// Apply indentation to wrapped lines and join them
return
result
.map { $0.isEmpty ? $0 : String(repeating: " ", count: wrappingIndent) + $0 }
.joined(separator: "\n")
return result.joined()
}

private func skipWhitespace(from index: String.Index) -> String.Index {
guard index < endIndex else { return index }

let remainingRange = index..<endIndex
let nonWhitespaceRange = rangeOfCharacter(
from: CharacterSet.whitespacesAndNewlines.inverted.union(CharacterSet.newlines),
range: remainingRange
)

if let nonWhitespaceStart = nonWhitespaceRange?.lowerBound {
if self[nonWhitespaceStart] == "\n" {
return nonWhitespaceStart // Stop at newline
}
return nonWhitespaceStart
} else {
return endIndex
}
}
}
98 changes: 44 additions & 54 deletions Tests/SwiftlyTests/StringExtensionsTests.swift
Original file line number Diff line number Diff line change
@@ -1,46 +1,53 @@
@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
simple
test
string
that
should be
wrapped at
the
specified
width.
"""

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(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)
#expect(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 input = """
Short line
This is a very long line that needs to be wrapped
Another short line
"""

let expected = """
Short line
This is a very
long line that
needs to be
wrapped
Another short line
Another short
line
"""

XCTAssertEqual(input.wrapText(to: 15), expected)
#expect(input.wrapText(to: 15) == expected)
}

@Test("Words longer than column width")
Expand All @@ -52,72 +59,55 @@ import XCTest
word
"""

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Text with no spaces")
func testNoSpaces() {
let input = "ThisIsALongStringWithNoSpaces"
let expected = "ThisIsALongStringWithNoSpaces"

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Empty string")
func testEmptyString() {
let input = ""
let expected = ""

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Single character")
func testSingleCharacter() {
let input = "X"
let expected = "X"

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(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)
#expect(input.wrapText(to: 10) == 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)
#expect(input.wrapText(to: 0) == input)
#expect(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)
#expect(input.wrapText(to: 1) == expected)
}

@Test("Special characters")
Expand All @@ -129,7 +119,7 @@ import XCTest
chars
"""

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Unicode characters")
Expand All @@ -140,69 +130,69 @@ import XCTest
😀🚀🌍
"""

XCTAssertEqual(input.wrapText(to: 15), expected)
#expect(input.wrapText(to: 15) == expected)
}

@Test("Irregular spacing")
func testIrregularSpacing() {
let input = "Words with irregular spacing"
let expected = """
Words with
irregular
spacing
"""
let expected = "Words \nwith \nirregular \nspacing"

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Tab characters")
func testTabCharacters() {
let input = "Text\twith\ttabs"
let expected = """
Text\twith
\ttabs
tabs
"""

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Trailing spaces")
func testTrailingSpaces() {
let input = "Text with trailing spaces "
let expected = """
Text with
trailing
spaces
"""
let expected = "Text with \ntrailing\nspaces "

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Leading spaces")
func testLeadingSpaces() {
let input = " Leading spaces with text"
let expected = """
Leading
spaces with
text
spaces
with text
"""

XCTAssertEqual(input.wrapText(to: 10), expected)
#expect(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)
#expect(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)
#expect(input.wrapText(to: 10) == expected)
}

@Test("Lines ending exactly at column boundary")
func testLinesEndingAtBoundary() {
let input = "exactlyten\nmoretextat\nthe end"
let expected = "exactlyten\nmoretextat\nthe end"

#expect(input.wrapText(to: 10) == expected)
}
}