├── Images ├── Docs.webp ├── HandySwift.png └── Apps │ ├── Posters.webp │ ├── CrossCraft.webp │ ├── FocusBeats.webp │ ├── FreelanceKit.webp │ ├── FreemiumKit.webp │ ├── TranslateKit.webp │ ├── GuidedGuestMode.webp │ └── PleydiaOrganizer.webp ├── .spi.yml ├── Sources └── HandySwift │ ├── HandySwift.docc │ ├── Resources │ │ ├── HandySwift.png │ │ ├── NewTypes.jpeg │ │ ├── Extensions.jpeg │ │ └── Extensions │ │ │ ├── APIKeys.png │ │ │ ├── SharePuzzle.png │ │ │ ├── MusicPlayer.jpeg │ │ │ ├── ProgressBar.jpeg │ │ │ ├── CrosswordGeneration.png │ │ │ └── PremiumPlanExpires.png │ ├── theme-settings.json │ ├── HandySwift.md │ └── Essentials │ │ └── New Types.md │ ├── Protocols │ ├── AutoConforming.swift │ ├── DivisibleArithmetic.swift │ └── Withable.swift │ ├── Extensions │ ├── JSONEncoderExt.swift │ ├── JSONDecoderExt.swift │ ├── NSRangeExt.swift │ ├── ArrayExt.swift │ ├── RandomAccessCollectionExt.swift │ ├── StringProtocolExt.swift │ ├── DispatchTimeIntervalExt.swift │ ├── SymmetricKeyExt.swift │ ├── DateExt.swift │ ├── IntExt.swift │ ├── FloatExt.swift │ ├── DoubleExt.swift │ ├── CaseIterableExt.swift │ ├── DataExt.swift │ ├── DictionaryExt.swift │ ├── CollectionExt.swift │ ├── ComparableExt.swift │ ├── TimeIntervalExt.swift │ ├── DurationExt.swift │ └── StringExt.swift │ ├── Globals.swift │ └── Types │ ├── OperatingSystem.swift │ ├── FrequencyTable.swift │ ├── Debouncer.swift │ ├── PrintRequestPlugin.swift │ ├── PrintResponsePlugin.swift │ ├── LogRequestPlugin.swift │ ├── LogResponsePlugin.swift │ ├── GregorianTime.swift │ ├── SortedArray.swift │ ├── GregorianDay.swift │ └── HandyRegex.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── .editorconfig ├── Tests └── HandySwiftTests │ ├── Extensions │ ├── NSObjectExtTests.swift │ ├── DispatchTimeIntervalExtTests.swift │ ├── NSRangeExtTests.swift │ ├── FloatExtTests.swift │ ├── DoubleExtTests.swift │ ├── IntExtTests.swift │ ├── ArrayExtTests.swift │ ├── DictionaryExtTests.swift │ ├── CollectionExtTests.swift │ ├── TimeIntervalExtTests.swift │ ├── StringExtTests.swift │ └── ComparableExtTests.swift │ ├── Protocols │ └── WithableTests.swift │ ├── Structs │ ├── GregorianDayTests.swift │ ├── FrequencyTableTests.swift │ ├── SortedArrayTests.swift │ └── HandyRegexTests.swift │ └── Types │ └── RESTClientTests.swift ├── Package.swift ├── .swiftformat ├── .github └── workflows │ └── main.yml ├── LICENSE ├── .gitignore ├── .swift-format ├── CODE_OF_CONDUCT.md └── README.md /Images/Docs.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Docs.webp -------------------------------------------------------------------------------- /Images/HandySwift.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/HandySwift.png -------------------------------------------------------------------------------- /Images/Apps/Posters.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/Posters.webp -------------------------------------------------------------------------------- /Images/Apps/CrossCraft.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/CrossCraft.webp -------------------------------------------------------------------------------- /Images/Apps/FocusBeats.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/FocusBeats.webp -------------------------------------------------------------------------------- /Images/Apps/FreelanceKit.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/FreelanceKit.webp -------------------------------------------------------------------------------- /Images/Apps/FreemiumKit.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/FreemiumKit.webp -------------------------------------------------------------------------------- /Images/Apps/TranslateKit.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/TranslateKit.webp -------------------------------------------------------------------------------- /Images/Apps/GuidedGuestMode.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/GuidedGuestMode.webp -------------------------------------------------------------------------------- /Images/Apps/PleydiaOrganizer.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Images/Apps/PleydiaOrganizer.webp -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [HandySwift] 5 | swift_version: 6.0 6 | -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/HandySwift.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/HandySwift.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/NewTypes.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/NewTypes.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/Extensions.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/APIKeys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/Extensions/APIKeys.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/SharePuzzle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/Extensions/SharePuzzle.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/MusicPlayer.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/Extensions/MusicPlayer.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/ProgressBar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/Extensions/ProgressBar.jpeg -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/CrosswordGeneration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/Extensions/CrosswordGeneration.png -------------------------------------------------------------------------------- /Sources/HandySwift/HandySwift.docc/Resources/Extensions/PremiumPlanExpires.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlineDev/HandySwift/HEAD/Sources/HandySwift/HandySwift.docc/Resources/Extensions/PremiumPlanExpires.png -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/NSObjectExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class NSObjectExtTests: XCTestCase { 6 | func testWith() { 7 | #if !os(Linux) 8 | let helloString: NSString? = ("Hello, world".mutableCopy() as? NSMutableString)?.with { $0.append("!") } 9 | XCTAssertEqual(helloString, "Hello, world!") 10 | #endif 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/DispatchTimeIntervalExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class DispatchTimeIntervalExtTests: XCTestCase { 6 | func testTimeInterval() { 7 | let dispatchTimeInterval = DispatchTimeInterval.milliseconds(500) 8 | let timeInterval = dispatchTimeInterval.timeInterval 9 | 10 | XCTAssertEqual(timeInterval, 0.5, accuracy: 0.001) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # Minimal SwiftFormat config for explicit self insertion only 2 | # Used in conjunction with Apple swift-format for comprehensive formatting 3 | # 4 | # IMPORTANT: Only handles explicit self insertion - all other formatting 5 | # is managed by Apple swift-format to avoid conflicts 6 | 7 | # Swift version 8 | --swift-version 6.1 9 | 10 | # Explicit self rule only 11 | --self insert 12 | 13 | # Use only the redundantSelf rule (exclusive list) 14 | --rules redundantSelf -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/NSRangeExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class NSRangeExtTests: XCTestCase { 6 | func testInitWithSwiftRange() { 7 | let testStrings = ["Simple String", "👪 👨‍👩‍👦 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧"] 8 | 9 | for string in testStrings { 10 | XCTAssertEqual((string as NSString).substring(with: NSRange(string.fullRange, in: string)), string) 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/FloatExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class FloatExtTests: XCTestCase { 6 | func testRound() { 7 | var price: Float = 2.875 8 | price.round(fractionDigits: 2) 9 | XCTAssertEqual(price, 2.88) 10 | 11 | price = 2.875 12 | price.round(fractionDigits: 2, rule: .down) 13 | XCTAssertEqual(price, 2.87) 14 | } 15 | 16 | func testRounded() { 17 | let price: Float = 2.875 18 | XCTAssertEqual(price.rounded(fractionDigits: 2), 2.88) 19 | XCTAssertEqual(price.rounded(fractionDigits: 2, rule: .down), 2.87) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/DoubleExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class DoubleExtTests: XCTestCase { 6 | func testRound() { 7 | var price: Double = 2.875 8 | price.round(fractionDigits: 2) 9 | XCTAssertEqual(price, 2.88) 10 | 11 | price = 2.875 12 | price.round(fractionDigits: 2, rule: .down) 13 | XCTAssertEqual(price, 2.87) 14 | } 15 | 16 | func testRounded() { 17 | let price: Double = 2.875 18 | XCTAssertEqual(price.rounded(fractionDigits: 2), 2.88) 19 | XCTAssertEqual(price.rounded(fractionDigits: 2, rule: .down), 2.87) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Protocols/WithableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | private struct TextFile: Withable { 6 | var contents: String 7 | var linesCount: Int 8 | } 9 | 10 | class WithableTests: XCTestCase { 11 | func testWith() { 12 | let textFile = TextFile(contents: "", linesCount: 0) 13 | XCTAssertEqual(textFile.contents, "") 14 | XCTAssertEqual(textFile.linesCount, 0) 15 | 16 | let modifiedTextFile = textFile.with { 17 | $0.contents = "Text" 18 | $0.linesCount = 5 19 | } 20 | XCTAssertEqual(textFile.contents, "") 21 | XCTAssertEqual(textFile.linesCount, 0) 22 | XCTAssertEqual(modifiedTextFile.contents, "Text") 23 | XCTAssertEqual(modifiedTextFile.linesCount, 5) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/IntExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class IntExtTests: XCTestCase { 6 | func testTimesMethod() { 7 | var testString = "" 8 | 9 | 0.times { testString += "." } 10 | XCTAssertEqual(testString, "") 11 | 12 | 3.times { testString += "." } 13 | XCTAssertEqual(testString, "...") 14 | } 15 | 16 | func testTimesMakeMethod() { 17 | var testArray = 0.timesMake { 1 } 18 | XCTAssertEqual(testArray, []) 19 | 20 | testArray = 3.timesMake { 1 } 21 | XCTAssertEqual(testArray, [1, 1, 1]) 22 | 23 | var index = 0 24 | testArray = 3.timesMake { 25 | index += 1 26 | return index 27 | } 28 | XCTAssertEqual(testArray, [1, 2, 3]) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/ArrayExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class ArrayExtTests: XCTestCase { 6 | struct T: Equatable { 7 | let a: Int, b: Int 8 | 9 | static func == (lhs: T, rhs: T) -> Bool { 10 | lhs.a == rhs.a && lhs.b == rhs.b 11 | } 12 | } 13 | 14 | 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)] 15 | 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)] 16 | 17 | func testRandomElements() { 18 | XCTAssertNil(([] as [Int]).randomElements(count: 2)) 19 | XCTAssertEqual([1, 2, 3].randomElements(count: 2)!.count, 2) 20 | XCTAssertEqual([1, 2, 3].randomElements(count: 10)!.count, 10) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/GregorianDayTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | @testable import HandySwift 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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/DictionaryExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class DictionaryExtTests: XCTestCase { 6 | func testInitWithSameCountKeysAndValues() { 7 | let keys = Array(0..<100) 8 | let values = Array(stride(from: 0, to: 10 * 100, by: 10)) 9 | 10 | let dict = [Int: Int](keys: keys, values: values) 11 | XCTAssertNotNil(dict) 12 | 13 | if let dict = dict { 14 | XCTAssertEqual(dict.keys.count, keys.count) 15 | XCTAssertEqual(dict.values.count, values.count) 16 | XCTAssertEqual(dict[99]!, values.last!) 17 | XCTAssertEqual(dict[0]!, values.first!) 18 | } 19 | } 20 | 21 | func testInitWithDifferentCountKeysAndValues() { 22 | let keys = Array(0..<50) 23 | let values = Array(stride(from: 10, to: 10 * 100, by: 10)) 24 | 25 | let dict = [Int: Int](keys: keys, values: values) 26 | XCTAssertNil(dict) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/Extensions/ArrayExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // - MARK: Migration 4 | extension Array { 5 | @available( 6 | *, 7 | unavailable, 8 | renamed: "sort(by:)", 9 | message: "Since SE-0372 shipped in Swift 5.8 `sort(by:)` is officially stable. Just remove the `stable` parameter." 10 | ) 11 | public mutating func sort(by areInIncreasingOrder: @escaping (Element, Element) -> Bool, stable: Bool) { fatalError() } 12 | 13 | @available( 14 | *, 15 | unavailable, 16 | renamed: "sorted(by:)", 17 | message: "Since SE-0372 shipped in Swift 5.8 `sorted(by:)` is officially stable. Just remove the `stable` parameter." 18 | ) 19 | public func sorted(by areInIncreasingOrder: @escaping (Element, Element) -> Bool, stable: Bool) -> [Element] { fatalError() } 20 | } 21 | 22 | extension Array where Element: Comparable { 23 | @available( 24 | *, 25 | unavailable, 26 | renamed: "sort()", 27 | message: "Since SE-0372 shipped in Swift 5.8 `sort()` is officially stable. Just remove the `stable` parameter." 28 | ) 29 | public mutating func sort(stable: Bool) { fatalError() } 30 | 31 | @available( 32 | *, 33 | unavailable, 34 | renamed: "sorted()", 35 | message: "Since SE-0372 shipped in Swift 5.8 `sorted()` is officially stable. Just remove the `stable` parameter." 36 | ) 37 | public func sorted(stable: Bool) -> [Element] { fatalError() } 38 | } 39 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/CollectionExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class CollectionExtTests: XCTestCase { 6 | func testTrySubscript() { 7 | let testArray = [0, 1, 2, 3, 20] 8 | 9 | XCTAssertNil(testArray[safe: 8]) 10 | XCTAssert(testArray[safe: -1] == nil) 11 | XCTAssert(testArray[safe: 0] != nil) 12 | XCTAssert(testArray[safe: 4] == testArray[4]) 13 | 14 | let secondTestArray: [Int] = [] 15 | XCTAssertNil(secondTestArray[safe: 0]) 16 | } 17 | 18 | func testSum() { 19 | let intArray = [1, 2, 3, 4, 5] 20 | XCTAssertEqual(intArray.sum(), 15) 21 | 22 | let doubleArray = [1.0, 2.0, 3.0, 4.0, 5.0] 23 | XCTAssertEqual(doubleArray.sum(), 15.0, accuracy: 0.001) 24 | } 25 | 26 | func testAverage() { 27 | let intArray = [1, 2, 10] 28 | XCTAssertEqual(intArray.average(), 4.333, accuracy: 0.001) 29 | 30 | #if canImport(CoreGraphics) 31 | let averageAsCGFloat: CGFloat = intArray.average() 32 | XCTAssertEqual(averageAsCGFloat, 4.333, accuracy: 0.001) 33 | #endif 34 | 35 | let doubleArray = [1.0, 2.0, 10.0] 36 | XCTAssertEqual(doubleArray.average(), 4.333, accuracy: 0.001) 37 | 38 | #if canImport(CoreGraphics) 39 | let cgFloatArray: [CGFloat] = [1.0, 2.0, 10.0] 40 | XCTAssertEqual(cgFloatArray.average(), 4.333, accuracy: 0.001) 41 | #endif 42 | } 43 | 44 | func testChunks() { 45 | XCTAssertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks(ofSize: 3), [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]) 46 | XCTAssertEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].chunks(ofSize: 5), [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/FrequencyTableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class FrequencyTableTests: XCTestCase { 6 | func testSample() { 7 | let values = ["Harry", "Hermione", "Ronald"] 8 | let frequencyTable = FrequencyTable(values: values) { [5, 10, 1][values.firstIndex(of: $0)!] } 9 | 10 | var allSamples: [String] = [] 11 | 12 | 16_000.times { allSamples.append(frequencyTable.randomElement()!) } 13 | 14 | let harryCount = allSamples.filter { $0 == "Harry" }.count 15 | XCTAssertGreaterThan(harryCount, 4_000) 16 | XCTAssertLessThan(harryCount, 6_000) 17 | 18 | let hermioneCount = allSamples.filter { $0 == "Hermione" }.count 19 | XCTAssertGreaterThan(hermioneCount, 9_000) 20 | XCTAssertLessThan(hermioneCount, 11_000) 21 | 22 | let ronaldCount = allSamples.filter { $0 == "Ronald" }.count 23 | XCTAssertGreaterThan(ronaldCount, 0) 24 | XCTAssertLessThan(ronaldCount, 2_000) 25 | } 26 | 27 | func testSampleWithSize() { 28 | let values = ["Harry", "Hermione", "Ronald"] 29 | let frequencyTable = FrequencyTable(values: values) { [5, 10, 1][values.firstIndex(of: $0)!] } 30 | 31 | let allSamples: [String] = frequencyTable.randomElements(count: 16_000)! 32 | 33 | let harryCount = allSamples.filter { $0 == "Harry" }.count 34 | XCTAssertGreaterThan(harryCount, 4_000) 35 | XCTAssertLessThan(harryCount, 6_000) 36 | 37 | let hermioneCount = allSamples.filter { $0 == "Hermione" }.count 38 | XCTAssertGreaterThan(hermioneCount, 9_000) 39 | XCTAssertLessThan(hermioneCount, 11_000) 40 | 41 | let ronaldCount = allSamples.filter { $0 == "Ronald" }.count 42 | XCTAssertGreaterThan(ronaldCount, 0) 43 | XCTAssertLessThan(ronaldCount, 2_000) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/TimeIntervalExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class TimeIntervalExtTests: XCTestCase { 6 | func testUnitInitialization() { 7 | XCTAssertEqual(TimeInterval.days(0.5), 12 * 60 * 60, accuracy: 0.001) 8 | XCTAssertEqual(TimeInterval.hours(0.5), 30 * 60, accuracy: 0.001) 9 | XCTAssertEqual(TimeInterval.minutes(0.5), 30, accuracy: 0.001) 10 | XCTAssertEqual(TimeInterval.seconds(0.5), 0.5, accuracy: 0.001) 11 | XCTAssertEqual(TimeInterval.milliseconds(0.5), 0.5 / 1_000, accuracy: 0.001) 12 | XCTAssertEqual(TimeInterval.microseconds(0.5), 0.5 / 1_000_000, accuracy: 0.001) 13 | XCTAssertEqual(TimeInterval.nanoseconds(0.5), 0.5 / 1_000_000_000, accuracy: 0.001) 14 | } 15 | 16 | func testUnitConversion() { 17 | let timeInterval = TimeInterval.hours(4) 18 | let multipledTimeInterval = timeInterval * 3 19 | 20 | XCTAssertEqual(multipledTimeInterval.days, 0.5, accuracy: 0.001) 21 | XCTAssertEqual(multipledTimeInterval.hours, 12, accuracy: 0.001) 22 | XCTAssertEqual(multipledTimeInterval.minutes, 12 * 60, accuracy: 0.001) 23 | XCTAssertEqual(multipledTimeInterval.seconds, 12 * 60 * 60, accuracy: 0.001) 24 | XCTAssertEqual(multipledTimeInterval.milliseconds, 12 * 60 * 60 * 1_000, accuracy: 0.001) 25 | XCTAssertEqual(multipledTimeInterval.microseconds, 12 * 60 * 60 * 1_000_000, accuracy: 0.001) 26 | XCTAssertEqual(multipledTimeInterval.nanoseconds, 12 * 60 * 60 * 1_000_000_000, accuracy: 0.001) 27 | } 28 | 29 | func testDurationConversion() { 30 | XCTAssertEqual(TimeInterval.milliseconds(0.999).duration().timeInterval.milliseconds, 0.999, accuracy: 0.000001) 31 | XCTAssertEqual(TimeInterval.seconds(2.5).duration().timeInterval.seconds, 2.5, accuracy: 0.001) 32 | XCTAssertEqual(TimeInterval.days(5).duration().timeInterval.days, 5, accuracy: 0.001) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/SortedArrayTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | class SortedArrayTests: XCTestCase { 6 | func testInitialization() { 7 | let intArray: [Int] = [9, 1, 3, 2, 5, 4, 6, 0, 8, 7] 8 | let sortedIntArray = SortedArray(intArray) 9 | 10 | XCTAssertEqual(sortedIntArray.array, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 11 | } 12 | 13 | func testFirstMatchingIndex() { 14 | let emptyArray: [Int] = [] 15 | let sortedEmptyArray = SortedArray(emptyArray) 16 | 17 | XCTAssertNil(sortedEmptyArray.firstIndex { _ in true }) 18 | 19 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 20 | let sortedIntArray = SortedArray(intArray) 21 | 22 | let expectedIndex = 3 23 | let resultingIndex = sortedIntArray.firstIndex { $0 >= 3 } 24 | 25 | XCTAssertEqual(resultingIndex, expectedIndex) 26 | } 27 | 28 | func testSubArrayToIndex() { 29 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 30 | let sortedIntArray = SortedArray(intArray) 31 | 32 | let index = sortedIntArray.firstIndex { $0 > 5 }! 33 | let sortedSubArray = sortedIntArray.prefix(upTo: index) 34 | 35 | XCTAssertEqual(sortedSubArray.array, [0, 1, 2, 3, 4, 5]) 36 | } 37 | 38 | func testSubArrayFromIndex() { 39 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 40 | let sortedIntArray = SortedArray(intArray) 41 | 42 | let index = sortedIntArray.firstIndex { $0 > 5 }! 43 | let sortedSubArray = sortedIntArray.suffix(from: index) 44 | 45 | XCTAssertEqual(sortedSubArray.array, [6, 7, 8, 9]) 46 | } 47 | 48 | func testCollectionFeatures() { 49 | let intArray: [Int] = [5, 6, 7, 8, 9, 0, 1, 2, 3, 4] 50 | let sortedIntArray = SortedArray(intArray) 51 | let expectedElementsSum = intArray.reduce(0) { result, element in result + element } 52 | 53 | var forEachElementsSum = 0 54 | sortedIntArray.forEach { forEachElementsSum += $0 } 55 | XCTAssertEqual(forEachElementsSum, expectedElementsSum) 56 | 57 | let reduceElementsSum = sortedIntArray.reduce(0) { result, element in result + element } 58 | XCTAssertEqual(reduceElementsSum, expectedElementsSum) 59 | 60 | let increasedByOneSortedArray = sortedIntArray.map { $0 + 1 } 61 | XCTAssertEqual(increasedByOneSortedArray, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/StringExtTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import HandySwift 4 | 5 | #if canImport(CryptoKit) 6 | import CryptoKit 7 | #endif 8 | 9 | class StringExtTests: XCTestCase { 10 | func testIsBlank() { 11 | XCTAssertTrue("".isBlank) 12 | XCTAssertTrue(" \t ".isBlank) 13 | XCTAssertTrue("\n".isBlank) 14 | XCTAssertFalse(" . ".isBlank) 15 | XCTAssertFalse("BB-8".isBlank) 16 | } 17 | 18 | func testInitRandomWithLengthAllowedCharactersType() { 19 | 10.times { 20 | XCTAssertEqual(String(randomWithLength: 5, allowedCharactersType: .numeric).count, 5) 21 | XCTAssertFalse(String(randomWithLength: 5, allowedCharactersType: .numeric).contains("a")) 22 | 23 | XCTAssertEqual(String(randomWithLength: 8, allowedCharactersType: .alphaNumeric).count, 8) 24 | XCTAssertFalse(String(randomWithLength: 8, allowedCharactersType: .numeric).contains(".")) 25 | } 26 | } 27 | 28 | func testSample() { 29 | XCTAssertNil("".randomElement()) 30 | XCTAssertNotNil("abc".randomElement()) 31 | XCTAssertTrue("abc".contains("abc".randomElement()!)) 32 | } 33 | 34 | func testSampleWithSize() { 35 | XCTAssertNil(([] as [Int]).randomElements(count: 2)) 36 | XCTAssertEqual([1, 2, 3].randomElements(count: 2)!.count, 2) 37 | XCTAssertEqual([1, 2, 3].randomElements(count: 10)!.count, 10) 38 | } 39 | 40 | func testFullRange() { 41 | let testStrings = ["Simple String", "👪 👨‍👩‍👦 👨‍👩‍👧 👨‍👩‍👧‍👦 👨‍👩‍👦‍👦 👨‍👩‍👧‍👧 👨‍👨‍👦 👨‍👨‍👧 👨‍👨‍👧‍👦 👨‍👨‍👦‍👦 👨‍👨‍👧‍👧 👩‍👩‍👦 👩‍👩‍👧 👩‍👩‍👧‍👦 👩‍👩‍👦‍👦 👩‍👩‍👧‍👧"] 42 | 43 | for string in testStrings { 44 | XCTAssertEqual(String(string[string.fullRange]), string) 45 | } 46 | } 47 | 48 | #if canImport(CryptoKit) 49 | @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) 50 | func testEncryptDecryptFullCircle() throws { 51 | let correctKey = SymmetricKey(size: .bits256) 52 | let wrongKey = SymmetricKey(size: .bits256) 53 | 54 | let plainText = "Harry Potter is a 🧙" 55 | let encryptedString = try plainText.encrypted(key: correctKey) 56 | XCTAssertNotEqual(encryptedString, plainText) 57 | XCTAssertEqual(try encryptedString.decrypted(key: correctKey), plainText) 58 | XCTAssertThrowsError(try encryptedString.decrypted(key: wrongKey)) 59 | } 60 | #endif 61 | } 62 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy" : { 3 | "accessLevel" : "private" 4 | }, 5 | "indentConditionalCompilationBlocks" : true, 6 | "indentSwitchCaseLabels" : false, 7 | "indentation" : { 8 | "spaces" : 3 9 | }, 10 | "lineBreakAroundMultilineExpressionChainComponents" : false, 11 | "lineBreakBeforeControlFlowKeywords" : false, 12 | "lineBreakBeforeEachArgument" : true, 13 | "lineBreakBeforeEachGenericRequirement" : false, 14 | "lineLength" : 150, 15 | "maximumBlankLines" : 1, 16 | "multiElementCollectionTrailingCommas" : true, 17 | "noAssignmentInExpressions" : { 18 | "allowedFunctions" : [ 19 | "XCTAssertNoThrow" 20 | ] 21 | }, 22 | "prioritizeKeepingFunctionOutputTogether" : false, 23 | "respectsExistingLineBreaks" : true, 24 | "rules" : { 25 | "AllPublicDeclarationsHaveDocumentation" : true, 26 | "AlwaysUseLiteralForEmptyCollectionInit" : true, 27 | "AlwaysUseLowerCamelCase" : true, 28 | "AmbiguousTrailingClosureOverload" : true, 29 | "BeginDocumentationCommentWithOneLineSummary" : true, 30 | "DoNotUseSemicolons" : true, 31 | "DontRepeatTypeInStaticProperties" : true, 32 | "FileScopedDeclarationPrivacy" : true, 33 | "FullyIndirectEnum" : true, 34 | "GroupNumericLiterals" : true, 35 | "IdentifiersMustBeASCII" : true, 36 | "NeverForceUnwrap" : false, 37 | "NeverUseForceTry" : false, 38 | "NeverUseImplicitlyUnwrappedOptionals" : false, 39 | "NoAccessLevelOnExtensionDeclaration" : true, 40 | "NoAssignmentInExpressions" : true, 41 | "NoBlockComments" : true, 42 | "NoCasesWithOnlyFallthrough" : true, 43 | "NoEmptyTrailingClosureParentheses" : true, 44 | "NoLabelsInCasePatterns" : true, 45 | "NoLeadingUnderscores" : false, 46 | "NoParensAroundConditions" : true, 47 | "NoPlaygroundLiterals" : true, 48 | "NoVoidReturnOnFunctionSignature" : true, 49 | "OmitExplicitReturns" : true, 50 | "OneCasePerLine" : true, 51 | "OneVariableDeclarationPerLine" : true, 52 | "OnlyOneTrailingClosureArgument" : true, 53 | "OrderedImports" : true, 54 | "ReplaceForEachWithForLoop" : true, 55 | "ReturnVoidInsteadOfEmptyTuple" : true, 56 | "TypeNamesShouldBeCapitalized" : true, 57 | "UseEarlyExits" : false, 58 | "UseExplicitNilCheckInConditions" : true, 59 | "UseLetInEveryBoundCaseVariable" : true, 60 | "UseShorthandTypeNames" : true, 61 | "UseSingleLinePropertyGetter" : true, 62 | "UseSynthesizedInitializer" : true, 63 | "UseTripleSlashForDocumentationComments" : true, 64 | "UseWhereClausesInForLoops" : true, 65 | "ValidateDocumentationComments" : true 66 | }, 67 | "spacesAroundRangeFormationOperators" : false, 68 | "tabWidth" : 8, 69 | "version" : 1 70 | } -------------------------------------------------------------------------------- /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/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( 60 | *, 61 | unavailable, 62 | renamed: "merging(_:uniquingKeysWith:)", 63 | message: "Remove the `with:` label and append `{ $1 }` as a `uniquingKeysWith` trailing closure to migrate." 64 | ) 65 | public func merged(with other: [Key: Value]) -> [Key: Value] { fatalError() } 66 | } 67 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Extensions/ComparableExtTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | @testable import HandySwift 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Types/RESTClientTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import HandySwift 5 | 6 | @Suite("RESTClient Tests") 7 | struct RESTClientTests { 8 | 9 | @Test("Complete multipart form-data integration") 10 | func completeMultipartFormDataIntegration() throws { 11 | struct TestModel: Codable, Sendable { 12 | let name: String 13 | let value: Int 14 | } 15 | 16 | let imageData = Data(repeating: 0xFF, count: 100) 17 | let testModel = TestModel(name: "test", value: 42) 18 | 19 | let multipartItems: [RESTClient.MultipartItem] = [ 20 | .init(name: "model", value: .text("gpt-image-1")), 21 | .init(name: "prompt", value: .text("Generate a liquid glass icon")), 22 | .init(name: "quality", value: .text("high")), 23 | .init(name: "style", value: .text("natural")), 24 | .init(name: "size", value: .text("1024x1024")), 25 | .init(name: "image", value: .data(imageData, fileName: "input.png", mimeType: "image/png")), 26 | .init(name: "settings", value: .json(testModel)), 27 | .init(name: "file_no_meta", value: .data(Data("content".utf8), fileName: nil, mimeType: nil)), 28 | ] 29 | 30 | let body = RESTClient.Body.multipart(multipartItems) 31 | let contentType = body.contentType 32 | let httpData = try body.httpData(jsonEncoder: JSONEncoder()) 33 | let dataString = String(decoding: httpData, as: UTF8.self) 34 | 35 | // Extract boundary for testing 36 | #expect(contentType.hasPrefix("multipart/form-data; boundary=")) 37 | let boundary = String(contentType.dropFirst("multipart/form-data; boundary=".count)) 38 | #expect(boundary.hasPrefix("handy-swift-boundary-")) 39 | 40 | // Build expected multipart structure with actual binary data 41 | let imageDataString = String(decoding: imageData, as: UTF8.self) // Convert the actual imageData to string 42 | let expectedStructure = """ 43 | --\(boundary)\r 44 | Content-Disposition: form-data; name="model"\r 45 | \r 46 | gpt-image-1\r 47 | --\(boundary)\r 48 | Content-Disposition: form-data; name="prompt"\r 49 | \r 50 | Generate a liquid glass icon\r 51 | --\(boundary)\r 52 | Content-Disposition: form-data; name="quality"\r 53 | \r 54 | high\r 55 | --\(boundary)\r 56 | Content-Disposition: form-data; name="style"\r 57 | \r 58 | natural\r 59 | --\(boundary)\r 60 | Content-Disposition: form-data; name="size"\r 61 | \r 62 | 1024x1024\r 63 | --\(boundary)\r 64 | Content-Disposition: form-data; name="image"; filename="input.png"\r 65 | Content-Type: image/png\r 66 | \r 67 | \(imageDataString)\r 68 | --\(boundary)\r 69 | Content-Disposition: form-data; name="settings"\r 70 | Content-Type: application/json\r 71 | \r 72 | {"name":"test","value":42}\r 73 | --\(boundary)\r 74 | Content-Disposition: form-data; name="file_no_meta"\r 75 | \r 76 | content\r 77 | --\(boundary)--\r 78 | 79 | """ 80 | 81 | // Verify the complete structure matches exactly what we expect 82 | #expect(dataString == expectedStructure) 83 | 84 | // Additional RFC 2046 compliance checks 85 | #expect(dataString.contains("\r\n")) 86 | #expect(!dataString.replacingOccurrences(of: "\r\n", with: "").contains("\n")) 87 | #expect(dataString.hasSuffix("--\(boundary)--\r\n")) 88 | 89 | // Verify boundary consistency and uniqueness 90 | #expect(dataString.components(separatedBy: "--\(boundary)").count >= 9) // 8 items + final boundary 91 | 92 | let body2 = RESTClient.Body.multipart(multipartItems) 93 | let boundary2 = String(body2.contentType.dropFirst("multipart/form-data; boundary=".count)) 94 | #expect(boundary != boundary2) // Different instances have unique boundaries 95 | 96 | #expect(body.contentType == body.contentType) // Same instance maintains consistent boundary 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /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 | self.valuesWithFrequencies = try values.map { ($0, try frequencyClosure($0)) } 53 | self.frequentValues = self.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/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 = 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 = 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 = self.clamped(to: limits) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /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/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 * self.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 * self.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 * self.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 / self.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 / self.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 / self.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/Types/PrintRequestPlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | /// A plugin for debugging HTTP requests by printing request details to the console. 8 | /// 9 | /// This plugin prints comprehensive request information including URL, HTTP method, headers, and body content. 10 | /// It's designed as a debugging tool and should only be used temporarily during development. 11 | /// 12 | /// ## Usage 13 | /// 14 | /// Add to your RESTClient for debugging: 15 | /// 16 | /// ```swift 17 | /// let client = RESTClient( 18 | /// baseURL: URL(string: "https://api.example.com")!, 19 | /// requestPlugins: [PrintRequestPlugin()], // debugOnly: true, redactAuthHeaders: true by default 20 | /// errorBodyToMessage: { _ in "Error" } 21 | /// ) 22 | /// ``` 23 | /// 24 | /// Both `debugOnly` and `redactAuthHeaders` default to `true` for security. You can disable these built-in protections if needed: 25 | /// 26 | /// ```swift 27 | /// // Default behavior (recommended) 28 | /// PrintRequestPlugin() // debugOnly: true, redactAuthHeaders: true 29 | /// 30 | /// // Disable debugOnly to log in production (discouraged) 31 | /// PrintRequestPlugin(debugOnly: false) 32 | /// 33 | /// // Disable redactAuthHeaders for debugging auth issues (use carefully) 34 | /// PrintRequestPlugin(redactAuthHeaders: false) 35 | /// ``` 36 | /// 37 | /// ## Output Example 38 | /// 39 | /// ``` 40 | /// [RESTClient] Sending POST request to 'https://api.example.com/v1/users' 41 | /// 42 | /// Headers: 43 | /// Authorization: Bearer [redacted] 44 | /// Content-Type: application/json 45 | /// User-Agent: MyApp/1.0 46 | /// 47 | /// Body: 48 | /// { 49 | /// "name": "John Doe", 50 | /// "email": "john@example.com" 51 | /// } 52 | /// ``` 53 | /// 54 | /// - Note: By default, logging only occurs in DEBUG builds and authentication headers are redacted for security. 55 | /// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection. 56 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 57 | public struct PrintRequestPlugin: RESTClient.RequestPlugin { 58 | /// Whether logging should only occur in DEBUG builds. 59 | /// 60 | /// When `true` (default), requests are only logged in DEBUG builds. 61 | /// When `false`, requests are logged in both DEBUG and release builds (not recommended for production). 62 | public let debugOnly: Bool 63 | 64 | /// Whether to redact authentication headers in output. 65 | /// 66 | /// When `true` (default), authentication headers are replaced with "[redacted]" for security. 67 | /// When `false`, the full header value is shown (use carefully for debugging auth issues). 68 | public let redactAuthHeaders: Bool 69 | 70 | /// Creates a new print request plugin. 71 | /// 72 | /// - Parameters: 73 | /// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`. 74 | /// - redactAuthHeaders: Whether to redact authentication headers. Defaults to `true`. 75 | public init(debugOnly: Bool = true, redactAuthHeaders: Bool = true) { 76 | self.debugOnly = debugOnly 77 | self.redactAuthHeaders = redactAuthHeaders 78 | } 79 | 80 | /// Applies the plugin to the request, printing request details if conditions are met. 81 | /// 82 | /// This method is called automatically by RESTClient before sending the request. 83 | /// 84 | /// - Parameter request: The URLRequest to potentially log and pass through unchanged. 85 | public func apply(to request: inout URLRequest) { 86 | if self.debugOnly { 87 | #if DEBUG 88 | self.printRequest(request) 89 | #endif 90 | } else { 91 | self.printRequest(request) 92 | } 93 | } 94 | 95 | /// Prints detailed request information to the console. 96 | /// 97 | /// - Parameter request: The URLRequest to print details for. 98 | private func printRequest(_ request: URLRequest) { 99 | var requestBodyString: String? 100 | if let bodyData = request.httpBody { 101 | requestBodyString = String(data: bodyData, encoding: .utf8) 102 | } 103 | 104 | // Clean headers formatting - sorted alphabetically for consistency 105 | let cleanHeaders = (request.allHTTPHeaderFields ?? [:]) 106 | .sorted { $0.key < $1.key } 107 | .map { " \($0.key): \(self.shouldRedactHeader($0.key) ? "[redacted]" : $0.value)" } 108 | .joined(separator: "\n") 109 | let headersString = cleanHeaders.isEmpty ? " (none)" : "\n\(cleanHeaders)" 110 | 111 | print( 112 | "[RESTClient] Sending \(request.httpMethod!) request to '\(request.url!)'\n\nHeaders:\(headersString)\n\nBody:\n\(requestBodyString ?? "No body")" 113 | ) 114 | } 115 | 116 | /// Determines whether a header should be redacted for security. 117 | /// 118 | /// - Parameter headerName: The header name to check. 119 | /// - Returns: `true` if the header should be redacted when `redactAuthHeaders` is enabled. 120 | private func shouldRedactHeader(_ headerName: String) -> Bool { 121 | guard self.redactAuthHeaders else { return false } 122 | 123 | let lowercasedName = headerName.lowercased() 124 | 125 | // Exact header name matches 126 | let exactMatches = [ 127 | "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", 128 | "x-access-token", "bearer", "apikey", "api-key", "access-token", 129 | "refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id", 130 | ] 131 | 132 | // Substring patterns that indicate sensitive content 133 | let sensitivePatterns = ["password", "secret", "token"] 134 | 135 | return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/PrintResponsePlugin.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(FoundationNetworking) 4 | import FoundationNetworking 5 | #endif 6 | 7 | /// A plugin for debugging HTTP responses by printing response details to the console. 8 | /// 9 | /// This plugin prints comprehensive response information including status code, headers, and body content. 10 | /// It's designed as a debugging tool and should only be used temporarily during development. 11 | /// 12 | /// ## Usage 13 | /// 14 | /// Add to your RESTClient for debugging: 15 | /// 16 | /// ```swift 17 | /// let client = RESTClient( 18 | /// baseURL: URL(string: "https://api.example.com")!, 19 | /// responsePlugins: [PrintResponsePlugin()], // debugOnly: true, redactAuthHeaders: true by default 20 | /// errorBodyToMessage: { _ in "Error" } 21 | /// ) 22 | /// ``` 23 | /// 24 | /// Both `debugOnly` and `redactAuthHeaders` default to `true` for security. You can disable these built-in protections if needed: 25 | /// 26 | /// ```swift 27 | /// // Default behavior (recommended) 28 | /// PrintResponsePlugin() // debugOnly: true, redactAuthHeaders: true 29 | /// 30 | /// // Disable debugOnly to log in production (discouraged) 31 | /// PrintResponsePlugin(debugOnly: false) 32 | /// 33 | /// // Disable redactAuthHeaders for debugging auth issues (use carefully) 34 | /// PrintResponsePlugin(redactAuthHeaders: false) 35 | /// ``` 36 | /// 37 | /// ## Output Example 38 | /// 39 | /// ``` 40 | /// [RESTClient] Response 200 from 'https://api.example.com/v1/users/123' 41 | /// 42 | /// Response headers: 43 | /// Content-Type: application/json 44 | /// Date: Wed, 08 Aug 2025 10:30:00 GMT 45 | /// Server: nginx/1.18.0 46 | /// Set-Cookie: session_token=[redacted] 47 | /// X-Request-ID: req_abc123def456 48 | /// 49 | /// Response body: 50 | /// { 51 | /// "id": 123, 52 | /// "name": "John Doe", 53 | /// "email": "john@example.com", 54 | /// "created_at": "2023-08-01T10:30:00Z" 55 | /// } 56 | /// ``` 57 | /// 58 | /// - Note: By default, logging only occurs in DEBUG builds and authentication headers are redacted for security. 59 | /// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection. 60 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 61 | public struct PrintResponsePlugin: RESTClient.ResponsePlugin { 62 | /// Whether logging should only occur in DEBUG builds. 63 | /// 64 | /// When `true` (default), responses are only logged in DEBUG builds. 65 | /// When `false`, responses are logged in both DEBUG and release builds (not recommended for production). 66 | public let debugOnly: Bool 67 | 68 | /// Whether to redact authentication headers in output. 69 | /// 70 | /// When `true` (default), authentication headers like Authorization and Set-Cookie are replaced with "[redacted]" for security. 71 | /// When `false`, the full header value is shown (use carefully for debugging auth issues). 72 | public let redactAuthHeaders: Bool 73 | 74 | /// Creates a new print response plugin. 75 | /// 76 | /// - Parameters: 77 | /// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`. 78 | /// - redactAuthHeaders: Whether to redact authentication headers. Defaults to `true`. 79 | public init(debugOnly: Bool = true, redactAuthHeaders: Bool = true) { 80 | self.debugOnly = debugOnly 81 | self.redactAuthHeaders = redactAuthHeaders 82 | } 83 | 84 | /// Applies the plugin to the response, printing response details if conditions are met. 85 | /// 86 | /// This method is called automatically by RESTClient after receiving the response. 87 | /// The response and data are passed through unchanged. 88 | /// 89 | /// - Parameters: 90 | /// - response: The HTTPURLResponse to potentially log. 91 | /// - data: The response body data to potentially log. 92 | /// - Throws: Does not throw errors, but passes through any errors from the response processing. 93 | public func apply(to response: inout HTTPURLResponse, data: inout Data) throws { 94 | if self.debugOnly { 95 | #if DEBUG 96 | self.printResponse(response, data: data) 97 | #endif 98 | } else { 99 | self.printResponse(response, data: data) 100 | } 101 | } 102 | 103 | /// Prints detailed response information to the console. 104 | /// 105 | /// - Parameters: 106 | /// - response: The HTTPURLResponse to print details for. 107 | /// - data: The response body data to print. 108 | private func printResponse(_ response: HTTPURLResponse, data: Data) { 109 | var responseBodyString: String? 110 | if !data.isEmpty { 111 | responseBodyString = String(data: data, encoding: .utf8) 112 | } 113 | 114 | // Clean headers formatting - sorted alphabetically for consistency, no AnyHashable wrappers 115 | var headersString = "" 116 | let cleanHeaders = response.allHeaderFields 117 | .compactMapValues { "\($0)" } 118 | .sorted { "\($0.key)" < "\($1.key)" } 119 | .map { " \($0.key): \(self.shouldRedactHeader("\($0.key)") ? "[redacted]" : $0.value)" } 120 | .joined(separator: "\n") 121 | headersString = cleanHeaders.isEmpty ? " (none)" : "\n\(cleanHeaders)" 122 | 123 | print( 124 | "[RESTClient] Response \(response.statusCode) from '\(response.url!)'\n\nResponse headers:\(headersString)\n\nResponse body:\n\(responseBodyString ?? "No body")" 125 | ) 126 | } 127 | 128 | /// Determines whether a header should be redacted for security. 129 | /// 130 | /// - Parameter headerName: The header name to check. 131 | /// - Returns: `true` if the header should be redacted when `redactAuthHeaders` is enabled. 132 | private func shouldRedactHeader(_ headerName: String) -> Bool { 133 | guard self.redactAuthHeaders else { return false } 134 | 135 | let lowercasedName = headerName.lowercased() 136 | 137 | // Exact header name matches 138 | let exactMatches = [ 139 | "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", 140 | "x-access-token", "bearer", "apikey", "api-key", "access-token", 141 | "refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id", 142 | ] 143 | 144 | // Substring patterns that indicate sensitive content 145 | let sensitivePatterns = ["password", "secret", "token"] 146 | 147 | return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /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/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 | ### Networking & Debugging 113 | 114 | Building REST API clients is common in modern apps. HandySwift provides ``RESTClient`` to simplify this: 115 | 116 | ```swift 117 | let client = RESTClient( 118 | baseURL: URL(string: "https://api.example.com")!, 119 | baseHeaders: ["Authorization": "Bearer \(token)"], 120 | errorBodyToMessage: { _ in "Error" } 121 | ) 122 | 123 | let user: User = try await client.fetchAndDecode(method: .get, path: "users/me") 124 | ``` 125 | 126 | When debugging API issues, choose the appropriate logging plugins based on your platform: 127 | 128 | #### For iOS/macOS/tvOS/watchOS Apps (Recommended) 129 | 130 | Use OSLog-based plugins for structured, searchable logging: 131 | 132 | ```swift 133 | let client = RESTClient( 134 | baseURL: URL(string: "https://api.example.com")!, 135 | requestPlugins: [LogRequestPlugin(debugOnly: true)], // Structured request logging 136 | responsePlugins: [LogResponsePlugin(debugOnly: true)], // Structured response logging 137 | errorBodyToMessage: { try JSONDecoder().decode(YourAPIErrorType.self, from: $0).message } 138 | ) 139 | ``` 140 | 141 | These plugins use the modern OSLog framework for structured logging that integrates with Console.app and Instruments for advanced debugging. 142 | 143 | #### For Server-Side Swift (Vapor/Linux) 144 | 145 | Use print-based plugins for console output where OSLog is not available: 146 | 147 | ```swift 148 | let client = RESTClient( 149 | baseURL: URL(string: "https://api.example.com")!, 150 | requestPlugins: [PrintRequestPlugin(debugOnly: true)], // Console request logging 151 | responsePlugins: [PrintResponsePlugin(debugOnly: true)], // Console response logging 152 | errorBodyToMessage: { try JSONDecoder().decode(YourAPIErrorType.self, from: $0).message } 153 | ) 154 | ``` 155 | 156 | These plugins are particularly helpful when adopting new APIs, providing detailed request/response information to help diagnose issues. The `debugOnly: true` parameter ensures they only operate in DEBUG builds, making them safe to leave in your code. 157 | 158 | ## Topics 159 | 160 | ### Collections 161 | 162 | - ``FrequencyTable`` 163 | - ``SortedArray`` 164 | 165 | ### Date & Time 166 | 167 | - ``GregorianDay`` 168 | - ``GregorianTime`` 169 | 170 | ### UI Helpers 171 | 172 | - ``Debouncer`` 173 | - ``OperatingSystem`` (short: ``OS``) 174 | 175 | ### Networking & Debugging 176 | 177 | - ``RESTClient`` 178 | - ``LogRequestPlugin`` (for iOS/macOS/tvOS/watchOS apps) 179 | - ``LogResponsePlugin`` (for iOS/macOS/tvOS/watchOS apps) 180 | - ``PrintRequestPlugin`` (for server-side Swift/Vapor) 181 | - ``PrintResponsePlugin`` (for server-side Swift/Vapor) 182 | 183 | ### Other 184 | 185 | - ``delay(by:qosClass:_:)-8iw4f`` 186 | - ``delay(by:qosClass:_:)-yedf`` 187 | - ``HandyRegex`` 188 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/LogRequestPlugin.swift: -------------------------------------------------------------------------------- 1 | #if canImport(OSLog) 2 | import Foundation 3 | import OSLog 4 | 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | /// A plugin for debugging HTTP requests using OSLog structured logging. 10 | /// 11 | /// This plugin logs comprehensive request information including URL, HTTP method, headers, and body content 12 | /// using the modern OSLog framework for structured, searchable logging in apps. 13 | /// It's designed as a debugging tool and should only be used temporarily during development. 14 | /// 15 | /// ## Usage 16 | /// 17 | /// Add to your RESTClient for debugging: 18 | /// 19 | /// ```swift 20 | /// let client = RESTClient( 21 | /// baseURL: URL(string: "https://api.example.com")!, 22 | /// requestPlugins: [LogRequestPlugin()], // debugOnly: true, redactAuthHeaders: true by default 23 | /// errorBodyToMessage: { _ in "Error" } 24 | /// ) 25 | /// ``` 26 | /// 27 | /// Both `debugOnly` and `redactAuthHeaders` default to `true` for security. You can disable these built-in protections if needed: 28 | /// 29 | /// ```swift 30 | /// // Default behavior (recommended) 31 | /// LogRequestPlugin() // debugOnly: true, redactAuthHeaders: true 32 | /// 33 | /// // Disable debugOnly to log in production (discouraged) 34 | /// LogRequestPlugin(debugOnly: false) 35 | /// 36 | /// // Disable redactAuthHeaders for debugging auth issues (use carefully) 37 | /// LogRequestPlugin(redactAuthHeaders: false) 38 | /// ``` 39 | /// 40 | /// ## Log Output 41 | /// 42 | /// Logs are sent to the unified logging system with subsystem "RESTClient" and category "requests". 43 | /// Use Console.app or Instruments to view structured logs with searchable metadata. 44 | /// 45 | /// Example log entry: 46 | /// ``` 47 | /// [RESTClient] Sending POST request to 'https://api.example.com/v1/users' 48 | /// Headers: Authorization=[redacted], Content-Type=application/json 49 | /// Body: {"name": "John Doe", "email": "john@example.com"} 50 | /// ``` 51 | /// 52 | /// - Note: By default, logging only occurs in DEBUG builds and authentication headers are redacted for security. 53 | /// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection. 54 | /// - Important: For server-side Swift (Vapor), use ``PrintRequestPlugin`` instead as OSLog is not available on Linux. 55 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 56 | public struct LogRequestPlugin: RESTClient.RequestPlugin { 57 | /// Whether logging should only occur in DEBUG builds. 58 | /// 59 | /// When `true` (default), requests are only logged in DEBUG builds. 60 | /// When `false`, requests are logged in both DEBUG and release builds (not recommended for production). 61 | public let debugOnly: Bool 62 | 63 | /// Whether to redact authentication headers in output. 64 | /// 65 | /// When `true` (default), authentication headers are replaced with "[redacted]" for security. 66 | /// When `false`, the full header value is shown (use carefully for debugging auth issues). 67 | public let redactAuthHeaders: Bool 68 | 69 | /// The logger instance used for structured logging. 70 | private let logger = Logger(subsystem: "RESTClient", category: "requests") 71 | 72 | /// Creates a new log request plugin. 73 | /// 74 | /// - Parameters: 75 | /// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`. 76 | /// - redactAuthHeaders: Whether to redact authentication headers. Defaults to `true`. 77 | public init(debugOnly: Bool = true, redactAuthHeaders: Bool = true) { 78 | self.debugOnly = debugOnly 79 | self.redactAuthHeaders = redactAuthHeaders 80 | } 81 | 82 | /// Applies the plugin to the request, logging request details if conditions are met. 83 | /// 84 | /// This method is called automatically by RESTClient before sending the request. 85 | /// 86 | /// - Parameter request: The URLRequest to potentially log and pass through unchanged. 87 | public func apply(to request: inout URLRequest) { 88 | if self.debugOnly { 89 | #if DEBUG 90 | self.logRequest(request) 91 | #endif 92 | } else { 93 | self.logRequest(request) 94 | } 95 | } 96 | 97 | /// Logs detailed request information using OSLog. 98 | /// 99 | /// - Parameter request: The URLRequest to log details for. 100 | private func logRequest(_ request: URLRequest) { 101 | let method = request.httpMethod ?? "UNKNOWN" 102 | let url = request.url?.absoluteString ?? "Unknown URL" 103 | 104 | // Format headers for logging 105 | let headers = (request.allHTTPHeaderFields ?? [:]) 106 | .sorted { $0.key < $1.key } 107 | .map { "\($0.key)=\(self.shouldRedactHeader($0.key) ? "[redacted]" : $0.value)" } 108 | .joined(separator: ", ") 109 | 110 | // Format body for logging 111 | var bodyString = "No body" 112 | if let bodyData = request.httpBody, 113 | let body = String(data: bodyData, encoding: .utf8) 114 | { 115 | bodyString = body 116 | } 117 | 118 | // Log with structured data 119 | self.logger.info( 120 | "Sending \(method, privacy: .public) request to '\(url, privacy: .public)'" 121 | ) 122 | self.logger.debug("Headers: \(headers, privacy: .private)") 123 | self.logger.debug("Body: \(bodyString, privacy: .private)") 124 | } 125 | 126 | /// Determines whether a header should be redacted for security. 127 | /// 128 | /// - Parameter headerName: The header name to check. 129 | /// - Returns: `true` if the header should be redacted when `redactAuthHeaders` is enabled. 130 | private func shouldRedactHeader(_ headerName: String) -> Bool { 131 | guard self.redactAuthHeaders else { return false } 132 | 133 | let lowercasedName = headerName.lowercased() 134 | 135 | // Exact header name matches 136 | let exactMatches = [ 137 | "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", 138 | "x-access-token", "bearer", "apikey", "api-key", "access-token", 139 | "refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id", 140 | ] 141 | 142 | // Substring patterns that indicate sensitive content 143 | let sensitivePatterns = ["password", "secret", "token"] 144 | 145 | return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) } 146 | } 147 | } 148 | #endif 149 | -------------------------------------------------------------------------------- /Sources/HandySwift/Types/LogResponsePlugin.swift: -------------------------------------------------------------------------------- 1 | #if canImport(OSLog) 2 | import Foundation 3 | import OSLog 4 | 5 | #if canImport(FoundationNetworking) 6 | import FoundationNetworking 7 | #endif 8 | 9 | /// A plugin for debugging HTTP responses using OSLog structured logging. 10 | /// 11 | /// This plugin logs comprehensive response information including status code, headers, and body content 12 | /// using the modern OSLog framework for structured, searchable logging in apps. 13 | /// It's designed as a debugging tool and should only be used temporarily during development. 14 | /// 15 | /// ## Usage 16 | /// 17 | /// Add to your RESTClient for debugging: 18 | /// 19 | /// ```swift 20 | /// let client = RESTClient( 21 | /// baseURL: URL(string: "https://api.example.com")!, 22 | /// responsePlugins: [LogResponsePlugin()], // debugOnly: true, redactAuthHeaders: true by default 23 | /// errorBodyToMessage: { _ in "Error" } 24 | /// ) 25 | /// ``` 26 | /// 27 | /// Both `debugOnly` and `redactAuthHeaders` default to `true` for security. You can disable these built-in protections if needed: 28 | /// 29 | /// ```swift 30 | /// // Default behavior (recommended) 31 | /// LogResponsePlugin() // debugOnly: true, redactAuthHeaders: true 32 | /// 33 | /// // Disable debugOnly to log in production (discouraged) 34 | /// LogResponsePlugin(debugOnly: false) 35 | /// 36 | /// // Disable redactAuthHeaders for debugging auth issues (use carefully) 37 | /// LogResponsePlugin(redactAuthHeaders: false) 38 | /// ``` 39 | /// 40 | /// ## Log Output 41 | /// 42 | /// Logs are sent to the unified logging system with subsystem "RESTClient" and category "responses". 43 | /// Use Console.app or Instruments to view structured logs with searchable metadata. 44 | /// 45 | /// Example log entry: 46 | /// ``` 47 | /// [RESTClient] Response 200 from 'https://api.example.com/v1/users/123' 48 | /// Headers: Content-Type=application/json, Set-Cookie=[redacted] 49 | /// Body: {"id": 123, "name": "John Doe", "email": "john@example.com"} 50 | /// ``` 51 | /// 52 | /// - Note: By default, logging only occurs in DEBUG builds and authentication headers are redacted for security. 53 | /// - Important: The plugin is safe to leave in your code with default settings thanks to `debugOnly` protection. 54 | /// - Important: For server-side Swift (Vapor), use ``PrintResponsePlugin`` instead as OSLog is not available on Linux. 55 | @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) 56 | public struct LogResponsePlugin: RESTClient.ResponsePlugin { 57 | /// Whether logging should only occur in DEBUG builds. 58 | /// 59 | /// When `true` (default), responses are only logged in DEBUG builds. 60 | /// When `false`, responses are logged in both DEBUG and release builds (not recommended for production). 61 | public let debugOnly: Bool 62 | 63 | /// Whether to redact authentication headers in output. 64 | /// 65 | /// When `true` (default), authentication headers like Authorization and Set-Cookie are replaced with "[redacted]" for security. 66 | /// When `false`, the full header value is shown (use carefully for debugging auth issues). 67 | public let redactAuthHeaders: Bool 68 | 69 | /// The logger instance used for structured logging. 70 | private let logger = Logger(subsystem: "RESTClient", category: "responses") 71 | 72 | /// Creates a new log response plugin. 73 | /// 74 | /// - Parameters: 75 | /// - debugOnly: Whether logging should only occur in DEBUG builds. Defaults to `true`. 76 | /// - redactAuthHeaders: Whether to redact authentication headers. Defaults to `true`. 77 | public init(debugOnly: Bool = true, redactAuthHeaders: Bool = true) { 78 | self.debugOnly = debugOnly 79 | self.redactAuthHeaders = redactAuthHeaders 80 | } 81 | 82 | /// Applies the plugin to the response, logging response details if conditions are met. 83 | /// 84 | /// This method is called automatically by RESTClient after receiving the response. 85 | /// The response and data are passed through unchanged. 86 | /// 87 | /// - Parameters: 88 | /// - response: The HTTPURLResponse to potentially log. 89 | /// - data: The response body data to potentially log. 90 | /// - Throws: Does not throw errors, but passes through any errors from the response processing. 91 | public func apply(to response: inout HTTPURLResponse, data: inout Data) throws { 92 | if self.debugOnly { 93 | #if DEBUG 94 | self.logResponse(response, data: data) 95 | #endif 96 | } else { 97 | self.logResponse(response, data: data) 98 | } 99 | } 100 | 101 | /// Logs detailed response information using OSLog. 102 | /// 103 | /// - Parameters: 104 | /// - response: The HTTPURLResponse to log details for. 105 | /// - data: The response body data to log. 106 | private func logResponse(_ response: HTTPURLResponse, data: Data) { 107 | let statusCode = response.statusCode 108 | let url = response.url?.absoluteString ?? "Unknown URL" 109 | 110 | // Format headers for logging 111 | let headers = response.allHeaderFields 112 | .compactMapValues { "\($0)" } 113 | .sorted { "\($0.key)" < "\($1.key)" } 114 | .map { "\($0.key)=\(self.shouldRedactHeader("\($0.key)") ? "[redacted]" : $0.value)" } 115 | .joined(separator: ", ") 116 | 117 | // Format body for logging 118 | var bodyString = "No body" 119 | if !data.isEmpty, 120 | let body = String(data: data, encoding: .utf8) 121 | { 122 | bodyString = body 123 | } 124 | 125 | // Log with structured data 126 | self.logger.info( 127 | "Response \(statusCode, privacy: .public) from '\(url, privacy: .public)'" 128 | ) 129 | self.logger.debug("Headers: \(headers, privacy: .private)") 130 | self.logger.debug("Body: \(bodyString, privacy: .private)") 131 | } 132 | 133 | /// Determines whether a header should be redacted for security. 134 | /// 135 | /// - Parameter headerName: The header name to check. 136 | /// - Returns: `true` if the header should be redacted when `redactAuthHeaders` is enabled. 137 | private func shouldRedactHeader(_ headerName: String) -> Bool { 138 | guard self.redactAuthHeaders else { return false } 139 | 140 | let lowercasedName = headerName.lowercased() 141 | 142 | // Exact header name matches 143 | let exactMatches = [ 144 | "authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token", 145 | "x-access-token", "bearer", "apikey", "api-key", "access-token", 146 | "refresh-token", "jwt", "session-token", "csrf-token", "x-csrf-token", "x-session-id", 147 | ] 148 | 149 | // Substring patterns that indicate sensitive content 150 | let sensitivePatterns = ["password", "secret", "token"] 151 | 152 | return exactMatches.contains(lowercasedName) || sensitivePatterns.contains { lowercasedName.contains($0) } 153 | } 154 | } 155 | #endif 156 | -------------------------------------------------------------------------------- /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( 162 | *, 163 | deprecated, 164 | renamed: "GregorianTime", 165 | message: "Use GregorianTime instead. This type has been renamed for better clarity and consistency." 166 | ) 167 | public typealias GregorianTimeOfDay = GregorianTime 168 | -------------------------------------------------------------------------------- /Tests/HandySwiftTests/Structs/HandyRegexTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | @testable import HandySwift 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 | -------------------------------------------------------------------------------- /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/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 | self.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 | self.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 !self.array.isEmpty else { return nil } 72 | 73 | if let first = array.first, predicate(first) { return self.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 = self.array.startIndex 79 | var upperIndex = self.array.endIndex 80 | 81 | while lowerIndex != upperIndex { 82 | let middleIndex = lowerIndex + (upperIndex - lowerIndex) / 2 83 | guard predicate(self.array[middleIndex]) else { 84 | lowerIndex = middleIndex + 1 85 | continue 86 | } 87 | 88 | upperIndex = middleIndex 89 | foundMatch = true 90 | } 91 | 92 | guard foundMatch else { return nil } 93 | return lowerIndex 94 | } 95 | 96 | /// Returns a sub array of a SortedArray up to a given index (excluding it) without resorting. 97 | /// 98 | /// - Complexity: O(1) 99 | /// 100 | /// - Parameters: 101 | /// - index: The upper bound index until which to include elements. 102 | /// - Returns: A new SortedArray instance including all elements until the specified index (exluding it). 103 | @inlinable 104 | public func prefix(upTo index: Int) -> SortedArray { 105 | let subarray = Array(array[array.indices.prefix(upTo: index)]) 106 | return SortedArray(sequence: subarray, preSorted: true) 107 | } 108 | 109 | /// Returns a sub array of a SortedArray up to a given index (including it) without resorting. 110 | /// 111 | /// - Complexity: O(1) 112 | /// 113 | /// - Parameters: 114 | /// - index: The upper bound index until which to include elements. 115 | /// - Returns: A new SortedArray instance including all elements until the specified index (including it). 116 | @inlinable 117 | public func prefix(through index: Int) -> SortedArray { 118 | let subarray = Array(array[array.indices.prefix(through: index)]) 119 | return SortedArray(sequence: subarray, preSorted: true) 120 | } 121 | 122 | /// Returns a sub array of a SortedArray starting at a given index without resorting. 123 | /// 124 | /// - Complexity: O(1) 125 | /// 126 | /// - Parameters: 127 | /// - index: The lower bound index from which to start including elements. 128 | /// - Returns: A new SortedArray instance including all elements starting at the specified index. 129 | @inlinable 130 | public func suffix(from index: Int) -> SortedArray { 131 | let subarray = Array(array[array.indices.suffix(from: index)]) 132 | return SortedArray(sequence: subarray, preSorted: true) 133 | } 134 | 135 | /// Adds a new item to the sorted array. 136 | /// 137 | /// - Complexity: O(log(n)) 138 | /// 139 | /// - Parameters: 140 | /// - newElement: The new element to be inserted into the array. 141 | @inlinable 142 | public mutating func insert(_ newElement: Element) { 143 | let insertIndex = self.internalArray.firstIndex { $0 >= newElement } ?? self.internalArray.endIndex 144 | self.internalArray.insert(newElement, at: insertIndex) 145 | } 146 | 147 | /// Adds the contents of a sequence to the SortedArray. 148 | /// 149 | /// - Complexity: O(n * log(n)) 150 | /// 151 | /// - Parameters: 152 | /// - sequence 153 | @inlinable 154 | public mutating func insert(contentsOf sequence: S) where S.Iterator.Element == Element { 155 | sequence.forEach { self.insert($0) } 156 | } 157 | 158 | /// Removes an item from the sorted array. 159 | /// 160 | /// - Complexity: O(1) 161 | /// 162 | /// - Parameters: 163 | /// - index: The index of the element to remove from the sorted array. 164 | @inlinable 165 | public mutating func remove(at index: Int) { 166 | self.internalArray.remove(at: index) 167 | } 168 | 169 | /// Removes an item from the sorted array. 170 | /// 171 | /// - Complexity: O(*n*), where *n* is the length of the collection. 172 | @inlinable 173 | public mutating func removeAll(where condition: (Element) -> Bool) { 174 | self.internalArray.removeAll(where: condition) 175 | } 176 | 177 | /// Accesses a contiguous subrange of the SortedArray's elements. 178 | /// 179 | /// - Parameter 180 | /// - bounds: A range of the SortedArray's indices. The bounds of the range must be valid indices. 181 | @inlinable 182 | public subscript(bounds: Range) -> SortedArray { 183 | SortedArray(sequence: self.array[bounds], preSorted: true) 184 | } 185 | } 186 | 187 | extension SortedArray: BidirectionalCollection { 188 | /// The position of the first element in a nonempty collection. 189 | /// 190 | /// If the collection is empty, `startIndex` is equal to `endIndex`. 191 | public typealias Index = Array.Index 192 | 193 | /// The position of the first element in a nonempty collection. 194 | /// 195 | /// If the collection is empty, `startIndex` is equal to `endIndex`. 196 | @inlinable public var startIndex: Int { 197 | self.internalArray.startIndex 198 | } 199 | 200 | /// The collection's "past-the-end" position---that is, the position one greater than the last valid subscript argument. 201 | /// 202 | /// When you need a range that includes the last element of a collection, use the `..<` operator with `endIndex`. 203 | public var endIndex: Int { 204 | self.internalArray.endIndex 205 | } 206 | 207 | /// Returns the elements of the collection in sorted order. 208 | /// 209 | /// - Returns: An array containing the sorted elements of the collection. 210 | @inlinable 211 | public func sorted() -> [Element] { 212 | self.internalArray 213 | } 214 | 215 | /// Returns the position immediately after the given index. 216 | /// 217 | /// - Parameter index: A valid index of the collection. `index` must be less than `endIndex`. 218 | /// - Returns: The index value immediately after `index`. 219 | public func index(after index: Int) -> Int { 220 | self.internalArray.index(after: index) 221 | } 222 | 223 | /// Returns the position immediately before the given index. 224 | /// 225 | /// - Parameter index: A valid index of the collection. `index` must be greater than `startIndex`. 226 | /// - Returns: The index value immediately before `index`. 227 | public func index(before index: Int) -> Int { 228 | self.internalArray.index(before: index) 229 | } 230 | 231 | /// Accesses the element at the specified position. 232 | /// 233 | /// - Parameter position: The position of the element to access. `position` must be a valid index of the collection. 234 | /// - Returns: The element at the specified index. 235 | public subscript(position: Int) -> Element { 236 | self.internalArray[position] 237 | } 238 | } 239 | 240 | extension SortedArray: ExpressibleByArrayLiteral { 241 | /// The type of the elements of an array literal. 242 | public typealias ArrayLiteralElement = Element 243 | 244 | /// Creates an instance initialized with the given elements. 245 | /// 246 | /// - Parameter elements: A variadic list of elements of the new array. 247 | public init(arrayLiteral elements: Element...) { 248 | self.init(elements) 249 | } 250 | } 251 | 252 | extension SortedArray: Codable where Element: Codable {} 253 | 254 | extension SortedArray: RandomAccessCollection {} 255 | 256 | extension SortedArray: CustomStringConvertible { 257 | public var description: String { self.array.description } 258 | } 259 | 260 | // - MARK: Migration 261 | extension SortedArray { 262 | @available(*, unavailable, renamed: "firstIndex(where:)") 263 | public func index(where predicate: (Element) -> Bool) -> Int? { fatalError() } 264 | } 265 | -------------------------------------------------------------------------------- /Sources/HandySwift/Extensions/StringExt.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | #if canImport(CryptoKit) 4 | import CryptoKit 5 | #endif 6 | 7 | extension String { 8 | /// Checks if the string contains any characters other than whitespace or newline characters. 9 | /// This can be useful for validating input fields where a non-empty value is required. 10 | /// 11 | /// Example: 12 | /// ```swift 13 | /// " \t ".isBlank // => true 14 | /// "Hello".isBlank // => false 15 | /// ``` 16 | /// 17 | /// - Returns: `true` if the string contains non-whitespace characters, `false` otherwise. 18 | public var isBlank: Bool { self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } 19 | 20 | /// Returns the range containing the full string. 21 | /// Useful for operations that require a `Range`, such as modifications and substring extraction. 22 | /// 23 | /// Example: 24 | /// ```swift 25 | /// let unicodeString = "Hello composed unicode symbols! 👨‍👩‍👧‍👦👨‍👨‍👦‍👦👩‍👩‍👧‍👧" 26 | /// unicodeString[unicodeString.fullRange] // => same string 27 | /// ``` 28 | /// 29 | /// - Returns: The range representing the full string. 30 | public var fullRange: Range { 31 | self.startIndex.. NSRange representing the full string 41 | /// ``` 42 | /// 43 | /// - Returns: The NSRange representation of the full string range. 44 | public var fullNSRange: NSRange { 45 | NSRange(self.fullRange, in: self) 46 | } 47 | 48 | /// Creates a new instance with a random numeric/alphabetic/alphanumeric string of given length. 49 | /// This is useful for generating random identifiers, test data, or any scenario where random strings are needed. 50 | /// 51 | /// Examples: 52 | /// ```swift 53 | /// String(randomWithLength: 4, allowedCharactersType: .numeric) // => "8503" 54 | /// String(randomWithLength: 6, allowedCharactersType: .alphabetic) // => "ysTUzU" 55 | /// String(randomWithLength: 8, allowedCharactersType: .alphaNumeric) // => "2TgM5sUG" 56 | /// ``` 57 | /// 58 | /// - Parameters: 59 | /// - randomWithLength: The length of the random string to create. 60 | /// - allowedCharactersType: The type of allowed characters, see enum ``AllowedCharacters``. 61 | public init(randomWithLength length: Int, allowedCharactersType: AllowedCharacters) { 62 | let allowedCharsString: String = { 63 | switch allowedCharactersType { 64 | case .numeric: 65 | return "0123456789" 66 | 67 | case .alphabetic: 68 | return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 69 | 70 | case .alphaNumeric: 71 | return "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 72 | 73 | case let .allCharactersIn(allowedCharactersString): 74 | return allowedCharactersString 75 | } 76 | }() 77 | 78 | self.init(allowedCharsString.randomElements(count: length)!) 79 | } 80 | 81 | /// 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. 82 | /// 83 | /// Example: 84 | /// ```swift 85 | /// let allowedChars = "abcdefghijklmnopqrstuvwxyz" 86 | /// let randomChars = allowedChars.randomElements(count: 5) // Example output: "xkqoi" 87 | /// ``` 88 | /// 89 | /// - Parameters: 90 | /// - count: The number of random characters wanted. 91 | /// - Returns: A string with the given number of random characters or `nil` if empty. 92 | @inlinable 93 | public func randomElements(count: Int) -> String? { 94 | guard !self.isEmpty else { return nil } 95 | return String(count.timesMake { self.randomElement()! }) 96 | } 97 | } 98 | 99 | extension String { 100 | /// The type of allowed characters. 101 | /// This is used in conjunction with `init(randomWithLength:allowedCharactersType:)` to specify the characters that can be included in the random string. 102 | public enum AllowedCharacters { 103 | /// Allow all numbers from 0 to 9. Useful for numeric identifiers or pin codes. 104 | case numeric 105 | /// Allow all alphabetic characters ignoring case. Useful for textual data where numbers are not needed. 106 | case alphabetic 107 | /// Allow both numbers and alphabetic characters ignoring case. Useful for alphanumeric identifiers. 108 | case alphaNumeric 109 | /// Allow all characters appearing within the specified string. This gives you full control over the characters that can appear in the random string. 110 | case allCharactersIn(String) 111 | } 112 | } 113 | 114 | #if canImport(CryptoKit) 115 | extension String { 116 | /// Error types that may occur during cryptographic operations. 117 | public enum CryptingError: LocalizedError { 118 | case convertingStringToDataFailed 119 | case decryptingDataFailed 120 | case convertingDataToStringFailed 121 | 122 | public var errorDescription: String? { 123 | switch self { 124 | case .convertingDataToStringFailed: 125 | return "Converting Data to String failed." 126 | 127 | case .decryptingDataFailed: 128 | return "Decrypting Data failed." 129 | 130 | case .convertingStringToDataFailed: 131 | return "Converting String to Data failed." 132 | } 133 | } 134 | } 135 | 136 | /// Encrypts this plain text `String` with the given key using AES.GCM and returns a base64 encoded representation of the encrypted data. 137 | /// This method is useful for securing sensitive information before storing or transmitting it. 138 | /// 139 | /// Example: 140 | /// ```swift 141 | /// let key = SymmetricKey(size: .bits256) 142 | /// let plainText = "Sensitive information" 143 | /// let encryptedString = try plainText.encrypted(key: key) 144 | /// print(encryptedString) // Encrypted base64 string 145 | /// ``` 146 | /// 147 | /// - Parameter key: The symmetric key used for encryption. 148 | /// - Returns: A base64 encoded representation of the encrypted data. 149 | /// - Throws: A ``CryptingError`` if encryption fails. 150 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 151 | public func encrypted(key: SymmetricKey) throws -> String { 152 | guard let plainData = self.data(using: .utf8) else { 153 | throw CryptingError.convertingStringToDataFailed 154 | } 155 | 156 | let encryptedData = try plainData.encrypted(key: key) 157 | return encryptedData.base64EncodedString() 158 | } 159 | 160 | /// Decrypts this base64 encoded representation of encrypted data with the given key using AES.GCM and returns the decrypted plain text `String`. 161 | /// This method allows the secure transmission or storage of sensitive information to be reversed, returning the original plain text. 162 | /// 163 | /// Example: 164 | /// ```swift 165 | /// let key = SymmetricKey(size: .bits256) 166 | /// let encryptedString = "Base64EncodedEncryptedString" 167 | /// let decryptedString = try encryptedString.decrypted(key: key) 168 | /// print(decryptedString) // Original sensitive information 169 | /// ``` 170 | /// 171 | /// - Parameter key: The symmetric key used for decryption. 172 | /// - Returns: The decrypted plain text `String`. 173 | /// - Throws: A ``CryptingError`` if decryption fails. 174 | @available(iOS 13, macOS 10.15, tvOS 13, visionOS 1, watchOS 6, *) 175 | public func decrypted(key: SymmetricKey) throws -> String { 176 | guard let encryptedData = Data(base64Encoded: self) else { 177 | throw CryptingError.decryptingDataFailed 178 | } 179 | 180 | let plainData = try encryptedData.decrypted(key: key) 181 | guard let plainString = String(data: plainData, encoding: .utf8) else { 182 | throw CryptingError.convertingDataToStringFailed 183 | } 184 | 185 | return plainString 186 | } 187 | 188 | /// Splits the String into word tokens that are folded for case-insensitive, diacritics-insensitive, and width-insensitive operations such as search. 189 | /// 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. 190 | /// 191 | /// - 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. 192 | /// - Returns: An array of normalized, tokenized strings. 193 | /// 194 | /// ## Example: 195 | /// ``` 196 | /// let sentence = "Café au lait" 197 | /// let tokens = sentence.tokenized() 198 | /// print(tokens) // Output: ["cafe", "au", "lait"] 199 | /// ``` 200 | public func tokenized(locale: Locale? = nil) -> [String] { 201 | self.components(separatedBy: .whitespacesAndNewlines).map { word in 202 | word.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale) 203 | } 204 | } 205 | 206 | /// Splits both the current string and the search text into word tokens and performs a case-insensitive, diacritics-insensitive search. 207 | /// 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. 208 | /// 209 | /// - Parameters: 210 | /// - searchText: The text to search for within this String. 211 | /// - 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. 212 | /// - Returns: `true` if all tokens from the search text are prefixes of any token in this String; otherwise, `false`. 213 | /// 214 | /// ## Example: 215 | /// ``` 216 | /// let text = "Terms and Conditions" 217 | /// let searchResult = text.matchesTokenizedPrefixes(in: "ter con") 218 | /// print(searchResult) // Output: true 219 | /// ``` 220 | public func matchesTokenizedPrefixes(in searchText: String, locale: Locale? = nil) -> Bool { 221 | let tokens = self.tokenized(locale: locale) 222 | return searchText.tokenized(locale: locale).allSatisfy { searchToken in 223 | tokens.contains { $0.hasPrefix(searchToken) } 224 | } 225 | } 226 | } 227 | #endif 228 | 229 | // - MARK: Migration 230 | extension String { 231 | @available(*, unavailable, renamed: "randomElement()") 232 | public var sample: Character? { fatalError() } 233 | 234 | @available( 235 | *, 236 | unavailable, 237 | renamed: "trimmingCharacters(in:)", 238 | message: "Pass `.whitespacesAndNewlines` to the functions `in` parameter for same behavior." 239 | ) 240 | public func stripped() -> String { fatalError() } 241 | 242 | @available(*, unavailable, renamed: "randomElements(count:)") 243 | public func sample(size: Int) -> String? { fatalError() } 244 | } 245 | -------------------------------------------------------------------------------- /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 { 93 | $0.year += overflowingYears 94 | $0.month = newMonth + 1 95 | } 96 | } 97 | 98 | /// Reverses the date by the specified number of months. 99 | /// 100 | /// - Parameter months: The number of months to reverse the date by. 101 | /// - Returns: A new `GregorianDay` instance reversed by the specified number of months. 102 | /// 103 | /// - 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. 104 | /// 105 | /// Example: 106 | /// ```swift 107 | /// let yesterday = GregorianDay.today.reversed(byMonths: 1) 108 | /// ``` 109 | public func reversed(byMonths months: Int) -> Self { 110 | self.advanced(byMonths: -months) 111 | } 112 | 113 | /// Advances the date by the specified number of years. 114 | /// 115 | /// - Parameter years: The number of years to advance the date by. 116 | /// - Returns: A new `GregorianDay` instance advanced by the specified number of years. The day and month stay the same. 117 | /// 118 | /// - 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. 119 | /// 120 | /// Example: 121 | /// ```swift 122 | /// let tomorrow = GregorianDay.today.advanced(byYears: 1) 123 | /// ``` 124 | public func advanced(byYears years: Int) -> Self { 125 | self.with { $0.year += years } 126 | } 127 | 128 | /// Reverses the date by the specified number of years. 129 | /// 130 | /// - Parameter years: The number of years to reverse the date by. 131 | /// - Returns: A new `GregorianDay` instance reversed by the specified number of years. The day and month stay the same. 132 | /// 133 | /// - 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. 134 | /// Example: 135 | /// ```swift 136 | /// let yesterday = GregorianDay.today.reversed(byYears: 1) 137 | /// ``` 138 | public func reversed(byYears years: Int) -> Self { 139 | self.advanced(byYears: -years) 140 | } 141 | 142 | /// Returns the start of the day represented by the date. 143 | /// 144 | /// - Parameter timeZone: The time zone for which to calculate the start of the day. Defaults to the users current timezone. 145 | /// - Returns: A `Date` representing the start of the day. 146 | /// 147 | /// Example: 148 | /// ```swift 149 | /// let startOfToday = GregorianDay.today.startOfDay() 150 | /// ``` 151 | public func startOfDay(timeZone: TimeZone = .current) -> Date { 152 | let components = DateComponents( 153 | calendar: Calendar(identifier: .gregorian), 154 | timeZone: timeZone, 155 | year: self.year, 156 | month: self.month, 157 | day: self.day 158 | ) 159 | return components.date! 160 | } 161 | 162 | /// Returns the start of the month represented by the date. 163 | /// 164 | /// - Parameter timeZone: The time zone for which to calculate the start of the month. Defaults to the users current timezone. 165 | /// - Returns: A `Date` representing the start of the month. 166 | /// 167 | /// Example: 168 | /// ```swift 169 | /// let startOfThisMonth = GregorianDay.today.startOfMonth() 170 | /// ``` 171 | public func startOfMonth(timeZone: TimeZone = .current) -> Date { 172 | let components = DateComponents( 173 | calendar: Calendar(identifier: .gregorian), 174 | timeZone: timeZone, 175 | year: self.year, 176 | month: self.month, 177 | day: 1 178 | ) 179 | return components.date! 180 | } 181 | 182 | /// Returns the start of the year represented by the date. 183 | /// 184 | /// - Parameter timeZone: The time zone for which to calculate the start of the year. Defaults to the users current timezone. 185 | /// - Returns: A `Date` representing the start of the year. 186 | /// 187 | /// Example: 188 | /// ```swift 189 | /// let startOfThisYear = GregorianDay.today.startOfYear() 190 | /// ``` 191 | public func startOfYear(timeZone: TimeZone = .current) -> Date { 192 | let components = DateComponents( 193 | calendar: Calendar(identifier: .gregorian), 194 | timeZone: timeZone, 195 | year: self.year, 196 | month: 1, 197 | day: 1 198 | ) 199 | return components.date! 200 | } 201 | 202 | /// Returns the middle of the day represented by the date. 203 | /// 204 | /// - Parameter timeZone: The time zone for which to calculate the middle of the day. Defaults to UTC. 205 | /// - Returns: A `Date` representing the middle of the day. 206 | /// 207 | /// - 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. 208 | /// 209 | /// Example: 210 | /// ```swift 211 | /// let midOfToday: Date = GregorianDay.today.midOfDay() // the middle of today in UTC time zone 212 | /// ``` 213 | public func midOfDay(timeZone: TimeZone = TimeZone(secondsFromGMT: 0)!) -> Date { 214 | let components = DateComponents( 215 | calendar: Calendar(identifier: .gregorian), 216 | timeZone: timeZone, 217 | year: self.year, 218 | month: self.month, 219 | day: self.day, 220 | hour: 12 221 | ) 222 | return components.date! 223 | } 224 | 225 | /// Returns a `Date` representing this day at the specified time. 226 | /// 227 | /// - Parameters: 228 | /// - timeOfDay: The time of day to set for the resulting date. 229 | /// - timeZone: The time zone for which to calculate the date. Defaults to the users current timezone. 230 | /// - Returns: A `Date` representing this day at the specified time. 231 | /// 232 | /// Example: 233 | /// ```swift 234 | /// let noonToday = GregorianDay.today.date(timeOfDay: .noon) // today at 12:00 235 | /// ``` 236 | public func date(timeOfDay: GregorianTime, timeZone: TimeZone = .current) -> Date { 237 | timeOfDay.date(day: self, timeZone: timeZone) 238 | } 239 | } 240 | 241 | extension GregorianDay: Codable { 242 | /// Initializes a `GregorianDay` instance by decoding from the provided decoder. 243 | /// 244 | /// - Parameter decoder: The decoder to read data from. 245 | /// - Throws: An error if reading from the decoder fails, or if the data is corrupted or cannot be decoded. 246 | public init(from decoder: Decoder) throws { 247 | let container = try decoder.singleValueContainer() 248 | let dateString = try container.decode(String.self) 249 | 250 | let formatter = DateFormatter() 251 | formatter.dateFormat = "yyyy-MM-dd" 252 | formatter.calendar = Calendar(identifier: .gregorian) 253 | 254 | guard let date = formatter.date(from: dateString) else { 255 | throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string") 256 | } 257 | 258 | self = GregorianDay(date: date) 259 | } 260 | 261 | /// Encodes the `GregorianDay` instance into the provided encoder. 262 | /// 263 | /// - Parameter encoder: The encoder to write data to. 264 | /// - Throws: An error if encoding fails. 265 | public func encode(to encoder: Encoder) throws { 266 | var container = encoder.singleValueContainer() 267 | try container.encode(self.iso8601Formatted) 268 | } 269 | } 270 | 271 | extension GregorianDay: Hashable, Sendable {} 272 | extension GregorianDay: Identifiable { 273 | /// The identifier of the `GregorianDay` instance, which is a string representation of its year, month, and day. 274 | public var id: String { "\(self.year)-\(self.month)-\(self.day)" } 275 | } 276 | 277 | extension GregorianDay: Comparable { 278 | /// Compares two `GregorianDay` instances for order. 279 | /// 280 | /// - Parameters: 281 | /// - left: The first `GregorianDay` instance to compare. 282 | /// - right: The second `GregorianDay` instance to compare. 283 | /// - Returns: `true` if the `left` date is less than the `right` date; otherwise, `false`. 284 | public static func < (left: GregorianDay, right: GregorianDay) -> Bool { 285 | guard left.year == right.year else { return left.year < right.year } 286 | guard left.month == right.month else { return left.month < right.month } 287 | return left.day < right.day 288 | } 289 | } 290 | 291 | extension GregorianDay { 292 | /// The `GregorianDay` representing yesterday's date. 293 | public static var yesterday: Self { GregorianDay(date: Date()).advanced(by: -1) } 294 | 295 | /// The `GregorianDay` representing today's date. 296 | public static var today: Self { GregorianDay(date: Date()) } 297 | 298 | /// The `GregorianDay` representing tomorrow's date. 299 | public static var tomorrow: Self { GregorianDay(date: Date()).advanced(by: 1) } 300 | } 301 | 302 | extension GregorianDay: Withable {} 303 | -------------------------------------------------------------------------------- /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 | self.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 | self.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 | self.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 | self.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.." 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 && lhs.regularExpression.options == rhs.regularExpression.options 196 | } 197 | } 198 | 199 | extension HandyRegex: Hashable { 200 | /// Manages hashing of the `Regex` instance. 201 | public func hash(into hasher: inout Hasher) { 202 | hasher.combine(self.regularExpression) 203 | } 204 | } 205 | 206 | extension HandyRegex { 207 | /// `Options` defines alternate behaviours of regular expressions when matching. 208 | public struct Options: OptionSet, Sendable { 209 | /// Ignores the case of letters when matching. 210 | public static let ignoreCase = Options(rawValue: 1) 211 | 212 | /// Ignore any metacharacters in the pattern, treating every character as 213 | /// a literal. 214 | public static let ignoreMetacharacters = Options(rawValue: 1 << 1) 215 | 216 | /// By default, "^" matches the beginning of the string and "$" matches the 217 | /// end of the string, ignoring any newlines. With this option, "^" will 218 | /// the beginning of each line, and "$" will match the end of each line. 219 | public static let anchorsMatchLines = Options(rawValue: 1 << 2) 220 | 221 | /// Usually, "." matches all characters except newlines (\n). Using this, 222 | /// options will allow "." to match newLines 223 | public static let dotMatchesLineSeparators = Options(rawValue: 1 << 3) 224 | 225 | /// The raw value of the `OptionSet` 226 | public let rawValue: Int 227 | 228 | /// Transform an instance of `Regex.Options` into the equivalent `NSRegularExpression.Options`. 229 | /// 230 | /// - returns: The equivalent `NSRegularExpression.Options`. 231 | var toNSRegularExpressionOptions: NSRegularExpression.Options { 232 | var options = NSRegularExpression.Options() 233 | if contains(.ignoreCase) { options.insert(.caseInsensitive) } 234 | if contains(.ignoreMetacharacters) { options.insert(.ignoreMetacharacters) } 235 | if contains(.anchorsMatchLines) { options.insert(.anchorsMatchLines) } 236 | if contains(.dotMatchesLineSeparators) { options.insert(.dotMatchesLineSeparators) } 237 | return options 238 | } 239 | 240 | /// The raw value init for the `OptionSet` 241 | public init(rawValue: Int) { 242 | self.rawValue = rawValue 243 | } 244 | } 245 | } 246 | 247 | extension HandyRegex { 248 | /// A `Match` encapsulates the result of a single match in a string, 249 | /// providing access to the matched string, as well as any capture groups within 250 | /// that string. 251 | public class Match: CustomStringConvertible { 252 | /// The entire matched string. 253 | public lazy var string: String = { 254 | String(describing: self.baseString[self.range]) 255 | }() 256 | 257 | /// The range of the matched string. 258 | public lazy var range: Range = { 259 | Range(self.result.range, in: self.baseString)! 260 | }() 261 | 262 | /// The matching string for each capture group in the regular expression 263 | /// (if any). 264 | /// 265 | /// **Note:** Usually if the match was successful, the captures will by 266 | /// definition be non-nil. However if a given capture group is optional, the 267 | /// captured string may also be nil, depending on the particular string that 268 | /// is being matched against. 269 | /// 270 | /// Example: 271 | /// 272 | /// let regex = Regex("(a)?(b)") 273 | /// 274 | /// regex.matches(in: "ab")first?.captures // [Optional("a"), Optional("b")] 275 | /// regex.matches(in: "b").first?.captures // [nil, Optional("b")] 276 | public lazy var captures: [String?] = { 277 | let captureRanges = stride(from: 0, to: result.numberOfRanges, by: 1) 278 | .map(self.result.range) 279 | .dropFirst() 280 | .map { [unowned self] in 281 | Range($0, in: self.baseString) 282 | } 283 | 284 | return captureRanges.map { [unowned self] captureRange in 285 | guard let captureRange = captureRange else { return nil } 286 | return String(describing: self.baseString[captureRange]) 287 | } 288 | }() 289 | 290 | private let result: NSTextCheckingResult 291 | 292 | private let baseString: String 293 | 294 | @usableFromInline 295 | internal init(result: NSTextCheckingResult, in string: String) { 296 | precondition( 297 | result.regularExpression != nil, 298 | "NSTextCheckingResult must originate from regular expression parsing." 299 | ) 300 | 301 | self.result = result 302 | self.baseString = string 303 | } 304 | 305 | /// Returns a new string where the matched string is replaced according to the `template`. 306 | /// 307 | /// The template string may be a literal string, or include template variables: 308 | /// the variable `$0` will be replaced with the entire matched substring, `$1` 309 | /// with the first capture group, etc. 310 | /// 311 | /// For example, to include the literal string "$1" in the replacement string, 312 | /// you must escape the "$": `\$1`. 313 | /// 314 | /// - parameters: 315 | /// - template: The template string used to replace matches. 316 | /// 317 | /// - returns: A string with `template` applied to the matched string. 318 | public func string(applyingTemplate template: String) -> String { 319 | self.result.regularExpression!.replacementString( 320 | for: self.result, 321 | in: self.baseString, 322 | offset: 0, 323 | template: template 324 | ) 325 | } 326 | 327 | /// Returns a string describing the match. 328 | public var description: String { 329 | "Match<\"\(self.string)\">" 330 | } 331 | } 332 | } 333 | --------------------------------------------------------------------------------