diff --git a/README.md b/README.md index c031bf7..d2867e0 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 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`. + ## Author 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/Sources/StringSearchKit/String+Additions.swift b/Sources/StringSearchKit/String+Additions.swift index 6040849..9933883 100644 --- a/Sources/StringSearchKit/String+Additions.swift +++ b/Sources/StringSearchKit/String+Additions.swift @@ -30,15 +30,19 @@ extension String { return !isEmpty } - var firstCharacter: Character? { - return first + func normalized() -> String { + return trimmingCharacters(in: .whitespacesAndNewlines).caseFolded() } - func normalized() -> String { - return lowercased().trimmingCharacters(in: .whitespacesAndNewlines) + // Trims leading whitespace with the same character set as normalized(), so prefixes and stored strings agree. + func normalizedPrefix() -> String { + let trimmed = unicodeScalars.drop(while: CharacterSet.whitespacesAndNewlines.contains) + return String(String.UnicodeScalarView(trimmed)).caseFolded() } - func byChompingHead() -> String { - return String(dropFirst()) + // 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") } } diff --git a/Sources/StringSearchKit/StringDictionary.swift b/Sources/StringSearchKit/StringDictionary.swift index 3025350..e273592 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 @@ -47,9 +50,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 +62,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 +103,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 +111,7 @@ public class StringDictionary: StringDictionaryType { /// - Parameter string: The string to insert. public func add(string: String) { stringStore.add(string: string) + recordResultForm(of: string) } /// Returns a Boolean value indicating whether the dictionary contains the @@ -130,44 +132,28 @@ 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) - return preservesCase ? originalCaseWords(for: results) : results + return stringStore.strings(withPrefix: prefix).map { wordMap[$0] ?? $0 } } } // MARK: - private extension StringDictionary { - func populate(with strings: [String]) { - strings.addEntries(to: stringStore) + // 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() - 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 - } - } - } - - func originalCaseWords(for words: [String]) -> [String] { - return words.map { [weak self] (word) -> String in - if let originalWord = self?.wordMap[word] { - return originalWord - } - return word - } + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + let resultForm = preservesCase ? trimmed : trimmed.lowercased() + wordMap[key] = resultForm == key ? nil : resultForm } } diff --git a/Sources/StringSearchKit/StringLoader.swift b/Sources/StringSearchKit/StringLoader.swift index 4546e3f..77c42ee 100644 --- a/Sources/StringSearchKit/StringLoader.swift +++ b/Sources/StringSearchKit/StringLoader.swift @@ -32,25 +32,29 @@ 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 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 line, or an empty array if the - /// file cannot be read. + /// - 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] { - 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) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } /// 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 +62,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-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] { - 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/Sources/StringSearchKit/Trie.swift b/Sources/StringSearchKit/Trie.swift index d60216f..ba778c7 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] { @@ -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 { @@ -78,18 +98,13 @@ class Trie { func strings(withPrefix prefix: String = "") -> [String] { - let values: NSMutableSet = NSMutableSet() + let word = prefix.normalizedPrefix() - let word = prefix.normalized() - strings(withPrefixWord: word, - prefix: word, - node: head, - accumulatedStrings: values) + guard let (prefixNode, storedPrefix) = node(reaching: word) else { + return [] + } - return - values - .compactMap { $0 as? String } - .sorted() + return strings(below: prefixNode, prefix: storedPrefix).sorted() } } @@ -97,64 +112,47 @@ class Trie { private extension Trie { - func remove(string: String, node: TrieNode? = nil) { - - guard let firstChar = string.firstCharacter, - let current = node?.children[firstChar] - else { - return - } + // 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)? { - let remainingString = string.byChompingHead() + var current = head + var storedPrefix: [Character] = [] - if !remainingString.hasMoreCharacters, current.isTerminating { - current.isTerminating = false - } - else { - remove(string: remainingString, node: current) + for character in word { + guard let next = current.children[character] else { + return nil + } + current = next + storedPrefix.append(next.value) } - if current.canDelete { - current.delete() - } + return (current, String(storedPrefix)) } - func strings(withPrefixWord word: String, - prefix: String, - node: TrieNode? = nil, - accumulatedCharacters: String = "", - accumulatedStrings: NSMutableSet) { + // Depth-first walk with an explicit stack, so long strings cannot overflow the call stack. + func strings(below start: TrieNode, prefix: String) -> [String] { - guard let node = node else { return } + var results: [String] = [] + var path: [Character] = [] + var pending: [(node: TrieNode, depth: Int)] = [(start, 0)] - let nodeValue = nil == node.parent ? "" : String(node.value) - let stringSoFar = "\(accumulatedCharacters)\(nodeValue)" - if node.isTerminating && stringSoFar.hasPrefix(word) { - accumulatedStrings.add(stringSoFar) - } + while let (node, depth) = pending.popLast() { - if !prefix.hasMoreCharacters { + path.removeLast(path.count - max(depth - 1, 0)) + if depth > 0 { + path.append(node.value) + } - for entry in node.children { - strings(withPrefixWord: word, - prefix: prefix, - node: entry.value, - accumulatedCharacters: stringSoFar, - accumulatedStrings: accumulatedStrings) + if node.isTerminating { + results.append(prefix + String(path)) } - return - } - guard let character = prefix.firstCharacter, - let child = node.children[character] else { - return + 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..1d33f01 100644 --- a/Sources/StringSearchKit/TrieNode.swift +++ b/Sources/StringSearchKit/TrieNode.swift @@ -52,6 +52,21 @@ 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. + // A node that something else still holds keeps its subtree. + deinit { + var descendants = Array(nodes.values) + nodes.removeAll() + + while var node = descendants.popLast() { + guard isKnownUniquelyReferenced(&node) else { + continue + } + descendants.append(contentsOf: node.nodes.values) + node.nodes.removeAll() + } + } + func delete() { parent?.nodes[value] = nil } diff --git a/Tests/StringSearchKitTests/String+AdditionsTests.swift b/Tests/StringSearchKitTests/String+AdditionsTests.swift index 925ecdf..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() { @@ -66,23 +48,29 @@ class String_AdditionsTests: XCTestCase { XCTAssertEqual(expected, actual) } - // MARK:- byChompingHead tests - - func testByChompingHead_EmptyString_ReturnsEmptyString() { - let expected = "" - let actual = "".byChompingHead() + 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) } - func testByChompingHead_A_ReturnsEmptyString() { - let expected = "" - let actual = "A".byChompingHead() + // MARK: - normalizedPrefix tests + + func testNormalizedPrefix_LeadingZeroWidthSpace_RemovesItLikeNormalized() { + let expected = "\u{200B}APP".normalized() + let actual = "\u{200B}APP".normalizedPrefix() XCTAssertEqual(expected, actual) } - func testByChompingHead_ABC_ReturnsBC() { - let expected = "Bc" - let actual = "ABc".byChompingHead() + func testNormalizedPrefix_StringWrappedInWhitespace_KeepsTrailingWhitespace() { + let expected = "new " + let actual = " NEW ".normalizedPrefix() XCTAssertEqual(expected, actual) } } diff --git a/Tests/StringSearchKitTests/StringDictionaryTests.swift b/Tests/StringSearchKitTests/StringDictionaryTests.swift index bb20e15..7324e3f 100644 --- a/Tests/StringSearchKitTests/StringDictionaryTests.swift +++ b/Tests/StringSearchKitTests/StringDictionaryTests.swift @@ -194,6 +194,104 @@ 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 testStringsWithPrefix_TrailingSpace_MatchesOnlyStringsWithThatSpace() { + let expected = ["new york"] + let wordDictionary = StringDictionary(withStrings: ["new york", "newark"]) + let actual = wordDictionary.strings(withPrefix: "new ") + + 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 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) + let actual = wordDictionary.strings() + + 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") + 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/StringLoaderTests.swift b/Tests/StringSearchKitTests/StringLoaderTests.swift index e6209db..ff4f20c 100644 --- a/Tests/StringSearchKitTests/StringLoaderTests.swift +++ b/Tests/StringSearchKitTests/StringLoaderTests.swift @@ -62,6 +62,27 @@ class StringLoaderTests: XCTestCase { XCTAssertEqual(expected, actual.count) } + func testLoadDictionary_CRLFAndBlankLines_ReturnsOnlyNonEmptyLines() throws { + let expected = ["apple", " banana ", "cherry"] + 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) + } + func testLoadDictionary_ExistingFilename_ReturnsPopulatedArray() { let expected = 13 @@ -83,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) + } } 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) + } } diff --git a/Tests/StringSearchKitTests/TrieTests.swift b/Tests/StringSearchKitTests/TrieTests.swift index c9e7eea..c81970b 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) @@ -139,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) @@ -148,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") @@ -158,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) @@ -167,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) @@ -176,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) @@ -185,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) @@ -194,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) @@ -203,10 +216,66 @@ 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) } + + 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. + 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 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 { + autoreleasepool { + let trie = Trie() + trie.add(string: String(repeating: "a", count: 20_000)) + } + finished.fulfill() + } + + wait(for: [finished], timeout: 30) + } }