├── .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 | [](https://github.com/Finnvoor/FindFaster/actions/workflows/CI.yml) [](https://swiftpackageindex.com/Finnvoor/FindFaster) [](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..