Files

99 lines
3.8 KiB
Swift

import Foundation
public struct FuzzySearchText: Sendable {
private let foldedText: String
private let characters: [Character]
private let wordStarts: [Int]
public init(_ text: String) {
foldedText = Self.folded(text)
let characters = Array(foldedText)
self.characters = characters
var starts: [Int] = []
var previousWasWordCharacter = false
for index in characters.indices {
let isWordCharacter = characters[index].isLetter || characters[index].isNumber
if isWordCharacter && !previousWasWordCharacter { starts.append(index) }
previousWasWordCharacter = isWordCharacter
}
wordStarts = starts
}
public static func results<Value>(
for query: String, in values: [Value],
text: (Value) -> FuzzySearchText,
breakingTiesWith preferred: (Value, Value) -> Bool
) -> [Value] {
let query = folded(query.trimmingCharacters(in: .whitespacesAndNewlines))
guard !query.isEmpty else { return values.sorted(by: preferred) }
let characters = Array(query)
var ranked = values.compactMap { value -> (Value, Rank)? in
let searchable = text(value)
let name = searchable.foldedText
if name == query { return (value, Rank(category: 0)) }
if name.hasPrefix(query) { return (value, Rank(category: 1)) }
if searchable.wordStarts.contains(where: {
$0 > 0 && searchable.characters[$0...].starts(with: characters)
}) {
return (value, Rank(category: 2))
}
if matchesWordInitials(characters, in: searchable) {
return (value, Rank(category: 3))
}
if name.contains(query) { return (value, Rank(category: 4)) }
guard let rank = fuzzyRank(characters, in: searchable.characters) else { return nil }
return (value, rank)
}
ranked.sort { lhs, rhs in
if lhs.1 != rhs.1 { return lhs.1 < rhs.1 }
return preferred(lhs.0, rhs.0)
}
return ranked.map(\.0)
}
private struct Rank: Comparable {
let category: Int
var gaps: Int = 0
var start: Int = 0
static func < (lhs: Rank, rhs: Rank) -> Bool {
(lhs.category, lhs.gaps, lhs.start) < (rhs.category, rhs.gaps, rhs.start)
}
}
private static func matchesWordInitials(_ query: [Character], in text: FuzzySearchText) -> Bool {
var matched = 0
for start in text.wordStarts {
guard text.characters[start] == query[matched] else { continue }
matched += 1
if matched == query.count { return true }
}
return false
}
// ponytail: quadratic in text length; use a linear matcher if long text becomes searchable.
private static func fuzzyRank(_ query: [Character], in name: [Character]) -> Rank? {
guard query.count <= name.count else { return nil }
var best: Rank?
for start in name.indices where name[start] == query[0] {
var matched = 1
var end = start
while matched < query.count && end + 1 < name.count {
end += 1
if name[end] == query[matched] { matched += 1 }
}
guard matched == query.count else { continue }
let rank = Rank(category: 5, gaps: end - start + 1 - query.count, start: start)
if let best, best <= rank { continue }
best = rank
}
return best
}
private static let searchLocale = Locale(identifier: "en_US_POSIX")
private static func folded(_ string: String) -> String {
string.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: searchLocale)
}
}