diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..31ba163 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + DEVELOPER_DIR: /Applications/Xcode_15.0.app/Contents/Developer + +jobs: + build: + name: Build + runs-on: macOS-13 + strategy: + matrix: + destination: + - "generic/platform=ios" + - "platform=macOS" +# - "generic/platform=xros" + - "generic/platform=tvos" + - "generic/platform=watchos" + + steps: + - uses: actions/checkout@v3 + - name: Install xcbeautify + run: | + brew update + brew install xcbeautify + - name: Build platform ${{ matrix.destination }} + run: set -o pipefail && xcodebuild build -scheme FindFaster -destination "${{ matrix.destination }}" | xcbeautify --renderer github-actions + test: + name: Test + runs-on: macOS-13 + steps: + - uses: actions/checkout@v3 + - name: Install xcbeautify + run: | + brew update + brew install xcbeautify + - name: Test + run: set -o pipefail && xcodebuild test -scheme FindFaster -destination "platform=macOS" | xcbeautify --renderer github-actions diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..e0cf872 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [FindFaster] diff --git a/Package.swift b/Package.swift index 9665fac..625dc31 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.6 +// swift-tools-version: 5.7 import PackageDescription diff --git a/README.md b/README.md index 696b962..e049d96 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # FindFaster -[![](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) +[![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) +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. + +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. + +## Usage +### Async ```swift import FindFaster let text = "Lorem ipsum dolor sit amet" let search = "or" -for await index in text.fastSearch(for: search) { +for await index in text.fastSearchStream(for: search) { print("Found match at: \(index)") } @@ -17,6 +23,32 @@ for await index in text.fastSearch(for: search) { // Found match at: 15 ``` -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 `Equatable` and `Hashable`, and is especially useful for searching large amounts of data or long strings and displaying the results as they come in. +### Sync +```swift +import FindFaster -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. +let text = "Lorem ipsum dolor sit amet" +let search = "or" + +let results = text.fastSearch(for: search) +print("Results: \(results)") + +// Prints: +// Results: [1, 15] +``` + +### Closure-based +```swift +import FindFaster + +let text = "Lorem ipsum dolor sit amet" +let search = "or" + +text.fastSearch(for: search) { index in + print("Found match at: \(index)") +} + +// Prints: +// Found match at: 1 +// Found match at: 15 +``` diff --git a/Sources/FindFaster/BidirectionalCollection+fastSearch.swift b/Sources/FindFaster/BidirectionalCollection+fastSearch.swift index 528c171..c4a10e9 100644 --- a/Sources/FindFaster/BidirectionalCollection+fastSearch.swift +++ b/Sources/FindFaster/BidirectionalCollection+fastSearch.swift @@ -1,79 +1,111 @@ import Foundation -extension BidirectionalCollection where Element: Equatable, Element: Hashable { - public func fastSearch(for element: Self.Element) -> AsyncStream { - singleElementSearch(for: element) +public extension BidirectionalCollection where Element: Equatable, Element: Hashable { + /// Returns an `AsyncStream` delivering indices where the specified value appears in the collection. + /// - Parameter element: An element to search for in the collection. + /// - Returns: An `AsyncStream` delivering indices where `element` is found. + func fastSearchStream(for element: Element) -> AsyncStream { + fastSearchStream(for: [element]) } - public func fastSearch(for searchSequence: some Collection) -> AsyncStream { - switch searchSequence.count { - case 0: AsyncStream { $0.finish() } - case 1: singleElementSearch(for: searchSequence[searchSequence.startIndex]) - default: multiElementSearch(for: searchSequence) - } - } - - private func singleElementSearch(for element: Element) -> AsyncStream { + /// Returns an `AsyncStream` delivering indices where the specified sequence appears in the collection. + /// - Parameter searchSequence: A sequence of elements to search for in the collection. + /// - Returns: An `AsyncStream` delivering indices where `searchSequence` is found. + func fastSearchStream(for searchSequence: some Collection) -> AsyncStream { AsyncStream { continuation in let task = Task { - var currentIndex = startIndex - while currentIndex < endIndex, !Task.isCancelled { - if self[currentIndex] == element { - continuation.yield(currentIndex) - } - currentIndex = index(after: currentIndex) + fastSearch(for: searchSequence) { index in + continuation.yield(index) } continuation.finish() } - continuation.onTermination = { _ in - task.cancel() + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Returns the indices where the specified value appears in the collection. + /// - Parameters: + /// - element: An element to search for in the collection. + /// - onSearchResult: An optional closure that is called when a matching index is found. + /// - Returns: The indices where `element` is found. If `element` is not found in the collection, returns an empty array. + @discardableResult func fastSearch( + for element: Element, + onSearchResult: ((Index) -> Void)? = nil + ) -> [Index] { + fastSearch(for: [element], onSearchResult: onSearchResult) + } + + /// Returns the indices where the specified sequence appears in the collection. + /// - Parameters: + /// - searchSequence: A sequence of elements to search for in the collection. + /// - onSearchResult: An optional closure that is called when a matching index is found. + /// - Returns: The indices where `searchSequence` is found. If `searchSequence` is not found in the collection, returns an empty array. + @discardableResult func fastSearch( + for searchSequence: some Collection, + onSearchResult: ((Index) -> Void)? = nil + ) -> [Index] { + switch searchSequence.count { + case 0: return [] + case 1: return naiveSingleElementSearch(for: searchSequence.first!, onSearchResult: onSearchResult) + default: return boyerMooreMultiElementSearch(for: searchSequence, onSearchResult: onSearchResult) + } + } +} + +private extension BidirectionalCollection where Element: Equatable, Element: Hashable { + @discardableResult func naiveSingleElementSearch( + for element: Element, + onSearchResult: ((Index) -> Void)? = nil + ) -> [Index] { + var indices: [Index] = [] + var currentIndex = startIndex + while currentIndex < endIndex, !Task.isCancelled { + if self[currentIndex] == element { + indices.append(currentIndex) + onSearchResult?(currentIndex) } + currentIndex = index(after: currentIndex) } + return indices } /// Boyer–Moore algorithm - private func multiElementSearch(for searchSequence: some Collection) -> AsyncStream { - AsyncStream { continuation in - guard !searchSequence.isEmpty, searchSequence.count <= count else { - continuation.finish() - return - } + @discardableResult func boyerMooreMultiElementSearch( + for searchSequence: some Collection, + onSearchResult: ((Index) -> Void)? = nil + ) -> [Index] { + guard searchSequence.count <= count else { return [] } - let task = Task { - let skipTable: [Element: Int] = searchSequence - .enumerated() - .reduce(into: [:]) { $0[$1.element] = searchSequence.count - $1.offset - 1 } + var indices: [Index] = [] + let skipTable: [Element: Int] = searchSequence + .enumerated() + .reduce(into: [:]) { $0[$1.element] = searchSequence.count - $1.offset - 1 } - var currentIndex = index(startIndex, offsetBy: searchSequence.count - 1) - while currentIndex < endIndex, !Task.isCancelled { - let skip = skipTable[self[currentIndex]] ?? searchSequence.count - if skip == 0 { - let lowerBound = index(currentIndex, offsetBy: -searchSequence.count + 1) - let upperBound = index(currentIndex, offsetBy: 1) - if self[lowerBound..