Skip to content

Commit dd650c8

Browse files
authored
Refactor text wrapping implementation with improved logic (#374)
1 parent 921cf98 commit dd650c8

File tree

2 files changed

+133
-104
lines changed

2 files changed

+133
-104
lines changed
Lines changed: 89 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,103 @@
1-
/// A description
1+
import Foundation
2+
23
extension String {
3-
/// Wraps text to fit within specified column width
4-
///
5-
/// This method reformats the string to ensure each line fits within the specified column width,
6-
/// attempting to break at spaces when possible to avoid splitting words.
7-
///
8-
/// - Parameters:
9-
/// - columns: Maximum width (in characters) for each line
10-
/// - wrappingIndent: Number of spaces to add at the beginning of each wrapped line (not the first line)
11-
///
12-
/// - Returns: A new string with appropriate line breaks to maintain the specified column width
13-
func wrapText(to columns: Int, wrappingIndent: Int = 0) -> String {
14-
let effectiveColumns = columns - wrappingIndent
15-
guard effectiveColumns > 0 else { return self }
4+
func wrapText(to columns: Int) -> String {
5+
guard columns > 0 else { return self }
166

177
var result: [Substring] = []
18-
var currentIndex = self.startIndex
19-
20-
while currentIndex < self.endIndex {
21-
let nextChunk = self[currentIndex...].prefix(effectiveColumns)
22-
23-
// Handle line breaks in the current chunk
24-
if let lastLineBreak = nextChunk.lastIndex(of: "\n") {
25-
result.append(
26-
contentsOf: self[currentIndex..<lastLineBreak].split(
27-
separator: "\n", omittingEmptySubsequences: false
28-
))
29-
currentIndex = self.index(after: lastLineBreak)
8+
var current = startIndex
9+
10+
while current < endIndex {
11+
if self[current] == "\n" {
12+
result.append("\n")
13+
current = index(after: current)
3014
continue
3115
}
3216

33-
// We've reached the end of the string
34-
if nextChunk.endIndex == self.endIndex {
35-
result.append(self[currentIndex...])
36-
break
37-
}
17+
let remainingText: String.SubSequence = self[current...]
18+
let nextNewlineRange = remainingText.range(of: "\n")
19+
let lineEnd = nextNewlineRange?.lowerBound ?? endIndex
3820

39-
// Try to break at the last space within the column limit
40-
if let lastSpace = nextChunk.lastIndex(of: " ") {
41-
result.append(self[currentIndex..<lastSpace])
42-
currentIndex = self.index(after: lastSpace)
43-
continue
44-
}
21+
var lineStart = current
4522

46-
// If no space in the chunk, find the next space after column limit
47-
if let nextSpace = self[currentIndex...].firstIndex(of: " ") {
48-
result.append(self[currentIndex..<nextSpace])
49-
currentIndex = self.index(after: nextSpace)
50-
continue
23+
while lineStart < lineEnd {
24+
let remainingLength = distance(from: lineStart, to: lineEnd)
25+
26+
if remainingLength <= columns {
27+
result.append(self[lineStart..<lineEnd])
28+
lineStart = lineEnd
29+
continue
30+
}
31+
32+
let chunkEnd = index(lineStart, offsetBy: columns + 1, limitedBy: lineEnd) ?? lineEnd
33+
let chunkLength = distance(from: lineStart, to: chunkEnd)
34+
35+
if chunkLength <= columns {
36+
result.append(self[lineStart..<chunkEnd])
37+
lineStart = chunkEnd
38+
continue
39+
}
40+
41+
let nextCharIndex = index(lineStart, offsetBy: columns)
42+
43+
if self[nextCharIndex].isWhitespace && self[nextCharIndex] != "\n" {
44+
result.append(self[lineStart..<nextCharIndex])
45+
result.append("\n")
46+
lineStart = self.skipWhitespace(from: index(after: nextCharIndex))
47+
} else {
48+
var lastWhitespace: String.Index?
49+
var searchIndex = nextCharIndex
50+
51+
while searchIndex > lineStart {
52+
let prevIndex = index(before: searchIndex)
53+
if self[prevIndex].isWhitespace && self[prevIndex] != "\n" {
54+
lastWhitespace = prevIndex
55+
break
56+
}
57+
searchIndex = prevIndex
58+
}
59+
60+
if let lastWS = lastWhitespace {
61+
result.append(self[lineStart..<lastWS])
62+
result.append("\n")
63+
lineStart = self.skipWhitespace(from: index(after: lastWS))
64+
} else {
65+
let wordEndRange = self[lineStart...].rangeOfCharacter(from: .whitespacesAndNewlines)
66+
let wordEnd = wordEndRange?.lowerBound ?? lineEnd
67+
68+
result.append(self[lineStart..<wordEnd])
69+
if wordEnd < lineEnd && self[wordEnd] != "\n" {
70+
result.append("\n")
71+
lineStart = self.skipWhitespace(from: index(after: wordEnd))
72+
} else {
73+
lineStart = wordEnd
74+
}
75+
}
76+
}
5177
}
5278

53-
// No spaces left in the string - add the rest and finish
54-
result.append(self[currentIndex...])
55-
break
79+
current = lineEnd
5680
}
5781

58-
// Apply indentation to wrapped lines and join them
59-
return
60-
result
61-
.map { $0.isEmpty ? $0 : String(repeating: " ", count: wrappingIndent) + $0 }
62-
.joined(separator: "\n")
82+
return result.joined()
83+
}
84+
85+
private func skipWhitespace(from index: String.Index) -> String.Index {
86+
guard index < endIndex else { return index }
87+
88+
let remainingRange = index..<endIndex
89+
let nonWhitespaceRange = rangeOfCharacter(
90+
from: CharacterSet.whitespacesAndNewlines.inverted.union(CharacterSet.newlines),
91+
range: remainingRange
92+
)
93+
94+
if let nonWhitespaceStart = nonWhitespaceRange?.lowerBound {
95+
if self[nonWhitespaceStart] == "\n" {
96+
return nonWhitespaceStart // Stop at newline
97+
}
98+
return nonWhitespaceStart
99+
} else {
100+
return endIndex
101+
}
63102
}
64103
}
Lines changed: 44 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,53 @@
11
@testable import SwiftlyCore
22
import Testing
3-
import XCTest
43

54
@Suite struct StringExtensionsTests {
65
@Test("Basic text wrapping at column width")
76
func testBasicWrapping() {
87
let input = "This is a simple test string that should be wrapped at the specified width."
98
let expected = """
109
This is a
11-
simple test
12-
string that
10+
simple
11+
test
12+
string
13+
that
1314
should be
1415
wrapped at
1516
the
1617
specified
1718
width.
1819
"""
1920

20-
XCTAssertEqual(input.wrapText(to: 10), expected)
21+
#expect(input.wrapText(to: 10) == expected)
2122
}
2223

2324
@Test("Preserve existing line breaks")
2425
func testPreserveLineBreaks() {
2526
let input = "First line\nSecond line\nThird line"
2627
let expected = "First line\nSecond line\nThird line"
2728

28-
XCTAssertEqual(input.wrapText(to: 20), expected)
29+
#expect(input.wrapText(to: 20) == expected)
2930
}
3031

3132
@Test("Combine wrapping with existing line breaks")
3233
func testCombineWrappingAndLineBreaks() {
33-
let input = "Short line\nThis is a very long line that needs to be wrapped\nAnother short line"
34+
let input = """
35+
Short line
36+
This is a very long line that needs to be wrapped
37+
Another short line
38+
"""
39+
3440
let expected = """
3541
Short line
3642
This is a very
3743
long line that
3844
needs to be
3945
wrapped
40-
Another short line
46+
Another short
47+
line
4148
"""
4249

43-
XCTAssertEqual(input.wrapText(to: 15), expected)
50+
#expect(input.wrapText(to: 15) == expected)
4451
}
4552

4653
@Test("Words longer than column width")
@@ -52,72 +59,55 @@ import XCTest
5259
word
5360
"""
5461

55-
XCTAssertEqual(input.wrapText(to: 10), expected)
62+
#expect(input.wrapText(to: 10) == expected)
5663
}
5764

5865
@Test("Text with no spaces")
5966
func testNoSpaces() {
6067
let input = "ThisIsALongStringWithNoSpaces"
6168
let expected = "ThisIsALongStringWithNoSpaces"
6269

63-
XCTAssertEqual(input.wrapText(to: 10), expected)
70+
#expect(input.wrapText(to: 10) == expected)
6471
}
6572

6673
@Test("Empty string")
6774
func testEmptyString() {
6875
let input = ""
6976
let expected = ""
7077

71-
XCTAssertEqual(input.wrapText(to: 10), expected)
78+
#expect(input.wrapText(to: 10) == expected)
7279
}
7380

7481
@Test("Single character")
7582
func testSingleCharacter() {
7683
let input = "X"
7784
let expected = "X"
7885

79-
XCTAssertEqual(input.wrapText(to: 10), expected)
86+
#expect(input.wrapText(to: 10) == expected)
8087
}
8188

8289
@Test("Single line not exceeding width")
8390
func testSingleLineNoWrapping() {
8491
let input = "Short text"
8592
let expected = "Short text"
8693

87-
XCTAssertEqual(input.wrapText(to: 10), expected)
88-
}
89-
90-
@Test("Wrapping with indentation")
91-
func testWrappingWithIndent() {
92-
let input = "This is text that should be wrapped with indentation on new lines."
93-
let expected = """
94-
This is
95-
text that
96-
should be
97-
wrapped
98-
with
99-
indentation
100-
on new
101-
lines.
102-
"""
103-
104-
XCTAssertEqual(input.wrapText(to: 10, wrappingIndent: 2), expected)
94+
#expect(input.wrapText(to: 10) == expected)
10595
}
10696

10797
@Test("Zero or negative column width")
10898
func testZeroOrNegativeWidth() {
10999
let input = "This should not be wrapped"
110100

111-
XCTAssertEqual(input.wrapText(to: 0), input)
112-
XCTAssertEqual(input.wrapText(to: -5), input)
101+
#expect(input.wrapText(to: 0) == input)
102+
#expect(input.wrapText(to: -5) == input)
113103
}
114104

115105
@Test("Very narrow column width")
116106
func testVeryNarrowWidth() {
117107
let input = "A B C"
118108
let expected = "A\nB\nC"
119109

120-
XCTAssertEqual(input.wrapText(to: 1), expected)
110+
#expect(input.wrapText(to: 1) == expected)
121111
}
122112

123113
@Test("Special characters")
@@ -129,7 +119,7 @@ import XCTest
129119
chars
130120
"""
131121

132-
XCTAssertEqual(input.wrapText(to: 10), expected)
122+
#expect(input.wrapText(to: 10) == expected)
133123
}
134124

135125
@Test("Unicode characters")
@@ -140,69 +130,69 @@ import XCTest
140130
😀🚀🌍
141131
"""
142132

143-
XCTAssertEqual(input.wrapText(to: 15), expected)
133+
#expect(input.wrapText(to: 15) == expected)
144134
}
145135

146136
@Test("Irregular spacing")
147137
func testIrregularSpacing() {
148138
let input = "Words with irregular spacing"
149-
let expected = """
150-
Words with
151-
irregular
152-
spacing
153-
"""
139+
let expected = "Words \nwith \nirregular \nspacing"
154140

155-
XCTAssertEqual(input.wrapText(to: 10), expected)
141+
#expect(input.wrapText(to: 10) == expected)
156142
}
157143

158144
@Test("Tab characters")
159145
func testTabCharacters() {
160146
let input = "Text\twith\ttabs"
161147
let expected = """
162148
Text\twith
163-
\ttabs
149+
tabs
164150
"""
165151

166-
XCTAssertEqual(input.wrapText(to: 10), expected)
152+
#expect(input.wrapText(to: 10) == expected)
167153
}
168154

169155
@Test("Trailing spaces")
170156
func testTrailingSpaces() {
171157
let input = "Text with trailing spaces "
172-
let expected = """
173-
Text with
174-
trailing
175-
spaces
176-
"""
158+
let expected = "Text with \ntrailing\nspaces "
177159

178-
XCTAssertEqual(input.wrapText(to: 10), expected)
160+
#expect(input.wrapText(to: 10) == expected)
179161
}
180162

181163
@Test("Leading spaces")
182164
func testLeadingSpaces() {
183165
let input = " Leading spaces with text"
184166
let expected = """
185167
Leading
186-
spaces with
187-
text
168+
spaces
169+
with text
188170
"""
189171

190-
XCTAssertEqual(input.wrapText(to: 10), expected)
172+
#expect(input.wrapText(to: 10) == expected)
191173
}
192174

193175
@Test("Multiple consecutive newlines")
194176
func testMultipleNewlines() {
195177
let input = "First\n\nSecond\n\n\nThird"
196178
let expected = "First\n\nSecond\n\n\nThird"
197179

198-
XCTAssertEqual(input.wrapText(to: 10), expected)
180+
#expect(input.wrapText(to: 10) == expected)
199181
}
200182

201183
@Test("Edge case - exactly at column width")
202184
func testExactColumnWidth() {
203185
let input = "1234567890 abcdefghij"
204186
let expected = "1234567890\nabcdefghij"
205187

206-
XCTAssertEqual(input.wrapText(to: 10), expected)
188+
#expect(input.wrapText(to: 10) == expected)
189+
}
190+
191+
@Test("Lines ending exactly at column boundary")
192+
func testLinesEndingAtBoundary() {
193+
let input = "exactlyten\nmoretextat\nthe end"
194+
let expected = "exactlyten\nmoretextat\nthe end"
195+
196+
#expect(input.wrapText(to: 10) == expected)
207197
}
208198
}

0 commit comments

Comments
 (0)