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( 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) } }