From 4360dee8453dd2f1497e8481a29db5258252f6dc Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 10:55:15 +0100 Subject: [PATCH 01/14] =?UTF-8?q?fix(trie):=20=F0=9F=90=9B=20ignore=20stri?= =?UTF-8?q?ngs=20that=20are=20empty=20after=20trimming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/Trie.swift | 6 +++--- .../StringDictionaryTests.swift | 11 +++++++++++ Tests/StringSearchKitTests/TrieTests.swift | 13 +++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Sources/StringSearchKit/Trie.swift b/Sources/StringSearchKit/Trie.swift index d60216f..1505e1a 100644 --- a/Sources/StringSearchKit/Trie.swift +++ b/Sources/StringSearchKit/Trie.swift @@ -30,12 +30,12 @@ class Trie { func add(string aString: String) { - guard aString.hasMoreCharacters else { + let string = aString.normalized() + + guard string.hasMoreCharacters else { return } - let string = aString.normalized() - var current = head string.forEach { (character) in if let next = current.children[character] { diff --git a/Tests/StringSearchKitTests/StringDictionaryTests.swift b/Tests/StringSearchKitTests/StringDictionaryTests.swift index bb20e15..c08b68d 100644 --- a/Tests/StringSearchKitTests/StringDictionaryTests.swift +++ b/Tests/StringSearchKitTests/StringDictionaryTests.swift @@ -194,6 +194,17 @@ class StringDictionaryTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testInitWithTextFilePath_WhitespaceOnlyLine_IsNotStored() throws { + let expected = ["act", "app"] + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + try "act\n \napp\n".write(to: fileURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: fileURL) } + let wordDictionary = StringDictionary(withTextFilepath: fileURL.path) + let actual = wordDictionary.strings() + + XCTAssertEqual(expected, actual) + } + func testContains_PreservesCase_Activate_ReturnsTrue() { let wordDictionary = StringDictionary(withStrings: ["Activate"], preserveCase: true) diff --git a/Tests/StringSearchKitTests/TrieTests.swift b/Tests/StringSearchKitTests/TrieTests.swift index c9e7eea..6486e91 100644 --- a/Tests/StringSearchKitTests/TrieTests.swift +++ b/Tests/StringSearchKitTests/TrieTests.swift @@ -38,6 +38,19 @@ class TrieTests: XCTestCase { XCTAssertFalse(trie.contains(string: "")) } + func testAddString_WhitespaceOnly_IsNotStored() { + let expected: [String] = [] + trie.add(string: " \t\n") + let actual = trie.strings() + + XCTAssertEqual(expected, actual) + } + + func testAddString_WhitespaceOnly_ContainsEmptyStringIsFalse() { + trie.add(string: " \t\n") + XCTAssertFalse(trie.contains(string: "")) + } + func testAddString_Word_ContainsWordIsTrue() { let word = "word" trie.add(string: word) From 616283b0c4cd4c15e59a017fa21900125c367a2c Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 10:55:55 +0100 Subject: [PATCH 02/14] =?UTF-8?q?fix(dictionary):=20=F0=9F=90=9B=20keep=20?= =?UTF-8?q?the=20original=20case=20for=20strings=20added=20after=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StringSearchKit/StringDictionary.swift | 39 +++++++------------ .../StringDictionaryTests.swift | 26 +++++++++++++ 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/Sources/StringSearchKit/StringDictionary.swift b/Sources/StringSearchKit/StringDictionary.swift index 3025350..e71ba57 100644 --- a/Sources/StringSearchKit/StringDictionary.swift +++ b/Sources/StringSearchKit/StringDictionary.swift @@ -47,9 +47,7 @@ public class StringDictionary: StringDictionaryType { private(set) var preservesCase = false fileprivate let stringStore = Trie() - lazy var wordMap: [String: String] = { - return [:] - }() + private var wordMap: [String: String] = [:] // MARK: - Initialisers @@ -61,7 +59,7 @@ public class StringDictionary: StringDictionaryType { /// retain their original casing. Defaults to `false`. public init(withStrings strings: [String], preserveCase: Bool = false) { self.preservesCase = preserveCase - populate(with: strings) + add(strings: strings) } /// Creates a string dictionary by loading strings from a text file resource @@ -102,7 +100,7 @@ public class StringDictionary: StringDictionaryType { /// /// - Parameter strings: The strings to insert. public func add(strings: [String]) { - stringStore.add(strings: strings) + strings.forEach { add(string: $0) } } /// Adds a single string to the dictionary. @@ -110,6 +108,10 @@ public class StringDictionary: StringDictionaryType { /// - Parameter string: The string to insert. public func add(string: String) { stringStore.add(string: string) + + if preservesCase { + recordOriginalCase(of: string) + } } /// Returns a Boolean value indicating whether the dictionary contains the @@ -142,32 +144,17 @@ public class StringDictionary: StringDictionaryType { // MARK: - private extension StringDictionary { - func populate(with strings: [String]) { - strings.addEntries(to: stringStore) + func recordOriginalCase(of string: String) { + let key = string.normalized() - if preservesCase { - wordMap = createWordMap(using: strings, - finder: stringStore.strings(withPrefix:)) + guard key.hasMoreCharacters else { + return } - } - - func createWordMap(using words: [String], finder find: (String) -> [String]) -> [String: String] { - return words.reduce(into: [String: String]()) { (wordMap, originalWord) in - let matchedWords = find(originalWord) - let key = originalWord.lowercased() - if matchedWords.contains(key) { - wordMap[key] = originalWord - } - } + wordMap[key] = string.trimmingCharacters(in: .whitespacesAndNewlines) } func originalCaseWords(for words: [String]) -> [String] { - return words.map { [weak self] (word) -> String in - if let originalWord = self?.wordMap[word] { - return originalWord - } - return word - } + return words.map { wordMap[$0] ?? $0 } } } diff --git a/Tests/StringSearchKitTests/StringDictionaryTests.swift b/Tests/StringSearchKitTests/StringDictionaryTests.swift index c08b68d..16cb820 100644 --- a/Tests/StringSearchKitTests/StringDictionaryTests.swift +++ b/Tests/StringSearchKitTests/StringDictionaryTests.swift @@ -194,6 +194,32 @@ class StringDictionaryTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testAddString_PreservesCase_ReturnsOriginalCase() { + let expected = ["Act", "Apple"] + let wordDictionary = StringDictionary(withStrings: ["Act"], preserveCase: true) + wordDictionary.add(string: "Apple") + let actual = wordDictionary.strings() + + XCTAssertEqual(expected, actual) + } + + func testAddStrings_PreservesCase_ReturnsOriginalCase() { + let expected = ["App", "Apple"] + let wordDictionary = StringDictionary(withStrings: [], preserveCase: true) + wordDictionary.add(strings: ["Apple", "App"]) + let actual = wordDictionary.strings() + + XCTAssertEqual(expected, actual) + } + + func testInitWithStrings_PreservesCase_WhitespaceWrappedString_ReturnsTrimmedOriginalCase() { + let expected = ["Apple"] + let wordDictionary = StringDictionary(withStrings: [" Apple\n"], preserveCase: true) + let actual = wordDictionary.strings() + + XCTAssertEqual(expected, actual) + } + func testInitWithTextFilePath_WhitespaceOnlyLine_IsNotStored() throws { let expected = ["act", "app"] let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") From 1b71d8796b9a897e975f29526115775bc753eac9 Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 10:56:14 +0100 Subject: [PATCH 03/14] =?UTF-8?q?docs(dictionary):=20=F0=9F=93=9D=20docume?= =?UTF-8?q?nt=20that=20case=20variants=20collapse=20to=20the=20last=20one?= =?UTF-8?q?=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/StringDictionary.swift | 3 +++ Tests/StringSearchKitTests/StringDictionaryTests.swift | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/Sources/StringSearchKit/StringDictionary.swift b/Sources/StringSearchKit/StringDictionary.swift index e71ba57..7692999 100644 --- a/Sources/StringSearchKit/StringDictionary.swift +++ b/Sources/StringSearchKit/StringDictionary.swift @@ -36,6 +36,9 @@ import Foundation /// By default, lookups are case-insensitive. When `preserveCase` is `true`, /// results returned by ``strings(withPrefix:)`` retain their original casing. /// +/// Strings that differ only in case are stored once. With `preserveCase`, +/// results use the casing of the one added last. +/// /// ## Example /// /// ```swift diff --git a/Tests/StringSearchKitTests/StringDictionaryTests.swift b/Tests/StringSearchKitTests/StringDictionaryTests.swift index 16cb820..1d8abbf 100644 --- a/Tests/StringSearchKitTests/StringDictionaryTests.swift +++ b/Tests/StringSearchKitTests/StringDictionaryTests.swift @@ -220,6 +220,14 @@ class StringDictionaryTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testInitWithStrings_PreservesCase_CaseVariants_ReturnsLastAdded() { + let expected = ["APPLE"] + let wordDictionary = StringDictionary(withStrings: ["Apple", "apple", "APPLE"], preserveCase: true) + let actual = wordDictionary.strings() + + XCTAssertEqual(expected, actual) + } + func testInitWithTextFilePath_WhitespaceOnlyLine_IsNotStored() throws { let expected = ["act", "app"] let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") From fa44a2d3e0891ae8b802d4810b1b0940ce18c1c3 Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 10:56:51 +0100 Subject: [PATCH 04/14] =?UTF-8?q?fix(trie):=20=F0=9F=90=9B=20keep=20traili?= =?UTF-8?q?ng=20whitespace=20in=20a=20search=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/String+Additions.swift | 4 ++++ Sources/StringSearchKit/StringDictionary.swift | 5 +++-- Sources/StringSearchKit/Trie.swift | 2 +- Tests/StringSearchKitTests/String+AdditionsTests.swift | 8 ++++++++ Tests/StringSearchKitTests/StringDictionaryTests.swift | 8 ++++++++ 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Sources/StringSearchKit/String+Additions.swift b/Sources/StringSearchKit/String+Additions.swift index 6040849..8e577ea 100644 --- a/Sources/StringSearchKit/String+Additions.swift +++ b/Sources/StringSearchKit/String+Additions.swift @@ -38,6 +38,10 @@ extension String { return lowercased().trimmingCharacters(in: .whitespacesAndNewlines) } + func normalizedPrefix() -> String { + return String(lowercased().drop(while: \.isWhitespace)) + } + func byChompingHead() -> String { return String(dropFirst()) } diff --git a/Sources/StringSearchKit/StringDictionary.swift b/Sources/StringSearchKit/StringDictionary.swift index 7692999..1cd0034 100644 --- a/Sources/StringSearchKit/StringDictionary.swift +++ b/Sources/StringSearchKit/StringDictionary.swift @@ -135,8 +135,9 @@ public class StringDictionary: StringDictionaryType { /// returned strings retain their original casing; otherwise they are /// lowercased. /// - /// - Parameter prefix: The prefix to match against. Pass an empty string - /// (the default) to return all strings in the dictionary. + /// - Parameter prefix: The prefix to match against. Leading whitespace is + /// ignored; trailing whitespace is part of the prefix. Pass an empty + /// string (the default) to return all strings in the dictionary. /// - Returns: An array of matching strings, which may be empty. public func strings(withPrefix prefix: String = "") -> [String] { let results = stringStore.strings(withPrefix: prefix) diff --git a/Sources/StringSearchKit/Trie.swift b/Sources/StringSearchKit/Trie.swift index 1505e1a..34f8751 100644 --- a/Sources/StringSearchKit/Trie.swift +++ b/Sources/StringSearchKit/Trie.swift @@ -80,7 +80,7 @@ class Trie { let values: NSMutableSet = NSMutableSet() - let word = prefix.normalized() + let word = prefix.normalizedPrefix() strings(withPrefixWord: word, prefix: word, node: head, diff --git a/Tests/StringSearchKitTests/String+AdditionsTests.swift b/Tests/StringSearchKitTests/String+AdditionsTests.swift index 925ecdf..8205f70 100644 --- a/Tests/StringSearchKitTests/String+AdditionsTests.swift +++ b/Tests/StringSearchKitTests/String+AdditionsTests.swift @@ -66,6 +66,14 @@ class String_AdditionsTests: XCTestCase { XCTAssertEqual(expected, actual) } + // MARK: - normalizedPrefix tests + + func testNormalizedPrefix_StringWrappedInWhitespace_KeepsTrailingWhitespace() { + let expected = "new " + let actual = " NEW ".normalizedPrefix() + XCTAssertEqual(expected, actual) + } + // MARK:- byChompingHead tests func testByChompingHead_EmptyString_ReturnsEmptyString() { diff --git a/Tests/StringSearchKitTests/StringDictionaryTests.swift b/Tests/StringSearchKitTests/StringDictionaryTests.swift index 1d8abbf..6f6e946 100644 --- a/Tests/StringSearchKitTests/StringDictionaryTests.swift +++ b/Tests/StringSearchKitTests/StringDictionaryTests.swift @@ -220,6 +220,14 @@ class StringDictionaryTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testStringsWithPrefix_TrailingSpace_MatchesOnlyStringsWithThatSpace() { + let expected = ["new york"] + let wordDictionary = StringDictionary(withStrings: ["new york", "newark"]) + let actual = wordDictionary.strings(withPrefix: "new ") + + XCTAssertEqual(expected, actual) + } + func testInitWithStrings_PreservesCase_CaseVariants_ReturnsLastAdded() { let expected = ["APPLE"] let wordDictionary = StringDictionary(withStrings: ["Apple", "apple", "APPLE"], preserveCase: true) From b4a087c8899507685d2d385be31a400cce23a935 Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 10:58:27 +0100 Subject: [PATCH 05/14] =?UTF-8?q?fix(search):=20=F0=9F=90=9B=20match=20str?= =?UTF-8?q?ings=20with=20Unicode=20case=20folding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StringSearchKit/String+Additions.swift | 10 +++++-- .../StringSearchKit/StringDictionary.swift | 19 +++++------- .../String+AdditionsTests.swift | 12 ++++++++ .../StringDictionaryTests.swift | 29 +++++++++++++++++++ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/Sources/StringSearchKit/String+Additions.swift b/Sources/StringSearchKit/String+Additions.swift index 8e577ea..a7cd28f 100644 --- a/Sources/StringSearchKit/String+Additions.swift +++ b/Sources/StringSearchKit/String+Additions.swift @@ -35,11 +35,17 @@ extension String { } func normalized() -> String { - return lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + return trimmingCharacters(in: .whitespacesAndNewlines).caseFolded() } func normalizedPrefix() -> String { - return String(lowercased().drop(while: \.isWhitespace)) + return String(drop(while: \.isWhitespace)).caseFolded() + } + + // Case folding maps "İ" to "i" plus U+0307; drop the dot so "İstanbul" matches "ist". + private func caseFolded() -> String { + return folding(options: .caseInsensitive, locale: nil) + .replacingOccurrences(of: "i\u{307}", with: "i") } func byChompingHead() -> String { diff --git a/Sources/StringSearchKit/StringDictionary.swift b/Sources/StringSearchKit/StringDictionary.swift index 1cd0034..e273592 100644 --- a/Sources/StringSearchKit/StringDictionary.swift +++ b/Sources/StringSearchKit/StringDictionary.swift @@ -111,10 +111,7 @@ public class StringDictionary: StringDictionaryType { /// - Parameter string: The string to insert. public func add(string: String) { stringStore.add(string: string) - - if preservesCase { - recordOriginalCase(of: string) - } + recordResultForm(of: string) } /// Returns a Boolean value indicating whether the dictionary contains the @@ -140,25 +137,23 @@ public class StringDictionary: StringDictionaryType { /// string (the default) to return all strings in the dictionary. /// - Returns: An array of matching strings, which may be empty. public func strings(withPrefix prefix: String = "") -> [String] { - let results = stringStore.strings(withPrefix: prefix) - return preservesCase ? originalCaseWords(for: results) : results + return stringStore.strings(withPrefix: prefix).map { wordMap[$0] ?? $0 } } } // MARK: - private extension StringDictionary { - func recordOriginalCase(of string: String) { + // Results are keyed by the case-folded string; map back to the form to return when it differs. + func recordResultForm(of string: String) { let key = string.normalized() guard key.hasMoreCharacters else { return } - wordMap[key] = string.trimmingCharacters(in: .whitespacesAndNewlines) - } - - func originalCaseWords(for words: [String]) -> [String] { - return words.map { wordMap[$0] ?? $0 } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + let resultForm = preservesCase ? trimmed : trimmed.lowercased() + wordMap[key] = resultForm == key ? nil : resultForm } } diff --git a/Tests/StringSearchKitTests/String+AdditionsTests.swift b/Tests/StringSearchKitTests/String+AdditionsTests.swift index 8205f70..41f1bfa 100644 --- a/Tests/StringSearchKitTests/String+AdditionsTests.swift +++ b/Tests/StringSearchKitTests/String+AdditionsTests.swift @@ -66,6 +66,18 @@ class String_AdditionsTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testNormalized_DottedCapitalI_ReturnsPlainI() { + let expected = "istanbul" + let actual = "İSTANBUL".normalized() + XCTAssertEqual(Array(expected.unicodeScalars), Array(actual.unicodeScalars)) + } + + func testNormalized_SharpS_ReturnsDoubleS() { + let expected = "strasse" + let actual = "Straße".normalized() + XCTAssertEqual(expected, actual) + } + // MARK: - normalizedPrefix tests func testNormalizedPrefix_StringWrappedInWhitespace_KeepsTrailingWhitespace() { diff --git a/Tests/StringSearchKitTests/StringDictionaryTests.swift b/Tests/StringSearchKitTests/StringDictionaryTests.swift index 6f6e946..3ce85a0 100644 --- a/Tests/StringSearchKitTests/StringDictionaryTests.swift +++ b/Tests/StringSearchKitTests/StringDictionaryTests.swift @@ -228,6 +228,27 @@ class StringDictionaryTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testStringsWithPrefix_PreservesCase_DottedCapitalI_MatchesPlainI() { + let expected = ["İstanbul"] + let wordDictionary = StringDictionary(withStrings: ["İstanbul"], preserveCase: true) + let actual = wordDictionary.strings(withPrefix: "ist") + + XCTAssertEqual(expected, actual) + } + + func testStringsWithPrefix_SharpS_MatchesDoubleSAndReturnsLowercasedOriginal() { + let expected = ["straße"] + let wordDictionary = StringDictionary(withStrings: ["Straße"]) + let actual = wordDictionary.strings(withPrefix: "STRASS") + + XCTAssertEqual(expected, actual) + } + + func testContains_SharpS_DoubleS_ReturnsTrue() { + let wordDictionary = StringDictionary(withStrings: ["Straße"]) + XCTAssertTrue(wordDictionary.contains(string: "STRASSE")) + } + func testInitWithStrings_PreservesCase_CaseVariants_ReturnsLastAdded() { let expected = ["APPLE"] let wordDictionary = StringDictionary(withStrings: ["Apple", "apple", "APPLE"], preserveCase: true) @@ -236,6 +257,14 @@ class StringDictionaryTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testInitWithStrings_PreservesCase_LowercaseVariantAddedLast_ReturnsLowercase() { + let expected = ["apple"] + let wordDictionary = StringDictionary(withStrings: ["APPLE", "apple"], preserveCase: true) + let actual = wordDictionary.strings() + + XCTAssertEqual(expected, actual) + } + func testInitWithTextFilePath_WhitespaceOnlyLine_IsNotStored() throws { let expected = ["act", "app"] let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") From 00d1537f76cea5076d12cb5d02e22b4703b02dad Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:04:18 +0100 Subject: [PATCH 06/14] =?UTF-8?q?refactor(trie):=20=E2=99=BB=EF=B8=8F=20re?= =?UTF-8?q?move=20the=20Sequence=20helper=20that=20only=20tests=20used?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StringSearchKit/Sequence+Additions.swift | 32 ------------------- Tests/StringSearchKitTests/TrieTests.swift | 16 +++++----- 2 files changed, 8 insertions(+), 40 deletions(-) delete mode 100644 Sources/StringSearchKit/Sequence+Additions.swift diff --git a/Sources/StringSearchKit/Sequence+Additions.swift b/Sources/StringSearchKit/Sequence+Additions.swift deleted file mode 100644 index a17e30c..0000000 --- a/Sources/StringSearchKit/Sequence+Additions.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Created by Mike O. Abidakun 2017. -// Copyright (c) 2017-Present Mike O. Abidakun. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -import Foundation - -extension Sequence where Iterator.Element == String { - - func addEntries(to trie: Trie) { - forEach { - trie.add(string: $0) - } - } -} diff --git a/Tests/StringSearchKitTests/TrieTests.swift b/Tests/StringSearchKitTests/TrieTests.swift index 6486e91..6ada19d 100644 --- a/Tests/StringSearchKitTests/TrieTests.swift +++ b/Tests/StringSearchKitTests/TrieTests.swift @@ -152,7 +152,7 @@ class TrieTests: XCTestCase { func testStrings_ReturnsExpected() { let expected = ["fix", "fixed", "fixer"] - expected.addEntries(to: trie) + trie.add(strings: expected) let actual = trie.strings() XCTAssertEqual(expected, actual) @@ -161,7 +161,7 @@ class TrieTests: XCTestCase { func testStringsWithPrefix_A_ReturnsExpected() { let input = ["a", "abc", "bc", "bob"] - input.addEntries(to: trie) + trie.add(strings: input) let actual = trie.strings(withPrefix: "a") @@ -171,7 +171,7 @@ class TrieTests: XCTestCase { func testStringsWithPrefix_Bob_ReturnsExpected() { let input = ["a", "bob", "abc", "bobbing"] - input.addEntries(to: trie) + trie.add(strings: input) let actual = trie.strings(withPrefix: "bob") XCTAssertEqual(["bob", "bobbing"], actual) @@ -180,7 +180,7 @@ class TrieTests: XCTestCase { func testStringsWithPrefix_MixedCase_ReturnsExpected() { let input = ["a", "bob", "abc", "bobbing"] - input.addEntries(to: trie) + trie.add(strings: input) let actual = trie.strings(withPrefix: "BoB") XCTAssertEqual(["bob", "bobbing"], actual) @@ -189,7 +189,7 @@ class TrieTests: XCTestCase { func testStringsWithPrefix_LongerPrefix_ReturnsExpected() { let input = ["a", "bob", "abc", "bobbing"] - input.addEntries(to: trie) + trie.add(strings: input) let actual = trie.strings(withPrefix: "bobb") XCTAssertEqual(["bobbing"], actual) @@ -198,7 +198,7 @@ class TrieTests: XCTestCase { func testStringsWithPrefix_LongerPrefixMultipleWords_ReturnsExpected() { let input = ["a", "bob", "abc", "bobbing", "bobbled"] - input.addEntries(to: trie) + trie.add(strings: input) let actual = trie.strings(withPrefix: "bobb") XCTAssertEqual(["bobbing", "bobbled"], actual) @@ -207,7 +207,7 @@ class TrieTests: XCTestCase { func testStringsWithPrefix_EmptyString_ReturnsExpected() { let expected = ["a", "bob", "abc", "bobbing", "bobbled"].sorted() - expected.addEntries(to: trie) + trie.add(strings: expected) let actual = trie.strings(withPrefix: "") XCTAssertEqual(expected, actual) @@ -216,7 +216,7 @@ class TrieTests: XCTestCase { func testStringsWithPrefix_NoString_ReturnsExpected() { let expected = ["a", "bob", "abc", "bobbing", "bobbled"].sorted() - expected.addEntries(to: trie) + trie.add(strings: expected) let actual = trie.strings() XCTAssertEqual(expected, actual) From 51de61810d2d5d896bc3655cc88a337c86816af1 Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:11:56 +0100 Subject: [PATCH 07/14] =?UTF-8?q?fix(trie):=20=F0=9F=90=9B=20search=20and?= =?UTF-8?q?=20free=20long=20strings=20without=20recursion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/Trie.swift | 73 +++++++++++----------- Sources/StringSearchKit/TrieNode.swift | 11 ++++ Tests/StringSearchKitTests/TrieTests.swift | 33 +++++++++- 3 files changed, 78 insertions(+), 39 deletions(-) diff --git a/Sources/StringSearchKit/Trie.swift b/Sources/StringSearchKit/Trie.swift index 34f8751..f9a6e05 100644 --- a/Sources/StringSearchKit/Trie.swift +++ b/Sources/StringSearchKit/Trie.swift @@ -78,18 +78,13 @@ class Trie { func strings(withPrefix prefix: String = "") -> [String] { - let values: NSMutableSet = NSMutableSet() - let word = prefix.normalizedPrefix() - strings(withPrefixWord: word, - prefix: word, - node: head, - accumulatedStrings: values) - - return - values - .compactMap { $0 as? String } - .sorted() + + guard let prefixNode = node(reaching: word) else { + return [] + } + + return strings(below: prefixNode, prefix: word).sorted() } } @@ -119,42 +114,44 @@ private extension Trie { } } - func strings(withPrefixWord word: String, - prefix: String, - node: TrieNode? = nil, - accumulatedCharacters: String = "", - accumulatedStrings: NSMutableSet) { + func node(reaching word: String) -> TrieNode? { - guard let node = node else { return } + var current = head - let nodeValue = nil == node.parent ? "" : String(node.value) - let stringSoFar = "\(accumulatedCharacters)\(nodeValue)" - if node.isTerminating && stringSoFar.hasPrefix(word) { - accumulatedStrings.add(stringSoFar) + for character in word { + guard let next = current.children[character] else { + return nil + } + current = next } - if !prefix.hasMoreCharacters { + return current + } + + // Depth-first walk with an explicit stack, so long strings cannot overflow the call stack. + func strings(below start: TrieNode, prefix: String) -> [String] { - for entry in node.children { - strings(withPrefixWord: word, - prefix: prefix, - node: entry.value, - accumulatedCharacters: stringSoFar, - accumulatedStrings: accumulatedStrings) + var results: [String] = [] + var path: [Character] = [] + var pending: [(node: TrieNode, depth: Int)] = [(start, 0)] + + while let (node, depth) = pending.popLast() { + + path.removeLast(path.count - max(depth - 1, 0)) + if depth > 0 { + path.append(node.value) } - return - } - guard let character = prefix.firstCharacter, - let child = node.children[character] else { - return + if node.isTerminating { + results.append(prefix + String(path)) + } + + for child in node.children.values { + pending.append((child, depth + 1)) + } } - strings(withPrefixWord: word, - prefix: prefix.byChompingHead(), - node: child, - accumulatedCharacters: stringSoFar, - accumulatedStrings: accumulatedStrings) + return results } } diff --git a/Sources/StringSearchKit/TrieNode.swift b/Sources/StringSearchKit/TrieNode.swift index 31d0804..69a429b 100644 --- a/Sources/StringSearchKit/TrieNode.swift +++ b/Sources/StringSearchKit/TrieNode.swift @@ -52,6 +52,17 @@ class TrieNode: CustomStringConvertible { self.parent = parent } + // Free descendants one at a time; letting each node free its children recursively overflows the stack for long strings. + deinit { + var descendants = Array(nodes.values) + nodes.removeAll() + + while let node = descendants.popLast() { + descendants.append(contentsOf: node.nodes.values) + node.nodes.removeAll() + } + } + func delete() { parent?.nodes[value] = nil } diff --git a/Tests/StringSearchKitTests/TrieTests.swift b/Tests/StringSearchKitTests/TrieTests.swift index 6ada19d..ad99a51 100644 --- a/Tests/StringSearchKitTests/TrieTests.swift +++ b/Tests/StringSearchKitTests/TrieTests.swift @@ -218,8 +218,39 @@ class TrieTests: XCTestCase { let expected = ["a", "bob", "abc", "bobbing", "bobbled"].sorted() trie.add(strings: expected) let actual = trie.strings() - + XCTAssertEqual(expected, actual) } + + // MARK: - long strings + + // Background threads have a small stack, so recursion per character overflows it. + func testStringsWithPrefix_LongStringOnBackgroundThread_ReturnsString() { + let longString = String(repeating: "a", count: 20_000) + var actual: [String] = [] + let finished = expectation(description: "search finished") + DispatchQueue.global().async { + let trie = Trie() + trie.add(string: longString) + actual = trie.strings(withPrefix: "a") + finished.fulfill() + } + wait(for: [finished], timeout: 30) + + XCTAssertEqual([longString], actual) + } + + func testDeinit_LongStringOnBackgroundThread_FreesWithoutCrashing() { + let finished = expectation(description: "trie freed") + DispatchQueue.global().async { + autoreleasepool { + let trie = Trie() + trie.add(string: String(repeating: "a", count: 20_000)) + } + finished.fulfill() + } + + wait(for: [finished], timeout: 30) + } } From fc583000c4a42f181b5661a90a39138da6164835 Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:12:31 +0100 Subject: [PATCH 08/14] =?UTF-8?q?fix(loader):=20=F0=9F=90=9B=20drop=20empt?= =?UTF-8?q?y=20lines=20and=20treat=20CRLF=20as=20one=20line=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/StringLoader.swift | 37 ++++++------------- .../StringLoaderTests.swift | 10 +++++ 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/Sources/StringSearchKit/StringLoader.swift b/Sources/StringSearchKit/StringLoader.swift index 4546e3f..e68636d 100644 --- a/Sources/StringSearchKit/StringLoader.swift +++ b/Sources/StringSearchKit/StringLoader.swift @@ -32,25 +32,26 @@ public class StringLoader { /// Loads strings from a text file at the given file path. /// - /// The file is expected to contain one string per line. Leading and - /// trailing whitespace is trimmed from the file contents before splitting. + /// The file is expected to contain one string per line. Any line ending + /// (`\n`, `\r\n` or `\r`) separates lines, and empty lines are skipped. + /// Whitespace within a line is kept. /// /// - Parameter path: The absolute path to the text file. - /// - Returns: An array of strings, one per line, or an empty array if the - /// file cannot be read. + /// - Returns: An array of strings, one per non-empty line, or an empty + /// array if the file cannot be read. public static func load(withFilepath path: String) -> [String] { - guard let contents = try? String(contentsOfFile: path).trimmingCharacters(in: .whitespacesAndNewlines) else { + guard let contents = try? String(contentsOfFile: path) else { return [] } - return contents.components(separatedBy: .newlines) + return contents.split(whereSeparator: \.isNewline).map(String.init) } /// Loads strings from a named text file resource in a bundle. /// /// The file is expected to have a `.txt` extension and contain one - /// string per line. + /// string per line. Lines are split as in ``load(withFilepath:)``. /// /// - Parameters: /// - name: The name of the text file resource, without the `.txt` @@ -58,29 +59,15 @@ public class StringLoader { /// - bundle: The bundle that contains the resource. Defaults to the /// main bundle. Pass `Bundle.module` for a resource in a Swift /// package target. - /// - Returns: An array of strings, one per line, or an empty array if the - /// resource cannot be found or read. + /// - Returns: An array of strings, one per non-empty line, or an empty + /// array if the resource cannot be found or read. public static func load(fromTextFileNamed name: String, in bundle: Bundle = .main) -> [String] { - guard let contents = loadTextFile(named: name, in: bundle) else { - return [] - } - - return contents.components(separatedBy: .newlines) - } -} - -// MARK: - - -private extension StringLoader { - - static func loadTextFile(named name: String, in bundle: Bundle) -> String? { - guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let filepath = bundle.path(forResource: name, ofType: .textFileExtension) else { - return nil + return [] } - return try? String(contentsOfFile: filepath).trimmingCharacters(in: .whitespacesAndNewlines) + return load(withFilepath: filepath) } } diff --git a/Tests/StringSearchKitTests/StringLoaderTests.swift b/Tests/StringSearchKitTests/StringLoaderTests.swift index e6209db..5753004 100644 --- a/Tests/StringSearchKitTests/StringLoaderTests.swift +++ b/Tests/StringSearchKitTests/StringLoaderTests.swift @@ -62,6 +62,16 @@ class StringLoaderTests: XCTestCase { XCTAssertEqual(expected, actual.count) } + func testLoadDictionary_CRLFAndBlankLines_ReturnsOnlyNonEmptyLines() throws { + let expected = ["apple", " banana ", "cherry"] + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + try "apple\r\n banana \r\n\r\n\ncherry\r\n".write(to: fileURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: fileURL) } + let actual = StringLoader.load(withFilepath: fileURL.path) + + XCTAssertEqual(expected, actual) + } + func testLoadDictionary_ExistingFilename_ReturnsPopulatedArray() { let expected = 13 From 77d201ad4059dbccefb7d84b9ee77207b838776b Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:12:57 +0100 Subject: [PATCH 09/14] =?UTF-8?q?fix(search):=20=F0=9F=90=9B=20trim=20pref?= =?UTF-8?q?ixes=20with=20the=20same=20whitespace=20set=20as=20stored=20str?= =?UTF-8?q?ings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/String+Additions.swift | 4 +++- .../String+AdditionsTests.swift | 6 ++++++ .../StringDictionaryTests.swift | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Sources/StringSearchKit/String+Additions.swift b/Sources/StringSearchKit/String+Additions.swift index a7cd28f..73dd1ff 100644 --- a/Sources/StringSearchKit/String+Additions.swift +++ b/Sources/StringSearchKit/String+Additions.swift @@ -38,8 +38,10 @@ extension String { return trimmingCharacters(in: .whitespacesAndNewlines).caseFolded() } + // Trims leading whitespace with the same character set as normalized(), so prefixes and stored strings agree. func normalizedPrefix() -> String { - return String(drop(while: \.isWhitespace)).caseFolded() + let trimmed = unicodeScalars.drop(while: CharacterSet.whitespacesAndNewlines.contains) + return String(String.UnicodeScalarView(trimmed)).caseFolded() } // Case folding maps "İ" to "i" plus U+0307; drop the dot so "İstanbul" matches "ist". diff --git a/Tests/StringSearchKitTests/String+AdditionsTests.swift b/Tests/StringSearchKitTests/String+AdditionsTests.swift index 41f1bfa..9f7b53b 100644 --- a/Tests/StringSearchKitTests/String+AdditionsTests.swift +++ b/Tests/StringSearchKitTests/String+AdditionsTests.swift @@ -80,6 +80,12 @@ class String_AdditionsTests: XCTestCase { // MARK: - normalizedPrefix tests + func testNormalizedPrefix_LeadingZeroWidthSpace_RemovesItLikeNormalized() { + let expected = "\u{200B}APP".normalized() + let actual = "\u{200B}APP".normalizedPrefix() + XCTAssertEqual(expected, actual) + } + func testNormalizedPrefix_StringWrappedInWhitespace_KeepsTrailingWhitespace() { let expected = "new " let actual = " NEW ".normalizedPrefix() diff --git a/Tests/StringSearchKitTests/StringDictionaryTests.swift b/Tests/StringSearchKitTests/StringDictionaryTests.swift index 3ce85a0..7324e3f 100644 --- a/Tests/StringSearchKitTests/StringDictionaryTests.swift +++ b/Tests/StringSearchKitTests/StringDictionaryTests.swift @@ -249,6 +249,22 @@ class StringDictionaryTests: XCTestCase { XCTAssertTrue(wordDictionary.contains(string: "STRASSE")) } + func testStringsWithPrefix_LeadingZeroWidthSpace_MatchesLikeContains() { + let expected = ["apple"] + let wordDictionary = StringDictionary(withStrings: ["\u{200B}apple"]) + let actual = wordDictionary.strings(withPrefix: "\u{200B}app") + + XCTAssertEqual(expected, actual) + } + + func testStringsWithPrefix_CombiningMarkAfterLeadingSpace_MatchesLikeContains() { + let expected = ["\u{301}new"] + let wordDictionary = StringDictionary(withStrings: [" \u{301}new"]) + let actual = wordDictionary.strings(withPrefix: " \u{301}new") + + XCTAssertEqual(expected, actual) + } + func testInitWithStrings_PreservesCase_CaseVariants_ReturnsLastAdded() { let expected = ["APPLE"] let wordDictionary = StringDictionary(withStrings: ["Apple", "apple", "APPLE"], preserveCase: true) From 0293cfb85ce791a695880b9c59b62771f5d57599 Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:18:27 +0100 Subject: [PATCH 10/14] =?UTF-8?q?docs(readme):=20=F0=9F=93=9D=20describe?= =?UTF-8?q?=20the=20matching=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index c031bf7..26224d2 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,13 @@ let searchResults = stringDictionary.strings(withPrefix: "app") // searchResults will then contain ["App", "Apple"] ``` +### Matching Rules + +- Matching ignores case, including Unicode case rules: `"Straße"` matches the prefix `"strass"`, and `"İstanbul"` matches `"ist"`. +- A prefix ignores leading whitespace, but trailing whitespace is part of it: `"new "` matches `"new york"` but not `"newark"`. +- Text files may use `\n` or `\r\n` line endings. Empty lines are skipped. +- Strings that differ only in case are stored once. The one added last decides the case of the result when `preserveCase` is `true`. + ## Author From 4958ae746a8feac0b58dba048e68e143e3bd319d Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:24:45 +0100 Subject: [PATCH 11/14] =?UTF-8?q?fix(loader):=20=F0=9F=90=9B=20skip=20whit?= =?UTF-8?q?espace-only=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- Sources/StringSearchKit/StringLoader.swift | 13 +++++---- .../StringLoaderTests.swift | 28 ++++++++++++++++--- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 26224d2..d2867e0 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ let searchResults = stringDictionary.strings(withPrefix: "app") - Matching ignores case, including Unicode case rules: `"Straße"` matches the prefix `"strass"`, and `"İstanbul"` matches `"ist"`. - A prefix ignores leading whitespace, but trailing whitespace is part of it: `"new "` matches `"new york"` but not `"newark"`. -- Text files may use `\n` or `\r\n` line endings. Empty lines are skipped. +- Text files may use `\n` or `\r\n` line endings. Empty and whitespace-only lines are skipped. - Strings that differ only in case are stored once. The one added last decides the case of the result when `preserveCase` is `true`. diff --git a/Sources/StringSearchKit/StringLoader.swift b/Sources/StringSearchKit/StringLoader.swift index e68636d..77c42ee 100644 --- a/Sources/StringSearchKit/StringLoader.swift +++ b/Sources/StringSearchKit/StringLoader.swift @@ -33,11 +33,11 @@ public class StringLoader { /// Loads strings from a text file at the given file path. /// /// The file is expected to contain one string per line. Any line ending - /// (`\n`, `\r\n` or `\r`) separates lines, and empty lines are skipped. - /// Whitespace within a line is kept. + /// (`\n`, `\r\n` or `\r`) separates lines, and empty or whitespace-only + /// lines are skipped. Whitespace within a line is kept. /// /// - Parameter path: The absolute path to the text file. - /// - Returns: An array of strings, one per non-empty line, or an empty + /// - Returns: An array of strings, one per non-blank line, or an empty /// array if the file cannot be read. public static func load(withFilepath path: String) -> [String] { @@ -45,7 +45,10 @@ public class StringLoader { return [] } - return contents.split(whereSeparator: \.isNewline).map(String.init) + return contents + .split(whereSeparator: \.isNewline) + .map(String.init) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } /// Loads strings from a named text file resource in a bundle. @@ -59,7 +62,7 @@ public class StringLoader { /// - bundle: The bundle that contains the resource. Defaults to the /// main bundle. Pass `Bundle.module` for a resource in a Swift /// package target. - /// - Returns: An array of strings, one per non-empty line, or an empty + /// - Returns: An array of strings, one per non-blank line, or an empty /// array if the resource cannot be found or read. public static func load(fromTextFileNamed name: String, in bundle: Bundle = .main) -> [String] { diff --git a/Tests/StringSearchKitTests/StringLoaderTests.swift b/Tests/StringSearchKitTests/StringLoaderTests.swift index 5753004..ff4f20c 100644 --- a/Tests/StringSearchKitTests/StringLoaderTests.swift +++ b/Tests/StringSearchKitTests/StringLoaderTests.swift @@ -64,10 +64,21 @@ class StringLoaderTests: XCTestCase { func testLoadDictionary_CRLFAndBlankLines_ReturnsOnlyNonEmptyLines() throws { let expected = ["apple", " banana ", "cherry"] - let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") - try "apple\r\n banana \r\n\r\n\ncherry\r\n".write(to: fileURL, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: fileURL) } - let actual = StringLoader.load(withFilepath: fileURL.path) + let actual = try load(fileContents: "apple\r\n banana \r\n\r\n\ncherry\r\n") + + XCTAssertEqual(expected, actual) + } + + func testLoadDictionary_WhitespaceOnlyLines_AreSkipped() throws { + let expected = ["apple", "cherry"] + let actual = try load(fileContents: "apple\n \n\t\r\ncherry") + + XCTAssertEqual(expected, actual) + } + + func testLoadDictionary_PaddedFirstAndLastLines_KeepTheirWhitespace() throws { + let expected = [" apple", "cherry "] + let actual = try load(fileContents: " apple\ncherry ") XCTAssertEqual(expected, actual) } @@ -93,4 +104,13 @@ class StringLoaderTests: XCTestCase { XCTAssertEqual(expected, actual.last) } + + // MARK: - helpers + + private func load(fileContents: String) throws -> [String] { + let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).txt") + try fileContents.write(to: fileURL, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: fileURL) } + return StringLoader.load(withFilepath: fileURL.path) + } } From 60545820963f9f7d76122b31f02fb4c20dbc4f3e Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:25:10 +0100 Subject: [PATCH 12/14] =?UTF-8?q?fix(trie):=20=F0=9F=90=9B=20build=20resul?= =?UTF-8?q?ts=20from=20the=20stored=20characters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/Trie.swift | 11 +++++++---- Tests/StringSearchKitTests/TrieTests.swift | 8 ++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Sources/StringSearchKit/Trie.swift b/Sources/StringSearchKit/Trie.swift index f9a6e05..ed07d35 100644 --- a/Sources/StringSearchKit/Trie.swift +++ b/Sources/StringSearchKit/Trie.swift @@ -80,11 +80,11 @@ class Trie { let word = prefix.normalizedPrefix() - guard let prefixNode = node(reaching: word) else { + guard let (prefixNode, storedPrefix) = node(reaching: word) else { return [] } - return strings(below: prefixNode, prefix: word).sorted() + return strings(below: prefixNode, prefix: storedPrefix).sorted() } } @@ -114,18 +114,21 @@ private extension Trie { } } - func node(reaching word: String) -> TrieNode? { + // Returns the node at the end of `word` and the stored text of that path, which may differ in bytes from `word` (for example a precomposed "é"). + func node(reaching word: String) -> (node: TrieNode, storedPrefix: String)? { var current = head + var storedPrefix: [Character] = [] for character in word { guard let next = current.children[character] else { return nil } current = next + storedPrefix.append(next.value) } - return current + return (current, String(storedPrefix)) } // Depth-first walk with an explicit stack, so long strings cannot overflow the call stack. diff --git a/Tests/StringSearchKitTests/TrieTests.swift b/Tests/StringSearchKitTests/TrieTests.swift index ad99a51..9edf42b 100644 --- a/Tests/StringSearchKitTests/TrieTests.swift +++ b/Tests/StringSearchKitTests/TrieTests.swift @@ -222,6 +222,14 @@ class TrieTests: XCTestCase { XCTAssertEqual(expected, actual) } + func testStringsWithPrefix_DecomposedPrefixForPrecomposedString_ReturnsStoredScalars() { + let expected = Array("caf\u{E9}".unicodeScalars) + trie.add(string: "caf\u{E9}") + let actual = trie.strings(withPrefix: "CAFE\u{301}").first.map { Array($0.unicodeScalars) } + + XCTAssertEqual(expected, actual) + } + // MARK: - long strings // Background threads have a small stack, so recursion per character overflows it. From a1f5b538b4db1999b554f5a81a4f5029bfaa2587 Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:25:55 +0100 Subject: [PATCH 13/14] =?UTF-8?q?fix(trie):=20=F0=9F=90=9B=20remove=20long?= =?UTF-8?q?=20strings=20without=20recursion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StringSearchKit/String+Additions.swift | 8 ---- Sources/StringSearchKit/Trie.swift | 44 +++++++++---------- .../String+AdditionsTests.swift | 38 ---------------- Tests/StringSearchKitTests/TrieTests.swift | 17 +++++++ 4 files changed, 38 insertions(+), 69 deletions(-) diff --git a/Sources/StringSearchKit/String+Additions.swift b/Sources/StringSearchKit/String+Additions.swift index 73dd1ff..9933883 100644 --- a/Sources/StringSearchKit/String+Additions.swift +++ b/Sources/StringSearchKit/String+Additions.swift @@ -30,10 +30,6 @@ extension String { return !isEmpty } - var firstCharacter: Character? { - return first - } - func normalized() -> String { return trimmingCharacters(in: .whitespacesAndNewlines).caseFolded() } @@ -49,8 +45,4 @@ extension String { return folding(options: .caseInsensitive, locale: nil) .replacingOccurrences(of: "i\u{307}", with: "i") } - - func byChompingHead() -> String { - return String(dropFirst()) - } } diff --git a/Sources/StringSearchKit/Trie.swift b/Sources/StringSearchKit/Trie.swift index ed07d35..ba778c7 100644 --- a/Sources/StringSearchKit/Trie.swift +++ b/Sources/StringSearchKit/Trie.swift @@ -56,7 +56,27 @@ class Trie { } func remove(string: String) { - remove(string: string.normalized(), node: head) + + var path: [TrieNode] = [] + var current = head + + for character in string.normalized() { + guard let next = current.children[character] else { + return + } + current = next + path.append(next) + } + + current.isTerminating = false + + // Prune from the end of the string back towards the root, stopping at the first node still in use. + for node in path.reversed() { + guard node.canDelete else { + return + } + node.delete() + } } func contains(string: String) -> Bool { @@ -92,28 +112,6 @@ class Trie { private extension Trie { - func remove(string: String, node: TrieNode? = nil) { - - guard let firstChar = string.firstCharacter, - let current = node?.children[firstChar] - else { - return - } - - let remainingString = string.byChompingHead() - - if !remainingString.hasMoreCharacters, current.isTerminating { - current.isTerminating = false - } - else { - remove(string: remainingString, node: current) - } - - if current.canDelete { - current.delete() - } - } - // Returns the node at the end of `word` and the stored text of that path, which may differ in bytes from `word` (for example a precomposed "é"). func node(reaching word: String) -> (node: TrieNode, storedPrefix: String)? { diff --git a/Tests/StringSearchKitTests/String+AdditionsTests.swift b/Tests/StringSearchKitTests/String+AdditionsTests.swift index 9f7b53b..406c763 100644 --- a/Tests/StringSearchKitTests/String+AdditionsTests.swift +++ b/Tests/StringSearchKitTests/String+AdditionsTests.swift @@ -34,24 +34,6 @@ class String_AdditionsTests: XCTestCase { XCTAssertFalse("".hasMoreCharacters) } - // MARK: - firstCharacter tests - - func testFirstCharacter_EmptyString_ReturnsNil() { - XCTAssertNil("".firstCharacter) - } - - func testFirstCharacter_A_ReturnsA() { - let expected: Character = "A" - let actual = "A".firstCharacter - XCTAssertEqual(expected, actual) - } - - func testFirstCharacter_CBA_ReturnsC() { - let expected: Character = "C" - let actual = "CBA".firstCharacter - XCTAssertEqual(expected, actual) - } - // MARK: - normalized tests func testNormalized_VALUE_ReturnsLowerCasedValue() { @@ -91,24 +73,4 @@ class String_AdditionsTests: XCTestCase { let actual = " NEW ".normalizedPrefix() XCTAssertEqual(expected, actual) } - - // MARK:- byChompingHead tests - - func testByChompingHead_EmptyString_ReturnsEmptyString() { - let expected = "" - let actual = "".byChompingHead() - XCTAssertEqual(expected, actual) - } - - func testByChompingHead_A_ReturnsEmptyString() { - let expected = "" - let actual = "A".byChompingHead() - XCTAssertEqual(expected, actual) - } - - func testByChompingHead_ABC_ReturnsBC() { - let expected = "Bc" - let actual = "ABc".byChompingHead() - XCTAssertEqual(expected, actual) - } } diff --git a/Tests/StringSearchKitTests/TrieTests.swift b/Tests/StringSearchKitTests/TrieTests.swift index 9edf42b..c81970b 100644 --- a/Tests/StringSearchKitTests/TrieTests.swift +++ b/Tests/StringSearchKitTests/TrieTests.swift @@ -248,6 +248,23 @@ class TrieTests: XCTestCase { XCTAssertEqual([longString], actual) } + func testRemoveString_LongStringOnBackgroundThread_LeavesTrieEmpty() { + let expected: [String] = [] + var actual: [String] = ["not run"] + let finished = expectation(description: "remove finished") + DispatchQueue.global().async { + let trie = Trie() + let longString = String(repeating: "a", count: 20_000) + trie.add(string: longString) + trie.remove(string: longString) + actual = trie.strings() + finished.fulfill() + } + wait(for: [finished], timeout: 30) + + XCTAssertEqual(expected, actual) + } + func testDeinit_LongStringOnBackgroundThread_FreesWithoutCrashing() { let finished = expectation(description: "trie freed") DispatchQueue.global().async { From 3e737ec2f8c136d3c185cbd5a01e46602c54ac6c Mon Sep 17 00:00:00 2001 From: "Mike O. Abidakun" Date: Sat, 24 Oct 2026 11:26:38 +0100 Subject: [PATCH 14/14] =?UTF-8?q?fix(trie):=20=F0=9F=90=9B=20free=20only?= =?UTF-8?q?=20subtrees=20that=20nothing=20else=20holds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/StringSearchKit/TrieNode.swift | 6 +++++- Tests/StringSearchKitTests/TrieNodeTests.swift | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Sources/StringSearchKit/TrieNode.swift b/Sources/StringSearchKit/TrieNode.swift index 69a429b..1d33f01 100644 --- a/Sources/StringSearchKit/TrieNode.swift +++ b/Sources/StringSearchKit/TrieNode.swift @@ -53,11 +53,15 @@ class TrieNode: CustomStringConvertible { } // Free descendants one at a time; letting each node free its children recursively overflows the stack for long strings. + // A node that something else still holds keeps its subtree. deinit { var descendants = Array(nodes.values) nodes.removeAll() - while let node = descendants.popLast() { + while var node = descendants.popLast() { + guard isKnownUniquelyReferenced(&node) else { + continue + } descendants.append(contentsOf: node.nodes.values) node.nodes.removeAll() } diff --git a/Tests/StringSearchKitTests/TrieNodeTests.swift b/Tests/StringSearchKitTests/TrieNodeTests.swift index 027f01e..449b101 100644 --- a/Tests/StringSearchKitTests/TrieNodeTests.swift +++ b/Tests/StringSearchKitTests/TrieNodeTests.swift @@ -126,4 +126,15 @@ class TrieNodeTests: XCTestCase { XCTAssertNil(child.parent) } + + func testDeinit_ChildHeldElsewhere_KeepsItsChildren() { + let expected = 1 + var root: TrieNode? = TrieNode(with: "#") + let child = TrieNode.make(with: "a", parent: root) + TrieNode.make(with: "b", parent: child) + root = nil + let actual = child.children.count + + XCTAssertEqual(expected, actual) + } }