From 0e605f170b68ad844d583803e49e768a2eaa1ac5 Mon Sep 17 00:00:00 2001 From: Rick Hohler Date: Thu, 29 Oct 2026 20:31:34 -0500 Subject: [PATCH] Make StringExtractor.extractStrings linear Runs were built a Character at a time with String.count (linear) checked before every byte. Gather bytes and build each string once; results are unchanged (equivalence test against the old implementation on random data, including CR LF pairs and minLength 0). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + .../Algorithms/Text/StringExtractor.swift | 28 +++++-- .../Algorithms/Binary/BinaryReaderTests.swift | 21 ----- .../Text/StringExtractorTests.swift | 83 +++++++++++++++++++ 4 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 Tests/DesignAlgorithmsKitTests/Algorithms/Text/StringExtractorTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3ede8..9106898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift b/Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift index 98eae4a..ae956eb 100644 --- a/Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift +++ b/Sources/DesignAlgorithmsKit/Algorithms/Text/StringExtractor.swift @@ -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() @@ -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 + } } diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift index 3cde364..27e12b0 100644 --- a/Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/Binary/BinaryReaderTests.swift @@ -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()), []) - } -} diff --git a/Tests/DesignAlgorithmsKitTests/Algorithms/Text/StringExtractorTests.swift b/Tests/DesignAlgorithmsKitTests/Algorithms/Text/StringExtractorTests.swift new file mode 100644 index 0000000..bdb8e8e --- /dev/null +++ b/Tests/DesignAlgorithmsKitTests/Algorithms/Text/StringExtractorTests.swift @@ -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..