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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 0 additions & 32 deletions Sources/StringSearchKit/Sequence+Additions.swift

This file was deleted.

16 changes: 10 additions & 6 deletions Sources/StringSearchKit/String+Additions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
52 changes: 19 additions & 33 deletions Sources/StringSearchKit/StringDictionary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -102,14 +103,15 @@ 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.
///
/// - 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
Expand All @@ -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
}
}
40 changes: 15 additions & 25 deletions Sources/StringSearchKit/StringLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,55 +32,45 @@ 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`
/// extension.
/// - 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)
}
}
Loading
Loading