99 lines
3.8 KiB
Swift
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)
|
|
}
|
|
}
|