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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- `StringExtractor.extractStrings` is linear in the data's size: it gathered each run a `Character` at a time and measured the string (`count`, linear) before every byte, so a run cost up to `maxLength` times its length. Runs are now gathered as bytes; the results are unchanged (checked against the previous implementation on random data).

## [1.12.0] - 2026-10-24

### Notes
Expand Down
28 changes: 23 additions & 5 deletions Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,38 @@ public enum StringExtractor {
/// A run is a sequence of bytes in 32...126 or 160...255 (read as Latin-1), tab, line feed
/// or carriage return. Each run is truncated to `maxLength` characters, trimmed of
/// surrounding whitespace and newlines, and kept when at least `minLength` characters remain.
///
/// Runs are gathered as bytes and turned into a string once, so the time is linear in the
/// data's size.
public static func extractStrings(
from data: Data,
minLength: Int = minimumStringLength,
maxLength: Int = maximumStringLength
) -> [String] {
var strings: [String] = []
var current = ""
var run: [UInt8] = []
run.reserveCapacity(maxLength)
// Characters in `run`: a carriage return and the line feed after it are one Character.
var characters = 0

func flush() {
let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
defer { run.removeAll(keepingCapacity: true); characters = 0 }
var trimmed = ""
if let first = run.firstIndex(where: { !isWhitespaceOrNewline($0) }),
let last = run.lastIndex(where: { !isWhitespaceOrNewline($0) }) {
trimmed = String(String.UnicodeScalarView(run[first...last].map { Unicode.Scalar($0) }))
}
if trimmed.count >= minLength {
strings.append(trimmed)
}
current = ""
}

for byte in data {
if (32...126).contains(byte) || byte >= 160 || byte == 9 || byte == 10 || byte == 13 {
if current.count < maxLength {
current.append(Character(UnicodeScalar(byte)))
guard characters < maxLength else { continue }
run.append(byte)
if !(byte == 10 && run.dropLast().last == 13) {
characters += 1 // a line feed after a carriage return joins its Character
}
} else {
flush()
Expand All @@ -41,4 +53,10 @@ public enum StringExtractor {
flush()
return strings
}

/// Latin-1 bytes in `CharacterSet.whitespacesAndNewlines`: tab, line feed, carriage return,
/// space and no-break space (the printable set has no other).
private static func isWhitespaceOrNewline(_ byte: UInt8) -> Bool {
byte == 32 || byte == 9 || byte == 10 || byte == 13 || byte == 0xA0
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,24 +117,3 @@ final class BinaryDataReaderTests: XCTestCase {
XCTAssertThrowsError(try BinaryDataReader.write(UInt32(1), to: &data, at: 4, endian: .big))
}
}

final class StringExtractorTests: XCTestCase {
func testRunsBetweenControlBytes() {
var data = Data("ABC".utf8)
data.append(0)
data.append(contentsOf: Array("HELLO".utf8))
data.append(0)
data.append(contentsOf: Array(" WORLD ".utf8))
data.append(1)
XCTAssertEqual(StringExtractor.extractStrings(from: data), ["HELLO", "WORLD"])
}

func testLengthLimitsAndLatin1() {
let long = Data(repeating: 0x41, count: 300)
XCTAssertEqual(StringExtractor.extractStrings(from: long).first?.count, StringExtractor.maximumStringLength)
XCTAssertEqual(StringExtractor.extractStrings(from: long, maxLength: 10), [String(repeating: "A", count: 10)])
XCTAssertEqual(StringExtractor.extractStrings(from: Data([0x41, 0x42, 0x00]), minLength: 2), ["AB"])
XCTAssertEqual(StringExtractor.extractStrings(from: Data([0xA9, 0x41, 0x42, 0x43])), ["\u{A9}ABC"])
XCTAssertEqual(StringExtractor.extractStrings(from: Data()), [])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import XCTest
@testable import DesignAlgorithmsKit

final class StringExtractorTests: XCTestCase {
func testRunsBetweenControlBytes() {
var data = Data("ABC".utf8)
data.append(0)
data.append(contentsOf: Array("HELLO".utf8))
data.append(0)
data.append(contentsOf: Array(" WORLD ".utf8))
data.append(1)
XCTAssertEqual(StringExtractor.extractStrings(from: data), ["HELLO", "WORLD"])
}

func testLengthLimitsAndLatin1() {
let long = Data(repeating: 0x41, count: 300)
XCTAssertEqual(StringExtractor.extractStrings(from: long).first?.count, StringExtractor.maximumStringLength)
XCTAssertEqual(StringExtractor.extractStrings(from: long, maxLength: 10), [String(repeating: "A", count: 10)])
XCTAssertEqual(StringExtractor.extractStrings(from: Data([0x41, 0x42, 0x00]), minLength: 2), ["AB"])
XCTAssertEqual(StringExtractor.extractStrings(from: Data([0xA9, 0x41, 0x42, 0x43])), ["\u{A9}ABC"])
XCTAssertEqual(StringExtractor.extractStrings(from: Data()), [])
}

/// The implementation before the linear rewrite: a String built a Character at a time.
private func reference(_ data: Data, minLength: Int, maxLength: Int) -> [String] {
var strings: [String] = []
var current = ""
func flush() {
let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.count >= minLength { strings.append(trimmed) }
current = ""
}
for byte in data {
if (32...126).contains(byte) || byte >= 160 || byte == 9 || byte == 10 || byte == 13 {
if current.count < maxLength { current.append(Character(UnicodeScalar(byte))) }
} else {
flush()
}
}
flush()
return strings
}

func testRunsAreTrimmedAndShortOnesDropped() {
let data = Data(" HELLO WORLD \u{0}AB\u{0}\tCOPYRIGHT 1984\r\n".utf8)
XCTAssertEqual(StringExtractor.extractStrings(from: data), ["HELLO WORLD", "COPYRIGHT 1984"])
}

func testLatin1BytesAreCharacters() {
XCTAssertEqual(StringExtractor.extractStrings(from: Data([0xA9, 0x20, 0x31, 0x39, 0x38, 0x33])), ["\u{A9} 1983"])
}

func testLongRunsAreTruncated() {
let data = Data(repeating: 0x41, count: 1000)
XCTAssertEqual(StringExtractor.extractStrings(from: data, maxLength: 10), ["AAAAAAAAAA"])
}

/// The same strings as the reference on random bytes weighted toward text, whitespace and
/// carriage return / line feed pairs, at several length limits.
func testMatchesTheReferenceOnRandomData() {
var generator = SystemRandomNumberGenerator()
let extra: [UInt8] = [9, 10, 13, 13, 10, 0xA0, 0xA9, 0xE9, 0, 1, 0x80]
let alphabet = Array(UInt8(32)...UInt8(126)) + extra
for round in 0..<200 {
let count = Int.random(in: 0...400, using: &generator)
let data = Data((0..<count).map { _ in alphabet.randomElement(using: &generator)! })
for (minLength, maxLength) in [(4, 256), (1, 8), (3, 3), (0, 1)] {
XCTAssertEqual(StringExtractor.extractStrings(from: data, minLength: minLength, maxLength: maxLength),
reference(data, minLength: minLength, maxLength: maxLength),
"round \(round), min \(minLength), max \(maxLength)")
}
}
}

func testLargeDataIsFast() {
var bytes = [UInt8](repeating: 0, count: 4_000_000)
for index in bytes.indices where index % 97 != 0 { bytes[index] = UInt8(65 + index % 26) }
let data = Data(bytes)
let start = ContinuousClock.now
XCTAssertEqual(StringExtractor.extractStrings(from: data).count, 41_238)
XCTAssertLessThan(ContinuousClock.now - start, .seconds(5))
}
}
Loading