├── .editorconfig ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .spi.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── CODE_OF_CONDUCT.md ├── Images ├── Apps │ ├── CrossCraft.webp │ ├── FocusBeats.webp │ ├── FreelanceKit.webp │ ├── FreemiumKit.webp │ ├── GuidedGuestMode.webp │ ├── PleydiaOrganizer.webp │ ├── Posters.webp │ └── TranslateKit.webp ├── Docs.webp └── HandySwift.png ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── HandySwift │ ├── Extensions │ ├── ArrayExt.swift │ ├── CaseIterableExt.swift │ ├── CollectionExt.swift │ ├── ComparableExt.swift │ ├── DataExt.swift │ ├── DateExt.swift │ ├── DictionaryExt.swift │ ├── DispatchTimeIntervalExt.swift │ ├── DoubleExt.swift │ ├── DurationExt.swift │ ├── FloatExt.swift │ ├── IntExt.swift │ ├── JSONDecoderExt.swift │ ├── JSONEncoderExt.swift │ ├── NSRangeExt.swift │ ├── RandomAccessCollectionExt.swift │ ├── SequenceExt.swift │ ├── StringExt.swift │ ├── StringProtocolExt.swift │ ├── SymmetricKeyExt.swift │ └── TimeIntervalExt.swift │ ├── Globals.swift │ ├── HandySwift.docc │ ├── Essentials │ │ ├── Extensions.md │ │ └── New Types.md │ ├── HandySwift.md │ ├── Resources │ │ ├── Extensions.jpeg │ │ ├── Extensions │ │ │ ├── APIKeys.png │ │ │ ├── CrosswordGeneration.png │ │ │ ├── MusicPlayer.jpeg │ │ │ ├── PremiumPlanExpires.png │ │ │ ├── ProgressBar.jpeg │ │ │ └── SharePuzzle.png │ │ ├── HandySwift.png │ │ └── NewTypes.jpeg │ └── theme-settings.json │ ├── Protocols │ ├── AutoConforming.swift │ ├── DivisibleArithmetic.swift │ └── Withable.swift │ └── Types │ ├── Debouncer.swift │ ├── FrequencyTable.swift │ ├── GregorianDay.swift │ ├── GregorianTime.swift │ ├── HandyRegex.swift │ ├── OperatingSystem.swift │ ├── RESTClient.swift │ └── SortedArray.swift └── Tests └── HandySwiftTests ├── Extensions ├── ArrayExtTests.swift ├── CollectionExtTests.swift ├── ComparableExtTests.swift ├── DictionaryExtTests.swift ├── DispatchTimeIntervalExtTests.swift ├── DoubleExtTests.swift ├── FloatExtTests.swift ├── IntExtTests.swift ├── NSObjectExtTests.swift ├── NSRangeExtTests.swift ├── StringExtTests.swift └── TimeIntervalExtTests.swift ├── Protocols └── WithableTests.swift └── Structs ├── FrequencyTableTests.swift ├── GregorianDayTests.swift ├── HandyRegexTests.swift └── SortedArrayTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | 6 | indent_style = space 7 | tab_width = 6 8 | indent_size = 3 9 | 10 | end_of_line = lf 11 | insert_final_newline = true 12 | 13 | max_line_length = 160 14 | trim_trailing_whitespace = true 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main, versions] 6 | 7 | pull_request: 8 | branches: [main] 9 | 10 | jobs: 11 | test-linux: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Run tests 18 | run: swift test 19 | 20 | test-macos: 21 | runs-on: macos-15 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Run tests 27 | run: swift test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | 3 | # General 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Icon must end with two \r 9 | Icon 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # Xcode 31 | # 32 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 33 | 34 | ## Build generated 35 | build/ 36 | DerivedData 37 | 38 | ## Various settings 39 | *.pbxuser 40 | !default.pbxuser 41 | *.mode1v3 42 | !default.mode1v3 43 | *.mode2v3 44 | !default.mode2v3 45 | *.perspectivev3 46 | !default.perspectivev3 47 | xcuserdata 48 | 49 | ## Other 50 | *.xccheckout 51 | *.moved-aside 52 | *.xcuserstate 53 | *.xcscmblueprint 54 | 55 | ## Obj-C/Swift specific 56 | *.hmap 57 | *.ipa 58 | 59 | # Swift Package Manager 60 | # 61 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 62 | # Packages/ 63 | .build/ 64 | 65 | # CocoaPods 66 | # 67 | # We recommend against adding the Pods directory to your .gitignore. However 68 | # you should judge for yourself, the pros and cons are mentioned at: 69 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 70 | # 71 | # Pods/ 72 | 73 | # Carthage 74 | # 75 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 76 | # Carthage/Checkouts 77 | 78 | Carthage/Build 79 | 80 | # fastlane 81 | # 82 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 83 | # screenshots whenever they are needed. 84 | # For more information about the recommended setup visit: 85 | # https://github.com/fastlane/fastlane/blob/master/docs/Gitignore.md 86 | 87 | fastlane/report.xml 88 | fastlane/screenshots 89 | HandySwift.framework.zip 90 | docs/ 91 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [HandySwift] 5 | swift_version: 6.0 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at github [at] fline [dot] dev. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Images/Apps/CrossCraft.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/CrossCraft.webp -------------------------------------------------------------------------------- /Images/Apps/FocusBeats.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/FocusBeats.webp -------------------------------------------------------------------------------- /Images/Apps/FreelanceKit.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/FreelanceKit.webp -------------------------------------------------------------------------------- /Images/Apps/FreemiumKit.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/FreemiumKit.webp -------------------------------------------------------------------------------- /Images/Apps/GuidedGuestMode.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/GuidedGuestMode.webp -------------------------------------------------------------------------------- /Images/Apps/PleydiaOrganizer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/PleydiaOrganizer.webp -------------------------------------------------------------------------------- /Images/Apps/Posters.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/Posters.webp -------------------------------------------------------------------------------- /Images/Apps/TranslateKit.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Apps/TranslateKit.webp -------------------------------------------------------------------------------- /Images/Docs.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/Docs.webp -------------------------------------------------------------------------------- /Images/HandySwift.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Images/HandySwift.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015-2024 FlineDev (alias Cihat Gündüz) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "HandySwift", 6 | platforms: [.iOS(.v12), .macOS(.v10_14), .tvOS(.v13), .visionOS(.v1), .watchOS(.v6)], 7 | products: [.library(name: "HandySwift", targets: ["HandySwift"])], 8 | targets: [ 9 | .target(name: "HandySwift"), 10 | .testTarget(name: "HandySwiftTests", dependencies: ["HandySwift"]), 11 | ] 12 | ) 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFlineDev%2FHandySwift%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/FlineDev/HandySwift) 4 | 5 | # HandySwift 6 | 7 | The goal of this library is to **provide handy features** that didn't make it into the Swift standard library (yet). 8 | 9 | Checkout [HandySwiftUI](https://github.com/FlineDev/HandySwiftUI) for handy UI features that should have been part of SwiftUI in the first place. 10 | 11 | 12 | ## Documentation 13 | 14 | Learn how you can make the most of HandySwift by reading the guides inside the documentation: 15 | 16 | [📖 Open HandySwift Documentation](https://swiftpackageindex.com/FlineDev/HandySwift/documentation/handyswift) 17 | 18 | 19 | 20 | 21 | 22 | 23 | ## Showcase 24 | 25 | I extracted this library from my following Indie apps (rate them with 5 stars to thank me!): 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 39 | 46 | 47 | 48 | 49 | 54 | 61 | 62 | 63 | 64 | 69 | 76 | 77 | 78 | 79 | 84 | 91 | 92 | 93 | 94 | 99 | 106 | 107 | 108 | 109 | 114 | 121 | 122 | 123 | 124 | 129 | 136 | 137 | 138 | 139 | 144 | 151 | 152 | 153 |
App IconApp Name & DescriptionSupported Platforms
35 | 36 | 37 | 38 | 40 | 41 | TranslateKit: App Localizer 42 | 43 |
44 | Simple drag & drop translation of String Catalog files with support for multiple translation services & smart correctness checks. 45 |
Mac
50 | 51 | 52 | 53 | 55 | 56 | Pleydia Organizer: Movie & Series Renamer 57 | 58 |
59 | Simple, fast, and smart media management for your Movie, TV Show and Anime collection. 60 |
Mac
65 | 66 | 67 | 68 | 70 | 71 | FreemiumKit: In-App Purchases 72 | 73 |
74 | Simple In-App Purchases and Subscriptions for Apple Platforms: Automation, Paywalls, A/B Testing, Live Notifications, PPP, and more. 75 |
iPhone, iPad, Mac, Vision
80 | 81 | 82 | 83 | 85 | 86 | FreelanceKit: Time Tracking 87 | 88 |
89 | Simple & affordable time tracking with a native experience for all  devices. iCloud sync & CSV export included. 90 |
iPhone, iPad, Mac, Vision
95 | 96 | 97 | 98 | 100 | 101 | CrossCraft: Custom Crosswords 102 | 103 |
104 | Create themed & personalized crosswords. Solve them yourself or share them to challenge others. 105 |
iPhone, iPad, Mac, Vision
110 | 111 | 112 | 113 | 115 | 116 | FocusBeats: Pomodoro + Music 117 | 118 |
119 | Deep Focus with proven Pomodoro method & select Apple Music playlists & themes. Automatically pauses music during breaks. 120 |
iPhone, iPad, Mac, Vision
125 | 126 | 127 | 128 | 130 | 131 | Guided Guest Mode 132 | 133 |
134 | Showcase Apple Vision Pro effortlessly to friends & family. Customizable, easy-to-use guides for everyone! 135 |
Vision
140 | 141 | 142 | 143 | 145 | 146 | Posters: Discover Movies at Home 147 | 148 |
149 | Auto-updating & interactive posters for your home with trailers, showtimes, and links to streaming services. 150 |
Vision
154 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/ArrayExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // - MARK: Migration 4 | extension Array { 5 | @available(*, unavailable, renamed: "sort(by:)", message: "Since SE-0372 shipped in Swift 5.8 `sort(by:)` is officially stable. Just remove the `stable` parameter.") 6 | public mutating func sort(by areInIncreasingOrder: @escaping (Element, Element) -> Bool, stable: Bool) { fatalError() } 7 | 8 | @available(*, unavailable, renamed: "sorted(by:)", message: "Since SE-0372 shipped in Swift 5.8 `sorted(by:)` is officially stable. Just remove the `stable` parameter.") 9 | public func sorted(by areInIncreasingOrder: @escaping (Element, Element) -> Bool, stable: Bool) -> [Element] { fatalError() } 10 | } 11 | 12 | extension Array where Element: Comparable { 13 | @available(*, unavailable, renamed: "sort()", message: "Since SE-0372 shipped in Swift 5.8 `sort()` is officially stable. Just remove the `stable` parameter.") 14 | public mutating func sort(stable: Bool) { fatalError() } 15 | 16 | @available(*, unavailable, renamed: "sorted()", message: "Since SE-0372 shipped in Swift 5.8 `sorted()` is officially stable. Just remove the `stable` parameter.") 17 | public func sorted(stable: Bool) -> [Element] { fatalError() } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/CaseIterableExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension CaseIterable { 4 | /// Returns an array containing all cases of the conforming type, with `nil` prefixed. 5 | /// This can be especially useful in SwiftUI Pickers where you want to offer an option 6 | /// to not select any value. For example, in a Picker view for selecting an optional 7 | /// category, prefixing with `nil` allows users to explicitly select 'None' as an option. 8 | /// 9 | /// Example for SwiftUI: 10 | /// ```swift 11 | /// enum Category: String, CaseIterable { 12 | /// case books, movies, music 13 | /// } 14 | /// 15 | /// @State private var selectedCategory: Category? = nil 16 | /// 17 | /// var body: some View { 18 | /// Picker("Category", selection: $selectedCategory) { 19 | /// Text("None").tag(Category?.none) 20 | /// ForEach(Category.allCasesPrefixedByNil, id: \.self) { category in 21 | /// Text(category?.rawValue.capitalized ?? "None").tag(category as Category?) 22 | /// } 23 | /// } 24 | /// } 25 | /// ``` 26 | /// - Returns: An array of optional values including `nil` followed by all cases of the type. 27 | public static var allCasesPrefixedByNil: [Self?] { 28 | [.none] + self.allCases.map(Optional.init) 29 | } 30 | 31 | /// Returns an array containing all cases of the conforming type, with `nil` suffixed. 32 | /// While similar to `allCasesPrefixedByNil`, this variation is useful when the 'None' 33 | /// option is more logically placed at the end of the list in the UI. This can align with 34 | /// user expectations in certain contexts, where selecting 'None' or 'Not specified' is 35 | /// considered an action taken after reviewing all available options. 36 | /// 37 | /// Example for SwiftUI: 38 | /// ```swift 39 | /// enum Level: String, CaseIterable { 40 | /// case beginner, intermediate, advanced 41 | /// } 42 | /// 43 | /// @State private var selectedLevel: Level? = nil 44 | /// 45 | /// var body: some View { 46 | /// Picker("Level", selection: $selectedLevel) { 47 | /// ForEach(Level.allCasesSuffixedByNil, id: \.self) { level in 48 | /// Text(level?.rawValue.capitalized ?? "None").tag(level as Level?) 49 | /// } 50 | /// } 51 | /// } 52 | /// ``` 53 | /// - Returns: An array of optional values including all cases of the type followed by `nil`. 54 | public static var allCasesSuffixedByNil: [Self?] { 55 | self.allCases.map(Optional.init) + [.none] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/CollectionExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Collection { 4 | /// Returns the element at the specified index or `nil` if no element at the given index exists. 5 | /// This is particularly useful when dealing with collections of elements where you're unsure if an index is within the collection's bounds. 6 | /// It helps prevent crashes due to accessing an index out of bounds. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// let testArray = [0, 1, 2, 3, 20] 11 | /// let element = testArray[safe: 4] // => Optional(20) 12 | /// let outOfBoundsElement = testArray[safe: 20] // => nil 13 | /// ``` 14 | /// 15 | /// - Parameter index: The index of the element to retrieve. 16 | /// - Returns: The element at the specified index, or `nil` if the index is out of bounds. 17 | @inlinable 18 | public subscript(safe index: Index) -> Element? { 19 | self.indices.contains(index) ? self[index] : nil 20 | } 21 | 22 | /// Splits the collection into smaller chunks of the specified size. 23 | /// 24 | /// This method is useful for processing large datasets in manageable pieces, such as logging data, sending network requests, or performing batch updates. 25 | /// 26 | /// - Parameter size: The size of each chunk. Must be greater than 0. 27 | /// - Returns: An array of arrays, where each subarray represents a chunk of elements. The last chunk may contain fewer elements if the collection cannot be evenly divided. 28 | /// 29 | /// - Example: 30 | /// ```swift 31 | /// let numbers = Array(1...10) 32 | /// let chunks = numbers.chunks(ofSize: 3) 33 | /// print(chunks) 34 | /// // Output: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]] 35 | /// ``` 36 | /// 37 | /// - Example: Processing items in chunks 38 | /// ```swift 39 | /// let tasks = ["Task1", "Task2", "Task3", "Task4"] 40 | /// for chunk in tasks.chunks(ofSize: 2) { 41 | /// for task in chunk { 42 | /// task.perform() 43 | /// } 44 | /// 45 | /// print("Processed chunk of tasks: \(chunk)") 46 | /// } 47 | /// // Output: 48 | /// // Processed chunk of tasks: ["Task1", "Task2"] 49 | /// // Processed chunk of tasks: ["Task3", "Task4"] 50 | /// ``` 51 | public func chunks(ofSize size: Int) -> [[Element]] { 52 | guard size > 0 else { fatalError("Chunk size must be greater than 0.") } 53 | 54 | var result: [[Element]] = [] 55 | var chunk: [Element] = [] 56 | var count = 0 57 | 58 | for element in self { 59 | chunk.append(element) 60 | count += 1 61 | if count == size { 62 | result.append(chunk) 63 | chunk.removeAll(keepingCapacity: true) 64 | count = 0 65 | } 66 | } 67 | 68 | if !chunk.isEmpty { 69 | result.append(chunk) 70 | } 71 | 72 | return result 73 | } 74 | } 75 | 76 | extension Collection where Element: DivisibleArithmetic { 77 | /// Returns the average of all elements. It sums up all the elements and then divides by the count of the collection. 78 | /// This method requires that the element type conforms to `DivisibleArithmetic`, which includes numeric types such as `Int`, `Double`, etc. 79 | /// 80 | /// Example: 81 | /// ```swift 82 | /// let numbers: [Double] = [10.75, 20.75, 30.25, 40.25] 83 | /// let averageValue = numbers.average() // => 25.5 84 | /// ``` 85 | /// 86 | /// - Returns: The average value of all elements in the collection. 87 | @inlinable 88 | public func average() -> Element { 89 | self.sum() / Element(self.count) 90 | } 91 | } 92 | 93 | extension Collection where Element == Int { 94 | /// Returns the average of all elements as a Double value. 95 | /// This is useful for `Int` collections where the precision of the average calculation is important and you prefer the result to be a `Double`. 96 | /// 97 | /// Example: 98 | /// ```swift 99 | /// let numbers = [10, 20, 30, 40] 100 | /// let averageValue: Double = numbers.average() // => 25.0 101 | /// ``` 102 | /// 103 | /// - Returns: The average value of all elements in the collection as a Double. 104 | @inlinable 105 | public func average() -> ReturnType { 106 | ReturnType(self.sum()) / ReturnType(self.count) 107 | } 108 | } 109 | 110 | // MARK: Migration 111 | extension Collection { 112 | @available(*, unavailable, renamed: "subscript(safe:)") 113 | public subscript(try index: Index) -> Element? { fatalError() } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/ComparableExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Comparable { 4 | /// Returns `self` clamped to the given closed range limits. 5 | /// This method ensures that the value remains within a specific range. 6 | /// If the value is outside the range, it's adjusted to the nearest boundary of the range. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// let myNum = 3 11 | /// let clampedNum = myNum.clamped(to: 0 ... 6) // => 3 12 | /// let clampedNumBelow = myNum.clamped(to: 0 ... 2) // => 2 13 | /// let clampedNumAbove = myNum.clamped(to: 4 ... 6) // => 4 14 | /// ``` 15 | /// 16 | /// - Parameter limits: The closed range determining the minimum and maximum value. 17 | /// - Returns: 18 | /// - `self`, if it is inside the given limits. 19 | /// - `lowerBound` of the given limits, if `self` is smaller than it. 20 | /// - `upperBound` of the given limits, if `self` is greater than it. 21 | @inlinable 22 | public func clamped(to limits: ClosedRange) -> Self { 23 | if limits.lowerBound > self { 24 | limits.lowerBound 25 | } else if limits.upperBound < self { 26 | limits.upperBound 27 | } else { 28 | self 29 | } 30 | } 31 | 32 | /// Returns `self` clamped to the given partial range (from) limits. This method ensures that the value does not fall below a specified minimum. 33 | /// 34 | /// Example: 35 | /// ```swift 36 | /// let myNum = 3 37 | /// let clampedNum = myNum.clamped(to: 5...) // => 5 38 | /// ``` 39 | /// 40 | /// - Parameter limits: The partial range (from) determining the minimum value. 41 | /// - Returns: 42 | /// - `self`, if it is inside the given limits. 43 | /// - `lowerBound` of the given limits, if `self` is smaller than it. 44 | @inlinable 45 | public func clamped(to limits: PartialRangeFrom) -> Self { 46 | limits.lowerBound > self ? limits.lowerBound : self 47 | } 48 | 49 | /// Returns `self` clamped to the given partial range (through) limits. 50 | /// This method ensures that the value does not exceed a specified maximum. 51 | /// 52 | /// Example: 53 | /// ```swift 54 | /// let myNum = 7 55 | /// let clampedNum = myNum.clamped(to: ...5) // => 5 56 | /// ``` 57 | /// 58 | /// - Parameter limits: The partial range (through) determining the maximum value. 59 | /// - Returns: 60 | /// - `self`, if it is inside the given limits. 61 | /// - `upperBound` of the given limits, if `self` is greater than it. 62 | @inlinable 63 | public func clamped(to limits: PartialRangeThrough) -> Self { 64 | limits.upperBound < self ? limits.upperBound : self 65 | } 66 | 67 | /// Clamps `self` to the given closed range limits. 68 | /// Modifies the original value to ensure it falls within a specific range, adjusting it to the nearest boundary if necessary. 69 | /// 70 | /// Example: 71 | /// ```swift 72 | /// var myNum = 3 73 | /// myNum.clamp(to: 0...2) 74 | /// print(myNum) // => 2 75 | /// ``` 76 | /// 77 | /// - Parameter limits: The closed range determining minimum and maximum value. 78 | @inlinable 79 | public mutating func clamp(to limits: ClosedRange) { 80 | self = clamped(to: limits) 81 | } 82 | 83 | /// Clamps `self` to the given partial range (from) limits. 84 | /// Modifies the original value to ensure it does not fall below a specified minimum. 85 | /// 86 | /// Example: 87 | /// ```swift 88 | /// var myNum = 3 89 | /// myNum.clamp(to: 5...) 90 | /// print(myNum) // => 5 91 | /// ``` 92 | /// 93 | /// - Parameter limits: The partial range (from) determining the minimum value. 94 | @inlinable 95 | public mutating func clamp(to limits: PartialRangeFrom) { 96 | self = clamped(to: limits) 97 | } 98 | 99 | /// Clamps `self` to the given partial range (through) limits. 100 | /// Modifies the original value to ensure it does not exceed a specified maximum. 101 | /// 102 | /// Example: 103 | /// ```swift 104 | /// var myNum = 7 105 | /// myNum.clamp(to: ...5) 106 | /// print(myNum) // => 5 107 | /// ``` 108 | /// 109 | /// - `self`, if it is inside the given limits. 110 | /// - `upperBound` of the given limits, if `self` is greater than it. 111 | /// 112 | /// - Parameter limits: The partial range (through) determining the maximum value. 113 | @inlinable 114 | public mutating func clamp(to limits: PartialRangeThrough) { 115 | self = clamped(to: limits) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/DataExt.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CryptoKit) 2 | import CryptoKit 3 | import Foundation 4 | 5 | extension Data { 6 | /// Encrypts this plain `Data` using AES.GCM with the provided key. 7 | /// This method is useful for encrypting data before securely storing or transmitting it. 8 | /// Ensure that the `SymmetricKey` used for encryption is securely managed and stored. 9 | /// 10 | /// Example: 11 | /// ```swift 12 | /// let key = SymmetricKey(size: .bits256) 13 | /// let plainData = "Harry Potter is a 🧙".data(using: .utf8)! 14 | /// do { 15 | /// let encryptedData = try plainData.encrypted(key: key) 16 | /// // Use encryptedData as needed 17 | /// } catch { 18 | /// print("Encryption failed: \(error)") 19 | /// } 20 | /// ``` 21 | /// 22 | /// - Parameters: 23 | /// - key: The symmetric key to use for encryption. 24 | /// - Returns: The encrypted `Data`. 25 | /// - Throws: An error if encryption fails. 26 | /// - Note: Available on iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, and later. 27 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 28 | public func encrypted(key: SymmetricKey) throws -> Data { 29 | try AES.GCM.seal(self, using: key).combined! 30 | } 31 | 32 | /// Decrypts this encrypted data using AES.GCM with the provided key. 33 | /// This method is crucial for converting encrypted data back to its original form securely. 34 | /// Ensure the `SymmetricKey` used matches the one used for encryption. 35 | /// 36 | /// Example: 37 | /// ```swift 38 | /// let key = SymmetricKey(size: .bits256) 39 | /// // Assuming encryptedData is the Data we previously encrypted 40 | /// do { 41 | /// let decryptedData = try encryptedData.decrypted(key: key) 42 | /// let decryptedString = String(data: decryptedData, encoding: .utf8)! 43 | /// // Use decryptedString or decryptedData as needed 44 | /// } catch { 45 | /// print("Decryption failed: \(error)") 46 | /// } 47 | /// ``` 48 | /// 49 | /// - Parameters: 50 | /// - key: The symmetric key to use for decryption. 51 | /// - Returns: The decrypted `Data`. 52 | /// - Throws: An error if decryption fails. 53 | /// - Note: Available on iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, and later. 54 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 55 | public func decrypted(key: SymmetricKey) throws -> Data { 56 | let sealedBox = try AES.GCM.SealedBox(combined: self) 57 | return try AES.GCM.open(sealedBox, using: key) 58 | } 59 | } 60 | #endif 61 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/DateExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Date { 4 | /// Creates a new Date by combining a ``GregorianDay`` with a ``GregorianTime``. 5 | /// 6 | /// This initializer allows you to create a Date instance from separate day and time components. 7 | /// The resulting date will be in the specified timezone (defaulting to the current timezone). 8 | /// 9 | /// Example: 10 | /// ```swift 11 | /// let day = GregorianDay(year: 2024, month: 3, day: 21) 12 | /// let time = GregorianTime(hour: 14, minute: 30) 13 | /// let eventTime = Date(day: day, time: time) 14 | /// ``` 15 | /// 16 | /// - Parameters: 17 | /// - day: The GregorianDay representing the date components. 18 | /// - time: The GregorianTime representing the time components. 19 | /// - timeZone: The timezone to use for the date creation. Defaults to the current timezone. 20 | public init(day: GregorianDay, time: GregorianTime, timeZone: TimeZone = .current) { 21 | self = time.date(day: day, timeZone: timeZone) 22 | } 23 | 24 | /// Returns a date offset by the specified time interval from this date to the past. 25 | /// This method is useful when you need to calculate a date that occurred a certain duration before the current date instance. 26 | /// For example, if you want to find out what the date was 2 hours ago from a given date, you can use this method by passing the time interval for 2 hours in seconds. 27 | /// 28 | /// Example: 29 | /// ```swift 30 | /// let now = Date() 31 | /// let twoHoursInSeconds: TimeInterval = 2 * 60 * 60 32 | /// let twoHoursAgo = now.reversed(by: twoHoursInSeconds) 33 | /// print("Two hours ago: \(twoHoursAgo)") 34 | /// ``` 35 | /// 36 | /// - Parameter interval: The time interval offset to subtract from this date. 37 | /// - Returns: A date offset by subtracting the specified time interval from this date. 38 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 39 | public func reversed(by interval: TimeInterval) -> Date { 40 | self.advanced(by: -interval) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/DictionaryExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Dictionary { 4 | /// Initializes a new `Dictionary` and populates it with keys and values arrays. 5 | /// This method is particularly useful when you have separate arrays of keys and values and you need to combine them into a single dictionary. 6 | /// It ensures that each key is mapped to its corresponding value based on their order in the arrays. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// let names = ["firstName", "lastName"] 11 | /// let values = ["Harry", "Potter"] 12 | /// let person = Dictionary(keys: names, values: values) 13 | /// // => ["firstName": "Harry", "lastName": "Potter"] 14 | /// ``` 15 | /// 16 | /// - Parameters: 17 | /// - keys: An array containing keys to be added to the dictionary. 18 | /// - values: An array containing values corresponding to the keys. 19 | /// 20 | /// - Requires: The number of elements in `keys` must be equal to the number of elements in `values`. 21 | /// 22 | /// - Returns: A new dictionary initialized with the provided keys and values arrays, or `nil` if the number of elements in the arrays differs. 23 | @inlinable 24 | public init?(keys: [Key], values: [Value]) { 25 | guard keys.count == values.count else { return nil } 26 | self.init() 27 | for (index, key) in keys.enumerated() { self[key] = values[index] } 28 | } 29 | 30 | /// Transforms the keys of the dictionary using the given closure, returning a new dictionary with the transformed keys. 31 | /// 32 | /// - Parameter transform: A closure that takes a key from the dictionary as its argument and returns a new key. 33 | /// - Returns: A dictionary with keys transformed by the `transform` closure and the same values as the original dictionary. 34 | /// - Throws: Rethrows any error thrown by the `transform` closure. 35 | /// 36 | /// - Warning: If the `transform` closure produces duplicate keys, the values of earlier keys will be overridden by the values of later keys in the resulting dictionary. 37 | /// 38 | /// - Example: 39 | /// ``` 40 | /// let originalDict = ["one": 1, "two": 2, "three": 3] 41 | /// let transformedDict = originalDict.mapKeys { $0.uppercased() } 42 | /// // transformedDict will be ["ONE": 1, "TWO": 2, "THREE": 3] 43 | /// ``` 44 | @inlinable 45 | public func mapKeys(_ transform: (Key) throws -> K) rethrows -> [K: Value] { 46 | var transformedDict: [K: Value] = [:] 47 | for (key, value) in self { 48 | transformedDict[try transform(key)] = value 49 | } 50 | return transformedDict 51 | } 52 | } 53 | 54 | // - MARK: Migration 55 | extension Dictionary { 56 | @available(*, unavailable, renamed: "merge(_:uniquingKeysWith:)", message: "Append `{ $1 }` as a `uniquingKeysWith` trailing closure to migrate.") 57 | public mutating func merge(_ other: [Key: Value]) { fatalError() } 58 | 59 | @available(*, unavailable, renamed: "merging(_:uniquingKeysWith:)", message: "Remove the `with:` label and append `{ $1 }` as a `uniquingKeysWith` trailing closure to migrate.") 60 | public func merged(with other: [Key: Value]) -> [Key: Value] { fatalError() } 61 | } 62 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/DispatchTimeIntervalExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension DispatchTimeInterval { 4 | /// Converts the dispatch time interval to seconds using the `TimeInterval` type. 5 | /// This is useful for when you need to work with `DispatchTimeInterval` values in contexts that require `TimeInterval` (in seconds), 6 | /// such as scheduling timers, animations, or any operations that are based on seconds. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// let delay = DispatchTimeInterval.seconds(5) 11 | /// Timer.scheduledTimer(withTimeInterval: delay.timeInterval, repeats: false) { _ in 12 | /// print("Timer fired after 5 seconds.") 13 | /// } 14 | /// ``` 15 | /// 16 | /// - Returns: The time interval in seconds. For `.never`, returns `TimeInterval.infinity` to represent an indefinite time interval. 17 | public var timeInterval: TimeInterval { 18 | switch self { 19 | case let .seconds(seconds): 20 | Double(seconds) 21 | 22 | case let .milliseconds(milliseconds): 23 | Double(milliseconds) / TimeInterval.millisecondsPerSecond 24 | 25 | case let .microseconds(microseconds): 26 | Double(microseconds) / TimeInterval.microsecondsPerSecond 27 | 28 | case let .nanoseconds(nanoseconds): 29 | Double(nanoseconds) / TimeInterval.nanosecondsPerSecond 30 | 31 | case .never: 32 | TimeInterval.infinity 33 | 34 | @unknown default: 35 | fatalError("Unknown DispatchTimeInterval unit.") 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/DoubleExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Double { 4 | /// Rounds the value to an integral value using the specified number of fraction digits and rounding rule. 5 | /// This is useful for when you need precise control over the rounding behavior of floating-point calculations, 6 | /// such as in financial calculations where rounding to a specific number of decimal places is required. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// var price: Double = 2.875 11 | /// price.round(fractionDigits: 2) // price becomes 2.88 12 | /// 13 | /// // Using a different rounding rule: 14 | /// price.round(fractionDigits: 2, rule: .down) // price becomes 2.87 15 | /// ``` 16 | /// 17 | /// - Parameters: 18 | /// - fractionDigits: The number of fraction digits to round to. 19 | /// - rule: The rounding rule to apply. Default is `.toNearestOrAwayFromZero`. 20 | /// 21 | /// - Note: Dropping the `rule` parameter will default to “schoolbook rounding”. 22 | public mutating func round(fractionDigits: Int, rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) { 23 | let divisor = pow(10.0, Double(fractionDigits)) 24 | self = (self * divisor).rounded(rule) / divisor 25 | } 26 | 27 | /// Returns this value rounded to an integral value using the specified number of fraction digits and rounding rule. 28 | /// This method does not mutate the original value but instead returns a new `Double` that is the result of the rounding operation, 29 | /// making it suitable for cases where the original value must remain unchanged. 30 | /// 31 | /// Example: 32 | /// ```swift 33 | /// let price: Double = 2.875 34 | /// let roundedPrice = price.rounded(fractionDigits: 2) // => 2.88 35 | /// 36 | /// // Using a different rounding rule: 37 | /// let roundedDownPrice = price.rounded(fractionDigits: 2, rule: .down) // => 2.87 38 | /// ``` 39 | /// 40 | /// - Parameters: 41 | /// - fractionDigits: The number of fraction digits to round to. 42 | /// - rule: The rounding rule to apply. Default is `.toNearestOrAwayFromZero`. 43 | /// 44 | /// - Note: Dropping the `rule` parameter will default to “schoolbook rounding”. 45 | /// 46 | /// - Returns: The rounded value. 47 | public func rounded(fractionDigits: Int, rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Double { 48 | let divisor = pow(10.0, Double(fractionDigits)) 49 | return (self * divisor).rounded(rule) / divisor 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/DurationExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 4 | extension Duration { 5 | /// Returns the duration as a `TimeInterval`. 6 | /// 7 | /// This can be useful for interfacing with APIs that require `TimeInterval` (which is measured in seconds), allowing you to convert a `Duration` directly to the needed format. 8 | /// 9 | /// Example: 10 | /// ```swift 11 | /// let duration = Duration.hours(2) 12 | /// let timeInterval = duration.timeInterval // Converts to TimeInterval for compatibility 13 | /// ``` 14 | /// 15 | /// - Returns: The duration as a `TimeInterval`, which represents the duration in seconds. 16 | public var timeInterval: TimeInterval { 17 | TimeInterval(self.components.seconds) + (TimeInterval(self.components.attoseconds) / 1_000_000_000_000_000_000) 18 | } 19 | 20 | /// Constructs a `Duration` given a number of weeks represented as a `BinaryInteger`. 21 | /// 22 | /// This method can be particularly useful when working with durations that span several weeks, such as project timelines or subscription periods. 23 | /// 24 | /// Example: 25 | /// ```swift 26 | /// let threeWeeksDuration = Duration.weeks(3) // Creates a Duration of 3 weeks 27 | /// ``` 28 | /// 29 | /// - Parameter weeks: The number of weeks. 30 | /// - Returns: A `Duration` representing the given number of weeks. 31 | public static func weeks(_ weeks: T) -> Duration { 32 | self.days(weeks * 7) 33 | } 34 | 35 | /// Constructs a `Duration` given a number of days represented as a `BinaryInteger`. 36 | /// 37 | /// Useful for creating durations for events, reminders, or deadlines that are a specific number of days in the future. 38 | /// 39 | /// Example: 40 | /// ```swift 41 | /// let tenDaysDuration = Duration.days(10) // Creates a Duration of 10 days 42 | /// ``` 43 | /// 44 | /// - Parameter days: The number of days. 45 | /// - Returns: A `Duration` representing the given number of days. 46 | public static func days(_ days: T) -> Duration { 47 | self.hours(days * 24) 48 | } 49 | 50 | /// Constructs a `Duration` given a number of hours represented as a `BinaryInteger`. 51 | /// 52 | /// Can be used to schedule events or tasks that are several hours long. 53 | /// 54 | /// Example: 55 | /// ```swift 56 | /// let eightHoursDuration = Duration.hours(8) // Creates a Duration of 8 hours 57 | /// ``` 58 | /// 59 | /// - Parameter hours: The number of hours. 60 | /// - Returns: A `Duration` representing the given number of hours. 61 | public static func hours(_ hours: T) -> Duration { 62 | self.minutes(hours * 60) 63 | } 64 | 65 | /// Constructs a `Duration` given a number of minutes represented as a `BinaryInteger`. 66 | /// 67 | /// This is helpful for precise time measurements, such as cooking timers, short breaks, or meeting durations. 68 | /// 69 | /// Example: 70 | /// ```swift 71 | /// let fifteenMinutesDuration = Duration.minutes(15) // Creates a Duration of 15 minutes 72 | /// ``` 73 | /// 74 | /// - Parameter minutes: The number of minutes. 75 | /// - Returns: A `Duration` representing the given number of minutes. 76 | public static func minutes(_ minutes: T) -> Duration { 77 | self.seconds(minutes * 60) 78 | } 79 | 80 | /// Formats the duration to a human-readable string with auto-scaling. 81 | /// 82 | /// This function takes a duration and formats it into a string that represents the duration in the largest non-zero unit, scaling from seconds up to days. 83 | /// The output is dynamically scaled to provide a succinct and easily understandable representation of the duration. 84 | /// 85 | /// Examples: 86 | /// - For a duration of 45 seconds, the output will be `"45s"`. 87 | /// - For a duration of 150 minutes, the output will be `"2h 30m"` as it scales up from minutes to hours and minutes. 88 | /// - For a duration of 25 hours, the output will be `"1d 1h"`, scaling up to days and hours. 89 | /// - For a duration of 2 days and 0 hours, the output will be `"2d"` as it omits the zero hour part. 90 | /// 91 | /// - Returns: A formatted string representing the duration. If the duration is less than a second, it returns `"???"`. For zero returns `"0s"`. 92 | public func autoscaleFormatted() -> String { 93 | guard self != .zero else { return "0s" } 94 | 95 | var leftoverTimeInterval = self.timeInterval 96 | let fullDays = Int(leftoverTimeInterval.days) 97 | 98 | leftoverTimeInterval -= .days(Double(fullDays)) 99 | let fullHours = Int(leftoverTimeInterval.hours) 100 | 101 | leftoverTimeInterval -= .hours(Double(fullHours)) 102 | let fullMinutes = Int(leftoverTimeInterval.minutes) 103 | 104 | leftoverTimeInterval -= .minutes(Double(fullMinutes)) 105 | let fullSeconds = Int(leftoverTimeInterval.seconds) 106 | 107 | if fullDays > 0 { 108 | guard fullHours != 0 else { return "\(fullDays)d" } 109 | return "\(fullDays)d \(fullHours)h" 110 | } else if fullHours > 0 { 111 | guard fullMinutes != 0 else { return "\(fullHours)h" } 112 | return "\(fullHours)h \(fullMinutes)m" 113 | } else if fullMinutes > 0 { 114 | guard fullSeconds != 0 else { return "\(fullMinutes)m" } 115 | return "\(fullMinutes)m \(fullSeconds)s" 116 | } else if fullSeconds > 0 { 117 | return "\(fullSeconds)s" 118 | } else { 119 | return "???" 120 | } 121 | } 122 | } 123 | 124 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 125 | extension Duration { 126 | /// Multiplies the duration by a given factor. This can be useful when scaling animations or timer intervals proportionally. 127 | /// 128 | /// Example: 129 | /// ```swift 130 | /// let originalDuration = Duration.seconds(2) 131 | /// let doubledDuration = originalDuration.multiplied(by: 2) // Doubles the duration to 4 seconds 132 | /// ``` 133 | /// 134 | /// - Parameter factor: The multiplication factor. 135 | /// - Returns: A `Duration` representing the multiplied duration. 136 | public func multiplied(by factor: Double) -> Duration { 137 | (self.timeInterval * factor).duration() 138 | } 139 | 140 | /// Multiplies the duration by a given factor, allowing for integer factors. This is convenient for simple multiplications where decimal precision isn't needed. 141 | /// 142 | /// Example: 143 | /// ```swift 144 | /// let originalDuration = Duration.seconds(3) 145 | /// let tripledDuration = originalDuration.multiplied(by: 3) // Triples the duration to 9 seconds 146 | /// ``` 147 | /// 148 | /// - Parameter factor: The multiplication factor. 149 | /// - Returns: A `Duration` representing the multiplied duration. 150 | public func multiplied(by factor: Int) -> Duration { 151 | self.multiplied(by: Double(factor)) 152 | } 153 | 154 | /// Divides the duration by a given denominator, useful for reducing animation durations or calculating partial intervals. 155 | /// 156 | /// Example: 157 | /// ```swift 158 | /// let originalDuration = Duration.seconds(10) 159 | /// let halvedDuration = originalDuration.divided(by: 2) // Halves the duration to 5 seconds 160 | /// ``` 161 | /// 162 | /// - Parameter denominator: The denominator. 163 | /// - Returns: A `Duration` representing the divided duration. 164 | public func divided(by denominator: Double) -> Duration { 165 | (self.timeInterval / denominator).duration() 166 | } 167 | 168 | /// Divides the duration by a given denominator using an integer value. This method simplifies division when working with whole numbers. 169 | /// 170 | /// Example: 171 | /// ```swift 172 | /// let originalDuration = Duration.seconds(8) 173 | /// let quarteredDuration = originalDuration.divided(by: 4) // Divides the duration to 2 seconds 174 | /// ``` 175 | /// 176 | /// - Parameter denominator: The denominator. 177 | /// - Returns: A `Duration` representing the divided duration. 178 | public func divided(by denominator: Int) -> Duration { 179 | self.divided(by: Double(denominator)) 180 | } 181 | 182 | /// Divides the duration by another duration, returning the ratio of the two. This can be used to compare durations or compute proportions. 183 | /// 184 | /// Example: 185 | /// ```swift 186 | /// let durationA = Duration.seconds(5) 187 | /// let durationB = Duration.seconds(2) 188 | /// let ratio = durationA.divided(by: durationB) // Calculates the ratio, which is 2.5 189 | /// ``` 190 | /// 191 | /// - Parameter duration: The duration to divide by. 192 | /// - Returns: The ratio of the two durations. 193 | public func divided(by duration: Duration) -> Double { 194 | self.timeInterval / duration.timeInterval 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/FloatExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Float { 4 | /// Rounds the value to an integral value using the specified number of fraction digits and rounding rule. 5 | /// This is useful for when you need precise control over the formatting of floating-point numbers, 6 | /// for example, when displaying currency or other numerical calculations that require a specific number of decimal places. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// var price: Float = 2.875 11 | /// price.round(fractionDigits: 2) // => 2.88 12 | /// 13 | /// // Using a specific rounding rule: 14 | /// price.round(fractionDigits: 2, rule: .down) // => 2.87 15 | /// ``` 16 | /// 17 | /// - Parameters: 18 | /// - fractionDigits: The number of fraction digits to round to. 19 | /// - rule: The rounding rule to use. Defaults to `.toNearestOrAwayFromZero`. 20 | /// 21 | /// - Note: Dropping the `rule` parameter will default to “schoolbook rounding”. 22 | public mutating func round(fractionDigits: Int, rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) { 23 | let divisor = pow(10.0, Float(fractionDigits)) 24 | self = (self * divisor).rounded(rule) / divisor 25 | } 26 | 27 | /// Returns this value rounded to an integral value using the specified number of fraction digits and rounding rule. 28 | /// Similar to `round`, but this method does not modify the original value and instead returns a new `Float` with the rounded value. 29 | /// This is particularly handy in functional programming paradigms where immutability is preferred. 30 | /// 31 | /// Example: 32 | /// ```swift 33 | /// let originalPrice: Float = 2.875 34 | /// let roundedPrice = originalPrice.rounded(fractionDigits: 2) // => 2.88 35 | /// 36 | /// // With explicit rounding rule: 37 | /// let roundedDownPrice = originalPrice.rounded(fractionDigits: 2, rule: .down) // => 2.87 38 | /// ``` 39 | /// 40 | /// - Parameters: 41 | /// - fractionDigits: The number of fraction digits to round to. 42 | /// - rule: The rounding rule to use. Defaults to `.toNearestOrAwayFromZero`. 43 | /// 44 | /// - Note: Dropping the `rule` parameter will default to “schoolbook rounding”. 45 | public func rounded(fractionDigits: Int, rule: FloatingPointRoundingRule = .toNearestOrAwayFromZero) -> Float { 46 | let divisor = pow(10.0, Float(fractionDigits)) 47 | return (self * divisor).rounded(rule) / divisor 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/IntExt.swift: -------------------------------------------------------------------------------- 1 | extension Int { 2 | /// Runs the code passed as a closure the specified number of times. 3 | /// This method is useful for repeating an action multiple times, such as logging a message or incrementing a value. 4 | /// It guards against negative values, ensuring the closure is only run for positive counts. 5 | /// 6 | /// Example: 7 | /// ```swift 8 | /// 3.times { print("Hello World!") } 9 | /// // This will print "Hello World!" 3 times. 10 | /// ``` 11 | /// 12 | /// - Parameters: 13 | /// - closure: The code to be run multiple times. 14 | /// - Throws: Any error thrown by the closure. 15 | @inlinable 16 | public func times(_ closure: () throws -> Void) rethrows { 17 | guard self > 0 else { return } 18 | for _ in 0..(_ closure: () throws -> ReturnType) rethrows -> [ReturnType] { 36 | guard self > 0 else { return [] } 37 | return try (0..(randomBelow upperLimit: Int, using generator: inout Generator) { fatalError() } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/JSONDecoderExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension JSONDecoder { 4 | /// A pre-configured JSONDecoder that automatically converts snake_case JSON property names 5 | /// to camelCase when decoding into Swift types 6 | /// 7 | /// This decoder handles incoming JSON with snake_case keys and converts them to match 8 | /// Swift's camelCase property naming convention. 9 | /// 10 | /// Example usage: 11 | /// ``` 12 | /// let jsonString = """ 13 | /// { 14 | /// "first_name": "John", 15 | /// "last_name": "Doe" 16 | /// } 17 | /// """ 18 | /// let user = try JSONDecoder.snakeCase.decode(User.self, from: jsonData) 19 | /// // Results in: User(firstName: "John", lastName: "Doe") 20 | /// ``` 21 | public static var snakeCase: JSONDecoder { 22 | let decoder = JSONDecoder() 23 | decoder.keyDecodingStrategy = .convertFromSnakeCase 24 | return decoder 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/JSONEncoderExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension JSONEncoder { 4 | /// A pre-configured JSONEncoder that automatically converts Swift's camelCase property names 5 | /// to snake_case when encoding JSON 6 | /// 7 | /// Instead of creating a new encoder and configuring it each time, this provides a ready-to-use 8 | /// encoder with snake_case conversion. 9 | /// 10 | /// Example usage: 11 | /// ``` 12 | /// let user = User(firstName: "John", lastName: "Doe") 13 | /// let jsonData = try JSONEncoder.snakeCase.encode(user) 14 | /// // Results in: {"first_name": "John", "last_name": "Doe"} 15 | /// ``` 16 | public static var snakeCase: JSONEncoder { 17 | let encoder = JSONEncoder() 18 | encoder.keyEncodingStrategy = .convertToSnakeCase 19 | return encoder 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/NSRangeExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension NSRange { 4 | /// Initializes an NSRange from a Swift String.Range when the String is provided. 5 | /// This is useful for operations that require NSRange, such as string manipulation with `NSRegularExpression`, or when working with UIKit components that deal with attributed strings. 6 | /// 7 | /// Example: 8 | /// ```swift 9 | /// let string = "Hello World!" 10 | /// let swiftRange = string.startIndex.., in string: String) { 20 | self.init() 21 | self.location = string.utf16.distance(from: string.startIndex, to: range.lowerBound) 22 | self.length = string.utf16.distance(from: range.lowerBound, to: range.upperBound) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/RandomAccessCollectionExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension RandomAccessCollection where Index == Int { 4 | /// Returns a given number of random elements from the collection. 5 | /// This method is useful when you need a subset of elements for sampling, testing, or any other case where random selection from a collection is required. 6 | /// If the collection is empty, `nil` is returned instead. 7 | /// 8 | /// Example: 9 | /// ```swift 10 | /// let numbers = [1, 2, 3, 4, 5] 11 | /// if let randomNumbers = numbers.randomElements(count: 3) { 12 | /// print(randomNumbers) 13 | /// } 14 | /// // Output: [2, 1, 4] (example output, actual output will vary) 15 | /// ``` 16 | /// 17 | /// - Parameters: 18 | /// - count: The number of random elements wanted. 19 | /// - Returns: An array with the given number of random elements or `nil` if the collection is empty. 20 | @inlinable 21 | public func randomElements(count: Int) -> [Element]? { 22 | guard !self.isEmpty else { return nil } 23 | return count.timesMake { self.randomElement()! } 24 | } 25 | } 26 | 27 | // - MARK: Migration 28 | extension RandomAccessCollection where Index == Int { 29 | @available(*, unavailable, renamed: "randomElement()") 30 | public var sample: Element? { fatalError() } 31 | 32 | @available(*, unavailable, renamed: "randomElements(count:)") 33 | public func sample(size: Int) -> [Element]? { fatalError() } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/StringExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(CryptoKit) 3 | import CryptoKit 4 | #endif 5 | 6 | extension String { 7 | /// Checks if the string contains any characters other than whitespace or newline characters. 8 | /// This can be useful for validating input fields where a non-empty value is required. 9 | /// 10 | /// Example: 11 | /// ```swift 12 | /// " \t ".isBlank // => true 13 | /// "Hello".isBlank // => false 14 | /// ``` 15 | /// 16 | /// - Returns: `true` if the string contains non-whitespace characters, `false` otherwise. 17 | public var isBlank: Bool { self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } 18 | 19 | /// Returns the range containing the full string. 20 | /// Useful for operations that require a `Range`, such as modifications and substring extraction. 21 | /// 22 | /// Example: 23 | /// ```swift 24 | /// let unicodeString = "Hello composed unicode symbols! 👨‍👩‍👧‍👦👨‍👨‍👦‍👦👩‍👩‍👧‍👧" 25 | /// unicodeString[unicodeString.fullRange] // => same string 26 | /// ``` 27 | /// 28 | /// - Returns: The range representing the full string. 29 | public var fullRange: Range { 30 | self.startIndex.. NSRange representing the full string 40 | /// ``` 41 | /// 42 | /// - Returns: The NSRange representation of the full string range. 43 | public var fullNSRange: NSRange { 44 | NSRange(fullRange, in: self) 45 | } 46 | 47 | /// Creates a new instance with a random numeric/alphabetic/alphanumeric string of given length. 48 | /// This is useful for generating random identifiers, test data, or any scenario where random strings are needed. 49 | /// 50 | /// Examples: 51 | /// ```swift 52 | /// String(randomWithLength: 4, allowedCharactersType: .numeric) // => "8503" 53 | /// String(randomWithLength: 6, allowedCharactersType: .alphabetic) // => "ysTUzU" 54 | /// String(randomWithLength: 8, allowedCharactersType: .alphaNumeric) // => "2TgM5sUG" 55 | /// ``` 56 | /// 57 | /// - Parameters: 58 | /// - randomWithLength: The length of the random string to create. 59 | /// - allowedCharactersType: The type of allowed characters, see enum ``AllowedCharacters``. 60 | public init(randomWithLength length: Int, allowedCharactersType: AllowedCharacters) { 61 | let allowedCharsString: String = { 62 | switch allowedCharactersType { 63 | case .numeric: 64 | return "0123456789" 65 | 66 | case .alphabetic: 67 | return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 68 | 69 | case .alphaNumeric: 70 | return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 71 | 72 | case let .allCharactersIn(allowedCharactersString): 73 | return allowedCharactersString 74 | } 75 | }() 76 | 77 | self.init(allowedCharsString.randomElements(count: length)!) 78 | } 79 | 80 | /// Returns a given number of random characters from the string. Useful for scenarios like sampling characters or generating substrings from a set of allowed characters. 81 | /// 82 | /// Example: 83 | /// ```swift 84 | /// let allowedChars = "abcdefghijklmnopqrstuvwxyz" 85 | /// let randomChars = allowedChars.randomElements(count: 5) // Example output: "xkqoi" 86 | /// ``` 87 | /// 88 | /// - Parameters: 89 | /// - count: The number of random characters wanted. 90 | /// - Returns: A string with the given number of random characters or `nil` if empty. 91 | @inlinable 92 | public func randomElements(count: Int) -> String? { 93 | guard !self.isEmpty else { return nil } 94 | return String(count.timesMake { self.randomElement()! }) 95 | } 96 | } 97 | 98 | extension String { 99 | /// The type of allowed characters. 100 | /// This is used in conjunction with `init(randomWithLength:allowedCharactersType:)` to specify the characters that can be included in the random string. 101 | public enum AllowedCharacters { 102 | /// Allow all numbers from 0 to 9. Useful for numeric identifiers or pin codes. 103 | case numeric 104 | /// Allow all alphabetic characters ignoring case. Useful for textual data where numbers are not needed. 105 | case alphabetic 106 | /// Allow both numbers and alphabetic characters ignoring case. Useful for alphanumeric identifiers. 107 | case alphaNumeric 108 | /// Allow all characters appearing within the specified string. This gives you full control over the characters that can appear in the random string. 109 | case allCharactersIn(String) 110 | } 111 | } 112 | 113 | #if canImport(CryptoKit) 114 | extension String { 115 | /// Error types that may occur during cryptographic operations. 116 | public enum CryptingError: LocalizedError { 117 | case convertingStringToDataFailed 118 | case decryptingDataFailed 119 | case convertingDataToStringFailed 120 | 121 | public var errorDescription: String? { 122 | switch self { 123 | case .convertingDataToStringFailed: 124 | return "Converting Data to String failed." 125 | 126 | case .decryptingDataFailed: 127 | return "Decrypting Data failed." 128 | 129 | case .convertingStringToDataFailed: 130 | return "Converting String to Data failed." 131 | } 132 | } 133 | } 134 | 135 | /// Encrypts this plain text `String` with the given key using AES.GCM and returns a base64 encoded representation of the encrypted data. 136 | /// This method is useful for securing sensitive information before storing or transmitting it. 137 | /// 138 | /// Example: 139 | /// ```swift 140 | /// let key = SymmetricKey(size: .bits256) 141 | /// let plainText = "Sensitive information" 142 | /// let encryptedString = try plainText.encrypted(key: key) 143 | /// print(encryptedString) // Encrypted base64 string 144 | /// ``` 145 | /// 146 | /// - Parameter key: The symmetric key used for encryption. 147 | /// - Returns: A base64 encoded representation of the encrypted data. 148 | /// - Throws: A ``CryptingError`` if encryption fails. 149 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 150 | public func encrypted(key: SymmetricKey) throws -> String { 151 | guard let plainData = self.data(using: .utf8) else { 152 | throw CryptingError.convertingStringToDataFailed 153 | } 154 | 155 | let encryptedData = try plainData.encrypted(key: key) 156 | return encryptedData.base64EncodedString() 157 | } 158 | 159 | /// Decrypts this base64 encoded representation of encrypted data with the given key using AES.GCM and returns the decrypted plain text `String`. 160 | /// This method allows the secure transmission or storage of sensitive information to be reversed, returning the original plain text. 161 | /// 162 | /// Example: 163 | /// ```swift 164 | /// let key = SymmetricKey(size: .bits256) 165 | /// let encryptedString = "Base64EncodedEncryptedString" 166 | /// let decryptedString = try encryptedString.decrypted(key: key) 167 | /// print(decryptedString) // Original sensitive information 168 | /// ``` 169 | /// 170 | /// - Parameter key: The symmetric key used for decryption. 171 | /// - Returns: The decrypted plain text `String`. 172 | /// - Throws: A ``CryptingError`` if decryption fails. 173 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 174 | public func decrypted(key: SymmetricKey) throws -> String { 175 | guard let encryptedData = Data(base64Encoded: self) else { 176 | throw CryptingError.decryptingDataFailed 177 | } 178 | 179 | let plainData = try encryptedData.decrypted(key: key) 180 | guard let plainString = String(data: plainData, encoding: .utf8) else { 181 | throw CryptingError.convertingDataToStringFailed 182 | } 183 | 184 | return plainString 185 | } 186 | 187 | /// Splits the String into word tokens that are folded for case-insensitive, diacritics-insensitive, and width-insensitive operations such as search. 188 | /// This is particularly useful for string normalization in search queries, where the goal is to match strings regardless of their case, diacritics, or full-width/half-width characters. 189 | /// 190 | /// - Parameter locale: Optional. The locale to use for the folding operation. If `nil`, the system's current locale is used. This affects the folding behavior, especially for diacritics. 191 | /// - Returns: An array of normalized, tokenized strings. 192 | /// 193 | /// ## Example: 194 | /// ``` 195 | /// let sentence = "Café au lait" 196 | /// let tokens = sentence.tokenized() 197 | /// print(tokens) // Output: ["cafe", "au", "lait"] 198 | /// ``` 199 | public func tokenized(locale: Locale? = nil) -> [String] { 200 | self.components(separatedBy: .whitespacesAndNewlines).map { word in 201 | word.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale) 202 | } 203 | } 204 | 205 | /// Splits both the current string and the search text into word tokens and performs a case-insensitive, diacritics-insensitive search. 206 | /// It matches the start of each token in the search text with the tokens in the current string, making it suitable for prefix-based search queries. 207 | /// 208 | /// - Parameters: 209 | /// - searchText: The text to search for within this String. 210 | /// - locale: Optional. The locale to use for the insensitivity folding operation. If `nil`, the system's current locale is used. This can impact how characters are folded for comparison. 211 | /// - Returns: `true` if all tokens from the search text are prefixes of any token in this String; otherwise, `false`. 212 | /// 213 | /// ## Example: 214 | /// ``` 215 | /// let text = "Terms and Conditions" 216 | /// let searchResult = text.matchesTokenizedPrefixes(in: "ter con") 217 | /// print(searchResult) // Output: true 218 | /// ``` 219 | public func matchesTokenizedPrefixes(in searchText: String, locale: Locale? = nil) -> Bool { 220 | let tokens = self.tokenized(locale: locale) 221 | return searchText.tokenized(locale: locale).allSatisfy { searchToken in 222 | tokens.contains { $0.hasPrefix(searchToken) } 223 | } 224 | } 225 | } 226 | #endif 227 | 228 | 229 | // - MARK: Migration 230 | extension String { 231 | @available(*, unavailable, renamed: "randomElement()") 232 | public var sample: Character? { fatalError() } 233 | 234 | @available(*, unavailable, renamed: "trimmingCharacters(in:)", message: "Pass `.whitespacesAndNewlines` to the functions `in` parameter for same behavior.") 235 | public func stripped() -> String { fatalError() } 236 | 237 | @available(*, unavailable, renamed: "randomElements(count:)") 238 | public func sample(size: Int) -> String? { fatalError() } 239 | } 240 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/StringProtocolExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension StringProtocol { 4 | /// Returns a variation of the string with the first character uppercased. 5 | /// This is useful for formatting text that needs to start with a capital letter, such as titles or names, while preserving the case of the rest of the string. 6 | /// 7 | /// Example: 8 | /// ```swift 9 | /// let exampleString = "hello World" 10 | /// print(exampleString.firstUppercased) // Hello World 11 | /// ``` 12 | /// 13 | /// - Returns: A string with the first character converted to uppercase. 14 | public var firstUppercased: String { self.prefix(1).uppercased() + self.dropFirst() } 15 | 16 | /// Returns a variation of the string with the first character lowercased. 17 | /// This can be useful in scenarios where a string starts with an uppercase letter but needs to be integrated into a sentence or phrase seamlessly. 18 | /// 19 | /// Example: 20 | /// ```swift 21 | /// let exampleString = "Hello world" 22 | /// print(exampleString.firstLowercased) // hello world 23 | /// ``` 24 | /// 25 | /// - Returns: A string with the first character converted to lowercase and the rest unchanged. 26 | public var firstLowercased: String { self.prefix(1).lowercased() + self.dropFirst() } 27 | } 28 | 29 | // - MARK: Migration 30 | extension StringProtocol { 31 | @available(*, unavailable, renamed: "firstUppercased") 32 | public var firstCapitalized: String { fatalError() } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/SymmetricKeyExt.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CryptoKit) 2 | import Foundation 3 | import CryptoKit 4 | 5 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 6 | extension SymmetricKey { 7 | /// Returns a Base64-encoded string representation of the symmetric key. This can be useful for storing the key in a format that can be easily transmitted or stored. 8 | /// 9 | /// Example: 10 | /// ```swift 11 | /// let key = SymmetricKey(size: .bits256) 12 | /// let base64String = key.base64EncodedString 13 | /// // Now `base64String` can be stored or transmitted, and later used to recreate the SymmetricKey. 14 | /// ``` 15 | /// 16 | /// - Returns: A Base64-encoded string representation of the symmetric key. 17 | public var base64EncodedString: String { 18 | withUnsafeBytes { Data($0).base64EncodedString() } 19 | } 20 | 21 | /// Initializes a symmetric key from a Base64-encoded string. This is particularly useful for reconstructing a symmetric key from a stored or transmitted Base64-encoded string. 22 | /// 23 | /// Example: 24 | /// ```swift 25 | /// let base64String = "your_base64_encoded_string_here" 26 | /// if let key = SymmetricKey(base64Encoded: base64String) { 27 | /// // Use `key` here. 28 | /// } else { 29 | /// // Handle the error: the provided string was not a valid Base64-encoded SymmetricKey. 30 | /// } 31 | /// ``` 32 | /// 33 | /// - Parameter base64Encoded: The Base64-encoded string representing the symmetric key. 34 | /// - Returns: A symmetric key initialized from the Base64-encoded string, or `nil` if the input string is invalid. 35 | public init?(base64Encoded: String) { 36 | guard let data = Data(base64Encoded: base64Encoded) else { return nil } 37 | self.init(data: data) 38 | } 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/TimeIntervalExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension TimeInterval { 4 | /// The number of seconds in a day. 5 | @usableFromInline 6 | internal static var secondsPerDay: Double { 24 * 60 * 60 } 7 | 8 | /// The number of seconds in an hour. 9 | @usableFromInline 10 | internal static var secondsPerHour: Double { 60 * 60 } 11 | 12 | /// The number of seconds in a minute. 13 | @usableFromInline 14 | internal static var secondsPerMinute: Double { 60 } 15 | 16 | /// The number of milliseconds in a second. 17 | @usableFromInline 18 | internal static var millisecondsPerSecond: Double { 1_000 } 19 | 20 | /// The number of microseconds in a second. 21 | @usableFromInline 22 | internal static var microsecondsPerSecond: Double { 1_000 * 1_000 } 23 | 24 | /// The number of nanoseconds in a second. 25 | @usableFromInline 26 | internal static var nanosecondsPerSecond: Double { 1_000 * 1_000 * 1_000 } 27 | 28 | /// Returns the time interval converted to days. 29 | /// 30 | /// - Returns: The `TimeInterval` in days. 31 | @inlinable 32 | public var days: Double { 33 | self / TimeInterval.secondsPerDay 34 | } 35 | 36 | /// Returns the time interval converted to hours. 37 | /// 38 | /// - Returns: The `TimeInterval` in hours. 39 | @inlinable 40 | public var hours: Double { 41 | self / TimeInterval.secondsPerHour 42 | } 43 | 44 | /// Returns the time interval converted to minutes. 45 | /// 46 | /// - Returns: The `TimeInterval` in minutes. 47 | @inlinable 48 | public var minutes: Double { 49 | self / TimeInterval.secondsPerMinute 50 | } 51 | 52 | /// Returns the time interval as is, representing seconds. 53 | /// 54 | /// - Returns: The `TimeInterval` in seconds. 55 | @inlinable 56 | public var seconds: Double { 57 | self 58 | } 59 | 60 | /// Returns the time interval converted to milliseconds. 61 | /// 62 | /// - Returns: The `TimeInterval` in milliseconds. 63 | @inlinable 64 | public var milliseconds: Double { 65 | self * TimeInterval.millisecondsPerSecond 66 | } 67 | 68 | /// Returns the time interval converted to microseconds. 69 | /// 70 | /// - Returns: The `TimeInterval` in microseconds. 71 | @inlinable 72 | public var microseconds: Double { 73 | self * TimeInterval.microsecondsPerSecond 74 | } 75 | 76 | /// Returns the time interval converted to nanoseconds. 77 | /// 78 | /// - Returns: The `TimeInterval` in nanoseconds. 79 | @inlinable 80 | public var nanoseconds: Double { 81 | self * TimeInterval.nanosecondsPerSecond 82 | } 83 | 84 | /// Converts the provided value to `TimeInterval` representing days. 85 | /// 86 | /// - Parameter value: The value to convert. 87 | /// - Returns: The time interval in days. 88 | @inlinable 89 | public static func days(_ value: Double) -> TimeInterval { 90 | value * secondsPerDay 91 | } 92 | 93 | /// Converts the provided value to `TimeInterval` representing hours. 94 | /// 95 | /// - Parameter value: The value to convert. 96 | /// - Returns: The time interval in hours. 97 | @inlinable 98 | public static func hours(_ value: Double) -> TimeInterval { 99 | value * secondsPerHour 100 | } 101 | 102 | /// Converts the provided value to `TimeInterval` representing minutes. 103 | /// 104 | /// - Parameter value: The value to convert. 105 | /// - Returns: The time interval in minutes. 106 | @inlinable 107 | public static func minutes(_ value: Double) -> TimeInterval { 108 | value * secondsPerMinute 109 | } 110 | 111 | /// Converts the provided value to `TimeInterval` representing seconds. 112 | /// 113 | /// - Parameter value: The value to convert. 114 | /// - Returns: The time interval in seconds. 115 | @inlinable 116 | public static func seconds(_ value: Double) -> TimeInterval { 117 | value 118 | } 119 | 120 | /// Converts the provided value to `TimeInterval` representing milliseconds. 121 | /// 122 | /// - Parameter value: The value to convert. 123 | /// - Returns: The time interval in milliseconds. 124 | @inlinable 125 | public static func milliseconds(_ value: Double) -> TimeInterval { 126 | value / millisecondsPerSecond 127 | } 128 | 129 | /// Converts the provided value to `TimeInterval` representing microseconds. 130 | /// 131 | /// - Parameter value: The value to convert. 132 | /// - Returns: The time interval in microseconds. 133 | @inlinable 134 | public static func microseconds(_ value: Double) -> TimeInterval { 135 | value / microsecondsPerSecond 136 | } 137 | 138 | /// Converts the provided value to `TimeInterval` representing nanoseconds. 139 | /// 140 | /// - Parameter value: The value to convert. 141 | /// - Returns: The time interval in nanoseconds. 142 | @inlinable 143 | public static func nanoseconds(_ value: Double) -> TimeInterval { 144 | value / nanosecondsPerSecond 145 | } 146 | 147 | /// Returns the `Duration` representation of the current time interval. 148 | /// 149 | /// - Returns: The `Duration` representation. 150 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 151 | public func duration() -> Duration { 152 | let fullSeconds = Int64(self.seconds) 153 | let remainingInterval = self - Double(fullSeconds) 154 | 155 | let attosecondsPerNanosecond = Double(1_000 * 1_000 * 1_000) 156 | let fullAttoseconds = Int64(remainingInterval.nanoseconds * attosecondsPerNanosecond) 157 | 158 | return Duration(secondsComponent: fullSeconds, attosecondsComponent: fullAttoseconds) 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Sources/HandySwift/Globals.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Runs code with a delay given in seconds, using the main thread by default or a specified QoS class. 4 | /// 5 | /// - Parameters: 6 | /// - timeInterval: The duration of the delay. E.g., `.seconds(1)` or `.milliseconds(200)`. 7 | /// - qosClass: The global QoS class to be used or `nil` to use the main thread. Defaults to `nil`. 8 | /// - closure: The code to run with a delay. 9 | public func delay(by timeInterval: TimeInterval, qosClass: DispatchQoS.QoSClass? = nil, _ closure: @Sendable @escaping () -> Void) { 10 | let dispatchQueue = qosClass != nil ? DispatchQueue.global(qos: qosClass!) : DispatchQueue.main 11 | dispatchQueue.asyncAfter(deadline: DispatchTime.now() + timeInterval, execute: closure) 12 | } 13 | 14 | /// Runs code with a delay given in seconds, using the main thread by default or a specified QoS class. 15 | /// 16 | /// - Parameters: 17 | /// - duration: The duration of the delay. E.g., `.seconds(1)` or `.milliseconds(200)`. 18 | /// - qosClass: The global QoS class to be used or `nil` to use the main thread. Defaults to `nil`. 19 | /// - closure: The code to run with a delay. 20 | @_disfavoredOverload 21 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 22 | public func delay(by duration: Duration, qosClass: DispatchQoS.QoSClass? = nil, _ closure: @Sendable @escaping () -> Void) { 23 | let dispatchQueue = qosClass != nil ? DispatchQueue.global(qos: qosClass!) : DispatchQueue.main 24 | dispatchQueue.asyncAfter(deadline: DispatchTime.now() + duration.timeInterval, execute: closure) 25 | } 26 | -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Essentials/Extensions.md: -------------------------------------------------------------------------------- 1 | # Extensions 2 | 3 | Making existing types more convenient to use. 4 | 5 | @Metadata { 6 | @PageImage(purpose: icon, source: "HandySwift") 7 | @PageImage(purpose: card, source: "Extensions") 8 | } 9 | 10 | ## Highlights 11 | 12 | In the [Topics](#topics) section below you can find a list of all extension properties & functions. Click on one to reveal more details. 13 | 14 | To get you started quickly, here are the ones I use in nearly all of my apps with a practical usage example for each: 15 | 16 | #### Safe Index Access 17 | 18 | ![](MusicPlayer) 19 | 20 | In [FocusBeats][FocusBeats] I'm accessing an array of music tracks using an index. With ``Swift/Collection/subscript(safe:)`` I avoid out of bounds crashes: 21 | 22 | ```swift 23 | var nextEntry: ApplicationMusicPlayer.Queue.Entry? { 24 | guard let nextEntry = playerQueue.entries[safe: currentEntryIndex + 1] else { return nil } 25 | return nextEntry 26 | } 27 | ``` 28 | 29 | You can use it on every type that conforms to ``Swift/Collection`` including `Array`, `Dictionary`, and `String`. Instead of calling the subscript `array[index]` which returns a non-Optional but crashes when the index is out of bounds, use the safer `array[safe: index]` which returns `nil` instead of crashing in those cases. 30 | 31 | #### Blank Strings vs Empty Strings 32 | 33 | ![](APIKeys) 34 | 35 | A common issue with text fields that are required to be non-empty is that users accidentally type a whitespace or newline character and don't recognize it. If the validation code just checks for `.isEmpty` the problem will go unnoticed. That's why in [TranslateKit][TranslateKit] when users enter an API key I make sure to first strip away any newlines and whitespaces from the beginning & end of the String before doing the `.isEmpty` check. And because this is something I do very often in many places, I wrote a helper: 36 | 37 | ```Swift 38 | Image(systemName: self.deepLAuthKey.isBlank ? "xmark.circle" : "checkmark.circle") 39 | .foregroundStyle(self.deepLAuthKey.isBlank ? .red : .green) 40 | ``` 41 | 42 | Just use ``Swift/String/isBlank`` instead of `isEmpty` to get the same behavior! 43 | 44 | #### Readable Time Intervals 45 | 46 | ![](PremiumPlanExpires) 47 | 48 | Whenever I used an API that expects a `TimeInterval` (which is just a typealias for `Double`), I missed the unit which lead to less readable code because you have to actively remember that the unit is "seconds". Also, when I needed a different unit like minutes or hours, I had to do the calculation manually. Not with HandySwift! 49 | 50 | Intead of passing a plain `Double` value like `60 * 5`, you can just pass `.minutes(5)`. For example in [TranslateKit][TranslateKit] to preview the view when a user unsubscribed I use this: 51 | 52 | ```swift 53 | #Preview("Expiring") { 54 | ContentView( 55 | hasPremiumAccess: true, 56 | premiumExpiresAt: Date.now.addingTimeInterval(.days(3)) 57 | ) 58 | } 59 | ``` 60 | 61 | You can even chain multiple units with a `+` sign to create a day in time like "09:41 AM": 62 | 63 | ```swift 64 | let startOfDay = Calendar.current.startOfDay(for: Date.now) 65 | let iPhoneRevealedAt = startOfDay.addingTimeInterval(.hours(9) + .minutes(41)) 66 | ``` 67 | 68 | Note that this API design is in line with ``Swift/Duration`` and ``Dispatch/DispatchTimeInterval`` which both already support things like `.milliseconds(250)`. But they stop at the seconds level, they don't go higher. HandySwift adds minutes, hours, days, and even weeks for those types, too. So you can write something like this: 69 | 70 | ```swift 71 | try await Task.sleep(for: .minutes(5)) 72 | ``` 73 | 74 | > Warning: Advancing time by intervals does not take into account complexities like daylight saving time. Use a `Calendar` for that. 75 | 76 | #### Calculate Averages 77 | 78 | ![](CrosswordGeneration) 79 | 80 | In the crossword generation algorithm within [CrossCraft][CrossCraft] I have a health function on every iteration that calculates the overall quality of the puzzle. Two different aspects are taken into consideration: 81 | 82 | ```swift 83 | /// A value between 0 and 1. 84 | func calculateQuality() -> Double { 85 | let fieldCoverage = Double(solutionBoard.fields) / Double(maxFillableFields) 86 | let intersectionsCoverage = Double(solutionBoard.intersections) / Double(maxIntersections) 87 | return [fieldCoverage, intersectionsCoverage].average() 88 | } 89 | ``` 90 | 91 | In previous versions I played around with different weights, for example giving intersections double the weight compared to field coverage. I could still achieve this using ``Swift/Collection/average()-3g44u`` like this in the last line: 92 | 93 | ```swift 94 | return [fieldCoverage, intersectionsCoverage, intersectionsCoverage].average() 95 | ``` 96 | 97 | #### Round Floating-Point Numbers 98 | 99 | ![](ProgressBar) 100 | 101 | When solving a puzzle in [CrossCraft][CrossCraft] you can see your current progress at the top of the screen. I use the built-in percent formatter (`.formatted(.percent)`) for numerics, but it requires a `Double` with a value between 0 and 1 (1 = 100%). Passing an `Int` like `12` unexpectedly renders as `0%`, so I can't simply do this: 102 | ```swift 103 | Int(fractionCompleted * 100).formatted(.percent) // => "0%" until "100%" 104 | ``` 105 | 106 | And just doing `fractionCompleted.formatted(.percent)` results in sometimes very long text such as `"0.1428571429"`. 107 | 108 | Instead, I make use of ``Swift/Double/rounded(fractionDigits:rule:)`` to round the `Double` to 2 significant digits like so: 109 | 110 | ```swift 111 | Text(fractionCompleted.rounded(fractionDigits: 2).formatted(.percent)) 112 | ``` 113 | 114 | > Note: There's also a mutating ``Swift/Double/round(fractionDigits:rule:)`` functions if you want to change a variable in-place. 115 | 116 | #### JSON Snake Case Conversion 117 | 118 | Many APIs return responses using snake_case naming (e.g., `first_name`, `date_of_birth`), while Swift uses camelCase by convention (e.g., `firstName`, `dateOfBirth`). Converting between these naming conventions is a very common need when working with JSON APIs: 119 | 120 | ```swift 121 | struct User: Codable { 122 | let firstName: String 123 | let lastName: String 124 | let dateOfBirth: Date 125 | let profileImageUrl: String? 126 | } 127 | 128 | func fetchUser(id: String) async throws -> User { 129 | let (data, _) = try await URLSession.shared.data(from: apiURL) 130 | 131 | // Automatically converts snake_case JSON to camelCase Swift properties 132 | return try JSONDecoder.snakeCase.decode(User.self, from: data) 133 | 134 | // Without this extension we'd need this every time: 135 | // let decoder = JSONDecoder() 136 | // decoder.keyDecodingStrategy = .convertFromSnakeCase 137 | // return try decoder.decode(User.self, from: data) 138 | } 139 | ``` 140 | 141 | Just use ``JSONDecoder.snakeCase`` to decode and ``JSONEncoder.snakeCase`` to encode instead of configuring a new instance each time! 142 | 143 | #### Symmetric Data Cryptography 144 | 145 | ![](SharePuzzle) 146 | 147 | Before uploading a crossword puzzle in [CrossCraft][CrossCraft] I make sure to encrypt it so tech-savvy people can't easily sniff the answers from the JSON like so: 148 | 149 | ```swift 150 | func upload(puzzle: Puzzle) async throws { 151 | let key = SymmetricKey(base64Encoded: "")! 152 | let plainData = try JSONEncoder().encode(puzzle) 153 | let encryptedData = try plainData.encrypted(key: key) 154 | 155 | // upload logic 156 | } 157 | ``` 158 | 159 | Note that the above code makes use of two extensions, first ``CryptoKit/SymmetricKey/init(base64Encoded:)`` is used to initialize the key, then ``Foundation/Data/encrypted(key:)`` encrypts the data using safe ``CryptoKit`` APIs internally you don't need to deal with. 160 | 161 | When another user downloads the same puzzle, I decrypt it with ``Foundation/Data/decrypted(key:)`` like so: 162 | 163 | ```swift 164 | func downloadPuzzle(from url: URL) async throws -> Puzzle { 165 | let encryptedData = // download logic 166 | 167 | let key = SymmetricKey(base64Encoded: "")! 168 | let plainData = try encryptedPuzzleData.decrypted(key: symmetricKey) 169 | return try JSONDecoder().decode(Puzzle.self, from: plainData) 170 | } 171 | ``` 172 | 173 | > Note: HandySwift also conveniently ships with ``Swift/String/encrypted(key:)`` and ``Swift/String/decrypted(key:)`` functions for `String` which return a base-64 encoded String representation of the encrypted data. Use it when you're dealing with String APIs. 174 | 175 | 176 | ## Topics 177 | 178 | ### CaseIterable 179 | 180 | - ``Swift/CaseIterable/allCasesPrefixedByNil`` 181 | - ``Swift/CaseIterable/allCasesSuffixedByNil`` 182 | 183 | ### Collection 184 | 185 | - ``Swift/Collection/average()-3g44u`` 186 | - ``Swift/Collection/average()-rtqg`` 187 | - ``Swift/Collection/chunks(ofSize:)`` 188 | - ``Swift/Collection/subscript(safe:)`` 189 | 190 | ### Comparable 191 | 192 | - ``Swift/Comparable/clamped(to:)-5ky9b`` 193 | - ``Swift/Comparable/clamped(to:)-4dzn7`` 194 | - ``Swift/Comparable/clamped(to:)-8mqt8`` 195 | - ``Swift/Comparable/clamp(to:)-4djv3`` 196 | - ``Swift/Comparable/clamp(to:)-7bpgp`` 197 | - ``Swift/Comparable/clamp(to:)-4ohw5`` 198 | 199 | ### Data 200 | 201 | - ``Foundation/Data/encrypted(key:)`` 202 | - ``Foundation/Data/decrypted(key:)`` 203 | 204 | ### Date 205 | 206 | - ``Foundation/Date/reversed(by:)`` 207 | 208 | ### Dictionary 209 | 210 | - ``Swift/Dictionary/init(keys:values:)`` 211 | - ``Swift/Dictionary/mapKeys(_:)`` 212 | 213 | ### DispatchTimeInterval 214 | 215 | - ``Dispatch/DispatchTimeInterval/timeInterval`` 216 | 217 | ### Double 218 | 219 | - ``Swift/Double/round(fractionDigits:rule:)`` 220 | - ``Swift/Double/rounded(fractionDigits:rule:)`` 221 | 222 | ### Duration 223 | 224 | - ``Swift/Duration/timeInterval`` 225 | - ``Swift/Duration/weeks(_:)`` 226 | - ``Swift/Duration/days(_:)`` 227 | - ``Swift/Duration/hours(_:)`` 228 | - ``Swift/Duration/minutes(_:)`` 229 | - ``Swift/Duration/autoscaleFormatted()`` 230 | - ``Swift/Duration/multiplied(by:)-49cn7`` 231 | - ``Swift/Duration/multiplied(by:)-6knjk`` 232 | - ``Swift/Duration/divided(by:)-1h9df`` 233 | - ``Swift/Duration/divided(by:)-2lwae`` 234 | - ``Swift/Duration/divided(by:)-5s60j`` 235 | 236 | ### Float 237 | 238 | - ``Swift/Float/round(fractionDigits:rule:)`` 239 | - ``Swift/Float/rounded(fractionDigits:rule:)`` 240 | 241 | ### Int 242 | 243 | - ``Swift/Int/times(_:)`` 244 | - ``Swift/Int/timesMake(_:)`` 245 | 246 | ### JSON Coding 247 | 248 | - ``Foundation/JSONDecoder/snakeCase`` 249 | - ``Foundation/JSONEncoder/snakeCase`` 250 | 251 | ### RandomAccessCollection 252 | 253 | - ``Swift/RandomAccessCollection/randomElements(count:)`` 254 | 255 | ### Sequence 256 | 257 | - ``Swift/Sequence/sorted(byKeyPath:)`` 258 | - ``Swift/Sequence/max(byKeyPath:)`` 259 | - ``Swift/Sequence/min(byKeyPath:)`` 260 | - ``Swift/Sequence/sum()`` 261 | - ``Swift/Sequence/sum(mapToNumeric:)`` 262 | - ``Swift/Sequence/count(where:)`` 263 | - ``Swift/Sequence/count(where:equalTo:)`` 264 | - ``Swift/Sequence/count(where:notEqualTo:)`` 265 | - ``Swift/Sequence/count(where:prefixedBy:)`` 266 | - ``Swift/Sequence/count(where:prefixedByOneOf:)`` 267 | - ``Swift/Sequence/count(where:contains:)`` 268 | - ``Swift/Sequence/count(where:containsOneOf:)`` 269 | - ``Swift/Sequence/count(where:suffixedBy:)`` 270 | - ``Swift/Sequence/count(where:suffixedByOneOf:)`` 271 | - ``Swift/Sequence/count(where:greaterThan:)`` 272 | - ``Swift/Sequence/count(where:greaterThanOrEqual:)`` 273 | - ``Swift/Sequence/count(where:lessThan:)`` 274 | - ``Swift/Sequence/count(where:lessThanOrEqual:)`` 275 | - ``Swift/Sequence/filter(where:equalTo:)`` 276 | - ``Swift/Sequence/filter(where:notEqualTo:)`` 277 | - ``Swift/Sequence/filter(where:prefixedBy:)`` 278 | - ``Swift/Sequence/filter(where:prefixedByOneOf:)`` 279 | - ``Swift/Sequence/filter(where:contains:)`` 280 | - ``Swift/Sequence/filter(where:containsOneOf:)`` 281 | - ``Swift/Sequence/filter(where:suffixedBy:)`` 282 | - ``Swift/Sequence/filter(where:suffixedByOneOf:)`` 283 | - ``Swift/Sequence/filter(where:greaterThan:)`` 284 | - ``Swift/Sequence/filter(where:greaterThanOrEqual:)`` 285 | - ``Swift/Sequence/filter(where:lessThan:)`` 286 | - ``Swift/Sequence/filter(where:lessThanOrEqual:)`` 287 | - ``Swift/Sequence/first(where:equalTo:)`` 288 | - ``Swift/Sequence/first(where:notEqualTo:)`` 289 | - ``Swift/Sequence/first(where:prefixedBy:)`` 290 | - ``Swift/Sequence/first(where:prefixedByOneOf:)`` 291 | - ``Swift/Sequence/first(where:contains:)`` 292 | - ``Swift/Sequence/first(where:containsOneOf:)`` 293 | - ``Swift/Sequence/first(where:suffixedBy:)`` 294 | - ``Swift/Sequence/first(where:suffixedByOneOf:)`` 295 | - ``Swift/Sequence/first(where:greaterThan:)`` 296 | - ``Swift/Sequence/first(where:greaterThanOrEqual:)`` 297 | - ``Swift/Sequence/first(where:lessThan:)`` 298 | - ``Swift/Sequence/first(where:lessThanOrEqual:)`` 299 | - ``Swift/Sequence/count(prefixedBy:)`` 300 | - ``Swift/Sequence/count(prefixedByOneOf:)`` 301 | - ``Swift/Sequence/count(contains:)`` 302 | - ``Swift/Sequence/count(containsOneOf:)`` 303 | - ``Swift/Sequence/count(suffixedBy:)`` 304 | - ``Swift/Sequence/count(suffixedByOneOf:)`` 305 | - ``Swift/Sequence/count(greaterThan:)`` 306 | - ``Swift/Sequence/count(greaterThanOrEqual:)`` 307 | - ``Swift/Sequence/count(lessThan:)`` 308 | - ``Swift/Sequence/count(lessThanOrEqual:)`` 309 | - ``Swift/Sequence/filter(prefixedBy:)`` 310 | - ``Swift/Sequence/filter(prefixedByOneOf:)`` 311 | - ``Swift/Sequence/filter(contains:)`` 312 | - ``Swift/Sequence/filter(containsOneOf:)`` 313 | - ``Swift/Sequence/filter(suffixedBy:)`` 314 | - ``Swift/Sequence/filter(suffixedByOneOf:)`` 315 | - ``Swift/Sequence/filter(greaterThan:)`` 316 | - ``Swift/Sequence/filter(greaterThanOrEqual:)`` 317 | - ``Swift/Sequence/filter(lessThan:)`` 318 | - ``Swift/Sequence/filter(lessThanOrEqual:)`` 319 | 320 | ### String 321 | 322 | - ``Swift/String/isBlank`` 323 | - ``Swift/String/fullRange`` 324 | - ``Swift/String/fullNSRange`` 325 | - ``Swift/String/init(randomWithLength:allowedCharactersType:)`` 326 | - ``Swift/String/randomElements(count:)`` 327 | - ``Swift/String/encrypted(key:)`` 328 | - ``Swift/String/decrypted(key:)`` 329 | - ``Swift/String/tokenized(locale:)`` 330 | - ``Swift/String/matchesTokenizedPrefixes(in:locale:)`` 331 | 332 | ### StringProtocol 333 | 334 | - ``Swift/StringProtocol/firstUppercased`` 335 | - ``Swift/StringProtocol/firstLowercased`` 336 | 337 | ### SymmetricKey 338 | 339 | - ``CryptoKit/SymmetricKey/base64EncodedString`` 340 | - ``CryptoKit/SymmetricKey/init(base64Encoded:)`` 341 | 342 | ### TimeInterval 343 | 344 | - ``Swift/Double/days`` 345 | - ``Swift/Double/hours`` 346 | - ``Swift/Double/minutes`` 347 | - ``Swift/Double/seconds`` 348 | - ``Swift/Double/milliseconds`` 349 | - ``Swift/Double/microseconds`` 350 | - ``Swift/Double/nanoseconds`` 351 | - ``Swift/Double/days(_:)`` 352 | - ``Swift/Double/hours(_:)`` 353 | - ``Swift/Double/minutes(_:)`` 354 | - ``Swift/Double/seconds(_:)`` 355 | - ``Swift/Double/milliseconds(_:)`` 356 | - ``Swift/Double/microseconds(_:)`` 357 | - ``Swift/Double/nanoseconds(_:)`` 358 | - ``Swift/Double/duration()`` 359 | 360 | 361 | [TranslateKit]: https://apps.apple.com/app/apple-store/id6476773066?pt=549314&ct=swiftpackageindex.com&mt=8 362 | [CrossCraft]: https://apps.apple.com/app/apple-store/id6472669260?pt=549314&ct=swiftpackageindex.com&mt=8 363 | [FocusBeats]: https://apps.apple.com/app/apple-store/id6477829138?pt=549314&ct=swiftpackageindex.com&mt=8 364 | [Guided Guest Mode]: https://apps.apple.com/app/apple-store/id6479207869?pt=549314&ct=swiftpackageindex.com&mt=8 365 | [Posters]: https://apps.apple.com/app/apple-store/id6478062053?pt=549314&ct=swiftpackageindex.com&mt=8 366 | -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Essentials/New Types.md: -------------------------------------------------------------------------------- 1 | # New Types 2 | 3 | Adding missing types and global functions. 4 | 5 | @Metadata { 6 | @PageImage(purpose: icon, source: "HandySwift") 7 | @PageImage(purpose: card, source: "NewTypes") 8 | } 9 | 10 | ## Highlights 11 | 12 | In the [Topics](#topics) section below you can find a list of all new types & functions. Click on one to reveal more details. 13 | 14 | To get you started quickly, here are the ones I use in nearly all of my apps with a practical usage example for each: 15 | 16 | ### Gregorian Day & Time 17 | 18 | You want to construct a `Date` from year, month, and day? Easy: 19 | 20 | ```swift 21 | GregorianDay(year: 1960, month: 11, day: 01).startOfDay() // => Date 22 | ``` 23 | 24 | You have a `Date` and want to store just the day part of the date, not the time? Just use ``GregorianDay`` in your model: 25 | 26 | ```swift 27 | struct User { 28 | let birthday: GregorianDay 29 | } 30 | 31 | let selectedDate = // coming from DatePicker 32 | let timCook = User(birthday: GregorianDay(date: selectedDate)) 33 | print(timCook.birthday.iso8601Formatted) // => "1960-11-01" 34 | ``` 35 | 36 | You just want today's date without time? 37 | 38 | ```swift 39 | GregorianDay.today 40 | ``` 41 | 42 | Works also with `.yesterday` and `.tomorrow`. For more, just call: 43 | 44 | ```swift 45 | let todayNextWeek = GregorianDay.today.advanced(by: 7) 46 | ``` 47 | 48 | > Note: `GregorianDay` conforms to all the protocols you would expect, such as `Codable`, `Hashable`, and `Comparable`. For encoding/decoding, it uses the ISO format as in "2014-07-13". 49 | 50 | ``GregorianTime`` is the counterpart: 51 | 52 | ```swift 53 | let iPhoneAnnounceTime = GregorianTime(hour: 09, minute: 41) 54 | let anHourFromNow = GregorianTime.now.advanced(by: .hours(1)) 55 | 56 | let date = iPhoneAnnounceTime.date(day: GregorianDay.today) // => Date 57 | ``` 58 | 59 | ### Delay & Debounce 60 | 61 | Have you ever wanted to delay some code and found this API annoying to remember & type out? 62 | 63 | ```swift 64 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(250)) { 65 | // your code 66 | } 67 | ``` 68 | 69 | HandySwift introduces a shorter version that's easier to remember: 70 | 71 | ```swift 72 | delay(by: .milliseconds(250)) { 73 | // your code 74 | } 75 | ``` 76 | 77 | It also supports different Quality of Service classes like `DispatchQueue` (default is main queue): 78 | 79 | ```swift 80 | delay(by: .milliseconds(250), qosClass: .background) { 81 | // your code 82 | } 83 | ``` 84 | 85 | While delaying is great for one-off tasks, sometimes there's fast input that causes performance or scalability issues. For example, a user might type fast in a search field. It's common practice to delay updating the search results and additionally cancelling any older inputs once the user makes a new one. This practice is called "Debouncing". And it's easy with HandySwift: 86 | 87 | ```swift 88 | @State private var searchText = "" 89 | let debouncer = Debouncer() 90 | 91 | var body: some View { 92 | List(filteredItems) { item in 93 | Text(item.title) 94 | } 95 | .searchable(text: self.$searchText) 96 | .onChange(of: self.searchText) { newValue in 97 | self.debouncer.delay(for: .milliseconds(500)) { 98 | // Perform search operation with the updated search text after 500 milliseconds of user inactivity 99 | self.performSearch(with: newValue) 100 | } 101 | } 102 | .onDisappear { 103 | debouncer.cancelAll() 104 | } 105 | } 106 | ``` 107 | 108 | Note that the ``Debouncer`` was stored in a property so ``Debouncer/cancelAll()`` could be called on disappear for cleanup. But the ``Debouncer/delay(for:id:operation:)-83bbm`` is where the magic happens – and you don't have to deal with the details! 109 | 110 | > Note: If you need multiple debouncing operations in one view, you don't need multiple debouncers. Just pass an `id` to the delay function. 111 | 112 | 113 | ## Topics 114 | 115 | ### Collections 116 | 117 | - ``FrequencyTable`` 118 | - ``SortedArray`` 119 | 120 | ### Date & Time 121 | 122 | - ``GregorianDay`` 123 | - ``GregorianTime`` 124 | 125 | ### UI Helpers 126 | 127 | - ``Debouncer`` 128 | - ``OperatingSystem`` (short: ``OS``) 129 | 130 | ### Other 131 | 132 | - ``delay(by:qosClass:_:)-8iw4f`` 133 | - ``delay(by:qosClass:_:)-yedf`` 134 | - ``HandyRegex`` 135 | -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/HandySwift.md: -------------------------------------------------------------------------------- 1 | # ``HandySwift`` 2 | 3 | Handy Swift features that didn't make it into the Swift standard library. 4 | 5 | @Metadata { 6 | @PageImage(purpose: icon, source: "HandySwift") 7 | } 8 | 9 | ## Essentials 10 | 11 | Learn how you can make the most of HandySwift with these guides: 12 | 13 | @Links(visualStyle: detailedGrid) { 14 | - 15 | - 16 | } 17 | -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/Extensions.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/APIKeys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/Extensions/APIKeys.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/CrosswordGeneration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/Extensions/CrosswordGeneration.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/MusicPlayer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/Extensions/MusicPlayer.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/PremiumPlanExpires.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/Extensions/PremiumPlanExpires.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/ProgressBar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/Extensions/ProgressBar.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/SharePuzzle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/Extensions/SharePuzzle.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/HandySwift.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/HandySwift.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/NewTypes.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/e9defa22f6c0f3ae529b6256375d679061f830ee/Sources/HandySwift/HandySwift.docc/Resources/NewTypes.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/theme-settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": { 3 | "color": { 4 | "header": "#002B7D", 5 | "documentation-intro-title": "#FFFFFF", 6 | "documentation-intro-figure": "#FFFFFF", 7 | "documentation-intro-fill": "radial-gradient(circle at top, var(--color-header) 30%, #000 100%)", 8 | "documentation-intro-accent": "var(--color-header)" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/HandySwift/Protocols/AutoConforming.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Adds conformance to the automatically inferrable protocols `Hashable`, `Codable`, and `Sendable`. 4 | public typealias AutoConforming = Hashable & Codable & Sendable 5 | -------------------------------------------------------------------------------- /Sources/HandySwift/Protocols/DivisibleArithmetic.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A type that conforms to `DivisibleArithmetic` provides basic arithmetic operations: addition, subtraction, multiplication, and division. 4 | public protocol DivisibleArithmetic: Numeric { 5 | /// Initializes an instance with the given integer value. 6 | /// 7 | /// - Parameter value: An integer value to initialize the instance. 8 | init(_ value: Int) 9 | 10 | /// Divides one value by another and returns the result. 11 | /// 12 | /// - Parameters: 13 | /// - lhs: The dividend. 14 | /// - rhs: The divisor. 15 | /// - Returns: The quotient of dividing `lhs` by `rhs`. 16 | static func / (lhs: Self, rhs: Self) -> Self 17 | } 18 | 19 | extension Double: DivisibleArithmetic {} 20 | extension Float: DivisibleArithmetic {} 21 | 22 | #if canImport(CoreGraphics) 23 | import CoreGraphics 24 | 25 | extension CGFloat: DivisibleArithmetic {} 26 | #endif 27 | -------------------------------------------------------------------------------- /Sources/HandySwift/Protocols/Withable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Simple protocol to facilitate modifying objects with multiple properties in a chainable and functional manner. 4 | public protocol Withable { /* no requirements */ } 5 | 6 | extension Withable { 7 | /// Returns a copy of the object (if a struct) or uses the same object (if a class), with modifications applied in a chainable manner. 8 | /// This is particularly useful for configuring objects upon initialization or making successive modifications. 9 | /// 10 | /// Example: 11 | /// ```swift 12 | /// struct Foo: Withable { 13 | /// var bar: Int 14 | /// var isEasy: Bool = false 15 | /// } 16 | /// 17 | /// let defaultFoo = Foo(bar: 5) 18 | /// let customFoo = Foo(bar: 5).with { 19 | /// $0.isEasy = true 20 | /// } 21 | /// 22 | /// print(defaultFoo.isEasy) // false 23 | /// print(customFoo.isEasy) // true 24 | /// ``` 25 | /// 26 | /// - Parameter config: A closure that accepts an inout reference to the object and modifies its properties. 27 | /// - Returns: A modified copy of the object (if a struct) or the same object (if a class). 28 | @inlinable 29 | public func with(_ config: (inout Self) throws -> Void) rethrows -> Self { 30 | var copy = self 31 | try config(©) 32 | return copy 33 | } 34 | } 35 | 36 | #if !os(Linux) 37 | extension NSObject: Withable {} 38 | #endif 39 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/Debouncer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A class for debouncing operations. 4 | /// 5 | /// Debouncing ensures that an operation is not executed multiple times within a given time frame, cancelling any duplicate operations. 6 | /// Only the last operation will be executed, and only after the given time frame has passed. 7 | /// 8 | /// - Note: This is useful for improving user experience in scenarios like search functionalities in apps, where you might want to reduce the number of search operations triggered by keystroke events. 9 | /// 10 | /// Example for SwiftUI's `.searchable` modifier: 11 | /// ```swift 12 | /// @State private var searchText = "" 13 | /// let debouncer = Debouncer() 14 | /// 15 | /// var body: some View { 16 | /// List(filteredItems) { item in 17 | /// Text(item.title) 18 | /// } 19 | /// .searchable(text: self.$searchText) 20 | /// .onChange(of: self.searchText) { newValue in 21 | /// self.debouncer.delay(for: 0.5) { 22 | /// // Perform search operation with the updated search text after 500 milliseconds of user inactivity 23 | /// self.performSearch(with: newValue) 24 | /// } 25 | /// } 26 | /// .onDisappear { 27 | /// debouncer.cancelAll() 28 | /// } 29 | /// } 30 | /// ``` 31 | public final class Debouncer { 32 | private var timerByID: [String: Timer] = [:] 33 | 34 | /// Initializes a new instance of the `Debouncer` class. 35 | public init() {} 36 | 37 | /// Delays operations and cancels all but the last one when the same `id` is provided. 38 | /// 39 | /// - Parameters: 40 | /// - duration: The time duration for the delay. 41 | /// - id: An optional identifier to distinguish different delays (default is "default"). 42 | /// - operation: The operation to be delayed and executed. 43 | /// 44 | /// This version of `delay` uses a `Duration` to specify the delay time, available in iOS 16 and later. 45 | /// 46 | /// Example: 47 | /// ```swift 48 | /// let debouncer = Debouncer() 49 | /// debouncer.delay(for: .milliseconds(500), id: "search") { 50 | /// // Perform some operation after a 500 milliseconds delay 51 | /// performOperation() 52 | /// } 53 | /// ``` 54 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 55 | public func delay(for duration: Duration, id: String = "default", operation: @Sendable @escaping () -> Void) { 56 | self.cancel(id: id) 57 | self.timerByID[id] = Timer.scheduledTimer(withTimeInterval: duration.timeInterval, repeats: false) { _ in 58 | operation() 59 | } 60 | } 61 | 62 | /// Delays operations and cancels all but the last one when the same `id` is provided. 63 | /// 64 | /// - Parameters: 65 | /// - interval: The time interval for the delay. 66 | /// - id: An optional identifier to distinguish different delays (default is "default"). 67 | /// - operation: The operation to be delayed and executed. 68 | /// 69 | /// This version of `delay` uses a `TimeInterval` to specify the delay time. 70 | /// 71 | /// Example for a generic operation: 72 | /// ```swift 73 | /// let debouncer = Debouncer() 74 | /// debouncer.delay(for: 0.5, id: "genericOperation") { 75 | /// // Perform some operation after a 500 milliseconds delay 76 | /// performOperation() 77 | /// } 78 | /// ``` 79 | public func delay(for interval: TimeInterval, id: String = "default", operation: @Sendable @escaping () -> Void) { 80 | self.cancel(id: id) 81 | self.timerByID[id] = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in 82 | operation() 83 | } 84 | } 85 | 86 | /// Cancels any in-flight operations with the provided `id`. 87 | /// 88 | /// - Parameter id: The identifier of the operation to be canceled. 89 | /// 90 | /// Example: 91 | /// ```swift 92 | /// var body: some View { 93 | /// ContentView() 94 | /// .onDisappear { 95 | /// debouncer.cancel(id: "search") 96 | /// } 97 | /// } 98 | /// ``` 99 | public func cancel(id: String) { 100 | self.timerByID[id]?.invalidate() 101 | } 102 | 103 | /// Cancels all in-flight operations independent of their `id`. This could be called `.onDisappear` when this is used in a SwiftUI view, for example. 104 | /// 105 | /// Example: 106 | /// ```swift 107 | /// var body: some View { 108 | /// ContentView() 109 | /// .onDisappear { 110 | /// debouncer.cancelAll() 111 | /// } 112 | /// } 113 | /// ``` 114 | public func cancelAll() { 115 | for timer in self.timerByID.values { 116 | timer.invalidate() 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/FrequencyTable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Data structure to retrieve random values with their frequency taken into account. 4 | /// 5 | /// Example: 6 | /// ```swift 7 | /// let fruits = ["apple", "banana", "orange"] 8 | /// let frequencyTable = FrequencyTable(values: fruits) { fruit in 9 | /// switch fruit { 10 | /// case "apple": 5 11 | /// case "banana": 3 12 | /// case "orange": 2 13 | /// default: 0 14 | /// } 15 | /// } 16 | /// 17 | /// let randomFruit = frequencyTable.randomElement() 18 | /// print(randomFruit) // Prints a random fruit considering the specified frequencies. 19 | /// ``` 20 | public struct FrequencyTable { 21 | @usableFromInline 22 | typealias Entry = (value: T, frequency: Int) 23 | 24 | @usableFromInline 25 | internal let valuesWithFrequencies: [Entry] 26 | 27 | /// Contains all values the amount of time of their frequencies. 28 | @usableFromInline 29 | internal let frequentValues: [T] 30 | 31 | /// Creates a new FrequencyTable instance with values and their frequencies provided. 32 | /// 33 | /// - Parameters: 34 | /// - values: An array full of values to be saved into the frequency table. 35 | /// - frequencyClosure: The closure to specify the frequency for a specific value. 36 | /// - Throws: Any errors thrown by the `frequencyClosure` are propagated upward. 37 | /// 38 | /// Example: 39 | /// ```swift 40 | /// let fruits = ["apple", "banana", "orange"] 41 | /// let frequencyTable = FrequencyTable(values: fruits) { fruit in 42 | /// switch fruit { 43 | /// case "apple": 5 44 | /// case "banana": 3 45 | /// case "orange": 2 46 | /// default: 0 47 | /// } 48 | /// } 49 | /// ``` 50 | @inlinable 51 | public init(values: [T], frequencyClosure: (T) throws -> Int) rethrows { 52 | valuesWithFrequencies = try values.map { ($0, try frequencyClosure($0)) } 53 | frequentValues = valuesWithFrequencies.reduce(into: []) { memo, entry in 54 | memo += Array(repeating: entry.value, count: entry.frequency) 55 | } 56 | } 57 | 58 | /// Returns a random value taking frequencies into account or nil if values are empty. 59 | /// 60 | /// - Returns: A random value taking frequencies into account or nil if values are empty. 61 | /// 62 | /// Example: 63 | /// ```swift 64 | /// let fruits = ["apple", "banana", "orange"] 65 | /// let frequencyTable = try FrequencyTable(values: fruits) { fruit in 66 | /// switch fruit { 67 | /// case "apple": 5 68 | /// case "banana": 3 69 | /// case "orange": 2 70 | /// default: 0 71 | /// } 72 | /// } 73 | /// 74 | /// let randomFruit = frequencyTable.randomElement() 75 | /// print(randomFruit) // Prints a random fruit considering the specified frequencies. 76 | /// ``` 77 | @inlinable 78 | public func randomElement() -> T? { self.frequentValues.randomElement() } 79 | 80 | /// Returns an array of random values taking frequencies into account or nil if values are empty. 81 | /// 82 | /// - Parameters: 83 | /// - count: The size of the resulting array of random values. 84 | /// - Returns: An array of random values or nil if values are empty. 85 | /// 86 | /// Example: 87 | /// ```swift 88 | /// let fruits = ["apple", "banana", "orange"] 89 | /// let frequencyTable = try FrequencyTable(values: fruits) { fruit in 90 | /// switch fruit { 91 | /// case "apple": 5 92 | /// case "banana": 3 93 | /// case "orange": 2 94 | /// default: 0 95 | /// } 96 | /// } 97 | /// 98 | /// let randomFruits = frequencyTable.randomElements(count: 3) 99 | /// print(randomFruits) // Prints an array of random fruits considering the specified frequencies. 100 | /// ``` 101 | @inlinable 102 | public func randomElements(count: Int) -> [T]? { 103 | guard !self.frequentValues.isEmpty else { return nil } 104 | return count.timesMake { self.frequentValues.randomElement()! } 105 | } 106 | } 107 | 108 | // - MARK: Migration 109 | extension FrequencyTable { 110 | @available(*, unavailable, renamed: "randomElement()") 111 | public var sample: T? { fatalError() } 112 | 113 | @available(*, unavailable, renamed: "randomElements(count:)") 114 | public func sample(size: Int) -> [T]? { fatalError() } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/GregorianDay.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A date without time information. 4 | /// 5 | /// Example: 6 | /// ```swift 7 | /// let yesterday = GregorianDay.yesterday 8 | /// print(yesterday.iso8601Formatted) // Prints the current date in ISO 8601 format, e.g. "2024-03-20" 9 | /// 10 | /// let tomorrow = yesterday.advanced(by: 2) 11 | /// let timCookBirthday = GregorianDay(year: 1960, month: 11, day: 01) 12 | /// 13 | /// let startOfDay = GregorianDay(date: Date()).startOfDay() 14 | /// ``` 15 | public struct GregorianDay { 16 | /// The year component of the date. 17 | public var year: Int 18 | /// The month component of the date. 19 | public var month: Int 20 | /// The day component of the date. 21 | public var day: Int 22 | 23 | /// Returns an ISO 8601 formatted String representation of the date, e.g., `2024-02-24`. 24 | public var iso8601Formatted: String { 25 | "\(String(format: "%04d", self.year))-\(String(format: "%02d", self.month))-\(String(format: "%02d", self.day))" 26 | } 27 | 28 | /// Initializes a `GregorianDay` instance with the given `Date`. 29 | /// 30 | /// - Parameter date: The date to extract components from. 31 | public init(date: Date) { 32 | let components = Calendar(identifier: .gregorian).dateComponents([.year, .month, .day], from: date) 33 | self.year = components.year! 34 | self.month = components.month! 35 | self.day = components.day! 36 | } 37 | 38 | /// Initializes a `GregorianDay` instance with the given year, month, and day. 39 | /// 40 | /// - Parameters: 41 | /// - year: The year of the date. 42 | /// - month: The month of the date. 43 | /// - day: The day of the date. 44 | public init(year: Int, month: Int, day: Int) { 45 | assert(month >= 1 && month <= 12) 46 | assert(day >= 1 && day <= 31) 47 | 48 | self.year = year 49 | self.month = month 50 | self.day = day 51 | } 52 | 53 | /// Advances the date by the specified number of days. 54 | /// 55 | /// - Parameter days: The number of days to advance the date by. 56 | /// - Returns: A new `GregorianDay` instance advanced by the specified number of days. 57 | /// 58 | /// Example: 59 | /// ```swift 60 | /// let tomorrow = GregorianDay.today.advanced(by: 1) 61 | /// ``` 62 | public func advanced(by days: Int) -> Self { 63 | GregorianDay(date: self.midOfDay().addingTimeInterval(.days(Double(days)))) 64 | } 65 | 66 | /// Reverses the date by the specified number of days. 67 | /// 68 | /// - Parameter days: The number of days to reverse the date by. 69 | /// - Returns: A new `GregorianDay` instance reversed by the specified number of days. 70 | /// 71 | /// Example: 72 | /// ```swift 73 | /// let yesterday = GregorianDay.today.reversed(by: 1) 74 | /// ``` 75 | public func reversed(by days: Int) -> Self { 76 | self.advanced(by: -days) 77 | } 78 | 79 | /// Advances the date by the specified number of months. 80 | /// 81 | /// - Parameter months: The number of months to advance the date by. 82 | /// - Returns: A new `GregorianDay` instance advanced by the specified number of months. 83 | /// 84 | /// - Warning: This may return an invalid date such as February 31st. Only use in combination with a method like ``startOfMonth(timeZone:)`` that removes the day. 85 | /// 86 | /// Example: 87 | /// ```swift 88 | /// let tomorrow = GregorianDay.today.advanced(byMonths: 1) 89 | /// ``` 90 | public func advanced(byMonths months: Int) -> Self { 91 | let (overflowingYears, newMonth) = (self.month + months - 1).quotientAndRemainder(dividingBy: 12) 92 | return self.with { $0.year += overflowingYears; $0.month = newMonth + 1 } 93 | } 94 | 95 | /// Reverses the date by the specified number of months. 96 | /// 97 | /// - Parameter months: The number of months to reverse the date by. 98 | /// - Returns: A new `GregorianDay` instance reversed by the specified number of months. 99 | /// 100 | /// - Warning: This may return an invalid date such as February 31st. Only use in combination with a method like ``startOfMonth(timeZone:)`` that removes the day. 101 | /// 102 | /// Example: 103 | /// ```swift 104 | /// let yesterday = GregorianDay.today.reversed(byMonths: 1) 105 | /// ``` 106 | public func reversed(byMonths months: Int) -> Self { 107 | self.advanced(byMonths: -months) 108 | } 109 | 110 | /// Advances the date by the specified number of years. 111 | /// 112 | /// - Parameter years: The number of years to advance the date by. 113 | /// - Returns: A new `GregorianDay` instance advanced by the specified number of years. The day and month stay the same. 114 | /// 115 | /// - Warning: This may return an invalid date such as February 31st. Only use in combination with a method like ``startOfMonth(timeZone:)`` that removes the day. 116 | /// 117 | /// Example: 118 | /// ```swift 119 | /// let tomorrow = GregorianDay.today.advanced(byYears: 1) 120 | /// ``` 121 | public func advanced(byYears years: Int) -> Self { 122 | self.with { $0.year += years } 123 | } 124 | 125 | /// Reverses the date by the specified number of years. 126 | /// 127 | /// - Parameter years: The number of years to reverse the date by. 128 | /// - Returns: A new `GregorianDay` instance reversed by the specified number of years. The day and month stay the same. 129 | /// 130 | /// - Warning: This may return an invalid date such as February 31st. Only use in combination with a method like ``startOfMonth(timeZone:)`` that removes the day. 131 | /// Example: 132 | /// ```swift 133 | /// let yesterday = GregorianDay.today.reversed(byYears: 1) 134 | /// ``` 135 | public func reversed(byYears years: Int) -> Self { 136 | self.advanced(byYears: -years) 137 | } 138 | 139 | /// Returns the start of the day represented by the date. 140 | /// 141 | /// - Parameter timeZone: The time zone for which to calculate the start of the day. Defaults to the users current timezone. 142 | /// - Returns: A `Date` representing the start of the day. 143 | /// 144 | /// Example: 145 | /// ```swift 146 | /// let startOfToday = GregorianDay.today.startOfDay() 147 | /// ``` 148 | public func startOfDay(timeZone: TimeZone = .current) -> Date { 149 | let components = DateComponents( 150 | calendar: Calendar(identifier: .gregorian), 151 | timeZone: timeZone, 152 | year: self.year, 153 | month: self.month, 154 | day: self.day 155 | ) 156 | return components.date! 157 | } 158 | 159 | /// Returns the start of the month represented by the date. 160 | /// 161 | /// - Parameter timeZone: The time zone for which to calculate the start of the month. Defaults to the users current timezone. 162 | /// - Returns: A `Date` representing the start of the month. 163 | /// 164 | /// Example: 165 | /// ```swift 166 | /// let startOfThisMonth = GregorianDay.today.startOfMonth() 167 | /// ``` 168 | public func startOfMonth(timeZone: TimeZone = .current) -> Date { 169 | let components = DateComponents( 170 | calendar: Calendar(identifier: .gregorian), 171 | timeZone: timeZone, 172 | year: self.year, 173 | month: self.month, 174 | day: 1 175 | ) 176 | return components.date! 177 | } 178 | 179 | /// Returns the start of the year represented by the date. 180 | /// 181 | /// - Parameter timeZone: The time zone for which to calculate the start of the year. Defaults to the users current timezone. 182 | /// - Returns: A `Date` representing the start of the year. 183 | /// 184 | /// Example: 185 | /// ```swift 186 | /// let startOfThisYear = GregorianDay.today.startOfYear() 187 | /// ``` 188 | public func startOfYear(timeZone: TimeZone = .current) -> Date { 189 | let components = DateComponents( 190 | calendar: Calendar(identifier: .gregorian), 191 | timeZone: timeZone, 192 | year: self.year, 193 | month: 1, 194 | day: 1 195 | ) 196 | return components.date! 197 | } 198 | 199 | /// Returns the middle of the day represented by the date. 200 | /// 201 | /// - Parameter timeZone: The time zone for which to calculate the middle of the day. Defaults to UTC. 202 | /// - Returns: A `Date` representing the middle of the day. 203 | /// 204 | /// - Note: If you need to pass a `Date` to an API that only cares about the day (not the time), calling ``midOfDay(timeZone:)`` ensures you get the same day independent of timezones. 205 | /// 206 | /// Example: 207 | /// ```swift 208 | /// let midOfToday: Date = GregorianDay.today.midOfDay() // the middle of today in UTC time zone 209 | /// ``` 210 | public func midOfDay(timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) -> Date { 211 | let components = DateComponents( 212 | calendar: Calendar(identifier: .gregorian), 213 | timeZone: timeZone, 214 | year: self.year, 215 | month: self.month, 216 | day: self.day, 217 | hour: 12 218 | ) 219 | return components.date! 220 | } 221 | 222 | /// Returns a `Date` representing this day at the specified time. 223 | /// 224 | /// - Parameters: 225 | /// - timeOfDay: The time of day to set for the resulting date. 226 | /// - timeZone: The time zone for which to calculate the date. Defaults to the users current timezone. 227 | /// - Returns: A `Date` representing this day at the specified time. 228 | /// 229 | /// Example: 230 | /// ```swift 231 | /// let noonToday = GregorianDay.today.date(timeOfDay: .noon) // today at 12:00 232 | /// ``` 233 | public func date(timeOfDay: GregorianTime, timeZone: TimeZone = .current) -> Date { 234 | timeOfDay.date(day: self, timeZone: timeZone) 235 | } 236 | } 237 | 238 | extension GregorianDay: Codable { 239 | /// Initializes a `GregorianDay` instance by decoding from the provided decoder. 240 | /// 241 | /// - Parameter decoder: The decoder to read data from. 242 | /// - Throws: An error if reading from the decoder fails, or if the data is corrupted or cannot be decoded. 243 | public init(from decoder: Decoder) throws { 244 | let container = try decoder.singleValueContainer() 245 | let dateString = try container.decode(String.self) 246 | 247 | let formatter = DateFormatter() 248 | formatter.dateFormat = "yyyy-MM-dd" 249 | formatter.calendar = Calendar(identifier: .gregorian) 250 | 251 | guard let date = formatter.date(from: dateString) else { 252 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string") 253 | } 254 | 255 | self = GregorianDay(date: date) 256 | } 257 | 258 | /// Encodes the `GregorianDay` instance into the provided encoder. 259 | /// 260 | /// - Parameter encoder: The encoder to write data to. 261 | /// - Throws: An error if encoding fails. 262 | public func encode(to encoder: Encoder) throws { 263 | var container = encoder.singleValueContainer() 264 | try container.encode(self.iso8601Formatted) 265 | } 266 | } 267 | 268 | extension GregorianDay: Hashable, Sendable {} 269 | extension GregorianDay: Identifiable { 270 | /// The identifier of the `GregorianDay` instance, which is a string representation of its year, month, and day. 271 | public var id: String { "\(self.year)-\(self.month)-\(self.day)" } 272 | } 273 | 274 | extension GregorianDay: Comparable { 275 | /// Compares two `GregorianDay` instances for order. 276 | /// 277 | /// - Parameters: 278 | /// - left: The first `GregorianDay` instance to compare. 279 | /// - right: The second `GregorianDay` instance to compare. 280 | /// - Returns: `true` if the `left` date is less than the `right` date; otherwise, `false`. 281 | public static func < (left: GregorianDay, right: GregorianDay) -> Bool { 282 | guard left.year == right.year else { return left.year < right.year } 283 | guard left.month == right.month else { return left.month < right.month } 284 | return left.day < right.day 285 | } 286 | } 287 | 288 | extension GregorianDay { 289 | /// The `GregorianDay` representing yesterday's date. 290 | public static var yesterday: Self { GregorianDay(date: Date()).advanced(by: -1) } 291 | 292 | /// The `GregorianDay` representing today's date. 293 | public static var today: Self { GregorianDay(date: Date()) } 294 | 295 | /// The `GregorianDay` representing tomorrow's date. 296 | public static var tomorrow: Self { GregorianDay(date: Date()).advanced(by: 1) } 297 | } 298 | 299 | extension GregorianDay: Withable {} 300 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/GregorianTime.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A time without date info. 4 | /// 5 | /// `GregorianTime` represents a time of day without any associated date information. It provides functionalities to work with time components like hour, minute, and second, and perform operations such as initializing from a given date, calculating durations, advancing, and reversing time. 6 | /// 7 | /// Example: 8 | /// ```swift 9 | /// // Initializing from a given date 10 | /// let date = Date() 11 | /// let timeOfDay = GregorianTime(date: date) 12 | /// 13 | /// // Calculating duration since the start of the day 14 | /// let durationSinceStartOfDay: Duration = timeOfDay.durationSinceStartOfDay 15 | /// let timeIntervalSinceStartOfDay: TimeInterval = durationSinceStartOfDay.timeInterval 16 | /// 17 | /// // Advancing time by a duration 18 | /// let advancedTime = timeOfDay.advanced(by: .hours(2) + .minutes(30)) 19 | /// 20 | /// // Reversing time by a duration 21 | /// let reversedTime = timeOfDay.reversed(by: .minutes(15)) 22 | /// ``` 23 | public struct GregorianTime { 24 | /// The number of days beyond the current day. 25 | public var overflowingDays: Int 26 | /// The hour component of the time. 27 | public var hour: Int 28 | /// The minute component of the time. 29 | public var minute: Int 30 | /// The second component of the time. 31 | public var second: Int 32 | 33 | /// Initializes a `GregorianTime` instance from a given date. 34 | /// 35 | /// - Parameter date: The date from which to extract time components. 36 | public init(date: Date) { 37 | let components = Calendar(identifier: .gregorian).dateComponents([.hour, .minute, .second], from: date) 38 | self.overflowingDays = 0 39 | self.hour = components.hour! 40 | self.minute = components.minute! 41 | self.second = components.second! 42 | } 43 | 44 | /// Initializes a `GregorianTime` instance with the provided time components. 45 | /// 46 | /// - Parameters: 47 | /// - hour: The hour component. 48 | /// - minute: The minute component. 49 | /// - second: The second component (default is 0). 50 | public init(hour: Int, minute: Int, second: Int = 0) { 51 | assert(hour >= 0 && hour < 24) 52 | assert(minute >= 0 && minute < 60) 53 | assert(second >= 0 && second < 60) 54 | 55 | self.overflowingDays = 0 56 | self.hour = hour 57 | self.minute = minute 58 | self.second = second 59 | } 60 | 61 | /// Returns a `Date` object representing the time on a given day. 62 | /// 63 | /// - Parameters: 64 | /// - day: The day to which the time belongs. 65 | /// - timeZone: The time zone to use for the conversion (default is the current time zone). 66 | /// - Returns: A `Date` object representing the time. 67 | public func date(day: GregorianDay, timeZone: TimeZone = .current) -> Date { 68 | let components = DateComponents( 69 | calendar: Calendar(identifier: .gregorian), 70 | timeZone: timeZone, 71 | year: day.year, 72 | month: day.month, 73 | day: day.day, 74 | hour: self.hour, 75 | minute: self.minute, 76 | second: self.second 77 | ) 78 | return components.date!.addingTimeInterval(.days(Double(self.overflowingDays))) 79 | } 80 | 81 | /// Initializes a `GregorianTime` instance from the duration since the start of the day. 82 | /// 83 | /// - Parameter durationSinceStartOfDay: The duration since the start of the day. 84 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 85 | public init(durationSinceStartOfDay: Duration) { 86 | self.overflowingDays = Int(durationSinceStartOfDay.timeInterval.days) 87 | self.hour = Int((durationSinceStartOfDay - .days(self.overflowingDays)).timeInterval.hours) 88 | self.minute = Int((durationSinceStartOfDay - .days(self.overflowingDays) - .hours(self.hour)).timeInterval.minutes) 89 | self.second = Int((durationSinceStartOfDay - .days(self.overflowingDays) - .hours(self.hour) - .minutes(self.minute)).timeInterval.seconds) 90 | } 91 | 92 | /// Returns the duration since the start of the day. 93 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 94 | public var durationSinceStartOfDay: Duration { 95 | .days(self.overflowingDays) + .hours(self.hour) + .minutes(self.minute) + .seconds(self.second) 96 | } 97 | 98 | /// Advances the time by the specified duration. 99 | /// 100 | /// - Parameter duration: The duration by which to advance the time. 101 | /// - Returns: A new `GregorianTime` instance advanced by the specified duration. 102 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 103 | public func advanced(by duration: Duration) -> Self { 104 | GregorianTime(durationSinceStartOfDay: self.durationSinceStartOfDay + duration) 105 | } 106 | 107 | /// Reverses the time by the specified duration. 108 | /// 109 | /// - Parameter duration: The duration by which to reverse the time. 110 | /// - Returns: A new `GregorianTime` instance reversed by the specified duration. 111 | @available(iOS 16, macOS 13, tvOS 16, visionOS 1, watchOS 9, *) 112 | public func reversed(by duration: Duration) -> Self { 113 | GregorianTime(durationSinceStartOfDay: self.durationSinceStartOfDay - duration) 114 | } 115 | } 116 | 117 | extension GregorianTime: Codable, Hashable, Sendable {} 118 | extension GregorianTime: Identifiable { 119 | /// The unique identifier of the time, formatted as "hour:minute:second". 120 | public var id: String { "\(self.hour):\(self.minute):\(self.second)" } 121 | } 122 | 123 | extension GregorianTime: Comparable { 124 | /// Compares two `GregorianTime` instances. 125 | /// 126 | /// - Parameters: 127 | /// - left: The left-hand side of the comparison. 128 | /// - right: The right-hand side of the comparison. 129 | /// - Returns: `true` if the left time is less than the right time; otherwise, `false`. 130 | public static func < (left: GregorianTime, right: GregorianTime) -> Bool { 131 | guard left.overflowingDays == right.overflowingDays else { return left.overflowingDays < right.overflowingDays } 132 | guard left.hour == right.hour else { return left.hour < right.hour } 133 | guard left.minute == right.minute else { return left.minute < right.minute } 134 | return left.second < right.second 135 | } 136 | } 137 | 138 | extension GregorianTime { 139 | /// The zero time of day (00:00:00). 140 | public static var zero: Self { GregorianTime(hour: 0, minute: 0, second: 0) } 141 | /// The current time of day. 142 | public static var now: Self { GregorianTime(date: Date()) } 143 | /// Noon (12:00:00). 144 | public static var noon: Self { GregorianTime(hour: 12, minute: 0, second: 0) } 145 | } 146 | 147 | extension GregorianTime: Withable {} 148 | 149 | /// Provides backward compatibility for the renamed `GregorianTime` type. 150 | /// 151 | /// This type has been renamed to ``GregorianTime`` to better reflect its purpose and maintain consistency with other types in the framework. 152 | /// 153 | /// Instead of using `GregorianTimeOfDay`, use ``GregorianTime``: 154 | /// ```swift 155 | /// // Old code: 156 | /// let time = GregorianTimeOfDay(hour: 14, minute: 30) 157 | /// 158 | /// // New code: 159 | /// let time = GregorianTime(hour: 14, minute: 30) 160 | /// ``` 161 | @available(*, deprecated, renamed: "GregorianTime", message: "Use GregorianTime instead. This type has been renamed for better clarity and consistency.") 162 | public typealias GregorianTimeOfDay = GregorianTime 163 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/HandyRegex.swift: -------------------------------------------------------------------------------- 1 | // Copyright © 2018 Flinesoft. All rights reserved. 2 | // Originally from: https://github.com/sharplet/Regex (modified to remove some weight). 3 | 4 | import Foundation 5 | 6 | /// `HandyRegex` is a swifty regex engine built on top of the NSRegularExpression API. 7 | /// 8 | /// > Warning: The HandyRegex type will be removed in a future version. Migrate to `Swift.Regex` if possible. 9 | /// 10 | /// #### init(_:options:) 11 | /// 12 | /// Initialize with pattern and, optionally, options. 13 | /// 14 | /// ``` swift 15 | /// let regex = try Regex("(Phil|John), [\\d]{4}") 16 | /// 17 | /// let options: Regex.Options = [.ignoreCase, .anchorsMatchLines, .dotMatchesLineSeparators, .ignoreMetacharacters] 18 | /// let regexWithOptions = try Regex("(Phil|John), [\\d]{4}", options: options) 19 | /// ``` 20 | /// 21 | /// #### regex.matches(_:) 22 | /// 23 | /// Checks whether regex matches string 24 | /// 25 | /// ``` swift 26 | /// regex.matches("Phil, 1991") // => true 27 | /// ```` 28 | /// 29 | /// #### regex.matches(in:) 30 | /// 31 | /// Returns all matches 32 | /// 33 | /// ``` swift 34 | /// regex.matches(in: "Phil, 1991 and John, 1985") 35 | /// // => [Match<"Phil, 1991">, Match<"John, 1985">] 36 | /// ``` 37 | /// 38 | /// #### regex.firstMatch(in:) 39 | /// 40 | /// Returns first match if any 41 | /// 42 | /// ``` swift 43 | /// regex.firstMatch(in: "Phil, 1991 and John, 1985") 44 | /// // => Match<"Phil, 1991"> 45 | /// ``` 46 | /// 47 | /// #### regex.replacingMatches(in:with:count:) 48 | /// 49 | /// Replaces all matches in a string with a template string, up to the a maximum of matches (count). 50 | /// 51 | /// ``` swift 52 | /// regex.replacingMatches(in: "Phil, 1991 and John, 1985", with: "$1 was born in $2", count: 2) 53 | /// // => "Phil was born in 1991 and John was born in 1985" 54 | /// ``` 55 | /// 56 | /// #### match.string 57 | /// 58 | /// Returns the captured string 59 | /// 60 | /// ``` swift 61 | /// match.string // => "Phil, 1991" 62 | /// ``` 63 | /// 64 | /// #### match.range 65 | /// 66 | /// Returns the range of the captured string within the base string 67 | /// 68 | /// ``` swift 69 | /// match.range // => Range 70 | /// ``` 71 | /// 72 | /// #### match.captures 73 | /// 74 | /// Returns the capture groups of a match 75 | /// 76 | /// ``` swift 77 | /// match.captures // => ["Phil", "1991"] 78 | /// ``` 79 | /// 80 | /// #### match.string(applyingTemplate:) 81 | /// 82 | /// Replaces the matched string with a template string 83 | /// 84 | /// ``` swift 85 | /// match.string(applyingTemplate: "$1 was born in $2") 86 | /// // => "Phil was born in 1991" 87 | /// ``` 88 | public struct HandyRegex { 89 | @usableFromInline 90 | internal let regularExpression: NSRegularExpression 91 | 92 | /// Create a `Regex` based on a pattern string. 93 | /// 94 | /// If `pattern` is not a valid regular expression, an error is thrown 95 | /// describing the failure. 96 | /// 97 | /// - parameters: 98 | /// - pattern: A pattern string describing the regex. 99 | /// - options: Configure regular expression matching options. 100 | /// For details, see `Regex.Options`. 101 | /// 102 | /// - throws: A value of `ErrorType` describing the invalid regular expression. 103 | @available(*, deprecated, message: "The HandyRegex type will be removed in a future version. Migrate to Swift.Regex if possible.") 104 | public init(_ pattern: String, options: Options = []) throws { 105 | regularExpression = try NSRegularExpression( 106 | pattern: pattern, 107 | options: options.toNSRegularExpressionOptions 108 | ) 109 | } 110 | 111 | /// Returns `true` if the regex matches `string`, otherwise returns `false`. 112 | /// 113 | /// - parameter string: The string to test. 114 | /// 115 | /// - returns: `true` if the regular expression matches, otherwise `false`. 116 | @available(*, deprecated, message: "The HandyRegex type will be removed in a future version. Migrate to Swift.Regex if possible.") 117 | @inlinable 118 | public func matches(_ string: String) -> Bool { 119 | firstMatch(in: string) != nil 120 | } 121 | 122 | /// If the regex matches `string`, returns a `Match` describing the 123 | /// first matched string and any captures. If there are no matches, returns 124 | /// `nil`. 125 | /// 126 | /// - parameter string: The string to match against. 127 | /// 128 | /// - returns: An optional `Match` describing the first match, or `nil`. 129 | @available(*, deprecated, message: "The HandyRegex type will be removed in a future version. Migrate to Swift.Regex if possible.") 130 | @inlinable 131 | public func firstMatch(in string: String) -> Match? { 132 | regularExpression 133 | .firstMatch(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) 134 | .map { Match(result: $0, in: string) } 135 | } 136 | 137 | /// If the regex matches `string`, returns an array of `Match`, describing 138 | /// every match inside `string`. If there are no matches, returns an empty 139 | /// array. 140 | /// 141 | /// - parameter string: The string to match against. 142 | /// 143 | /// - returns: An array of `Match` describing every match in `string`. 144 | @available(*, deprecated, message: "The HandyRegex type will be removed in a future version. Migrate to Swift.Regex if possible.") 145 | @inlinable 146 | public func matches(in string: String) -> [Match] { 147 | regularExpression 148 | .matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) 149 | .map { Match(result: $0, in: string) } 150 | } 151 | 152 | /// Returns a new string where each substring matched by `regex` is replaced 153 | /// with `template`. 154 | /// 155 | /// The template string may be a literal string, or include template variables: 156 | /// the variable `$0` will be replaced with the entire matched substring, `$1` 157 | /// with the first capture group, etc. 158 | /// 159 | /// For example, to include the literal string "$1" in the replacement string, 160 | /// you must escape the "$": `\$1`. 161 | /// 162 | /// - parameters: 163 | /// - regex: A regular expression to match against `self`. 164 | /// - template: A template string used to replace matches. 165 | /// - count: The maximum count of matches to replace, beginning with the first match. 166 | /// 167 | /// - returns: A string with all matches of `regex` replaced by `template`. 168 | @available(*, deprecated, message: "The HandyRegex will be removed in a future version. Migrate to Swift.Regex if possible.") 169 | @inlinable 170 | public func replacingMatches(in input: String, with template: String, count: Int? = nil) -> String { 171 | var output = input 172 | let matches = self.matches(in: input) 173 | let rangedMatches = Array(matches[0 ..< min(matches.count, count ?? .max)]) 174 | for match in rangedMatches.reversed() { 175 | let replacement = match.string(applyingTemplate: template) 176 | output.replaceSubrange(match.range, with: replacement) 177 | } 178 | 179 | return output 180 | } 181 | } 182 | 183 | extension HandyRegex: CustomStringConvertible { 184 | /// Returns a string describing the regex using its pattern string. 185 | public var description: String { 186 | "Regex<\"\(regularExpression.pattern)\">" 187 | } 188 | } 189 | 190 | extension HandyRegex: Equatable { 191 | /// Determines the equality of to `Regex`` instances. 192 | /// Two `Regex` are considered equal, if both the pattern string and the options 193 | /// passed on initialization are equal. 194 | public static func == (lhs: HandyRegex, rhs: HandyRegex) -> Bool { 195 | lhs.regularExpression.pattern == rhs.regularExpression.pattern && 196 | lhs.regularExpression.options == rhs.regularExpression.options 197 | } 198 | } 199 | 200 | extension HandyRegex: Hashable { 201 | /// Manages hashing of the `Regex` instance. 202 | public func hash(into hasher: inout Hasher) { 203 | hasher.combine(regularExpression) 204 | } 205 | } 206 | 207 | extension HandyRegex { 208 | /// `Options` defines alternate behaviours of regular expressions when matching. 209 | public struct Options: OptionSet, Sendable { 210 | /// Ignores the case of letters when matching. 211 | public static let ignoreCase = Options(rawValue: 1) 212 | 213 | /// Ignore any metacharacters in the pattern, treating every character as 214 | /// a literal. 215 | public static let ignoreMetacharacters = Options(rawValue: 1 << 1) 216 | 217 | /// By default, "^" matches the beginning of the string and "$" matches the 218 | /// end of the string, ignoring any newlines. With this option, "^" will 219 | /// the beginning of each line, and "$" will match the end of each line. 220 | public static let anchorsMatchLines = Options(rawValue: 1 << 2) 221 | 222 | /// Usually, "." matches all characters except newlines (\n). Using this, 223 | /// options will allow "." to match newLines 224 | public static let dotMatchesLineSeparators = Options(rawValue: 1 << 3) 225 | 226 | /// The raw value of the `OptionSet` 227 | public let rawValue: Int 228 | 229 | /// Transform an instance of `Regex.Options` into the equivalent `NSRegularExpression.Options`. 230 | /// 231 | /// - returns: The equivalent `NSRegularExpression.Options`. 232 | var toNSRegularExpressionOptions: NSRegularExpression.Options { 233 | var options = NSRegularExpression.Options() 234 | if contains(.ignoreCase) { options.insert(.caseInsensitive) } 235 | if contains(.ignoreMetacharacters) { options.insert(.ignoreMetacharacters) } 236 | if contains(.anchorsMatchLines) { options.insert(.anchorsMatchLines) } 237 | if contains(.dotMatchesLineSeparators) { options.insert(.dotMatchesLineSeparators) } 238 | return options 239 | } 240 | 241 | /// The raw value init for the `OptionSet` 242 | public init(rawValue: Int) { 243 | self.rawValue = rawValue 244 | } 245 | } 246 | } 247 | 248 | extension HandyRegex { 249 | /// A `Match` encapsulates the result of a single match in a string, 250 | /// providing access to the matched string, as well as any capture groups within 251 | /// that string. 252 | public class Match: CustomStringConvertible { 253 | /// The entire matched string. 254 | public lazy var string: String = { 255 | String(describing: self.baseString[self.range]) 256 | }() 257 | 258 | /// The range of the matched string. 259 | public lazy var range: Range = { 260 | Range(self.result.range, in: self.baseString)! 261 | }() 262 | 263 | /// The matching string for each capture group in the regular expression 264 | /// (if any). 265 | /// 266 | /// **Note:** Usually if the match was successful, the captures will by 267 | /// definition be non-nil. However if a given capture group is optional, the 268 | /// captured string may also be nil, depending on the particular string that 269 | /// is being matched against. 270 | /// 271 | /// Example: 272 | /// 273 | /// let regex = Regex("(a)?(b)") 274 | /// 275 | /// regex.matches(in: "ab")first?.captures // [Optional("a"), Optional("b")] 276 | /// regex.matches(in: "b").first?.captures // [nil, Optional("b")] 277 | public lazy var captures: [String?] = { 278 | let captureRanges = stride(from: 0, to: result.numberOfRanges, by: 1) 279 | .map(result.range) 280 | .dropFirst() 281 | .map { [unowned self] in 282 | Range($0, in: self.baseString) 283 | } 284 | 285 | return captureRanges.map { [unowned self] captureRange in 286 | guard let captureRange = captureRange else { return nil } 287 | return String(describing: self.baseString[captureRange]) 288 | } 289 | }() 290 | 291 | private let result: NSTextCheckingResult 292 | 293 | private let baseString: String 294 | 295 | @usableFromInline 296 | internal init(result: NSTextCheckingResult, in string: String) { 297 | precondition( 298 | result.regularExpression != nil, 299 | "NSTextCheckingResult must originate from regular expression parsing." 300 | ) 301 | 302 | self.result = result 303 | self.baseString = string 304 | } 305 | 306 | /// Returns a new string where the matched string is replaced according to the `template`. 307 | /// 308 | /// The template string may be a literal string, or include template variables: 309 | /// the variable `$0` will be replaced with the entire matched substring, `$1` 310 | /// with the first capture group, etc. 311 | /// 312 | /// For example, to include the literal string "$1" in the replacement string, 313 | /// you must escape the "$": `\$1`. 314 | /// 315 | /// - parameters: 316 | /// - template: The template string used to replace matches. 317 | /// 318 | /// - returns: A string with `template` applied to the matched string. 319 | public func string(applyingTemplate template: String) -> String { 320 | result.regularExpression!.replacementString( 321 | for: result, 322 | in: baseString, 323 | offset: 0, 324 | template: template 325 | ) 326 | } 327 | 328 | /// Returns a string describing the match. 329 | public var description: String { 330 | "Match<\"\(string)\">" 331 | } 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/OperatingSystem.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A shorthand for `OperatingSystem` to save typing work when using this often inside SwiftUI modifiers, for example. 4 | public typealias OS = OperatingSystem 5 | 6 | /// Represents the possible Operating Systems on which a Swift program might run. 7 | /// 8 | /// String example: 9 | /// ```swift 10 | /// let settingsAppName = OS.value(default: "Settings", macOS: "System Settings") 11 | /// print(settingsAppName) // Output will be "System Settings" if running on macOS, otherwise "Settings" for all other platforms 12 | /// ``` 13 | /// 14 | /// SwiftUI modifier example: 15 | /// ```swift 16 | /// Button("Close", "xmark.circle") { 17 | /// self.dismiss() 18 | /// } 19 | /// .labelStyle(.iconOnly) 20 | /// .frame( 21 | /// width: OS.value(default: 44, visionOS: 60), 22 | /// height: OS.value(default: 44, visionOS: 60) 23 | /// ) 24 | /// ``` 25 | public enum OperatingSystem: AutoConforming { 26 | // Apple Platforms 27 | case iOS 28 | case macOS 29 | case tvOS 30 | case visionOS 31 | case watchOS 32 | 33 | // Other 34 | case linux 35 | case windows 36 | 37 | /// Returns the current operating system. 38 | public static var current: OperatingSystem { 39 | #if os(iOS) 40 | return .iOS 41 | #elseif os(macOS) 42 | return .macOS 43 | #elseif os(tvOS) 44 | return .tvOS 45 | #elseif os(visionOS) 46 | return .visionOS 47 | #elseif os(watchOS) 48 | return .watchOS 49 | #elseif os(Linux) 50 | return .linux 51 | #elseif os(Windows) 52 | return .windows 53 | #else 54 | fatalError("Unsupported operating system") 55 | #endif 56 | } 57 | 58 | /// Returns the value provided for the OS-specific parameter if provided, else falls back to `default`. 59 | /// 60 | /// - Parameters: 61 | /// - defaultValue: The default value to use if no OS-specific value is provided. 62 | /// - iOS: The value specific to iOS. 63 | /// - macOS: The value specific to macOS. 64 | /// - tvOS: The value specific to tvOS. 65 | /// - visionOS: The value specific to visionOS. 66 | /// - watchOS: The value specific to watchOS. 67 | /// - linux: The value specific to Linux. 68 | /// - windows: The value specific to Windows. 69 | /// - Returns: The value provided for the OS-specific parameter if provided, else falls back to `default`. 70 | /// 71 | /// String example: 72 | /// ```swift 73 | /// let settingsAppName = OS.value(default: "Settings", macOS: "System Settings") 74 | /// print(settingsAppName) // Output will be "System Settings" if running on macOS, otherwise "Settings" for all other platforms 75 | /// ``` 76 | /// 77 | /// SwiftUI modifier example: 78 | /// ```swift 79 | /// Button("Close", "xmark.circle") { 80 | /// self.dismiss() 81 | /// } 82 | /// .labelStyle(.iconOnly) 83 | /// .frame( 84 | /// width: OS.value(default: 44, visionOS: 60), 85 | /// height: OS.value(default: 44, visionOS: 60) 86 | /// ) 87 | /// ``` 88 | public static func value( 89 | default defaultValue: T, 90 | iOS: T? = nil, 91 | macOS: T? = nil, 92 | tvOS: T? = nil, 93 | visionOS: T? = nil, 94 | watchOS: T? = nil, 95 | linux: T? = nil, 96 | windows: T? = nil 97 | ) -> T { 98 | switch Self.current { 99 | case .iOS: iOS ?? defaultValue 100 | case .macOS: macOS ?? defaultValue 101 | case .tvOS: tvOS ?? defaultValue 102 | case .visionOS: visionOS ?? defaultValue 103 | case .watchOS: watchOS ?? defaultValue 104 | case .linux: linux ?? defaultValue 105 | case .windows: windows ?? defaultValue 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/RESTClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | /// A client to consume a REST API. Uses JSON to encode/decode body data. 7 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 8 | public final class RESTClient: Sendable { 9 | public enum APIError: Error, LocalizedError, Sendable { 10 | public typealias Context = String 11 | 12 | case responsePluginFailed(Error, Context?) 13 | case failedToEncodeBody(Error, Context?) 14 | case failedToLoadData(Error, Context?) 15 | case failedToDecodeSuccessBody(Error, Context?) 16 | case failedToDecodeClientErrorBody(Error, Context?) 17 | case clientError(String, Context?) 18 | case unexpectedResponseType(URLResponse, Context?) 19 | case unexpectedStatusCode(Int, Context?) 20 | 21 | public var errorDescription: String? { 22 | switch self { 23 | case .responsePluginFailed(let error, _): 24 | "\(self.errorContext) Response plugin failed: \(error.localizedDescription)" 25 | case .failedToEncodeBody(let error, _): 26 | "\(self.errorContext) Failed to encode body: \(error.localizedDescription)" 27 | case .failedToLoadData(let error, _): 28 | "\(self.errorContext) Failed to load data: \(error.localizedDescription)" 29 | case .failedToDecodeSuccessBody(let error, _): 30 | "\(self.errorContext) Failed to decode success body: \(error.localizedDescription)" 31 | case .failedToDecodeClientErrorBody(let error, _): 32 | "\(self.errorContext) Failed to decode client error body: \(error.localizedDescription)" 33 | case .clientError(let string, _): 34 | "\(self.errorContext) \(string)" 35 | case .unexpectedResponseType(let urlResponse, _): 36 | "\(self.errorContext) Unexpected response type (non-HTTP): \(String(describing: type(of: urlResponse)))" 37 | case .unexpectedStatusCode(let int, _): 38 | "\(self.errorContext) Unexpected status code: \(int)" 39 | } 40 | } 41 | 42 | private var errorContext: String { 43 | switch self { 44 | case .responsePluginFailed(_, let context), .failedToEncodeBody(_, let context), .failedToLoadData(_, let context), 45 | .failedToDecodeSuccessBody(_, let context), .failedToDecodeClientErrorBody(_, let context), .clientError(_, let context): 46 | if let context { 47 | return "[\(context): Client Error]" 48 | } else { 49 | return "[Client Error]" 50 | } 51 | 52 | case .unexpectedResponseType(_, let context), .unexpectedStatusCode(_, let context): 53 | if let context { 54 | return "[\(context): Server Error]" 55 | } else { 56 | return "[Server Error]" 57 | } 58 | } 59 | } 60 | } 61 | 62 | public enum HTTPMethod: String, Sendable { 63 | case get = "GET" 64 | case post = "POST" 65 | case put = "PUT" 66 | case patch = "PATCH" 67 | case delete = "DELETE" 68 | } 69 | 70 | public enum Body: Sendable { 71 | case binary(Data) 72 | case json(Encodable & Sendable) 73 | case string(String) 74 | case form([URLQueryItem]) 75 | 76 | var contentType: String { 77 | switch self { 78 | case .binary: return "application/octet-stream" 79 | case .json: return "application/json" 80 | case .string: return "text/plain" 81 | case .form: return "application/x-www-form-urlencoded" 82 | } 83 | } 84 | 85 | func httpData(jsonEncoder: JSONEncoder) throws -> Data { 86 | switch self { 87 | case .binary(let data): 88 | return data 89 | 90 | case .json(let json): 91 | return try jsonEncoder.encode(json) 92 | 93 | case .string(let string): 94 | return Data(string.utf8) 95 | 96 | case .form(let queryItems): 97 | var urlComponents = URLComponents(string: "https://example.com")! 98 | urlComponents.queryItems = queryItems 99 | return Data(urlComponents.percentEncodedQuery!.utf8) 100 | } 101 | } 102 | } 103 | 104 | public protocol RequestPlugin: Sendable { 105 | func apply(to request: inout URLRequest) 106 | } 107 | 108 | public protocol ResponsePlugin: Sendable { 109 | func apply(to response: inout HTTPURLResponse, data: inout Data) throws 110 | } 111 | 112 | let baseURL: URL 113 | let baseHeaders: [String: String] 114 | let baseQueryItems: [URLQueryItem] 115 | let jsonEncoder: JSONEncoder 116 | let jsonDecoder: JSONDecoder 117 | let urlSession: URLSession 118 | let requestPlugins: [any RequestPlugin] 119 | let responsePlugins: [any ResponsePlugin] 120 | let baseErrorContext: String? 121 | let errorBodyToMessage: @Sendable (Data) throws -> String 122 | 123 | // no need to pass 'application/json' to `baseHeaders`, it'll automatically be added of a body is sent 124 | public init( 125 | baseURL: URL, 126 | baseHeaders: [String: String] = [:], 127 | baseQueryItems: [URLQueryItem] = [], 128 | jsonEncoder: JSONEncoder = .init(), 129 | jsonDecoder: JSONDecoder = .init(), 130 | urlSession: URLSession = .shared, 131 | requestPlugins: [any RequestPlugin] = [], 132 | responsePlugins: [any ResponsePlugin] = [], 133 | baseErrorContext: String? = nil, 134 | errorBodyToMessage: @Sendable @escaping (Data) throws -> String 135 | ) { 136 | self.baseURL = baseURL 137 | self.baseHeaders = baseHeaders 138 | self.baseQueryItems = baseQueryItems 139 | self.jsonEncoder = jsonEncoder 140 | self.jsonDecoder = jsonDecoder 141 | self.urlSession = urlSession 142 | self.requestPlugins = requestPlugins 143 | self.responsePlugins = responsePlugins 144 | self.baseErrorContext = baseErrorContext 145 | self.errorBodyToMessage = errorBodyToMessage 146 | } 147 | 148 | public func send( 149 | method: HTTPMethod, 150 | path: String, 151 | body: Body? = nil, 152 | extraHeaders: [String: String] = [:], 153 | extraQueryItems: [URLQueryItem] = [], 154 | errorContext: String? = nil 155 | ) async throws(APIError) { 156 | _ = try await self.fetchData(method: method, path: path, body: body, extraHeaders: extraHeaders, extraQueryItems: extraQueryItems) 157 | } 158 | 159 | public func fetchAndDecode( 160 | method: HTTPMethod, 161 | path: String, 162 | body: Body? = nil, 163 | extraHeaders: [String: String] = [:], 164 | extraQueryItems: [URLQueryItem] = [], 165 | errorContext: String? = nil 166 | ) async throws(APIError) -> ResponseBodyType { 167 | let responseData = try await self.fetchData(method: method, path: path, body: body, extraHeaders: extraHeaders, extraQueryItems: extraQueryItems) 168 | 169 | do { 170 | return try self.jsonDecoder.decode(ResponseBodyType.self, from: responseData) 171 | } catch { 172 | throw .failedToDecodeSuccessBody(error, self.errorContext(requestContext: errorContext)) 173 | } 174 | } 175 | 176 | public func fetchData( 177 | method: HTTPMethod, 178 | path: String, 179 | body: Body? = nil, 180 | extraHeaders: [String: String] = [:], 181 | extraQueryItems: [URLQueryItem] = [], 182 | errorContext: String? = nil 183 | ) async throws(APIError) -> Data { 184 | let url = self.baseURL 185 | .appending(path: path) 186 | .appending(queryItems: self.baseQueryItems + extraQueryItems) 187 | 188 | var request = URLRequest(url: url) 189 | request.httpMethod = method.rawValue 190 | 191 | for (field, value) in self.baseHeaders.merging(extraHeaders, uniquingKeysWith: { $1 }) { 192 | request.setValue(value, forHTTPHeaderField: field) 193 | } 194 | 195 | if let body { 196 | do { 197 | request.httpBody = try body.httpData(jsonEncoder: self.jsonEncoder) 198 | } catch { 199 | throw APIError.failedToEncodeBody(error, self.errorContext(requestContext: errorContext)) 200 | } 201 | 202 | request.setValue(body.contentType, forHTTPHeaderField: "Content-Type") 203 | } 204 | 205 | request.setValue("application/json", forHTTPHeaderField: "Accept") 206 | 207 | for plugin in self.requestPlugins { 208 | plugin.apply(to: &request) 209 | } 210 | 211 | let (data, response) = try await self.performRequest(request, errorContext: errorContext) 212 | return try await self.handle(data: data, response: response, for: request, errorContext: errorContext) 213 | } 214 | 215 | private func errorContext(requestContext: String?) -> String? { 216 | let context = [self.baseErrorContext, requestContext].compactMap { $0 }.joined(separator: "->") 217 | guard !context.isEmpty else { return nil } 218 | return context 219 | } 220 | 221 | private func performRequest(_ request: URLRequest, errorContext: String?) async throws(APIError) -> (Data, URLResponse) { 222 | self.logRequestIfDebug(request) 223 | 224 | let data: Data 225 | let response: URLResponse 226 | do { 227 | (data, response) = try await self.urlSession.data(for: request) 228 | } catch { 229 | throw APIError.failedToLoadData(error, self.errorContext(requestContext: errorContext)) 230 | } 231 | 232 | self.logResponseIfDebug(response, data: data) 233 | return (data, response) 234 | } 235 | 236 | private func handle(data: Data, response: URLResponse, for request: URLRequest, errorContext: String?, attempt: Int = 1) async throws(APIError) -> Data { 237 | guard var httpResponse = response as? HTTPURLResponse else { 238 | throw .unexpectedResponseType(response, self.errorContext(requestContext: errorContext)) 239 | } 240 | 241 | var data = data 242 | for responsePlugin in self.responsePlugins { 243 | do { 244 | try responsePlugin.apply(to: &httpResponse, data: &data) 245 | } catch { 246 | throw APIError.responsePluginFailed(error, self.errorContext(requestContext: errorContext)) 247 | } 248 | } 249 | 250 | switch httpResponse.statusCode { 251 | case 200..<300: 252 | return data 253 | 254 | case 429: 255 | guard attempt < 5 else { fallthrough } 256 | 257 | var sleepSeconds: Double = Double(attempt) 258 | 259 | // respect the server retry-after(-ms) value if it exists, allowing values betwen 0.5-5 seconds 260 | if 261 | let retryAfterMillisecondsString = httpResponse.value(forHTTPHeaderField: "retry-after-ms"), 262 | let retryAfterMilliseconds = Double(retryAfterMillisecondsString) 263 | { 264 | sleepSeconds = max(0.5, min(retryAfterMilliseconds, 5)) 265 | } else if 266 | let retryAfterString = httpResponse.value(forHTTPHeaderField: "retry-after"), 267 | let retryAfter = Double(retryAfterString) 268 | { 269 | sleepSeconds = max(0.5, min(retryAfter, 5)) 270 | } 271 | 272 | #if DEBUG 273 | print("Received Status Code 429 'Too Many Requests'. Retrying in \(sleepSeconds) second(s)...") 274 | #endif 275 | 276 | try? await Task.sleep(for: .seconds(sleepSeconds)) 277 | 278 | let (newData, newResponse) = try await self.performRequest(request, errorContext: errorContext) 279 | return try await self.handle(data: newData, response: newResponse, for: request, errorContext: errorContext, attempt: attempt + 1) 280 | 281 | case 400..<500: 282 | guard !data.isEmpty else { 283 | throw .clientError("Unexpected status code \(httpResponse.statusCode) without a response body.", self.errorContext(requestContext: errorContext)) 284 | } 285 | 286 | let clientErrorMessage: String 287 | do { 288 | clientErrorMessage = try self.errorBodyToMessage(data) 289 | } catch { 290 | throw .failedToDecodeClientErrorBody(error, self.errorContext(requestContext: errorContext)) 291 | } 292 | throw .clientError(clientErrorMessage, self.errorContext(requestContext: errorContext)) 293 | 294 | default: 295 | throw .unexpectedStatusCode(httpResponse.statusCode, self.errorContext(requestContext: errorContext)) 296 | } 297 | } 298 | 299 | private func logRequestIfDebug(_ request: URLRequest) { 300 | #if DEBUG 301 | var requestBodyString: String? 302 | if let bodyData = request.httpBody { 303 | requestBodyString = String(data: bodyData, encoding: .utf8) 304 | } 305 | 306 | print("[\(self)] Sending \(request.httpMethod!) request to '\(request.url!)': \(request)\n\nHeaders:\n\(request.allHTTPHeaderFields ?? [:])\n\nBody:\n\(requestBodyString ?? "No body")") 307 | #endif 308 | } 309 | 310 | private func logResponseIfDebug(_ response: URLResponse, data: Data?) { 311 | #if DEBUG 312 | var responseBodyString: String? 313 | if let data = data { 314 | responseBodyString = String(data: data, encoding: .utf8) 315 | } 316 | 317 | print("[\(self)] Received response & body from '\(response.url!)': \(response)\n\nResponse headers:\n\((response as? HTTPURLResponse)?.allHeaderFields ?? [:])\n\nResponse body:\n\(responseBodyString ?? "No body")") 318 | #endif 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/SortedArray.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Data structure to keep a sorted array of elements for fast access. 4 | /// 5 | /// - Note: Use `SortedArray` over a regular array when read access (like searches) inside the array are time-sensitive. Pre-sorting elements makes mutations (such as inserts) slightly slower but searches much faster. 6 | /// 7 | /// Example: 8 | /// ```swift 9 | /// // Operations such as initializing with an array literal or inserting keep the elements in the array sorted automatically: 10 | /// var sortedNumbers: SortedArray = [7, 5] 11 | /// sortedNumbers.insert(3) // Complexity: O(log(n)) 12 | /// print(sortedNumbers) // Output: [3, 5, 7] 13 | /// 14 | /// // Find the index of the first element matching a given predicate much faster (using binary search) than on regular arrays (in `O(log(n))`): 15 | /// let index = sortedNumbers.firstIndex { $0 >= 4 } 16 | /// print(index) // Output: Optional(1) 17 | /// 18 | /// // Get a sub-array from the start up to (excluding) a given index without the need to sort again (in `O(1)`): 19 | /// let prefix = sortedNumbers.prefix(upTo: 2) 20 | /// print(prefix) // Output: [3, 5] 21 | /// 22 | /// // Get a sub-array from a given index to the end (also in `O(1)`): 23 | /// let suffix = sortedNumbers.suffix(from: 1) 24 | /// print(suffix) // Output: [5, 7] 25 | /// 26 | /// // Convert back to a regular array when needed 27 | /// let numbers: [Int] = sortedNumbers.array 28 | /// ``` 29 | public struct SortedArray { 30 | @usableFromInline 31 | internal var internalArray: [Element] 32 | 33 | /// Returns the sorted array of elements. 34 | public var array: [Element] { self.internalArray } 35 | 36 | /// Creates a new, empty array. 37 | /// 38 | /// For example: 39 | /// 40 | /// var emptyArray = SortedArray() 41 | public init() { 42 | internalArray = [] 43 | } 44 | 45 | /// Creates a new SortedArray with a given sequence of elements and sorts its elements. 46 | /// 47 | /// - Complexity: The same as `sort()` on an Array –- probably O(n * log(n)). 48 | /// 49 | /// - Parameters: 50 | /// - array: The array to be initially sorted and saved. 51 | public init(_ sequence: S) where S.Iterator.Element == Element { 52 | self.init(sequence: sequence, preSorted: false) 53 | } 54 | 55 | @usableFromInline 56 | internal init(sequence: S, preSorted: Bool) where S.Iterator.Element == Element { 57 | internalArray = preSorted ? Array(sequence) : Array(sequence).sorted() 58 | } 59 | 60 | /// Returns the first index in which an element of the array satisfies the given predicate. 61 | /// Matching is done using binary search to minimize complexity. 62 | /// 63 | /// - Complexity: O(log(n)) 64 | /// 65 | /// - Parameters: 66 | /// - predicate: The predicate to match the elements against. 67 | /// - Returns: The index of the first matching element or `nil` if none of them matches. 68 | @inlinable 69 | public func firstIndex(where predicate: (Element) -> Bool) -> Int? { 70 | // cover trivial cases 71 | guard !array.isEmpty else { return nil } 72 | 73 | if let first = array.first, predicate(first) { return array.startIndex } 74 | if let last = array.last, !predicate(last) { return nil } 75 | 76 | // binary search for first matching element 77 | var foundMatch = false 78 | var lowerIndex = array.startIndex 79 | var upperIndex = array.endIndex 80 | 81 | while lowerIndex != upperIndex { 82 | let middleIndex = lowerIndex + (upperIndex - lowerIndex) / 2 83 | guard predicate(array[middleIndex]) else { lowerIndex = middleIndex + 1; continue } 84 | 85 | upperIndex = middleIndex 86 | foundMatch = true 87 | } 88 | 89 | guard foundMatch else { return nil } 90 | return lowerIndex 91 | } 92 | 93 | /// Returns a sub array of a SortedArray up to a given index (excluding it) without resorting. 94 | /// 95 | /// - Complexity: O(1) 96 | /// 97 | /// - Parameters: 98 | /// - index: The upper bound index until which to include elements. 99 | /// - Returns: A new SortedArray instance including all elements until the specified index (exluding it). 100 | @inlinable 101 | public func prefix(upTo index: Int) -> SortedArray { 102 | let subarray = Array(array[array.indices.prefix(upTo: index)]) 103 | return SortedArray(sequence: subarray, preSorted: true) 104 | } 105 | 106 | /// Returns a sub array of a SortedArray up to a given index (including it) without resorting. 107 | /// 108 | /// - Complexity: O(1) 109 | /// 110 | /// - Parameters: 111 | /// - index: The upper bound index until which to include elements. 112 | /// - Returns: A new SortedArray instance including all elements until the specified index (including it). 113 | @inlinable 114 | public func prefix(through index: Int) -> SortedArray { 115 | let subarray = Array(array[array.indices.prefix(through: index)]) 116 | return SortedArray(sequence: subarray, preSorted: true) 117 | } 118 | 119 | /// Returns a sub array of a SortedArray starting at a given index without resorting. 120 | /// 121 | /// - Complexity: O(1) 122 | /// 123 | /// - Parameters: 124 | /// - index: The lower bound index from which to start including elements. 125 | /// - Returns: A new SortedArray instance including all elements starting at the specified index. 126 | @inlinable 127 | public func suffix(from index: Int) -> SortedArray { 128 | let subarray = Array(array[array.indices.suffix(from: index)]) 129 | return SortedArray(sequence: subarray, preSorted: true) 130 | } 131 | 132 | /// Adds a new item to the sorted array. 133 | /// 134 | /// - Complexity: O(log(n)) 135 | /// 136 | /// - Parameters: 137 | /// - newElement: The new element to be inserted into the array. 138 | @inlinable 139 | public mutating func insert(_ newElement: Element) { 140 | let insertIndex = internalArray.firstIndex { $0 >= newElement } ?? internalArray.endIndex 141 | internalArray.insert(newElement, at: insertIndex) 142 | } 143 | 144 | /// Adds the contents of a sequence to the SortedArray. 145 | /// 146 | /// - Complexity: O(n * log(n)) 147 | /// 148 | /// - Parameters: 149 | /// - sequence 150 | @inlinable 151 | public mutating func insert(contentsOf sequence: S) where S.Iterator.Element == Element { 152 | sequence.forEach { insert($0) } 153 | } 154 | 155 | /// Removes an item from the sorted array. 156 | /// 157 | /// - Complexity: O(1) 158 | /// 159 | /// - Parameters: 160 | /// - index: The index of the element to remove from the sorted array. 161 | @inlinable 162 | public mutating func remove(at index: Int) { 163 | internalArray.remove(at: index) 164 | } 165 | 166 | /// Removes an item from the sorted array. 167 | /// 168 | /// - Complexity: O(*n*), where *n* is the length of the collection. 169 | @inlinable 170 | public mutating func removeAll(where condition: (Element) -> Bool) { 171 | internalArray.removeAll(where: condition) 172 | } 173 | 174 | /// Accesses a contiguous subrange of the SortedArray's elements. 175 | /// 176 | /// - Parameter 177 | /// - bounds: A range of the SortedArray's indices. The bounds of the range must be valid indices. 178 | @inlinable 179 | public subscript(bounds: Range) -> SortedArray { 180 | SortedArray(sequence: array[bounds], preSorted: true) 181 | } 182 | } 183 | 184 | extension SortedArray: BidirectionalCollection { 185 | /// The position of the first element in a nonempty collection. 186 | /// 187 | /// If the collection is empty, `startIndex` is equal to `endIndex`. 188 | public typealias Index = Array.Index 189 | 190 | /// The position of the first element in a nonempty collection. 191 | /// 192 | /// If the collection is empty, `startIndex` is equal to `endIndex`. 193 | @inlinable public var startIndex: Int { 194 | internalArray.startIndex 195 | } 196 | 197 | /// The collection's "past-the-end" position---that is, the position one greater than the last valid subscript argument. 198 | /// 199 | /// When you need a range that includes the last element of a collection, use the `..<` operator with `endIndex`. 200 | public var endIndex: Int { 201 | internalArray.endIndex 202 | } 203 | 204 | /// Returns the elements of the collection in sorted order. 205 | /// 206 | /// - Returns: An array containing the sorted elements of the collection. 207 | @inlinable 208 | public func sorted() -> [Element] { 209 | internalArray 210 | } 211 | 212 | /// Returns the position immediately after the given index. 213 | /// 214 | /// - Parameter index: A valid index of the collection. `index` must be less than `endIndex`. 215 | /// - Returns: The index value immediately after `index`. 216 | public func index(after index: Int) -> Int { 217 | internalArray.index(after: index) 218 | } 219 | 220 | /// Returns the position immediately before the given index. 221 | /// 222 | /// - Parameter index: A valid index of the collection. `index` must be greater than `startIndex`. 223 | /// - Returns: The index value immediately before `index`. 224 | public func index(before index: Int) -> Int { 225 | internalArray.index(before: index) 226 | } 227 | 228 | /// Accesses the element at the specified position. 229 | /// 230 | /// - Parameter position: The position of the element to access. `position` must be a valid index of the collection. 231 | /// - Returns: The element at the specified index. 232 | public subscript(position: Int) -> Element { 233 | internalArray[position] 234 | } 235 | } 236 | 237 | extension SortedArray: ExpressibleByArrayLiteral { 238 | /// The type of the elements of an array literal. 239 | public typealias ArrayLiteralElement = Element 240 | 241 | /// Creates an instance initialized with the given elements. 242 | /// 243 | /// - Parameter elements: A variadic list of elements of the new array. 244 | public init(arrayLiteral elements: Element...) { 245 | self.init(elements) 246 | } 247 | } 248 | 249 | extension SortedArray: Codable where Element: Codable {} 250 | 251 | extension SortedArray: RandomAccessCollection {} 252 | 253 | extension SortedArray: CustomStringConvertible { 254 | public var description: String { self.array.description } 255 | } 256 | 257 | // - MARK: Migration 258 | extension SortedArray { 259 | @available(*, unavailable, renamed: "firstIndex(where:)") 260 | public func index(where predicate: (Element) -> Bool) -> Int? { fatalError() } 261 | } 262 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/ArrayExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class ArrayExtTests: XCTestCase { 5 | struct T: Equatable { 6 | let a: Int, b: Int 7 | 8 | static func == (lhs: T, rhs: T) -> Bool { 9 | lhs.a == rhs.a && lhs.b == rhs.b 10 | } 11 | } 12 | 13 | let unsortedArray: [T] = [T(a: 0, b: 2), T(a: 1, b: 2), T(a: 2, b: 2), T(a: 3, b: 1), T(a: 4, b: 1), T(a: 5, b: 0)] 14 | let sortedArray: [T] = [T(a: 5, b: 0), T(a: 3, b: 1), T(a: 4, b: 1), T(a: 0, b: 2), T(a: 1, b: 2), T(a: 2, b: 2)] 15 | 16 | func testRandomElements() { 17 | XCTAssertNil(([] as [Int]).randomElements(count: 2)) 18 | XCTAssertEqual([1, 2, 3].randomElements(count: 2)!.count, 2) 19 | XCTAssertEqual([1, 2, 3].randomElements(count: 10)!.count, 10) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/CollectionExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class CollectionExtTests: XCTestCase { 5 | func testTrySubscript() { 6 | let testArray = [0, 1, 2, 3, 20] 7 | 8 | XCTAssertNil(testArray[safe: 8]) 9 | XCTAssert(testArray[safe: -1] == nil) 10 | XCTAssert(testArray[safe: 0] != nil) 11 | XCTAssert(testArray[safe: 4] == testArray[4]) 12 | 13 | let secondTestArray = [Int]() 14 | XCTAssertNil(secondTestArray[safe: 0]) 15 | } 16 | 17 | func testSum() { 18 | let intArray = [1, 2, 3, 4, 5] 19 | XCTAssertEqual(intArray.sum(), 15) 20 | 21 | let doubleArray = [1.0, 2.0, 3.0, 4.0, 5.0] 22 | XCTAssertEqual(doubleArray.sum(), 15.0, accuracy: 0.001) 23 | } 24 | 25 | func testAverage() { 26 | let intArray = [1, 2, 10] 27 | XCTAssertEqual(intArray.average(), 4.333, accuracy: 0.001) 28 | 29 | #if canImport(CoreGraphics) 30 | let averageAsCGFloat: CGFloat = intArray.average() 31 | XCTAssertEqual(averageAsCGFloat, 4.333, accuracy: 0.001) 32 | #endif 33 | 34 | let doubleArray = [1.0, 2.0, 10.0] 35 | XCTAssertEqual(doubleArray.average(), 4.333, accuracy: 0.001) 36 | 37 | #if canImport(CoreGraphics) 38 | let cgFloatArray: [CGFloat] = [1.0, 2.0, 10.0] 39 | XCTAssertEqual(cgFloatArray.average(), 4.333, accuracy: 0.001) 40 | #endif 41 | } 42 | 43 | func testChunks() { 44 | XCTAssertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks(ofSize: 3), [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]) 45 | XCTAssertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks(ofSize: 5), [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/ComparableExtTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @testable import HandySwift 4 | import XCTest 5 | 6 | class ComparableExtTests: XCTestCase { 7 | func testClampedClosedRange() { 8 | let myNum = 3 9 | XCTAssertEqual(myNum.clamped(to: 0 ... 4), 3) 10 | XCTAssertEqual(myNum.clamped(to: 0 ... 2), 2) 11 | XCTAssertEqual(myNum.clamped(to: 4 ... 6), 4) 12 | 13 | let myString = "d" 14 | XCTAssertEqual(myString.clamped(to: "a" ... "e"), "d") 15 | XCTAssertEqual(myString.clamped(to: "a" ... "c"), "c") 16 | XCTAssertEqual(myString.clamped(to: "e" ... "g"), "e") 17 | } 18 | 19 | func testClampedPartialRangeFrom() { 20 | let myNum = 3 21 | XCTAssertEqual(myNum.clamped(to: 2...), 3) 22 | XCTAssertEqual(myNum.clamped(to: 4...), 4) 23 | 24 | let myString = "d" 25 | XCTAssertEqual(myString.clamped(to: "c"...), "d") 26 | XCTAssertEqual(myString.clamped(to: "e"...), "e") 27 | } 28 | 29 | func testClampedPartialRangeThrough() { 30 | let myNum = 3 31 | XCTAssertEqual(myNum.clamped(to: ...4), 3) 32 | XCTAssertEqual(myNum.clamped(to: ...2), 2) 33 | 34 | let myString = "d" 35 | XCTAssertEqual(myString.clamped(to: ..."e"), "d") 36 | XCTAssertEqual(myString.clamped(to: ..."c"), "c") 37 | } 38 | 39 | func testClampClosedRange() { 40 | let myNum = 3 41 | 42 | var myNumCopy = myNum 43 | myNumCopy.clamp(to: 0 ... 4) 44 | XCTAssertEqual(myNumCopy, 3) 45 | 46 | myNumCopy = myNum 47 | myNumCopy.clamp(to: 0 ... 2) 48 | XCTAssertEqual(myNumCopy, 2) 49 | 50 | myNumCopy = myNum 51 | myNumCopy.clamp(to: 4 ... 6) 52 | XCTAssertEqual(myNumCopy, 4) 53 | 54 | let myString = "d" 55 | 56 | var myStringCopy = myString 57 | myStringCopy.clamp(to: "a" ... "e") 58 | XCTAssertEqual(myStringCopy, "d") 59 | 60 | myStringCopy = myString 61 | myStringCopy.clamp(to: "a" ... "c") 62 | XCTAssertEqual(myStringCopy, "c") 63 | 64 | myStringCopy = myString 65 | myStringCopy.clamp(to: "e" ... "g") 66 | XCTAssertEqual(myStringCopy, "e") 67 | } 68 | 69 | func testClampPartialRangeFrom() { 70 | let myNum = 3 71 | 72 | var myNumCopy = myNum 73 | myNumCopy.clamp(to: 2...) 74 | XCTAssertEqual(myNumCopy, 3) 75 | 76 | myNumCopy = myNum 77 | myNumCopy.clamp(to: 4...) 78 | XCTAssertEqual(myNumCopy, 4) 79 | 80 | let myString = "d" 81 | 82 | var myStringCopy = myString 83 | myStringCopy.clamp(to: "c"...) 84 | XCTAssertEqual(myStringCopy, "d") 85 | 86 | myStringCopy = myString 87 | myStringCopy.clamp(to: "e"...) 88 | XCTAssertEqual(myStringCopy, "e") 89 | } 90 | 91 | func testClampPartialRangeThrough() { 92 | let myNum = 3 93 | 94 | var myNumCopy = myNum 95 | myNumCopy.clamp(to: ...4) 96 | XCTAssertEqual(myNumCopy, 3) 97 | 98 | myNumCopy = myNum 99 | myNumCopy.clamp(to: ...2) 100 | XCTAssertEqual(myNumCopy, 2) 101 | 102 | let myString = "d" 103 | 104 | var myStringCopy = myString 105 | myStringCopy.clamp(to: ..."e") 106 | XCTAssertEqual(myStringCopy, "d") 107 | 108 | myStringCopy = myString 109 | myStringCopy.clamp(to: ..."c") 110 | XCTAssertEqual(myStringCopy, "c") 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/DictionaryExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class DictionaryExtTests: XCTestCase { 5 | func testInitWithSameCountKeysAndValues() { 6 | let keys = Array(0 ..< 100) 7 | let values = Array(stride(from: 0, to: 10 * 100, by: 10)) 8 | 9 | let dict = [Int: Int](keys: keys, values: values) 10 | XCTAssertNotNil(dict) 11 | 12 | if let dict = dict { 13 | XCTAssertEqual(dict.keys.count, keys.count) 14 | XCTAssertEqual(dict.values.count, values.count) 15 | XCTAssertEqual(dict[99]!, values.last!) 16 | XCTAssertEqual(dict[0]!, values.first!) 17 | } 18 | } 19 | 20 | func testInitWithDifferentCountKeysAndValues() { 21 | let keys = Array(0 ..< 50) 22 | let values = Array(stride(from: 10, to: 10 * 100, by: 10)) 23 | 24 | let dict = [Int: Int](keys: keys, values: values) 25 | XCTAssertNil(dict) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/DispatchTimeIntervalExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class DispatchTimeIntervalExtTests: XCTestCase { 5 | func testTimeInterval() { 6 | let dispatchTimeInterval = DispatchTimeInterval.milliseconds(500) 7 | let timeInterval = dispatchTimeInterval.timeInterval 8 | 9 | XCTAssertEqual(timeInterval, 0.5, accuracy: 0.001) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/DoubleExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class DoubleExtTests: XCTestCase { 5 | func testRound() { 6 | var price: Double = 2.875 7 | price.round(fractionDigits: 2) 8 | XCTAssertEqual(price, 2.88) 9 | 10 | price = 2.875 11 | price.round(fractionDigits: 2, rule: .down) 12 | XCTAssertEqual(price, 2.87) 13 | } 14 | 15 | func testRounded() { 16 | let price: Double = 2.875 17 | XCTAssertEqual(price.rounded(fractionDigits: 2), 2.88) 18 | XCTAssertEqual(price.rounded(fractionDigits: 2, rule: .down), 2.87) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/FloatExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class FloatExtTests: XCTestCase { 5 | func testRound() { 6 | var price: Float = 2.875 7 | price.round(fractionDigits: 2) 8 | XCTAssertEqual(price, 2.88) 9 | 10 | price = 2.875 11 | price.round(fractionDigits: 2, rule: .down) 12 | XCTAssertEqual(price, 2.87) 13 | } 14 | 15 | func testRounded() { 16 | let price: Float = 2.875 17 | XCTAssertEqual(price.rounded(fractionDigits: 2), 2.88) 18 | XCTAssertEqual(price.rounded(fractionDigits: 2, rule: .down), 2.87) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/IntExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class IntExtTests: XCTestCase { 5 | func testTimesMethod() { 6 | var testString = "" 7 | 8 | 0.times { testString += "." } 9 | XCTAssertEqual(testString, "") 10 | 11 | 3.times { testString += "." } 12 | XCTAssertEqual(testString, "...") 13 | } 14 | 15 | func testTimesMakeMethod() { 16 | var testArray = 0.timesMake { 1 } 17 | XCTAssertEqual(testArray, []) 18 | 19 | testArray = 3.timesMake { 1 } 20 | XCTAssertEqual(testArray, [1, 1, 1]) 21 | 22 | var index = 0 23 | testArray = 3.timesMake { index += 1; return index } 24 | XCTAssertEqual(testArray, [1, 2, 3]) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/NSObjectExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class NSObjectExtTests: XCTestCase { 5 | func testWith() { 6 | #if !os(Linux) 7 | let helloString: NSString? = ("Hello, world".mutableCopy() as? NSMutableString)?.with { $0.append("!") } 8 | XCTAssertEqual(helloString, "Hello, world!") 9 | #endif 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/NSRangeExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class NSRangeExtTests: XCTestCase { 5 | func testInitWithSwiftRange() { 6 | let testStrings = ["Simple String", "👪 👨‍👩‍👦 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧"] 7 | 8 | for string in testStrings { 9 | XCTAssertEqual((string as NSString).substring(with: NSRange(string.fullRange, in: string)), string) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/StringExtTests.swift: -------------------------------------------------------------------------------- 1 | #if canImport(CryptoKit) 2 | import CryptoKit 3 | #endif 4 | @testable import HandySwift 5 | import XCTest 6 | 7 | class StringExtTests: XCTestCase { 8 | func testIsBlank() { 9 | XCTAssertTrue("".isBlank) 10 | XCTAssertTrue(" \t ".isBlank) 11 | XCTAssertTrue("\n".isBlank) 12 | XCTAssertFalse(" . ".isBlank) 13 | XCTAssertFalse("BB-8".isBlank) 14 | } 15 | 16 | func testInitRandomWithLengthAllowedCharactersType() { 17 | 10.times { 18 | XCTAssertEqual(String(randomWithLength: 5, allowedCharactersType: .numeric).count, 5) 19 | XCTAssertFalse(String(randomWithLength: 5, allowedCharactersType: .numeric).contains("a")) 20 | 21 | XCTAssertEqual(String(randomWithLength: 8, allowedCharactersType: .alphaNumeric).count, 8) 22 | XCTAssertFalse(String(randomWithLength: 8, allowedCharactersType: .numeric).contains(".")) 23 | } 24 | } 25 | 26 | func testSample() { 27 | XCTAssertNil("".randomElement()) 28 | XCTAssertNotNil("abc".randomElement()) 29 | XCTAssertTrue("abc".contains("abc".randomElement()!)) 30 | } 31 | 32 | func testSampleWithSize() { 33 | XCTAssertNil(([] as [Int]).randomElements(count: 2)) 34 | XCTAssertEqual([1, 2, 3].randomElements(count: 2)!.count, 2) 35 | XCTAssertEqual([1, 2, 3].randomElements(count: 10)!.count, 10) 36 | } 37 | 38 | func testFullRange() { 39 | let testStrings = ["Simple String", "👪 👨‍👩‍👦 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧"] 40 | 41 | for string in testStrings { 42 | XCTAssertEqual(String(string[string.fullRange]), string) 43 | } 44 | } 45 | 46 | #if canImport(CryptoKit) 47 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 48 | func testEncryptDecryptFullCircle() throws { 49 | let correctKey = SymmetricKey(size: .bits256) 50 | let wrongKey = SymmetricKey(size: .bits256) 51 | 52 | let plainText = "Harry Potter is a 🧙" 53 | let encryptedString = try plainText.encrypted(key: correctKey) 54 | XCTAssertNotEqual(encryptedString, plainText) 55 | XCTAssertEqual(try encryptedString.decrypted(key: correctKey), plainText) 56 | XCTAssertThrowsError(try encryptedString.decrypted(key: wrongKey)) 57 | } 58 | #endif 59 | } 60 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/TimeIntervalExtTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class TimeIntervalExtTests: XCTestCase { 5 | func testUnitInitialization() { 6 | XCTAssertEqual(TimeInterval.days(0.5), 12 * 60 * 60, accuracy: 0.001) 7 | XCTAssertEqual(TimeInterval.hours(0.5), 30 * 60, accuracy: 0.001) 8 | XCTAssertEqual(TimeInterval.minutes(0.5), 30, accuracy: 0.001) 9 | XCTAssertEqual(TimeInterval.seconds(0.5), 0.5, accuracy: 0.001) 10 | XCTAssertEqual(TimeInterval.milliseconds(0.5), 0.5 / 1_000, accuracy: 0.001) 11 | XCTAssertEqual(TimeInterval.microseconds(0.5), 0.5 / 1_000_000, accuracy: 0.001) 12 | XCTAssertEqual(TimeInterval.nanoseconds(0.5), 0.5 / 1_000_000_000, accuracy: 0.001) 13 | } 14 | 15 | func testUnitConversion() { 16 | let timeInterval = TimeInterval.hours(4) 17 | let multipledTimeInterval = timeInterval * 3 18 | 19 | XCTAssertEqual(multipledTimeInterval.days, 0.5, accuracy: 0.001) 20 | XCTAssertEqual(multipledTimeInterval.hours, 12, accuracy: 0.001) 21 | XCTAssertEqual(multipledTimeInterval.minutes, 12 * 60, accuracy: 0.001) 22 | XCTAssertEqual(multipledTimeInterval.seconds, 12 * 60 * 60, accuracy: 0.001) 23 | XCTAssertEqual(multipledTimeInterval.milliseconds, 12 * 60 * 60 * 1_000, accuracy: 0.001) 24 | XCTAssertEqual(multipledTimeInterval.microseconds, 12 * 60 * 60 * 1_000_000, accuracy: 0.001) 25 | XCTAssertEqual(multipledTimeInterval.nanoseconds, 12 * 60 * 60 * 1_000_000_000, accuracy: 0.001) 26 | } 27 | 28 | func testDurationConversion() { 29 | XCTAssertEqual(TimeInterval.milliseconds(0.999).duration().timeInterval.milliseconds, 0.999, accuracy: 0.000001) 30 | XCTAssertEqual(TimeInterval.seconds(2.5).duration().timeInterval.seconds, 2.5, accuracy: 0.001) 31 | XCTAssertEqual(TimeInterval.days(5).duration().timeInterval.days, 5, accuracy: 0.001) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Protocols/WithableTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | private struct TextFile: Withable { 5 | var contents: String 6 | var linesCount: Int 7 | } 8 | 9 | class WithableTests: XCTestCase { 10 | func testWith() { 11 | let textFile = TextFile(contents: "", linesCount: 0) 12 | XCTAssertEqual(textFile.contents, "") 13 | XCTAssertEqual(textFile.linesCount, 0) 14 | 15 | let modifiedTextFile = textFile.with { $0.contents = "Text"; $0.linesCount = 5 } 16 | XCTAssertEqual(textFile.contents, "") 17 | XCTAssertEqual(textFile.linesCount, 0) 18 | XCTAssertEqual(modifiedTextFile.contents, "Text") 19 | XCTAssertEqual(modifiedTextFile.linesCount, 5) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/FrequencyTableTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class FrequencyTableTests: XCTestCase { 5 | func testSample() { 6 | let values = ["Harry", "Hermione", "Ronald"] 7 | let frequencyTable = FrequencyTable(values: values) { [5, 10, 1][values.firstIndex(of: $0)!] } 8 | 9 | var allSamples: [String] = [] 10 | 11 | 16_000.times { allSamples.append(frequencyTable.randomElement()!) } 12 | 13 | let harryCount = allSamples.filter { $0 == "Harry" }.count 14 | XCTAssertGreaterThan(harryCount, 4_000) 15 | XCTAssertLessThan(harryCount, 6_000) 16 | 17 | let hermioneCount = allSamples.filter { $0 == "Hermione" }.count 18 | XCTAssertGreaterThan(hermioneCount, 9_000) 19 | XCTAssertLessThan(hermioneCount, 11_000) 20 | 21 | let ronaldCount = allSamples.filter { $0 == "Ronald" }.count 22 | XCTAssertGreaterThan(ronaldCount, 0) 23 | XCTAssertLessThan(ronaldCount, 2_000) 24 | } 25 | 26 | func testSampleWithSize() { 27 | let values = ["Harry", "Hermione", "Ronald"] 28 | let frequencyTable = FrequencyTable(values: values) { [5, 10, 1][values.firstIndex(of: $0)!] } 29 | 30 | let allSamples: [String] = frequencyTable.randomElements(count: 16_000)! 31 | 32 | let harryCount = allSamples.filter { $0 == "Harry" }.count 33 | XCTAssertGreaterThan(harryCount, 4_000) 34 | XCTAssertLessThan(harryCount, 6_000) 35 | 36 | let hermioneCount = allSamples.filter { $0 == "Hermione" }.count 37 | XCTAssertGreaterThan(hermioneCount, 9_000) 38 | XCTAssertLessThan(hermioneCount, 11_000) 39 | 40 | let ronaldCount = allSamples.filter { $0 == "Ronald" }.count 41 | XCTAssertGreaterThan(ronaldCount, 0) 42 | XCTAssertLessThan(ronaldCount, 2_000) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/GregorianDayTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @testable import HandySwift 4 | import XCTest 5 | 6 | final class GregorianDayTests: XCTestCase { 7 | func testAdvancedByMonths() { 8 | let day = GregorianDay(year: 2024, month: 03, day: 26) 9 | let advancedByAMonth = day.advanced(byMonths: 1) 10 | 11 | XCTAssertEqual(advancedByAMonth.year, 2024) 12 | XCTAssertEqual(advancedByAMonth.month, 04) 13 | XCTAssertEqual(advancedByAMonth.day, 26) 14 | } 15 | 16 | func testReversedByYears() { 17 | let day = GregorianDay(year: 2024, month: 03, day: 26) 18 | let reversedByTwoYears = day.reversed(byYears: 2) 19 | 20 | XCTAssertEqual(reversedByTwoYears.year, 2022) 21 | XCTAssertEqual(reversedByTwoYears.month, 03) 22 | XCTAssertEqual(reversedByTwoYears.day, 26) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/HandyRegexTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | @testable import HandySwift 4 | import XCTest 5 | 6 | class RegexTests: XCTestCase { 7 | func testValidInitialization() { 8 | XCTAssertNoThrow({ try HandyRegex("abc") }) 9 | } 10 | 11 | func testInvalidInitialization() { 12 | do { 13 | _ = try HandyRegex("*") 14 | XCTFail("Regex initialization unexpectedly didn't fail") 15 | } catch {} 16 | } 17 | 18 | func testOptions() { 19 | let regexOptions1: HandyRegex.Options = [.ignoreCase, .ignoreMetacharacters, .anchorsMatchLines, .dotMatchesLineSeparators] 20 | let nsRegexOptions1: NSRegularExpression.Options = [.caseInsensitive, .ignoreMetacharacters, .anchorsMatchLines, .dotMatchesLineSeparators] 21 | 22 | let regexOptions2: HandyRegex.Options = [.ignoreMetacharacters] 23 | let nsRegexOptions2: NSRegularExpression.Options = [.ignoreMetacharacters] 24 | 25 | let regexOptions3: HandyRegex.Options = [] 26 | let nsRegexOptions3: NSRegularExpression.Options = [] 27 | 28 | XCTAssertEqual(regexOptions1.toNSRegularExpressionOptions, nsRegexOptions1) 29 | XCTAssertEqual(regexOptions2.toNSRegularExpressionOptions, nsRegexOptions2) 30 | XCTAssertEqual(regexOptions3.toNSRegularExpressionOptions, nsRegexOptions3) 31 | } 32 | 33 | func testMatchesBool() { 34 | let regex = try? HandyRegex("[1-9]+") 35 | XCTAssertTrue(regex!.matches("5")) 36 | } 37 | 38 | func testFirstMatch() { 39 | let regex = try? HandyRegex("[1-9]?+") 40 | XCTAssertEqual(regex?.firstMatch(in: "5 3 7")?.string, "5") 41 | } 42 | 43 | func testMatches() { 44 | let regex = try? HandyRegex("[1-9]+") 45 | XCTAssertEqual(regex?.matches(in: "5 432 11").map { $0.string }, ["5", "432", "11"]) 46 | 47 | let key = "bi" 48 | let complexRegex = try? HandyRegex(#"<\#(key)>([^<>]+)"#) 49 | XCTAssertEqual( 50 | complexRegex?.matches( 51 | in: 52 | "Add all your tasks in here. We will guide you with the right questions to get them organized." 53 | ).map { $0.string }, 54 | ["tasks", "organized"] 55 | ) 56 | } 57 | 58 | func testReplacingMatches() { 59 | let regex = try? HandyRegex("([1-9]+)") 60 | 61 | let stringAfterReplace1 = regex?.replacingMatches(in: "5 3 7", with: "2") 62 | let stringAfterReplace2 = regex?.replacingMatches(in: "5 3 7", with: "$1") 63 | let stringAfterReplace3 = regex?.replacingMatches(in: "5 3 7", with: "1$1,") 64 | let stringAfterReplace4 = regex?.replacingMatches(in: "5 3 7", with: "2", count: 5) 65 | let stringAfterReplace5 = regex?.replacingMatches(in: "5 3 7", with: "2", count: 2) 66 | 67 | XCTAssertEqual(stringAfterReplace1, "2 2 2") 68 | XCTAssertEqual(stringAfterReplace2, "5 3 7") 69 | XCTAssertEqual(stringAfterReplace3, "15, 13, 17,") 70 | XCTAssertEqual(stringAfterReplace4, "2 2 2") 71 | XCTAssertEqual(stringAfterReplace5, "2 2 7") 72 | } 73 | 74 | func testReplacingMatchesWithSpecialCharacters() { 75 | let testString = "\nSimuliere, wie gut ein \\nE-Fahrzeug zu dir passt\n" 76 | let newValue = "Simuliere, wie gut ein \\nE-Fahrzeug zu dir passt2" 77 | let expectedResult = "\nSimuliere, wie gut ein \\nE-Fahrzeug zu dir passt2\n" 78 | 79 | let regex = try? HandyRegex("(]* name=\"nav_menu_sim_info\"[^>]*>)(.*)()") 80 | let stringAfterReplace1 = regex?.replacingMatches(in: testString, with: "$1\(NSRegularExpression.escapedTemplate(for: newValue))$3") 81 | 82 | XCTAssertEqual(stringAfterReplace1, expectedResult) 83 | } 84 | 85 | func testMatchString() { 86 | let regex = try? HandyRegex("[1-9]+") 87 | let firstMatchString = regex?.firstMatch(in: "abc5def")?.string 88 | XCTAssertEqual(firstMatchString, "5") 89 | } 90 | 91 | func testMatchRange() { 92 | let regex = try? HandyRegex("[1-9]+") 93 | let text = "abc5def" 94 | let firstMatchRange = regex?.firstMatch(in: text)?.range 95 | XCTAssertEqual(firstMatchRange?.lowerBound.utf16Offset(in: text), 3) 96 | XCTAssertEqual(firstMatchRange?.upperBound.utf16Offset(in: text), 4) 97 | } 98 | 99 | func testMatchCaptures() { 100 | let regex = try? HandyRegex("([1-9])(Needed)(Optional)?") 101 | let match1 = regex?.firstMatch(in: "2Needed") 102 | let match2 = regex?.firstMatch(in: "5NeededOptional") 103 | 104 | enum CapturingError: Error { 105 | case indexTooHigh 106 | case noMatch 107 | } 108 | 109 | func captures(at index: Int, forMatch match: HandyRegex.Match?) throws -> String? { 110 | guard let captures = match?.captures else { throw CapturingError.noMatch } 111 | guard captures.count > index else { throw CapturingError.indexTooHigh } 112 | return captures[index] 113 | } 114 | 115 | do { 116 | let match1Capture0 = try captures(at: 0, forMatch: match1) 117 | let match1Capture1 = try captures(at: 1, forMatch: match1) 118 | let match1Capture2 = try captures(at: 2, forMatch: match1) 119 | 120 | let match2Capture0 = try captures(at: 0, forMatch: match2) 121 | let match2Capture1 = try captures(at: 1, forMatch: match2) 122 | let match2Capture2 = try captures(at: 2, forMatch: match2) 123 | 124 | XCTAssertEqual(match1Capture0, "2") 125 | XCTAssertEqual(match1Capture1, "Needed") 126 | XCTAssertNil(match1Capture2) 127 | 128 | XCTAssertEqual(match2Capture0, "5") 129 | XCTAssertEqual(match2Capture1, "Needed") 130 | XCTAssertEqual(match2Capture2, "Optional") 131 | } catch let error as CapturingError { 132 | switch error { 133 | case .indexTooHigh: 134 | XCTFail("Capturing group index is too high.") 135 | 136 | case .noMatch: 137 | XCTFail("The match is nil.") 138 | } 139 | } catch { 140 | XCTFail("An unexpected error occured.") 141 | } 142 | } 143 | 144 | func testMatchStringApplyingTemplate() { 145 | let regex = try? HandyRegex("([1-9])(Needed)") 146 | let match = regex?.firstMatch(in: "1Needed") 147 | XCTAssertEqual(match?.string(applyingTemplate: "Test$1ThatIs$2"), "Test1ThatIsNeeded") 148 | } 149 | 150 | func testEquatable() { 151 | do { 152 | let regex1 = try HandyRegex("abc") 153 | let regex2 = try HandyRegex("abc") 154 | let regex3 = try HandyRegex("cba") 155 | let regex4 = try HandyRegex("abc", options: [.ignoreCase]) 156 | let regex5 = regex1 157 | 158 | XCTAssertEqual(regex1, regex2) 159 | XCTAssertNotEqual(regex1, regex3) 160 | XCTAssertNotEqual(regex1, regex4) 161 | XCTAssertEqual(regex1, regex5) 162 | 163 | XCTAssertNotEqual(regex2, regex3) 164 | XCTAssertNotEqual(regex2, regex4) 165 | XCTAssertEqual(regex2, regex5) 166 | 167 | XCTAssertNotEqual(regex3, regex4) 168 | XCTAssertNotEqual(regex3, regex5) 169 | 170 | XCTAssertNotEqual(regex4, regex5) 171 | } catch { 172 | XCTFail("Sample Regex creation failed.") 173 | } 174 | } 175 | 176 | func testRegexCustomStringConvertible() { 177 | let regex = try? HandyRegex("foo") 178 | XCTAssertEqual(regex?.description, "Regex<\"foo\">") 179 | } 180 | 181 | func testMatchCustomStringConvertible() { 182 | let regex = try? HandyRegex("bar") 183 | let match = regex?.firstMatch(in: "bar")! 184 | XCTAssertEqual(match?.description, "Match<\"bar\">") 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/SortedArrayTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HandySwift 2 | import XCTest 3 | 4 | class SortedArrayTests: XCTestCase { 5 | func testInitialization() { 6 | let intArray: [Int] = [9, 1, 3, 2, 5, 4, 6, 0, 8, 7] 7 | let sortedIntArray = SortedArray(intArray) 8 | 9 | XCTAssertEqual(sortedIntArray.array, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 10 | } 11 | 12 | func testFirstMatchingIndex() { 13 | let emptyArray: [Int] = [] 14 | let sortedEmptyArray = SortedArray(emptyArray) 15 | 16 | XCTAssertNil(sortedEmptyArray.firstIndex { _ in true }) 17 | 18 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 19 | let sortedIntArray = SortedArray(intArray) 20 | 21 | let expectedIndex = 3 22 | let resultingIndex = sortedIntArray.firstIndex { $0 >= 3 } 23 | 24 | XCTAssertEqual(resultingIndex, expectedIndex) 25 | } 26 | 27 | func testSubArrayToIndex() { 28 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 29 | let sortedIntArray = SortedArray(intArray) 30 | 31 | let index = sortedIntArray.firstIndex { $0 > 5 }! 32 | let sortedSubArray = sortedIntArray.prefix(upTo: index) 33 | 34 | XCTAssertEqual(sortedSubArray.array, [0, 1, 2, 3, 4, 5]) 35 | } 36 | 37 | func testSubArrayFromIndex() { 38 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 39 | let sortedIntArray = SortedArray(intArray) 40 | 41 | let index = sortedIntArray.firstIndex { $0 > 5 }! 42 | let sortedSubArray = sortedIntArray.suffix(from: index) 43 | 44 | XCTAssertEqual(sortedSubArray.array, [6, 7, 8, 9]) 45 | } 46 | 47 | func testCollectionFeatures() { 48 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 49 | let sortedIntArray = SortedArray(intArray) 50 | let expectedElementsSum = intArray.reduce(0) { result, element in result + element } 51 | 52 | var forEachElementsSum = 0 53 | sortedIntArray.forEach { forEachElementsSum += $0 } 54 | XCTAssertEqual(forEachElementsSum, expectedElementsSum) 55 | 56 | let reduceElementsSum = sortedIntArray.reduce(0) { result, element in result + element } 57 | XCTAssertEqual(reduceElementsSum, expectedElementsSum) 58 | 59 | let increasedByOneSortedArray = sortedIntArray.map { $0 + 1 } 60 | XCTAssertEqual(increasedByOneSortedArray, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 61 | } 62 | } 63 | --------------------------------------------------------------------------------