├── .github └── workflows │ ├── build.yml │ └── swiftformat.yml ├── .gitignore ├── .spi.yml ├── .swiftformat ├── Benchmarks └── ParserBenchmarks │ └── ParserBenchmarks.swift ├── LICENSE ├── Mintfile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Parse3339 │ ├── Documentation.docc │ ├── Documentation.md │ └── Extensions │ │ └── Parts.md │ └── Parse3339.swift └── Tests └── Parse3339Tests └── Parse3339Tests.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-macos: 12 | runs-on: macos-13 13 | 14 | steps: 15 | - name: Set up Swift 16 | uses: swift-actions/setup-swift@v2 17 | with: 18 | swift-version: "5.10.1" 19 | 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Build 24 | run: swift build 25 | 26 | - name: Run tests 27 | run: swift test 28 | -------------------------------------------------------------------------------- /.github/workflows/swiftformat.yml: -------------------------------------------------------------------------------- 1 | name: SwiftFormat 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/swiftformat.yml' 7 | - '.swiftformat' 8 | - '**/*.swift' 9 | 10 | jobs: 11 | SwiftFormat: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Swift 17 | uses: swift-actions/setup-swift@v2 18 | with: 19 | swift-version: "5.10.1" 20 | 21 | - name: Set up Mint 22 | uses: irgaly/setup-mint@v1 23 | 24 | - name: Run SwiftFormat with Mint 25 | run: mint run swiftformat . --lint 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | .swiftpm 10 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Parse3339] 5 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --rules anyObjectProtocol,\ 2 | blankLinesAroundMark,\ 3 | blankLinesAtEndOfScope,\ 4 | blankLinesAtStartOfScope,\ 5 | blankLinesBetweenScopes,\ 6 | braces,\ 7 | consecutiveBlankLines,\ 8 | consecutiveSpaces,\ 9 | duplicateImports,\ 10 | elseOnSameLine,\ 11 | emptyBraces,\ 12 | hoistPatternLet,\ 13 | indent,\ 14 | initCoderUnavailable,\ 15 | isEmpty,\ 16 | leadingDelimiters,\ 17 | linebreakAtEndOfFile,\ 18 | modifierOrder,\ 19 | numberFormatting,\ 20 | preferKeyPath,\ 21 | redundantBackticks,\ 22 | redundantBreak,\ 23 | redundantExtensionACL,\ 24 | redundantFileprivate,\ 25 | redundantGet,\ 26 | redundantInit,\ 27 | redundantLet,\ 28 | redundantLetError,\ 29 | redundantObjc,\ 30 | redundantParens,\ 31 | redundantPattern,\ 32 | redundantRawValues,\ 33 | redundantReturn,\ 34 | redundantSelf,\ 35 | redundantVoidReturnType,\ 36 | semicolons,\ 37 | sortedImports,\ 38 | spaceAroundBraces,\ 39 | spaceAroundBrackets,\ 40 | spaceAroundComments,\ 41 | spaceAroundGenerics,\ 42 | spaceAroundOperators,\ 43 | spaceAroundParens,\ 44 | spaceInsideBraces,\ 45 | spaceInsideBrackets,\ 46 | spaceInsideComments,\ 47 | spaceInsideGenerics,\ 48 | spaceInsideParens,\ 49 | strongOutlets,\ 50 | strongifiedSelf,\ 51 | todos,\ 52 | trailingClosures,\ 53 | trailingCommas,\ 54 | trailingSpace,\ 55 | typeSugar,unusedArguments,\ 56 | void,\ 57 | wrap,\ 58 | wrapArguments,\ 59 | wrapAttributes,\ 60 | wrapMultilineStatementBraces,\ 61 | yodaConditions 62 | 63 | --binarygrouping 4,8 64 | --closingparen balanced 65 | --commas always 66 | --comments indent 67 | --decimalgrouping 3,6 68 | --elseposition same-line 69 | --hexgrouping 2,8 70 | --ifdef no-indent 71 | --indent 4 72 | --octalgrouping 4,8 73 | --self insert 74 | --stripunusedargs closure-only 75 | --swiftversion 5.8 76 | --wraparguments before-first 77 | --wrapcollections before-first 78 | -------------------------------------------------------------------------------- /Benchmarks/ParserBenchmarks/ParserBenchmarks.swift: -------------------------------------------------------------------------------- 1 | import Benchmark 2 | import Foundation 3 | import Parse3339 4 | 5 | let config = Benchmark.Configuration( 6 | timeUnits: .nanoseconds, 7 | maxDuration: .seconds(30), 8 | maxIterations: 100_000 9 | ) 10 | 11 | let benchmarks = { 12 | Benchmark("Parse with Parse3339 (DateComponents)", configuration: config) { benchmark in 13 | let s = "2023-07-04T08:21:25+03:00" 14 | for _ in benchmark.scaledIterations { 15 | let parsed = parse(s)! 16 | let dateComponents = parsed.dateComponents 17 | let parsedDate = dateComponents.date! 18 | blackHole(parsedDate) 19 | } 20 | } 21 | 22 | Benchmark("Parse with Parse3339 (Unix time)", configuration: config) { benchmark in 23 | let s = "2023-07-04T08:21:25+03:00" 24 | for _ in benchmark.scaledIterations { 25 | let parsed = parse(s)! 26 | let parsedDate = parsed.date 27 | blackHole(parsedDate) 28 | } 29 | } 30 | 31 | Benchmark("Parse with ISO8601DateFormatter", configuration: config) { benchmark in 32 | let fmt = ISO8601DateFormatter() 33 | fmt.formatOptions = .withInternetDateTime 34 | let s = "2023-07-04T08:21:25+03:00" 35 | 36 | for _ in benchmark.scaledIterations { 37 | let fmtDate = fmt.date(from: s)! 38 | blackHole(fmtDate) 39 | } 40 | } 41 | 42 | Benchmark("Parse with DateFormatter", configuration: config) { benchmark in 43 | let dateFormatter = DateFormatter() 44 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 45 | let s = "2023-07-04T08:21:25+03:00" 46 | 47 | for _ in benchmark.scaledIterations { 48 | let fmtDate = dateFormatter.date(from: s)! 49 | blackHole(fmtDate) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright © 2023 Juri Pakaste 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /Mintfile: -------------------------------------------------------------------------------- 1 | nicklockwood/SwiftFormat@0.51.12 2 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "package-benchmark", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/ordo-one/package-benchmark", 7 | "state" : { 8 | "revision" : "fe172a3a3fb9f18e134f97ad9293b89b5a79a5a9", 9 | "version" : "1.6.5" 10 | } 11 | }, 12 | { 13 | "identity" : "package-datetime", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/ordo-one/package-datetime", 16 | "state" : { 17 | "revision" : "42b948be6493bfbe81a6930a3d6986af028758bb", 18 | "version" : "1.0.1" 19 | } 20 | }, 21 | { 22 | "identity" : "package-histogram", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/ordo-one/package-histogram", 25 | "state" : { 26 | "revision" : "a69fa24d7b70421870cafa86340ece900489e17e", 27 | "version" : "0.1.2" 28 | } 29 | }, 30 | { 31 | "identity" : "package-jemalloc", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/ordo-one/package-jemalloc", 34 | "state" : { 35 | "revision" : "e8a5db026963f5bfeac842d9d3f2cc8cde323b49", 36 | "version" : "1.0.0" 37 | } 38 | }, 39 | { 40 | "identity" : "progress.swift", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/ordo-one/Progress.swift", 43 | "state" : { 44 | "revision" : "29dc5dc29d8408f42878b832c7aae38a35ff26ee", 45 | "version" : "1.0.3" 46 | } 47 | }, 48 | { 49 | "identity" : "swift-argument-parser", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/apple/swift-argument-parser", 52 | "state" : { 53 | "revision" : "fee6933f37fde9a5e12a1e4aeaa93fe60116ff2a", 54 | "version" : "1.2.2" 55 | } 56 | }, 57 | { 58 | "identity" : "swift-atomics", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/apple/swift-atomics", 61 | "state" : { 62 | "revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10", 63 | "version" : "1.1.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swift-extras-json", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/swift-extras/swift-extras-json", 70 | "state" : { 71 | "revision" : "122b9454ef01bf89a4c190b8fd3717ddd0a2fbd0", 72 | "version" : "0.6.0" 73 | } 74 | }, 75 | { 76 | "identity" : "swift-numerics", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/apple/swift-numerics", 79 | "state" : { 80 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", 81 | "version" : "1.0.2" 82 | } 83 | }, 84 | { 85 | "identity" : "swift-system", 86 | "kind" : "remoteSourceControl", 87 | "location" : "https://github.com/apple/swift-system", 88 | "state" : { 89 | "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", 90 | "version" : "1.2.1" 91 | } 92 | }, 93 | { 94 | "identity" : "texttable", 95 | "kind" : "remoteSourceControl", 96 | "location" : "https://github.com/ordo-one/TextTable", 97 | "state" : { 98 | "revision" : "a27a07300cf4ae322e0079ca0a475c5583dd575f", 99 | "version" : "0.0.2" 100 | } 101 | } 102 | ], 103 | "version" : 2 104 | } 105 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import Foundation 5 | import PackageDescription 6 | 7 | let package = Package( 8 | name: "Parse3339", 9 | products: [ 10 | .library( 11 | name: "Parse3339", 12 | targets: ["Parse3339"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/ordo-one/package-benchmark", .upToNextMajor(from: "1.6.5")), 17 | ], 18 | targets: [ 19 | .target(name: "Parse3339"), 20 | .testTarget( 21 | name: "Parse3339Tests", 22 | dependencies: ["Parse3339"] 23 | ), 24 | ] 25 | ) 26 | 27 | if ProcessInfo.processInfo.environment["PARSE3339_BENCHMARK"] != nil { 28 | package.platforms = [.macOS(.v13), .iOS(.v16)] 29 | package.targets += [ 30 | .executableTarget( 31 | name: "ParserBenchmarks", 32 | dependencies: [ 33 | .product(name: "Benchmark", package: "package-benchmark"), 34 | "Parse3339", 35 | ], 36 | path: "Benchmarks/ParserBenchmarks", 37 | plugins: [ 38 | .plugin(name: "BenchmarkPlugin", package: "package-benchmark"), 39 | ] 40 | ), 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build](https://github.com/juri/Parse3339/actions/workflows/build.yml/badge.svg)](https://github.com/juri/Parse3339/actions/workflows/build.yml) 2 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fjuri%2FParse3339%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/juri/Parse3339) 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fjuri%2FParse3339%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/juri/Parse3339) 4 | 5 | # Parse3339 6 | 7 | Parse3339 is a fast [RFC 3339] time stamp parser written in pure Swift. 8 | 9 | RFC 3339 specifies the commonly used subset of ISO 8601 suitable for time stamps. This parser restricts the subset even further. The following are the formats supported by Parse3339: 10 | 11 | - `2023-07-09T113:14:00+03:00` 12 | - `2023-07-09T113:14:00.2+03:00` 13 | - `2023-07-09T113:14:00Z` 14 | - `2023-07-09T113:14:00.2Z` 15 | 16 | There's nothing to configure, and it's all in just file in case you want to copy it over instead of using it as a package. 17 | 18 | [RFC 3339]: https://www.rfc-editor.org/rfc/rfc3339 19 | 20 | ## Usage 21 | 22 | ```swift 23 | import Parse3339 24 | 25 | let s = "2023-07-09T13:14:00+03:00" 26 | guard let parts = Parse3339.parse(s) else { 27 | return 28 | } 29 | let date = parts.date 30 | print(date.timeIntervalSinceReferenceDate) 31 | // output: 710590440.0 32 | ``` 33 | 34 | There's a helper function you can use with Foundation's `JSONDecoder`: 35 | 36 | ```swift 37 | import Parse3339 38 | 39 | let decoder = JSONDecoder() 40 | decoder.dateDecodingStrategy = .custom(Parse3339.parseFromDecoder(_:)) 41 | ``` 42 | 43 | For `Package.swift` snippets and documentation, visit the [Swift Package Index page](https://swiftpackageindex.com/juri/Parse3339). 44 | 45 | ## Speed and memory usage 46 | 47 | Parse3339 is pleasantly fast and stingy with memory usage. The package has benchmarks: 48 | 49 | ```sh 50 | env PARSE3339_BENCHMARK=1 swift package benchmark --target ParserBenchmarks 51 | ``` 52 | 53 | It has benchmarks that parse the same string using Foundation's `DateFormatter`, Foundation's `ISO8601DateFormatter`, Parse3339 creating a `Date` with Foundation's `DateComponents` and `Calendar`, and Parse3339 creating a `Date` with standard Unix functions. 54 | 55 | Output from one run: 56 | 57 | ``` 58 | Parse with DateFormatter 59 | ╒════════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕ 60 | │ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │ 61 | ╞════════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡ 62 | │ Malloc (total) │ 275 │ 275 │ 275 │ 275 │ 275 │ 275 │ 279 │ 100000 │ 63 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 64 | │ Memory (resident peak) (M) │ 11 │ 15 │ 19 │ 23 │ 25 │ 27 │ 27 │ 100000 │ 65 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 66 | │ Throughput (# / s) (K) │ 17 │ 16 │ 16 │ 16 │ 15 │ 12 │ 1 │ 100000 │ 67 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 68 | │ Time (total CPU) (ns) │ 59084 │ 59967 │ 60255 │ 61887 │ 64927 │ 82175 │ 236750 │ 100000 │ 69 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 70 | │ Time (wall clock) (ns) │ 58500 │ 59391 │ 59647 │ 61279 │ 64255 │ 83327 │ 513167 │ 100000 │ 71 | ╘════════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛ 72 | 73 | Parse with ISO8601DateFormatter 74 | ╒════════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕ 75 | │ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │ 76 | ╞════════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡ 77 | │ Malloc (total) │ 496 │ 496 │ 496 │ 496 │ 496 │ 496 │ 497 │ 100000 │ 78 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 79 | │ Memory (resident peak) (K) │ 9764 │ 9781 │ 9781 │ 9781 │ 9781 │ 9781 │ 9781 │ 100000 │ 80 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 81 | │ Throughput (# / s) (K) │ 11 │ 10 │ 10 │ 10 │ 10 │ 7 │ 0 │ 100000 │ 82 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 83 | │ Time (total CPU) (ns) │ 91375 │ 92351 │ 92799 │ 95551 │ 99519 │ 123007 │ 542458 │ 100000 │ 84 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 85 | │ Time (wall clock) (ns) │ 90792 │ 91711 │ 92159 │ 94911 │ 98879 │ 127487 │ 3553542 │ 100000 │ 86 | ╘════════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛ 87 | 88 | Parse with Parse3339 (DateComponents) 89 | ╒════════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕ 90 | │ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │ 91 | ╞════════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡ 92 | │ Malloc (total) │ 64 │ 64 │ 64 │ 64 │ 64 │ 64 │ 67 │ 100000 │ 93 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 94 | │ Memory (resident peak) (M) │ 9 │ 41 │ 73 │ 105 │ 124 │ 136 │ 137 │ 100000 │ 95 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 96 | │ Throughput (# / s) (K) │ 43 │ 42 │ 42 │ 41 │ 38 │ 29 │ 4 │ 100000 │ 97 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 98 | │ Time (total CPU) (ns) │ 23416 │ 23999 │ 24223 │ 25007 │ 26463 │ 34751 │ 206791 │ 100000 │ 99 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 100 | │ Time (wall clock) (ns) │ 22833 │ 23423 │ 23631 │ 24383 │ 25807 │ 34335 │ 211458 │ 100000 │ 101 | ╘════════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛ 102 | 103 | Parse with Parse3339 (Unix time) 104 | ╒════════════════════════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╤═════════╕ 105 | │ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │ 106 | ╞════════════════════════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╡ 107 | │ Malloc (total) │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 0 │ 100000 │ 108 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 109 | │ Memory (resident peak) (K) │ 7831 │ 7852 │ 7864 │ 7864 │ 7864 │ 7864 │ 7864 │ 100000 │ 110 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 111 | │ Throughput (# / s) (K) │ 263 │ 252 │ 247 │ 242 │ 233 │ 183 │ 17 │ 100000 │ 112 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 113 | │ Time (total CPU) (ns) │ 4333 │ 4503 │ 4543 │ 4667 │ 4875 │ 6543 │ 48584 │ 100000 │ 114 | ├────────────────────────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 115 | │ Time (wall clock) (ns) │ 3791 │ 3959 │ 4041 │ 4127 │ 4291 │ 5459 │ 56625 │ 100000 │ 116 | ╘════════════════════════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╧═════════╛ 117 | ``` 118 | -------------------------------------------------------------------------------- /Sources/Parse3339/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``Parse3339`` 2 | 3 | Parse RFC 3339 time stamps. 4 | 5 | ## Overview 6 | 7 | Parse3339 is a fast pure Swift parser for a subset of [RFC 3339] formatted time stamps. 8 | 9 | [RFC 3339]: https://www.rfc-editor.org/rfc/rfc3339 10 | 11 | ### Time stamp format 12 | 13 | The time stamp formats supported by Parse3339 are the following: 14 | 15 | - `2023-07-09T113:14:00+03:00` 16 | - `2023-07-09T113:14:00.2+03:00` 17 | - `2023-07-09T113:14:00Z` 18 | - `2023-07-09T113:14:00.2Z` 19 | 20 | Note that the RFC specifies more allowed variations than this parser supports. 21 | 22 | ### Usage 23 | 24 | ```swift 25 | import Parse3339 26 | 27 | let s = "2023-07-09T13:14:00+03:00" 28 | guard let parts = Parse3339.parse(s) else { 29 | return 30 | } 31 | let date = parts.date 32 | print(date.timeIntervalSinceReferenceDate) 33 | // output: 710590440.0 34 | ``` 35 | 36 | ## Topics 37 | 38 | ### Parsing 39 | 40 | - ``parse(_:)-89jso`` 41 | - ``parse(_:)-9on3x`` 42 | 43 | ### Parser output 44 | 45 | - ``Parts`` 46 | 47 | ### Codable support 48 | 49 | - ``parseFromDecoder(_:)`` 50 | -------------------------------------------------------------------------------- /Sources/Parse3339/Documentation.docc/Extensions/Parts.md: -------------------------------------------------------------------------------- 1 | # ``Parts`` 2 | 3 | ## Converting to Foundation types 4 | 5 | `Parts` provides two computed properties for creating Foundation types, ``Parts/date`` for creating a `Date` and ``Parts/dateComponents`` for creating a `DateComponents`. 6 | 7 | From the `DateComponents` value you can go on to create a `Date` using a `Calendar`; RFC 3339 time stamps are defined to be in the Gregorian calendar and they always have a time zone, so the `DateComponents` `Parts` creates have those filled and the `DateComponents` value's `date` property works as expected. 8 | 9 | However, ``Parts/date`` is a significantly faster path to `Date` than going via `DateComponents`. It uses the `timegm(3)` Unix function for calculating the `Date` value, and you can expect it to be several times faster than going via `DateComponents` and `Calendar` as of macOS Ventura. 10 | 11 | ## Topics 12 | 13 | ### Accessing parts of time stamp 14 | 15 | - ``Parts/year`` 16 | - ``Parts/month`` 17 | - ``Parts/day`` 18 | - ``Parts/hour`` 19 | - ``Parts/minute`` 20 | - ``Parts/second`` 21 | - ``Parts/secondFraction`` 22 | - ``Parts/secondFractionDigits`` 23 | - ``Parts/zone`` 24 | 25 | ### Converting values 26 | 27 | - ``Parts/nanosecond`` 28 | - ``Parts/zoneSeconds`` 29 | 30 | ### Creating Foundation types 31 | 32 | - ``Parts/date`` 33 | - ``Parts/dateComponents`` 34 | -------------------------------------------------------------------------------- /Sources/Parse3339/Parse3339.swift: -------------------------------------------------------------------------------- 1 | /* 2 | date-fullyear = 4DIGIT 3 | date-month = 2DIGIT ; 01-12 4 | date-mday = 2DIGIT ; 01-28, 01-29, 01-30, 01-31 based on 5 | ; month/year 6 | time-hour = 2DIGIT ; 00-23 7 | time-minute = 2DIGIT ; 00-59 8 | time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second 9 | ; rules 10 | time-secfrac = "." 1*DIGIT 11 | time-numoffset = ("+" / "-") time-hour ":" time-minute 12 | time-offset = "Z" / time-numoffset 13 | 14 | partial-time = time-hour ":" time-minute ":" time-second 15 | [time-secfrac] 16 | full-date = date-fullyear "-" date-month "-" date-mday 17 | full-time = partial-time time-offset 18 | 19 | date-time = full-date "T" full-time 20 | */ 21 | 22 | import Foundation 23 | 24 | private let calendar = Calendar(identifier: .gregorian) 25 | private let utc = TimeZone(secondsFromGMT: 0) 26 | 27 | // MARK: Parts 28 | 29 | /// `Parts` contains the parsed fields from a time stamp. 30 | public struct Parts: Sendable { 31 | /// The year. 32 | public let year: Int 33 | /// The month (1–12). 34 | public let month: Int 35 | /// The day (1–31). 36 | public let day: Int 37 | /// The hour (0–23). 38 | public let hour: Int 39 | /// The minute (0–59). 40 | public let minute: Int 41 | /// The second (0–60). 42 | public let second: Int 43 | /// Subsecond fraction. `03.1234` as the second and fraction results in `1234` in this field. See ``secondFractionDigits`` for 44 | /// the number of digits after the period. 45 | public let secondFraction: Int 46 | /// Number of subsecond fraction digits in the time stamp (0–10). 47 | public let secondFractionDigits: Int 48 | /// Time zone in minutes (-1439–1439). 49 | public let zone: Int 50 | 51 | /// The fractional second value in nanoseconds. 52 | public var nanosecond: Int { 53 | let nanoZeroes: Int = 9 54 | return self.secondFraction * power10(nanoZeroes - self.secondFractionDigits) 55 | } 56 | 57 | /// Time zone in seconds. 58 | public var zoneSeconds: Int { 59 | self.zone * 60 60 | } 61 | 62 | /// Parts as a `Date` value. 63 | public var date: Date { 64 | var t = tm( 65 | tm_sec: Int32(self.second), 66 | tm_min: Int32(self.minute), 67 | tm_hour: Int32(self.hour), 68 | tm_mday: Int32(self.day), 69 | tm_mon: Int32(self.month - 1), 70 | tm_year: Int32(self.year - 1900), 71 | tm_wday: 0, 72 | tm_yday: 0, 73 | tm_isdst: 0, 74 | tm_gmtoff: 0, 75 | tm_zone: nil 76 | ) 77 | let timet = timegm(&t) 78 | let offsetTimet = timet - self.zoneSeconds 79 | var timeInterval = TimeInterval(offsetTimet) 80 | timeInterval += TimeInterval(self.nanosecond) / 1_000_000_000 81 | return Date(timeIntervalSince1970: timeInterval) 82 | } 83 | 84 | /// Parts as a `DateComponents` value. 85 | public var dateComponents: DateComponents { 86 | let d = DateComponents( 87 | calendar: calendar, 88 | timeZone: self.zoneSeconds == 0 ? utc : TimeZone(secondsFromGMT: self.zoneSeconds), 89 | year: self.year, 90 | month: self.month, 91 | day: self.day, 92 | hour: self.hour, 93 | minute: self.minute, 94 | second: self.second, 95 | nanosecond: self.nanosecond 96 | ) 97 | return d 98 | } 99 | } 100 | 101 | extension Parts { 102 | fileprivate init(_ ps: ParseState) { 103 | self.init( 104 | year: ps.year, 105 | month: ps.month, 106 | day: ps.day, 107 | hour: ps.hour, 108 | minute: ps.minute, 109 | second: ps.second, 110 | secondFraction: ps.secondFraction, 111 | secondFractionDigits: ps.secondFractionDigits, 112 | zone: ps.zoneDirection.multiplier * (ps.zoneHour * 60 + ps.zoneMinute) 113 | ) 114 | } 115 | } 116 | 117 | // MARK: Parse functions 118 | 119 | /// Parse a `StringProtocol` into ``Parts``. 120 | /// 121 | /// - SeeAlso: ``parse(_:)-9on3x`` 122 | @inlinable 123 | public func parse(_ string: some StringProtocol) -> Parts? { 124 | parse(string.utf8) 125 | } 126 | 127 | /// Parse a sequence of `UInt8` values into ``Parts``. 128 | /// 129 | /// - SeeAlso: ``parse(_:)-89jso`` 130 | public func parse(_ seq: some Sequence) -> Parts? { 131 | var state = ParseState() 132 | 133 | for element in seq { 134 | switch state.field { 135 | case .year: 136 | if state.count == 4 { 137 | if element == Component.dash.rawValue { 138 | state.field = .month 139 | state.count = 0 140 | } else { 141 | return nil 142 | } 143 | } else if let num = parseDigit(element) { 144 | state.year = addDigit(num, to: state.year) 145 | state.count += 1 146 | } else { 147 | return nil 148 | } 149 | 150 | case .month: 151 | if state.count == 2 { 152 | if element == Component.dash.rawValue { 153 | guard checkMonth(state.month) else { 154 | return nil 155 | } 156 | state.field = .day 157 | state.count = 0 158 | } else { 159 | return nil 160 | } 161 | } else if let num = parseDigit(element) { 162 | state.month = addDigit(num, to: state.month) 163 | state.count += 1 164 | } else { 165 | return nil 166 | } 167 | 168 | case .day: 169 | if state.count == 2 { 170 | if element == Component.tee.rawValue { 171 | guard checkDay(state.day) else { 172 | return nil 173 | } 174 | state.field = .hour 175 | state.count = 0 176 | } else { 177 | return nil 178 | } 179 | } else if let num = parseDigit(element) { 180 | state.day = addDigit(num, to: state.day) 181 | state.count += 1 182 | } else { 183 | return nil 184 | } 185 | 186 | case .hour: 187 | if state.count == 2 { 188 | if element == Component.colon.rawValue { 189 | state.field = .minute 190 | state.count = 0 191 | } else { 192 | return nil 193 | } 194 | } else if let num = parseDigit(element) { 195 | state.hour = addDigit(num, to: state.hour) 196 | guard checkHour(state.hour) else { 197 | return nil 198 | } 199 | state.count += 1 200 | } else { 201 | return nil 202 | } 203 | 204 | case .minute: 205 | if state.count == 2 { 206 | if element == Component.colon.rawValue { 207 | state.field = .second 208 | state.count = 0 209 | } else { 210 | return nil 211 | } 212 | } else if let num = parseDigit(element) { 213 | state.minute = addDigit(num, to: state.minute) 214 | guard checkMinute(state.minute) else { 215 | return nil 216 | } 217 | state.count += 1 218 | } else { 219 | return nil 220 | } 221 | 222 | case .second: 223 | if state.count == 2 { 224 | if element == Component.period.rawValue { 225 | state.field = .secondFrac 226 | state.count = 0 227 | } else if element == Component.plus.rawValue { 228 | state.field = .zoneHour 229 | state.zoneDirection = .plus 230 | state.count = 0 231 | } else if element == Component.dash.rawValue { 232 | state.field = .zoneHour 233 | state.zoneDirection = .minus 234 | state.count = 0 235 | } else if element == Component.zed.rawValue { 236 | return Parts(state) 237 | } else { 238 | return nil 239 | } 240 | } else if let num = parseDigit(element) { 241 | state.second = addDigit(num, to: state.second) 242 | guard checkSecond(state.second) else { 243 | return nil 244 | } 245 | state.count += 1 246 | } else { 247 | return nil 248 | } 249 | 250 | case .secondFrac: 251 | if let num = parseDigit(element) { 252 | if state.secondFractionDigits >= 10 { 253 | return nil 254 | } 255 | state.secondFraction = addDigit(num, to: state.secondFraction) 256 | state.secondFractionDigits += 1 257 | } else if state.secondFractionDigits == 0 { 258 | return nil 259 | } else if element == Component.plus.rawValue { 260 | state.field = .zoneHour 261 | } else if element == Component.dash.rawValue { 262 | state.field = .zoneHour 263 | state.zoneDirection = .minus 264 | } else if element == Component.zed.rawValue { 265 | return Parts(state) 266 | } else { 267 | return nil 268 | } 269 | 270 | case .zoneHour: 271 | if state.count == 2 { 272 | if element == Component.colon.rawValue { 273 | state.field = .zoneMinute 274 | state.count = 0 275 | } else { 276 | return nil 277 | } 278 | } else if let num = parseDigit(element) { 279 | state.zoneHour = addDigit(num, to: state.zoneHour) 280 | guard checkHour(state.zoneHour) else { 281 | return nil 282 | } 283 | state.count += 1 284 | } else { 285 | return nil 286 | } 287 | 288 | case .zoneMinute: 289 | if state.count == 2 { 290 | return Parts(state) 291 | } else if let num = parseDigit(element) { 292 | state.zoneMinute = addDigit(num, to: state.zoneMinute) 293 | guard checkMinute(state.zoneMinute) else { 294 | return nil 295 | } 296 | state.count += 1 297 | } else { 298 | return nil 299 | } 300 | } 301 | } 302 | 303 | if case .zoneMinute = state.field, state.count == 2 { 304 | return Parts(state) 305 | } 306 | return nil 307 | } 308 | 309 | // MARK: JSONDecoder support 310 | 311 | /// Helper function for using Parse3339 with Codable types. 312 | /// 313 | /// Use `parseFromDecoder(_:)` with a custom date decoding strategy. The mechanism depends on 314 | /// the `TopLevelDecoder` implementation. With `JSONDecoder` you can use the `dateDecodingStrategy` 315 | /// property with a `custom` value like this: 316 | /// 317 | /// ```swift 318 | /// let decoder = JSONDecoder() 319 | /// decoder.dateDecodingStrategy = .custom(Parse3339.parseFromDecoder(_:)) 320 | /// ``` 321 | /// 322 | /// `parseFromDecoder` first decodes a `String` and then parses the string. For formats other than JSON you may get 323 | /// better performance by using an implementation that feeds bytes to ``parse(_:)-9on3x``. 324 | public func parseFromDecoder(_ decoder: some Decoder) throws -> Date { 325 | let container = try decoder.singleValueContainer() 326 | let str = try container.decode(String.self) 327 | guard let parsed = parse(str) else { 328 | throw DecodingError.typeMismatch( 329 | Date.self, 330 | DecodingError.Context( 331 | codingPath: [], 332 | debugDescription: "The string '\(str)' could not be parsed as a date" 333 | ) 334 | ) 335 | } 336 | return parsed.date 337 | } 338 | 339 | // MARK: - Private 340 | 341 | private enum ZoneDirection { 342 | case plus 343 | case minus 344 | 345 | var multiplier: Int { 346 | switch self { 347 | case .plus: return 1 348 | case .minus: return -1 349 | } 350 | } 351 | } 352 | 353 | private enum Field { 354 | case year 355 | case month 356 | case day 357 | case hour 358 | case minute 359 | case second 360 | case secondFrac 361 | case zoneHour 362 | case zoneMinute 363 | } 364 | 365 | private func addDigit(_ num: Int, to target: Int) -> Int { 366 | target * 10 + num 367 | } 368 | 369 | private enum Component: UInt8 { 370 | case n0 = 0x30 371 | case colon = 0x3A 372 | case dash = 0x2D 373 | case tee = 0x54 374 | case plus = 0x2B 375 | case period = 0x2E 376 | case zed = 0x5A 377 | } 378 | 379 | private struct ParseState { 380 | var field = Field.year 381 | var year = 0 382 | var month = 0 383 | var day = 0 384 | var hour = 0 385 | var minute = 0 386 | var second = 0 387 | var secondFraction = 0 388 | var secondFractionDigits = 0 389 | var zoneDirection = ZoneDirection.plus 390 | var zoneHour = 0 391 | var zoneMinute = 0 392 | var count = 0 393 | } 394 | 395 | func parseDigit(_ source: UInt8) -> Int? { 396 | let value = source &- Component.n0.rawValue 397 | return value < 10 ? Int(value) : nil 398 | } 399 | 400 | private func checkYear(_ year: Int) -> Bool { 401 | year >= 0 && year < 10000 402 | } 403 | 404 | private func checkMonth(_ month: Int) -> Bool { 405 | month > 0 && month < 13 406 | } 407 | 408 | private func checkDay(_ day: Int) -> Bool { 409 | day > 0 && day < 32 410 | } 411 | 412 | private func checkHour(_ hour: Int) -> Bool { 413 | hour >= 0 && hour < 24 414 | } 415 | 416 | private func checkMinute(_ minute: Int) -> Bool { 417 | minute >= 0 && minute < 60 418 | } 419 | 420 | private func checkSecond(_ second: Int) -> Bool { 421 | second >= 0 && second < 61 422 | } 423 | 424 | private func power10(_ n: Int) -> Int { 425 | var out = 1 426 | for _ in 0 ..< n { 427 | out *= 10 428 | } 429 | return out 430 | } 431 | -------------------------------------------------------------------------------- /Tests/Parse3339Tests/Parse3339Tests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import Parse3339 3 | import XCTest 4 | 5 | final class Parse3339Tests: XCTestCase { 6 | // MARK: Digits 7 | 8 | func testParseDigitSmallerThanNumbers() throws { 9 | XCTAssertNil(parseDigit(0x2F)) 10 | } 11 | 12 | func testParseDigitZero() throws { 13 | XCTAssertEqual(parseDigit(0x30), 0) 14 | } 15 | 16 | func testParseDigitNine() throws { 17 | XCTAssertEqual(parseDigit(0x39), 9) 18 | } 19 | 20 | func testParseDigitLargerThanNumbers() throws { 21 | XCTAssertNil(parseDigit(0x3A)) 22 | } 23 | 24 | // MARK: Full 25 | 26 | func testFullPlusZoneSuccessful() throws { 27 | let s = "2023-07-04T08:21:25.2+03:00" 28 | let parsed = try XCTUnwrap(parse(s)) 29 | 30 | XCTAssertEqual(parsed.year, 2023) 31 | XCTAssertEqual(parsed.month, 7) 32 | XCTAssertEqual(parsed.day, 4) 33 | XCTAssertEqual(parsed.hour, 8) 34 | XCTAssertEqual(parsed.minute, 21) 35 | XCTAssertEqual(parsed.second, 25) 36 | XCTAssertEqual(parsed.secondFraction, 2) 37 | XCTAssertEqual(parsed.secondFractionDigits, 1) 38 | XCTAssertEqual(parsed.zone, 180) 39 | XCTAssertEqual(parsed.nanosecond, 200_000_000) 40 | } 41 | 42 | func testFullPlusZoneWithMinutesSuccessful() throws { 43 | let s = "2023-07-04T08:21:25.2+03:37" 44 | let parsed = try XCTUnwrap(parse(s)) 45 | 46 | XCTAssertEqual(parsed.year, 2023) 47 | XCTAssertEqual(parsed.month, 7) 48 | XCTAssertEqual(parsed.day, 4) 49 | XCTAssertEqual(parsed.hour, 8) 50 | XCTAssertEqual(parsed.minute, 21) 51 | XCTAssertEqual(parsed.second, 25) 52 | XCTAssertEqual(parsed.secondFraction, 2) 53 | XCTAssertEqual(parsed.secondFractionDigits, 1) 54 | XCTAssertEqual(parsed.zone, 217) 55 | XCTAssertEqual(parsed.nanosecond, 200_000_000) 56 | } 57 | 58 | func testFullMinusZoneSuccessful() throws { 59 | let s = "2023-07-04T08:21:25.2-03:00" 60 | let parsed = try XCTUnwrap(parse(s)) 61 | 62 | XCTAssertEqual(parsed.year, 2023) 63 | XCTAssertEqual(parsed.month, 7) 64 | XCTAssertEqual(parsed.day, 4) 65 | XCTAssertEqual(parsed.hour, 8) 66 | XCTAssertEqual(parsed.minute, 21) 67 | XCTAssertEqual(parsed.second, 25) 68 | XCTAssertEqual(parsed.secondFraction, 2) 69 | XCTAssertEqual(parsed.secondFractionDigits, 1) 70 | XCTAssertEqual(parsed.zone, -180) 71 | XCTAssertEqual(parsed.nanosecond, 200_000_000) 72 | } 73 | 74 | func testFullMinusZoneWithMinutesSuccessful() throws { 75 | let s = "2023-07-04T08:21:25.2-03:14" 76 | let parsed = try XCTUnwrap(parse(s)) 77 | 78 | XCTAssertEqual(parsed.year, 2023) 79 | XCTAssertEqual(parsed.month, 7) 80 | XCTAssertEqual(parsed.day, 4) 81 | XCTAssertEqual(parsed.hour, 8) 82 | XCTAssertEqual(parsed.minute, 21) 83 | XCTAssertEqual(parsed.second, 25) 84 | XCTAssertEqual(parsed.secondFraction, 2) 85 | XCTAssertEqual(parsed.secondFractionDigits, 1) 86 | XCTAssertEqual(parsed.zone, -194) 87 | XCTAssertEqual(parsed.nanosecond, 200_000_000) 88 | } 89 | 90 | func testFullZZoneSuccessful() throws { 91 | let s = "2023-07-04T08:21:25.2Z" 92 | let parsed = try XCTUnwrap(parse(s)) 93 | 94 | XCTAssertEqual(parsed.year, 2023) 95 | XCTAssertEqual(parsed.month, 7) 96 | XCTAssertEqual(parsed.day, 4) 97 | XCTAssertEqual(parsed.hour, 8) 98 | XCTAssertEqual(parsed.minute, 21) 99 | XCTAssertEqual(parsed.second, 25) 100 | XCTAssertEqual(parsed.secondFraction, 2) 101 | XCTAssertEqual(parsed.secondFractionDigits, 1) 102 | XCTAssertEqual(parsed.zone, 0) 103 | XCTAssertEqual(parsed.nanosecond, 200_000_000) 104 | 105 | let unixTime = 1_688_458_885.2 106 | XCTAssertEqual(parsed.dateComponents.date!.timeIntervalSince1970, unixTime, accuracy: 0.1) 107 | } 108 | 109 | func testFullZZoneWithMillisecondsSuccessful() throws { 110 | let s = "2023-07-04T08:21:25.295Z" 111 | let parsed = try XCTUnwrap(parse(s)) 112 | 113 | XCTAssertEqual(parsed.year, 2023) 114 | XCTAssertEqual(parsed.month, 7) 115 | XCTAssertEqual(parsed.day, 4) 116 | XCTAssertEqual(parsed.hour, 8) 117 | XCTAssertEqual(parsed.minute, 21) 118 | XCTAssertEqual(parsed.second, 25) 119 | XCTAssertEqual(parsed.secondFraction, 295) 120 | XCTAssertEqual(parsed.secondFractionDigits, 3) 121 | XCTAssertEqual(parsed.zone, 0) 122 | XCTAssertEqual(parsed.nanosecond, 295_000_000) 123 | 124 | let unixTime = 1_688_458_885.295 125 | XCTAssertEqual(parsed.dateComponents.date!.timeIntervalSince1970, unixTime, accuracy: 0.001) 126 | } 127 | 128 | func testFullZZoneWithMicrosecondsSuccessful() throws { 129 | let s = "2023-07-04T08:21:25.295729Z" 130 | let parsed = try XCTUnwrap(parse(s)) 131 | 132 | XCTAssertEqual(parsed.year, 2023) 133 | XCTAssertEqual(parsed.month, 7) 134 | XCTAssertEqual(parsed.day, 4) 135 | XCTAssertEqual(parsed.hour, 8) 136 | XCTAssertEqual(parsed.minute, 21) 137 | XCTAssertEqual(parsed.second, 25) 138 | XCTAssertEqual(parsed.secondFraction, 295_729) 139 | XCTAssertEqual(parsed.secondFractionDigits, 6) 140 | XCTAssertEqual(parsed.zone, 0) 141 | XCTAssertEqual(parsed.nanosecond, 295_729_000) 142 | 143 | let unixTime = 1_688_458_885.295729 144 | XCTAssertEqual(parsed.dateComponents.date!.timeIntervalSince1970, unixTime, accuracy: 0.000001) 145 | } 146 | 147 | func testFullZZoneToDateWithNanosecondsSuccessful() throws { 148 | let s = "2023-07-04T08:21:25.295729572Z" 149 | let parsed = try XCTUnwrap(parse(s)) 150 | 151 | XCTAssertEqual(parsed.year, 2023) 152 | XCTAssertEqual(parsed.month, 7) 153 | XCTAssertEqual(parsed.day, 4) 154 | XCTAssertEqual(parsed.hour, 8) 155 | XCTAssertEqual(parsed.minute, 21) 156 | XCTAssertEqual(parsed.second, 25) 157 | XCTAssertEqual(parsed.secondFraction, 295_729_572) 158 | XCTAssertEqual(parsed.secondFractionDigits, 9) 159 | XCTAssertEqual(parsed.zone, 0) 160 | XCTAssertEqual(parsed.nanosecond, 295_729_572) 161 | 162 | let unixTime = 1_688_458_885.295729572 163 | XCTAssertEqual(parsed.date.timeIntervalSince1970, unixTime, accuracy: 0.000000001) 164 | } 165 | 166 | func testFullZZoneWithNanosecondsSuccessful() throws { 167 | let s = "2023-07-04T08:21:25.295729572Z" 168 | let parsed = try XCTUnwrap(parse(s)) 169 | 170 | XCTAssertEqual(parsed.year, 2023) 171 | XCTAssertEqual(parsed.month, 7) 172 | XCTAssertEqual(parsed.day, 4) 173 | XCTAssertEqual(parsed.hour, 8) 174 | XCTAssertEqual(parsed.minute, 21) 175 | XCTAssertEqual(parsed.second, 25) 176 | XCTAssertEqual(parsed.secondFraction, 295_729_572) 177 | XCTAssertEqual(parsed.secondFractionDigits, 9) 178 | XCTAssertEqual(parsed.zone, 0) 179 | XCTAssertEqual(parsed.nanosecond, 295_729_572) 180 | 181 | let unixTime = 1_688_458_885.295729572 182 | XCTAssertEqual(parsed.dateComponents.date!.timeIntervalSince1970, unixTime, accuracy: 0.000000001) 183 | } 184 | 185 | // MARK: Without fractions 186 | 187 | func testIntegralSecondsPlusZoneSuccessful() throws { 188 | let s = "2023-07-04T08:21:25+03:00" 189 | let parsed = try XCTUnwrap(parse(s)) 190 | 191 | XCTAssertEqual(parsed.year, 2023) 192 | XCTAssertEqual(parsed.month, 7) 193 | XCTAssertEqual(parsed.day, 4) 194 | XCTAssertEqual(parsed.hour, 8) 195 | XCTAssertEqual(parsed.minute, 21) 196 | XCTAssertEqual(parsed.second, 25) 197 | XCTAssertEqual(parsed.secondFraction, 0) 198 | XCTAssertEqual(parsed.secondFractionDigits, 0) 199 | XCTAssertEqual(parsed.zone, 180) 200 | XCTAssertEqual(parsed.nanosecond, 0) 201 | 202 | let fmtDate = try XCTUnwrap(isoFormatter.date(from: s)) 203 | let parsedDate = try XCTUnwrap(parsed.date) 204 | XCTAssertEqual(fmtDate, parsedDate) 205 | } 206 | 207 | func testIntegralSecondsPlusZoneWithMinutesSuccessful() throws { 208 | let s = "2023-07-04T08:21:25+03:37" 209 | let parsed = try XCTUnwrap(parse(s)) 210 | 211 | XCTAssertEqual(parsed.year, 2023) 212 | XCTAssertEqual(parsed.month, 7) 213 | XCTAssertEqual(parsed.day, 4) 214 | XCTAssertEqual(parsed.hour, 8) 215 | XCTAssertEqual(parsed.minute, 21) 216 | XCTAssertEqual(parsed.second, 25) 217 | XCTAssertEqual(parsed.secondFraction, 0) 218 | XCTAssertEqual(parsed.secondFractionDigits, 0) 219 | XCTAssertEqual(parsed.zone, 217) 220 | 221 | let fmtDate = try XCTUnwrap(isoFormatter.date(from: s)) 222 | let parsedDate = try XCTUnwrap(parsed.date) 223 | XCTAssertEqual(fmtDate, parsedDate) 224 | } 225 | 226 | func testIntegralSecondsMinusZoneSuccessful() throws { 227 | let s = "2023-07-04T08:21:25-03:00" 228 | let parsed = try XCTUnwrap(parse(s)) 229 | 230 | XCTAssertEqual(parsed.year, 2023) 231 | XCTAssertEqual(parsed.month, 7) 232 | XCTAssertEqual(parsed.day, 4) 233 | XCTAssertEqual(parsed.hour, 8) 234 | XCTAssertEqual(parsed.minute, 21) 235 | XCTAssertEqual(parsed.second, 25) 236 | XCTAssertEqual(parsed.secondFraction, 0) 237 | XCTAssertEqual(parsed.secondFractionDigits, 0) 238 | XCTAssertEqual(parsed.zone, -180) 239 | 240 | let fmtDate = try XCTUnwrap(isoFormatter.date(from: s)) 241 | let parsedDate = try XCTUnwrap(parsed.date) 242 | XCTAssertEqual(fmtDate, parsedDate) 243 | } 244 | 245 | func testIntegralSecondsMinusZoneWithMinutesSuccessful() throws { 246 | let s = "2023-07-04T08:21:25-03:14" 247 | let parsed = try XCTUnwrap(parse(s)) 248 | 249 | XCTAssertEqual(parsed.year, 2023) 250 | XCTAssertEqual(parsed.month, 7) 251 | XCTAssertEqual(parsed.day, 4) 252 | XCTAssertEqual(parsed.hour, 8) 253 | XCTAssertEqual(parsed.minute, 21) 254 | XCTAssertEqual(parsed.second, 25) 255 | XCTAssertEqual(parsed.secondFraction, 0) 256 | XCTAssertEqual(parsed.secondFractionDigits, 0) 257 | XCTAssertEqual(parsed.zone, -194) 258 | 259 | let fmtDate = try XCTUnwrap(isoFormatter.date(from: s)) 260 | let parsedDate = try XCTUnwrap(parsed.date) 261 | XCTAssertEqual(fmtDate, parsedDate) 262 | } 263 | 264 | func testIntegralSecondsZZoneSuccessful() throws { 265 | let s = "2023-07-04T08:21:25Z" 266 | let parsed = try XCTUnwrap(parse(s)) 267 | 268 | XCTAssertEqual(parsed.year, 2023) 269 | XCTAssertEqual(parsed.month, 7) 270 | XCTAssertEqual(parsed.day, 4) 271 | XCTAssertEqual(parsed.hour, 8) 272 | XCTAssertEqual(parsed.minute, 21) 273 | XCTAssertEqual(parsed.second, 25) 274 | XCTAssertEqual(parsed.secondFraction, 0) 275 | XCTAssertEqual(parsed.secondFractionDigits, 0) 276 | XCTAssertEqual(parsed.zone, 0) 277 | 278 | let fmtDate = try XCTUnwrap(isoFormatter.date(from: s)) 279 | let parsedDate = try XCTUnwrap(parsed.date) 280 | XCTAssertEqual(fmtDate, parsedDate) 281 | } 282 | 283 | // MARK: Truncated 284 | 285 | func testTruncated() throws { 286 | let s = "2023-07-04T08:21:25Z" 287 | for i in 1 ... s.count { 288 | let truncated = s.dropLast(i) 289 | XCTAssertNil(parse(truncated), "Expected nil when parsing '\(truncated)'") 290 | } 291 | } 292 | 293 | // MARK: Field limits 294 | 295 | func testZeroMonth() throws { 296 | let s = "2023-00-04T08:21:25Z" 297 | XCTAssertNil(parse(s)) 298 | } 299 | 300 | func testJanuary() throws { 301 | let s = "2023-01-04T08:21:25Z" 302 | let parsed = try XCTUnwrap(parse(s)) 303 | 304 | XCTAssertEqual(parsed.year, 2023) 305 | XCTAssertEqual(parsed.month, 1) 306 | XCTAssertEqual(parsed.day, 4) 307 | XCTAssertEqual(parsed.hour, 8) 308 | XCTAssertEqual(parsed.minute, 21) 309 | XCTAssertEqual(parsed.second, 25) 310 | XCTAssertEqual(parsed.secondFraction, 0) 311 | XCTAssertEqual(parsed.secondFractionDigits, 0) 312 | XCTAssertEqual(parsed.zone, 0) 313 | } 314 | 315 | func testDecemberMonth() throws { 316 | let s = "2023-12-04T08:21:25Z" 317 | let parsed = try XCTUnwrap(parse(s)) 318 | 319 | XCTAssertEqual(parsed.year, 2023) 320 | XCTAssertEqual(parsed.month, 12) 321 | XCTAssertEqual(parsed.day, 4) 322 | XCTAssertEqual(parsed.hour, 8) 323 | XCTAssertEqual(parsed.minute, 21) 324 | XCTAssertEqual(parsed.second, 25) 325 | XCTAssertEqual(parsed.secondFraction, 0) 326 | XCTAssertEqual(parsed.secondFractionDigits, 0) 327 | XCTAssertEqual(parsed.zone, 0) 328 | } 329 | 330 | func testLargeMonth() throws { 331 | let s = "2023-13-04T08:21:25Z" 332 | XCTAssertNil(parse(s)) 333 | } 334 | 335 | func testZeroDay() throws { 336 | let s = "2023-12-00T08:21:25Z" 337 | XCTAssertNil(parse(s)) 338 | } 339 | 340 | func testFirstDay() throws { 341 | let s = "2023-12-01T08:21:25Z" 342 | let parsed = try XCTUnwrap(parse(s)) 343 | 344 | XCTAssertEqual(parsed.year, 2023) 345 | XCTAssertEqual(parsed.month, 12) 346 | XCTAssertEqual(parsed.day, 1) 347 | XCTAssertEqual(parsed.hour, 8) 348 | XCTAssertEqual(parsed.minute, 21) 349 | XCTAssertEqual(parsed.second, 25) 350 | XCTAssertEqual(parsed.secondFraction, 0) 351 | XCTAssertEqual(parsed.secondFractionDigits, 0) 352 | XCTAssertEqual(parsed.zone, 0) 353 | } 354 | 355 | func testThirtyFirstDay() throws { 356 | let s = "2023-12-31T08:21:25Z" 357 | let parsed = try XCTUnwrap(parse(s)) 358 | 359 | XCTAssertEqual(parsed.year, 2023) 360 | XCTAssertEqual(parsed.month, 12) 361 | XCTAssertEqual(parsed.day, 31) 362 | XCTAssertEqual(parsed.hour, 8) 363 | XCTAssertEqual(parsed.minute, 21) 364 | XCTAssertEqual(parsed.second, 25) 365 | XCTAssertEqual(parsed.secondFraction, 0) 366 | XCTAssertEqual(parsed.secondFractionDigits, 0) 367 | XCTAssertEqual(parsed.zone, 0) 368 | } 369 | 370 | func testLargeDay() throws { 371 | let s = "2023-12-32T08:21:25Z" 372 | XCTAssertNil(parse(s)) 373 | } 374 | 375 | func testHour0() throws { 376 | let s = "2023-12-31T00:21:25Z" 377 | let parsed = try XCTUnwrap(parse(s)) 378 | 379 | XCTAssertEqual(parsed.year, 2023) 380 | XCTAssertEqual(parsed.month, 12) 381 | XCTAssertEqual(parsed.day, 31) 382 | XCTAssertEqual(parsed.hour, 0) 383 | XCTAssertEqual(parsed.minute, 21) 384 | XCTAssertEqual(parsed.second, 25) 385 | XCTAssertEqual(parsed.secondFraction, 0) 386 | XCTAssertEqual(parsed.secondFractionDigits, 0) 387 | XCTAssertEqual(parsed.zone, 0) 388 | } 389 | 390 | func testHour23() throws { 391 | let s = "2023-12-31T23:21:25Z" 392 | let parsed = try XCTUnwrap(parse(s)) 393 | 394 | XCTAssertEqual(parsed.year, 2023) 395 | XCTAssertEqual(parsed.month, 12) 396 | XCTAssertEqual(parsed.day, 31) 397 | XCTAssertEqual(parsed.hour, 23) 398 | XCTAssertEqual(parsed.minute, 21) 399 | XCTAssertEqual(parsed.second, 25) 400 | XCTAssertEqual(parsed.secondFraction, 0) 401 | XCTAssertEqual(parsed.secondFractionDigits, 0) 402 | XCTAssertEqual(parsed.zone, 0) 403 | } 404 | 405 | func testHour24() throws { 406 | let s = "2023-12-31T24:21:25Z" 407 | XCTAssertNil(parse(s)) 408 | } 409 | 410 | func testMinute0() throws { 411 | let s = "2023-12-31T09:00:25Z" 412 | let parsed = try XCTUnwrap(parse(s)) 413 | 414 | XCTAssertEqual(parsed.year, 2023) 415 | XCTAssertEqual(parsed.month, 12) 416 | XCTAssertEqual(parsed.day, 31) 417 | XCTAssertEqual(parsed.hour, 9) 418 | XCTAssertEqual(parsed.minute, 0) 419 | XCTAssertEqual(parsed.second, 25) 420 | XCTAssertEqual(parsed.secondFraction, 0) 421 | XCTAssertEqual(parsed.secondFractionDigits, 0) 422 | XCTAssertEqual(parsed.zone, 0) 423 | } 424 | 425 | func testMinute1() throws { 426 | let s = "2023-12-31T09:01:25Z" 427 | let parsed = try XCTUnwrap(parse(s)) 428 | 429 | XCTAssertEqual(parsed.year, 2023) 430 | XCTAssertEqual(parsed.month, 12) 431 | XCTAssertEqual(parsed.day, 31) 432 | XCTAssertEqual(parsed.hour, 9) 433 | XCTAssertEqual(parsed.minute, 1) 434 | XCTAssertEqual(parsed.second, 25) 435 | XCTAssertEqual(parsed.secondFraction, 0) 436 | XCTAssertEqual(parsed.secondFractionDigits, 0) 437 | XCTAssertEqual(parsed.zone, 0) 438 | } 439 | 440 | func testMinute59() throws { 441 | let s = "2023-12-31T09:01:25Z" 442 | let parsed = try XCTUnwrap(parse(s)) 443 | 444 | XCTAssertEqual(parsed.year, 2023) 445 | XCTAssertEqual(parsed.month, 12) 446 | XCTAssertEqual(parsed.day, 31) 447 | XCTAssertEqual(parsed.hour, 9) 448 | XCTAssertEqual(parsed.minute, 1) 449 | XCTAssertEqual(parsed.second, 25) 450 | XCTAssertEqual(parsed.secondFraction, 0) 451 | XCTAssertEqual(parsed.secondFractionDigits, 0) 452 | XCTAssertEqual(parsed.zone, 0) 453 | } 454 | 455 | func testMinute60() throws { 456 | let s = "2023-12-31T09:60:25Z" 457 | XCTAssertNil(parse(s)) 458 | } 459 | 460 | func testSecond0() throws { 461 | let s = "2023-12-31T09:00:00Z" 462 | let parsed = try XCTUnwrap(parse(s)) 463 | 464 | XCTAssertEqual(parsed.year, 2023) 465 | XCTAssertEqual(parsed.month, 12) 466 | XCTAssertEqual(parsed.day, 31) 467 | XCTAssertEqual(parsed.hour, 9) 468 | XCTAssertEqual(parsed.minute, 0) 469 | XCTAssertEqual(parsed.second, 0) 470 | XCTAssertEqual(parsed.secondFraction, 0) 471 | XCTAssertEqual(parsed.secondFractionDigits, 0) 472 | XCTAssertEqual(parsed.zone, 0) 473 | } 474 | 475 | func testSecond1() throws { 476 | let s = "2023-12-31T09:01:01Z" 477 | let parsed = try XCTUnwrap(parse(s)) 478 | 479 | XCTAssertEqual(parsed.year, 2023) 480 | XCTAssertEqual(parsed.month, 12) 481 | XCTAssertEqual(parsed.day, 31) 482 | XCTAssertEqual(parsed.hour, 9) 483 | XCTAssertEqual(parsed.minute, 1) 484 | XCTAssertEqual(parsed.second, 1) 485 | XCTAssertEqual(parsed.secondFraction, 0) 486 | XCTAssertEqual(parsed.secondFractionDigits, 0) 487 | XCTAssertEqual(parsed.zone, 0) 488 | } 489 | 490 | func testSecond60() throws { 491 | let s = "2023-12-31T09:01:60Z" 492 | let parsed = try XCTUnwrap(parse(s)) 493 | 494 | XCTAssertEqual(parsed.year, 2023) 495 | XCTAssertEqual(parsed.month, 12) 496 | XCTAssertEqual(parsed.day, 31) 497 | XCTAssertEqual(parsed.hour, 9) 498 | XCTAssertEqual(parsed.minute, 1) 499 | XCTAssertEqual(parsed.second, 60) 500 | XCTAssertEqual(parsed.secondFraction, 0) 501 | XCTAssertEqual(parsed.secondFractionDigits, 0) 502 | XCTAssertEqual(parsed.zone, 0) 503 | } 504 | 505 | func testSecond61() throws { 506 | let s = "2023-12-31T09:01:61Z" 507 | XCTAssertNil(parse(s)) 508 | } 509 | 510 | func testZoneHour0() throws { 511 | let s = "2023-12-31T00:21:25+00:12" 512 | let parsed = try XCTUnwrap(parse(s)) 513 | 514 | XCTAssertEqual(parsed.year, 2023) 515 | XCTAssertEqual(parsed.month, 12) 516 | XCTAssertEqual(parsed.day, 31) 517 | XCTAssertEqual(parsed.hour, 0) 518 | XCTAssertEqual(parsed.minute, 21) 519 | XCTAssertEqual(parsed.second, 25) 520 | XCTAssertEqual(parsed.secondFraction, 0) 521 | XCTAssertEqual(parsed.secondFractionDigits, 0) 522 | XCTAssertEqual(parsed.zone, 12) 523 | } 524 | 525 | func testZoneHour23() throws { 526 | let s = "2023-12-31T23:21:25+23:03" 527 | let parsed = try XCTUnwrap(parse(s)) 528 | 529 | XCTAssertEqual(parsed.year, 2023) 530 | XCTAssertEqual(parsed.month, 12) 531 | XCTAssertEqual(parsed.day, 31) 532 | XCTAssertEqual(parsed.hour, 23) 533 | XCTAssertEqual(parsed.minute, 21) 534 | XCTAssertEqual(parsed.second, 25) 535 | XCTAssertEqual(parsed.secondFraction, 0) 536 | XCTAssertEqual(parsed.secondFractionDigits, 0) 537 | XCTAssertEqual(parsed.zone, 1383) 538 | } 539 | 540 | func testHourZone24() throws { 541 | let s = "2023-12-31T12:21:25+24:00" 542 | XCTAssertNil(parse(s)) 543 | } 544 | 545 | func testZoneMinute0() throws { 546 | let s = "2023-12-31T09:00:25-13:00" 547 | let parsed = try XCTUnwrap(parse(s)) 548 | 549 | XCTAssertEqual(parsed.year, 2023) 550 | XCTAssertEqual(parsed.month, 12) 551 | XCTAssertEqual(parsed.day, 31) 552 | XCTAssertEqual(parsed.hour, 9) 553 | XCTAssertEqual(parsed.minute, 0) 554 | XCTAssertEqual(parsed.second, 25) 555 | XCTAssertEqual(parsed.secondFraction, 0) 556 | XCTAssertEqual(parsed.secondFractionDigits, 0) 557 | XCTAssertEqual(parsed.zone, -780) 558 | } 559 | 560 | func testZoneMinute1() throws { 561 | let s = "2023-12-31T09:01:24+02:01" 562 | let parsed = try XCTUnwrap(parse(s)) 563 | 564 | XCTAssertEqual(parsed.year, 2023) 565 | XCTAssertEqual(parsed.month, 12) 566 | XCTAssertEqual(parsed.day, 31) 567 | XCTAssertEqual(parsed.hour, 9) 568 | XCTAssertEqual(parsed.minute, 1) 569 | XCTAssertEqual(parsed.second, 24) 570 | XCTAssertEqual(parsed.secondFraction, 0) 571 | XCTAssertEqual(parsed.secondFractionDigits, 0) 572 | XCTAssertEqual(parsed.zone, 121) 573 | } 574 | 575 | func testZoneMinute59() throws { 576 | let s = "2023-12-31T09:01:25-15:59" 577 | let parsed = try XCTUnwrap(parse(s)) 578 | 579 | XCTAssertEqual(parsed.year, 2023) 580 | XCTAssertEqual(parsed.month, 12) 581 | XCTAssertEqual(parsed.day, 31) 582 | XCTAssertEqual(parsed.hour, 9) 583 | XCTAssertEqual(parsed.minute, 1) 584 | XCTAssertEqual(parsed.second, 25) 585 | XCTAssertEqual(parsed.secondFraction, 0) 586 | XCTAssertEqual(parsed.secondFractionDigits, 0) 587 | XCTAssertEqual(parsed.zone, -959) 588 | } 589 | 590 | func testZoneMinute60() throws { 591 | let s = "2023-12-31T09:12:25+08:60" 592 | XCTAssertNil(parse(s)) 593 | } 594 | 595 | // MARK: Short fields 596 | 597 | func testShortYear() throws { 598 | let s = "202-12-31T09:01:00Z" 599 | XCTAssertNil(parse(s)) 600 | } 601 | 602 | func testShortMonth() throws { 603 | let s = "2023-1-31T09:01:00Z" 604 | XCTAssertNil(parse(s)) 605 | } 606 | 607 | func testShortDay() throws { 608 | let s = "2023-01-3T09:01:00Z" 609 | XCTAssertNil(parse(s)) 610 | } 611 | 612 | func testShortHour() throws { 613 | let s = "2023-01-03T9:01:00Z" 614 | XCTAssertNil(parse(s)) 615 | } 616 | 617 | func testShortMinute() throws { 618 | let s = "2023-01-03T09:1:00Z" 619 | XCTAssertNil(parse(s)) 620 | } 621 | 622 | func testShortSecond() throws { 623 | let s = "2023-01-03T09:01:0Z" 624 | XCTAssertNil(parse(s)) 625 | } 626 | 627 | func testMissingSecondFraction() throws { 628 | let s = "2023-01-03T09:01:00.Z" 629 | XCTAssertNil(parse(s)) 630 | } 631 | 632 | func testShortZoneHour() throws { 633 | let s = "2023-01-03T09:01:01+0:00" 634 | XCTAssertNil(parse(s)) 635 | } 636 | 637 | // MARK: Long fields 638 | 639 | func testLongYear() throws { 640 | let s = "20200-12-31T09:01:00Z" 641 | XCTAssertNil(parse(s)) 642 | } 643 | 644 | func testLongMonth() throws { 645 | let s = "2023-100-31T09:01:00Z" 646 | XCTAssertNil(parse(s)) 647 | } 648 | 649 | func testLongDay() throws { 650 | let s = "2023-01-301T09:01:00Z" 651 | XCTAssertNil(parse(s)) 652 | } 653 | 654 | func testLongHour() throws { 655 | let s = "2023-01-03T091:01:00Z" 656 | XCTAssertNil(parse(s)) 657 | } 658 | 659 | func testLongMinute() throws { 660 | let s = "2023-01-03T09:011:00Z" 661 | XCTAssertNil(parse(s)) 662 | } 663 | 664 | func testLongSecond() throws { 665 | let s = "2023-01-03T09:01:001Z" 666 | XCTAssertNil(parse(s)) 667 | } 668 | 669 | func testLongFraction() throws { 670 | let s = "2023-01-03T09:01:00.12345678901Z" 671 | XCTAssertNil(parse(s)) 672 | } 673 | 674 | func testLongZoneHour() throws { 675 | let s = "2023-01-03T09:01:01+001:00" 676 | XCTAssertNil(parse(s)) 677 | } 678 | 679 | // MARK: Date generator 680 | 681 | func testDateGen() throws { 682 | let isoFormatterUTC = ISO8601DateFormatter() 683 | let isoFormatterPlus = ISO8601DateFormatter() 684 | let isoFormatterMinus = ISO8601DateFormatter() 685 | isoFormatterUTC.formatOptions = .withInternetDateTime 686 | isoFormatterPlus.formatOptions = .withInternetDateTime 687 | isoFormatterMinus.formatOptions = .withInternetDateTime 688 | isoFormatterPlus.timeZone = TimeZone(secondsFromGMT: 6000) 689 | isoFormatterMinus.timeZone = TimeZone(secondsFromGMT: -18000) 690 | 691 | let formatters = [isoFormatterUTC, isoFormatterPlus, isoFormatterMinus] 692 | 693 | for timeInterval in stride(from: 0, to: 1_000_000_000, by: 100_000) { 694 | for formatter in formatters { 695 | let str = formatter.string(from: Date(timeIntervalSince1970: TimeInterval(timeInterval))) 696 | let parsed = try XCTUnwrap(parse(str)) 697 | let unixDate = parsed.date 698 | let dateComponents = parsed.dateComponents 699 | let calendarDate = try XCTUnwrap(dateComponents.date) 700 | XCTAssertEqual(calendarDate, unixDate) 701 | } 702 | } 703 | } 704 | 705 | // MARK: Decodable 706 | 707 | func testDecodable() throws { 708 | struct Payload: Codable { 709 | let message: String 710 | let date: Date 711 | } 712 | 713 | let dateComponents = DateComponents( 714 | timeZone: TimeZone(identifier: "Europe/Helsinki"), 715 | year: 2023, 716 | month: 7, 717 | day: 11, 718 | hour: 8, 719 | minute: 49, 720 | second: 0 721 | ) 722 | let date = Calendar(identifier: .gregorian).date(from: dateComponents)! 723 | let payload = Payload(message: "hello world", date: date) 724 | 725 | let encoder = JSONEncoder() 726 | encoder.dateEncodingStrategy = .iso8601 727 | 728 | let json = try encoder.encode(payload) 729 | 730 | var didCallParse = false 731 | func wrappedParse(_ decoder: any Decoder) throws -> Date { 732 | didCallParse = true 733 | return try parseFromDecoder(decoder) 734 | } 735 | 736 | let decoder = JSONDecoder() 737 | decoder.dateDecodingStrategy = .custom(wrappedParse(_:)) 738 | let decoded = try decoder.decode(Payload.self, from: json) 739 | 740 | XCTAssertTrue(didCallParse) 741 | XCTAssertEqual(decoded.message, "hello world") 742 | XCTAssertEqual(decoded.date, date) 743 | } 744 | } 745 | 746 | let isoFormatter: ISO8601DateFormatter = { 747 | let fmt = ISO8601DateFormatter() 748 | fmt.formatOptions = .withInternetDateTime 749 | return fmt 750 | }() 751 | --------------------------------------------------------------------------------