├── .spi.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.swift ├── .swiftformat ├── .github └── workflows │ └── CI.yml ├── README.md ├── Tests └── FindFasterTests │ └── FindFasterTests.swift ├── Sources └── FindFaster │ └── BidirectionalCollection+fastSearch.swift └── LICENSE /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [FindFaster] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "FindFaster", 7 | platforms: [.iOS(.v13), .macOS(.v10_15)], 8 | products: [.library(name: "FindFaster", targets: ["FindFaster"])], 9 | targets: [ 10 | .target(name: "FindFaster"), 11 | .testTarget(name: "FindFasterTests", dependencies: ["FindFaster"]), 12 | ] 13 | ) 14 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --enable blankLineAfterImports 2 | --enable blankLinesBetweenImports 3 | --enable blockComments 4 | --enable docComments 5 | --enable isEmpty 6 | --enable markTypes 7 | --enable organizeDeclarations 8 | 9 | --disable numberFormatting 10 | --disable redundantNilInit 11 | --disable trailingCommas 12 | --disable wrapMultilineStatementBraces 13 | 14 | --ifdef no-indent 15 | --funcattributes same-line 16 | --typeattributes same-line 17 | --varattributes same-line 18 | --ranges no-space 19 | --header strip 20 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | env: 12 | DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer 13 | 14 | jobs: 15 | build: 16 | name: Build 17 | runs-on: macOS-13 18 | strategy: 19 | matrix: 20 | destination: 21 | - "generic/platform=ios" 22 | - "platform=macOS" 23 | # - "generic/platform=xros" 24 | - "generic/platform=tvos" 25 | - "generic/platform=watchos" 26 | 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Install xcbeautify 30 | run: | 31 | brew update 32 | brew install xcbeautify 33 | - name: Build platform ${{ matrix.destination }} 34 | run: set -o pipefail && xcodebuild build -scheme FindFaster -destination "${{ matrix.destination }}" | xcbeautify --renderer github-actions 35 | test: 36 | name: Test 37 | runs-on: macOS-13 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Install xcbeautify 41 | run: | 42 | brew update 43 | brew install xcbeautify 44 | - name: Test 45 | run: set -o pipefail && xcodebuild test -scheme FindFaster -destination "platform=macOS" | xcbeautify --renderer github-actions 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FindFaster 2 | 3 | [![CI](https://github.com/Finnvoor/FindFaster/actions/workflows/CI.yml/badge.svg)](https://github.com/Finnvoor/FindFaster/actions/workflows/CI.yml) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFinnvoor%2FFindFaster%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/Finnvoor/FindFaster) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFinnvoor%2FFindFaster%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/Finnvoor/FindFaster) 4 | 5 | Fast asynchronous swift collection search using the [_Boyer–Moore string-search algorithm_](https://en.wikipedia.org/wiki/Boyer%E2%80%93Moore_string-search_algorithm). `fastSearch` can be used with any `BidirectionalCollection` where `Element` is `Hashable`, and is especially useful for searching large amounts of data or long strings and displaying the results as they come in. 6 | 7 | FindFaster is used for find and replace in [HextEdit](https://apps.apple.com/app/apple-store/id1557247094?pt=120542042&ct=github&mt=8), a fast and native macOS hex editor. 8 | 9 | ## Usage 10 | ### Async 11 | ```swift 12 | import FindFaster 13 | 14 | let text = "Lorem ipsum dolor sit amet" 15 | let search = "or" 16 | 17 | for await index in text.fastSearchStream(for: search) { 18 | print("Found match at: \(index)") 19 | } 20 | 21 | // Prints: 22 | // Found match at: 1 23 | // Found match at: 15 24 | ``` 25 | 26 | ### Sync 27 | ```swift 28 | import FindFaster 29 | 30 | let text = "Lorem ipsum dolor sit amet" 31 | let search = "or" 32 | 33 | let results = text.fastSearch(for: search) 34 | print("Results: \(results)") 35 | 36 | // Prints: 37 | // Results: [1, 15] 38 | ``` 39 | 40 | ### Closure-based 41 | ```swift 42 | import FindFaster 43 | 44 | let text = "Lorem ipsum dolor sit amet" 45 | let search = "or" 46 | 47 | text.fastSearch(for: search) { index in 48 | print("Found match at: \(index)") 49 | } 50 | 51 | // Prints: 52 | // Found match at: 1 53 | // Found match at: 15 54 | ``` 55 | -------------------------------------------------------------------------------- /Tests/FindFasterTests/FindFasterTests.swift: -------------------------------------------------------------------------------- 1 | @testable import FindFaster 2 | import XCTest 3 | 4 | final class FindFasterTests: XCTestCase { 5 | let collection1 = (0..<100) 6 | let collection2 = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris vel lacus in risus finibus semper vel eu magna. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque pulvinar gravida varius. Nulla facilisi. Nullam dignissim egestas pellentesque. Morbi nulla sem, porta eu feugiat vitae, faucibus sit amet tellus. Curabitur nunc ligula, scelerisque id rhoncus ac, facilisis quis odio. Pellentesque egestas luctus rutrum. Nam auctor, ligula auctor suscipit elementum, nunc nunc dignissim nibh, eget sollicitudin diam ligula eget ligula. Etiam sed est fermentum, fermentum leo et, vestibulum nisi. Vivamus vestibulum quam sed mattis volutpat. Suspendisse a mi gravida, placerat metus vel, euismod quam. Sed vehicula velit a justo porta eleifend. Sed fringilla auctor nisi elementum lobortis." 7 | 8 | func testSingleElementSearchSync() { 9 | let search = collection1.randomElement()! 10 | let results = collection1.fastSearch(for: search) 11 | XCTAssertEqual(results, [search]) 12 | } 13 | 14 | func testMultiElementSearchSync() { 15 | let search = "et" 16 | let results = collection2 17 | .fastSearch(for: search) 18 | .map { collection2.distance(from: collection2.startIndex, to: $0) } 19 | XCTAssertEqual(results, [24, 35, 142, 179, 343, 535, 565, 615, 717]) 20 | } 21 | 22 | func testSingleElementSearchClosure() { 23 | let search = collection1.randomElement()! 24 | var results: [Int] = [] 25 | collection1.fastSearch(for: search) { index in 26 | results.append(index) 27 | } 28 | XCTAssertEqual(results, [search]) 29 | } 30 | 31 | func testMultiElementSearchClosure() { 32 | let search = "et" 33 | var results: [Int] = [] 34 | collection2.fastSearch(for: search) { index in 35 | results.append(self.collection2.distance(from: self.collection2.startIndex, to: index)) 36 | } 37 | XCTAssertEqual(results, [24, 35, 142, 179, 343, 535, 565, 615, 717]) 38 | } 39 | 40 | func testSingleElementSearchAsync() async { 41 | let search = collection1.randomElement()! 42 | var results: [Int] = [] 43 | for await index in collection1.fastSearchStream(for: search) { 44 | results.append(index) 45 | } 46 | XCTAssertEqual(results, [search]) 47 | } 48 | 49 | func testMultiElementSearchAsync() async { 50 | let search = "et" 51 | var results: [Int] = [] 52 | for await index in collection2.fastSearchStream(for: search) { 53 | results.append(collection2.distance(from: collection2.startIndex, to: index)) 54 | XCTAssertEqual(String(collection2[index.. AsyncStream { 8 | fastSearchStream(for: [element]) 9 | } 10 | 11 | /// Returns an `AsyncStream` delivering indices where the specified sequence appears in the collection. 12 | /// - Parameter searchSequence: A sequence of elements to search for in the collection. 13 | /// - Returns: An `AsyncStream` delivering indices where `searchSequence` is found. 14 | func fastSearchStream(for searchSequence: some Collection) -> AsyncStream { 15 | AsyncStream { continuation in 16 | let task = Task { 17 | fastSearch(for: searchSequence) { index in 18 | continuation.yield(index) 19 | } 20 | continuation.finish() 21 | } 22 | continuation.onTermination = { _ in task.cancel() } 23 | } 24 | } 25 | 26 | /// Returns the indices where the specified value appears in the collection. 27 | /// - Parameters: 28 | /// - element: An element to search for in the collection. 29 | /// - onSearchResult: An optional closure that is called when a matching index is found. 30 | /// - Returns: The indices where `element` is found. If `element` is not found in the collection, returns an empty array. 31 | @discardableResult func fastSearch( 32 | for element: Element, 33 | onSearchResult: ((Index) -> Void)? = nil 34 | ) -> [Index] { 35 | fastSearch(for: [element], onSearchResult: onSearchResult) 36 | } 37 | 38 | /// Returns the indices where the specified sequence appears in the collection. 39 | /// - Parameters: 40 | /// - searchSequence: A sequence of elements to search for in the collection. 41 | /// - onSearchResult: An optional closure that is called when a matching index is found. 42 | /// - Returns: The indices where `searchSequence` is found. If `searchSequence` is not found in the collection, returns an empty array. 43 | @discardableResult func fastSearch( 44 | for searchSequence: some Collection, 45 | onSearchResult: ((Index) -> Void)? = nil 46 | ) -> [Index] { 47 | switch searchSequence.count { 48 | case 0: return [] 49 | case 1: return naiveSingleElementSearch(for: searchSequence.first!, onSearchResult: onSearchResult) 50 | default: return boyerMooreMultiElementSearch(for: searchSequence, onSearchResult: onSearchResult) 51 | } 52 | } 53 | } 54 | 55 | private extension BidirectionalCollection where Element: Equatable, Element: Hashable { 56 | @discardableResult func naiveSingleElementSearch( 57 | for element: Element, 58 | onSearchResult: ((Index) -> Void)? = nil 59 | ) -> [Index] { 60 | var indices: [Index] = [] 61 | var currentIndex = startIndex 62 | while currentIndex < endIndex, !Task.isCancelled { 63 | if self[currentIndex] == element { 64 | indices.append(currentIndex) 65 | onSearchResult?(currentIndex) 66 | } 67 | currentIndex = index(after: currentIndex) 68 | } 69 | return indices 70 | } 71 | 72 | /// Boyer–Moore algorithm 73 | @discardableResult func boyerMooreMultiElementSearch( 74 | for searchSequence: some Collection, 75 | onSearchResult: ((Index) -> Void)? = nil 76 | ) -> [Index] { 77 | guard searchSequence.count <= count else { return [] } 78 | 79 | var indices: [Index] = [] 80 | let skipTable: [Element: Int] = searchSequence 81 | .enumerated() 82 | .reduce(into: [:]) { $0[$1.element] = searchSequence.count - $1.offset - 1 } 83 | 84 | var currentIndex = index(startIndex, offsetBy: searchSequence.count - 1) 85 | while currentIndex < endIndex, !Task.isCancelled { 86 | let skip = skipTable[self[currentIndex]] ?? searchSequence.count 87 | if skip == 0 { 88 | let lowerBound = index(currentIndex, offsetBy: -searchSequence.count + 1) 89 | let upperBound = index(currentIndex, offsetBy: 1) 90 | if self[lowerBound..