├── .github └── workflows │ ├── publish_docs.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.zig ├── build.zig.zon ├── demos.zig └── src ├── date.zig ├── date ├── epoch.zig └── gregorian.zig ├── datetime.zig ├── root.zig └── time.zig /.github/workflows/publish_docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: "pages" 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v4 26 | - name: Setup Pages 27 | uses: actions/configure-pages@v5 28 | - uses: mlugg/setup-zig@v1 29 | - run: zig build-lib src/root.zig -femit-docs=docs -fno-emit-bin 30 | - name: Upload artifact 31 | uses: actions/upload-pages-artifact@v3 32 | with: 33 | path: 'docs' 34 | - name: Deploy to GitHub Pages 35 | id: deployment 36 | uses: actions/deploy-pages@v4 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - uses: mlugg/setup-zig@v1 15 | - run: zig build test 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gdb_history 2 | .zig-cache/ 3 | zig-cache/ 4 | zig-out/ 5 | docs/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2023 clickingbuttons 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datetime 2 | 3 | ![zig-version](https://img.shields.io/badge/dynamic/regex?url=https%3A%2F%2Fraw.githubusercontent.com%2Fclickingbuttons%2Fdatetime%2Frefs%2Fheads%2Fmaster%2Fbuild.zig.zon&search=minimum_zig_version%5Cs*%3D%5Cs*%22(.*)%22&replace=%241&label=minimum%20zig%20version) 4 | ![tests](https://github.com/clickingbuttons/datetime/actions/workflows/test.yml/badge.svg) 5 | [![docs](https://github.com/clickingbuttons/datetime/actions/workflows/publish_docs.yml/badge.svg)](https://clickingbuttons.github.io/datetime) 6 | 7 | Generic Date, Time, and DateTime library. 8 | 9 | ## Installation 10 | ```sh 11 | zig fetch --save "https://github.com/clickingbuttons/datetime/archive/refs/tags/0.14.0.tar.gz" 12 | ``` 13 | 14 | ### build.zig 15 | ```zig 16 | const datetime = b.dependency("datetime", .{ 17 | .target = target, 18 | .optimize = optimize, 19 | }); 20 | your_lib_or_exe.root_module.addImport("datetime", datetime.module("datetime")); 21 | ``` 22 | 23 | ## Usage 24 | 25 | Check out [the demos](./demos.zig). Here's a simple one: 26 | ```zig 27 | const std = @import("std"); 28 | const datetime = @import("datetime"); 29 | 30 | test "now" { 31 | const date = datetime.Date.now(); 32 | std.debug.print("today's date is {rfc3339}\n", .{ date }); 33 | 34 | const time = datetime.Time.now(); 35 | std.debug.print("today's time is {rfc3339}\n", .{ time }); 36 | 37 | const nanotime = datetime.time.Nano.now(); 38 | std.debug.print("today's nanotime is {rfc3339}\n", .{ nanotime }); 39 | 40 | const dt = datetime.DateTime.now(); 41 | std.debug.print("today's date and time is {rfc3339}\n", .{ dt }); 42 | 43 | const NanoDateTime = datetime.datetime.Advanced(datetime.Date, datetime.time.Nano, false); 44 | const ndt = NanoDateTime.now(); 45 | std.debug.print("today's date and nanotime is {rfc3339}\n", .{ ndt }); 46 | } 47 | ``` 48 | 49 | Features: 50 | - Convert to/from epoch subseconds using world's fastest known algorithm. [^1] 51 | - Choose your precision: 52 | - Date's `Year` type. 53 | - Time's `Subsecond` type. 54 | - Date's `epoch` for subsecond conversion. 55 | - Whether DateTime has an `OffsetSeconds` field 56 | - Durations with addition. 57 | - RFC3339 parsing and formatting. 58 | - Use Comptime dates for epoch math. 59 | 60 | In-scope, PRs welcome: 61 | - [ ] Localization 62 | - [ ] Leap seconds 63 | 64 | ## Why yet another date time library? 65 | - I frequently use different precisions for years, subseconds, and UTC offsets. 66 | - Andrew [rejected this from stdlib.](https://github.com/ziglang/zig/pull/19549#issuecomment-2062091512) 67 | 68 | [^1]: [Euclidean Affine Functions by Cassio and Neri.](https://arxiv.org/pdf/2102.06959) 69 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) void { 4 | const target = b.standardTargetOptions(.{}); 5 | const optimize = b.standardOptimizeOption(.{}); 6 | 7 | const entry = b.path("src/root.zig"); 8 | const lib = b.addModule("datetime", .{ 9 | .root_source_file = entry, 10 | .target = target, 11 | .optimize = optimize, 12 | }); 13 | const lib_unit_tests = b.addTest(.{ 14 | .root_source_file = entry, 15 | .target = target, 16 | .optimize = optimize, 17 | }); 18 | const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); 19 | 20 | const demo = b.addTest(.{ 21 | .name = "demo", 22 | .root_source_file = b.path("demos.zig"), 23 | .target = target, 24 | .optimize = optimize, 25 | }); 26 | demo.root_module.addImport("datetime", lib); 27 | const run_demo = b.addRunArtifact(demo); 28 | 29 | const test_step = b.step("test", "Run unit tests"); 30 | test_step.dependOn(&run_lib_unit_tests.step); 31 | test_step.dependOn(&run_demo.step); 32 | } 33 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = .datetime, 3 | .version = "0.14.0", 4 | .minimum_zig_version = "0.14.0", 5 | .fingerprint = 0x93f3c6cab5757db5, // Changing this has security and trust implications. 6 | .paths = .{ 7 | "build.zig", 8 | "build.zig.zon", 9 | "src", 10 | "LICENSE", 11 | "README.md", 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /demos.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const datetime = @import("datetime"); 3 | 4 | test "now" { 5 | const date = datetime.Date.now(); 6 | std.debug.print("today's date is {rfc3339}\n", .{ date }); 7 | 8 | const time = datetime.Time.now(); 9 | std.debug.print("today's time is {rfc3339}\n", .{ time }); 10 | 11 | const nanotime = datetime.time.Nano.now(); 12 | std.debug.print("today's nanotime is {rfc3339}\n", .{ nanotime }); 13 | 14 | const dt = datetime.DateTime.now(); 15 | std.debug.print("today's date and time is {rfc3339}\n", .{ dt }); 16 | 17 | const NanoDateTime = datetime.datetime.Advanced(datetime.Date, datetime.time.Nano, false); 18 | const ndt = NanoDateTime.now(); 19 | std.debug.print("today's date and nanotime is {rfc3339}\n", .{ ndt }); 20 | } 21 | 22 | test "iterator" { 23 | const from = datetime.Date.now(); 24 | const to = from.add(.{ .days = 7 }); 25 | 26 | var i = from; 27 | while (i.toEpoch() < to.toEpoch()) : (i = i.add(.{ .days = 1 })) { 28 | std.debug.print("{s} {rfc3339}\n", .{ @tagName(i.weekday()), i }); 29 | } 30 | } 31 | 32 | test "RFC 3339" { 33 | const d1 = try datetime.Date.parseRfc3339("2024-04-27"); 34 | std.debug.print("d1 {rfc3339}\n", .{ d1 }); 35 | 36 | const DateTimeOffset = datetime.datetime.Advanced(datetime.Date, datetime.time.Sec, true); 37 | const d2 = try DateTimeOffset.parseRfc3339("2024-04-27T13:03:23-04:00"); 38 | std.debug.print("d2 {rfc3339}\n", .{ d2 }); 39 | } 40 | -------------------------------------------------------------------------------- /src/date.zig: -------------------------------------------------------------------------------- 1 | //! Gregorian calendars. 2 | pub const gregorian = @import("./date/gregorian.zig"); 3 | pub const epoch = @import("./date/epoch.zig"); 4 | 5 | pub const Comptime = gregorian.Comptime; 6 | pub const Date = Gregorian(i16, epoch.posix); 7 | pub const Gregorian = gregorian.Gregorian; 8 | pub const GregorianAdvanced = gregorian.Advanced; 9 | 10 | test { 11 | _ = gregorian; 12 | _ = epoch; 13 | } 14 | -------------------------------------------------------------------------------- /src/date/epoch.zig: -------------------------------------------------------------------------------- 1 | const ComptimeDate = @import("./gregorian.zig").Comptime; 2 | 3 | pub const posix = ComptimeDate.init(1970, 1, 1); 4 | pub const dos = ComptimeDate.init(1980, 1, 1); 5 | pub const ios = ComptimeDate.init(2001, 1, 1); 6 | pub const openvms = ComptimeDate.init(1858, 11, 17); 7 | pub const windows = ComptimeDate.init(1601, 1, 1); 8 | pub const amiga = ComptimeDate.init(1978, 1, 1); 9 | pub const pickos = ComptimeDate.init(1967, 12, 31); 10 | pub const gps = ComptimeDate.init(1980, 1, 6); 11 | pub const clr = ComptimeDate.init(1, 1, 1); 12 | pub const uefi = ComptimeDate.init(1582, 10, 15); 13 | pub const efi = ComptimeDate.init(1900, 1, 1); 14 | 15 | pub const zig = unix; 16 | pub const unix = posix; 17 | pub const android = posix; 18 | pub const os2 = dos; 19 | pub const bios = dos; 20 | pub const vfat = dos; 21 | pub const ntfs = windows; 22 | pub const zos = efi; 23 | pub const ntp = zos; 24 | pub const jbase = pickos; 25 | pub const aros = amiga; 26 | pub const morphos = amiga; 27 | pub const brew = gps; 28 | pub const atsc = gps; 29 | pub const go = clr; 30 | -------------------------------------------------------------------------------- /src/date/gregorian.zig: -------------------------------------------------------------------------------- 1 | //! World standard calendar. 2 | //! 3 | //! Introduced in 1582 as a revision of the Julian calendar. 4 | //! 5 | //! Can be projected backwards for dates before 1582 which makes it the 6 | //! "Proleptic Gregorian Calendar." 7 | const std = @import("std"); 8 | const epoch_mod = @import("./epoch.zig"); 9 | const IntFittingRange = std.math.IntFittingRange; 10 | const s_per_day = std.time.s_per_day; 11 | const expectEqual = std.testing.expectEqual; 12 | const assert = std.debug.assert; 13 | 14 | /// A date on the proleptic (projected backwards) Gregorian calendar. 15 | pub fn Advanced(comptime YearT: type, comptime epoch: Comptime, shift: comptime_int) type { 16 | return struct { 17 | year: Year, 18 | month: MonthT, 19 | day: DayT, 20 | 21 | pub const Year = YearT; 22 | pub const Month = MonthT; 23 | pub const Day = DayT; 24 | 25 | /// Inclusive. 26 | pub const min_epoch_day = epoch.daysUntil(Comptime.init(std.math.minInt(Year), 1, 1)); 27 | /// Inclusive. 28 | pub const max_epoch_day = epoch.daysUntil(Comptime.init(std.math.maxInt(Year), 12, 31)); 29 | 30 | pub const EpochDays = IntFittingRange(min_epoch_day, max_epoch_day); 31 | // These are used for math that should not overflow. 32 | const UEpochDays = std.meta.Int( 33 | .unsigned, 34 | std.math.ceilPowerOfTwoAssert(u16, @typeInfo(EpochDays).int.bits), 35 | ); 36 | const IEpochDays = std.meta.Int(.signed, @typeInfo(UEpochDays).int.bits); 37 | const EpochDaysWide = std.meta.Int( 38 | @typeInfo(EpochDays).int.signedness, 39 | @typeInfo(UEpochDays).int.bits, 40 | ); 41 | 42 | pub const zig_epoch_offset = epoch_mod.zig.daysUntil(epoch); 43 | // Variables in paper. 44 | const K = Computational.epoch_.daysUntil(epoch) + era.days * shift; 45 | const L = era.years * shift; 46 | 47 | // Type overflow checks 48 | comptime { 49 | const min_year_no_overflow = -L; 50 | const max_year_no_overflow = std.math.maxInt(UEpochDays) / days_in_year.numerator - L + 1; 51 | assert(min_year_no_overflow < std.math.minInt(Year)); 52 | assert(max_year_no_overflow > std.math.maxInt(Year)); 53 | 54 | const min_epoch_day_no_overflow = -K; 55 | const max_epoch_day_no_overflow = (std.math.maxInt(UEpochDays) - 3) / 4 - K; 56 | assert(min_epoch_day_no_overflow < min_epoch_day); 57 | assert(max_epoch_day_no_overflow > max_epoch_day); 58 | } 59 | 60 | /// Easier to count from. See section 4 of paper. 61 | const Computational = struct { 62 | year: UEpochDays, 63 | month: UIntFitting(14), 64 | day: UIntFitting(30), 65 | 66 | pub const epoch_ = Comptime.init(0, 3, 1); 67 | 68 | inline fn toGregorian(self: Computational, N_Y: UIntFitting(365)) Self { 69 | const last_day_of_jan = 306; 70 | const J: UEpochDays = if (N_Y >= last_day_of_jan) 1 else 0; 71 | 72 | const month: MonthInt = if (J != 0) self.month - 12 else self.month; 73 | const year: EpochDaysWide = @bitCast(self.year +% J -% L); 74 | 75 | return .{ 76 | .year = @intCast(year), 77 | .month = @enumFromInt(month), 78 | .day = @as(DayT, self.day) + 1, 79 | }; 80 | } 81 | 82 | inline fn fromGregorian(date: Self) Computational { 83 | const month: UIntFitting(14) = date.month.numeric(); 84 | const Widened = std.meta.Int( 85 | @typeInfo(Year).int.signedness, 86 | @typeInfo(UEpochDays).int.bits, 87 | ); 88 | const widened: Widened = date.year; 89 | const Y_G: UEpochDays = @bitCast(widened); 90 | const J: UEpochDays = if (month <= 2) 1 else 0; 91 | 92 | return .{ 93 | .year = Y_G +% L -% J, 94 | .month = if (J != 0) month + 12 else month, 95 | .day = date.day - 1, 96 | }; 97 | } 98 | }; 99 | 100 | const Self = @This(); 101 | 102 | pub fn init(year: Year, month: MonthT, day: DayT) Self { 103 | return .{ .year = year, .month = month, .day = day }; 104 | } 105 | 106 | pub fn now() Self { 107 | const epoch_days = @divFloor(std.time.timestamp(), s_per_day); 108 | return fromEpoch(@intCast(epoch_days + zig_epoch_offset)); 109 | } 110 | 111 | pub fn fromEpoch(days: EpochDays) Self { 112 | // This function is Figure 12 of the paper. 113 | // Besides being ported from C++, the following has changed: 114 | // - Seperate Year and UEpochDays types 115 | // - Rewrite EAFs in terms of `a` and `b` 116 | // - Add EAF bounds assertions 117 | // - Use bounded int types provided in Section 10 instead of u32 and u64 118 | // - Add computational calendar struct type 119 | // - Add comments referencing some proofs 120 | assert(days >= min_epoch_day); 121 | assert(days <= max_epoch_day); 122 | const mod = std.math.comptimeMod; 123 | const div = comptimeDivFloor; 124 | 125 | const widened: EpochDaysWide = days; 126 | const N = @as(UEpochDays, @bitCast(widened)) +% K; 127 | 128 | const a1 = 4; 129 | const b1 = 3; 130 | const N_1 = a1 * N + b1; 131 | const C = N_1 / era.days; 132 | const N_C: UIntFitting(36_564) = div(mod(N_1, era.days), a1); 133 | 134 | const N_2 = a1 * @as(UIntFitting(146_099), N_C) + b1; 135 | // n % 1461 == 2939745 * n % 2^32 / 2939745, 136 | // for all n in [0, 28825529) 137 | assert(N_2 < 28_825_529); 138 | const a2 = 2_939_745; 139 | const b2 = 0; 140 | const P_2_max = 429493804755; 141 | const P_2 = a2 * @as(UIntFitting(P_2_max), N_2) + b2; 142 | const Z: UIntFitting(99) = div(P_2, (1 << 32)); 143 | const N_Y: UIntFitting(365) = div(mod(P_2, (1 << 32)), a2 * a1); 144 | 145 | // (5 * n + 461) / 153 == (2141 * n + 197913) /2^16, 146 | // for all n in [0, 734) 147 | assert(N_Y < 734); 148 | const a3 = 2_141; 149 | const b3 = 197_913; 150 | const N_3 = a3 * @as(UIntFitting(979_378), N_Y) + b3; 151 | 152 | const computational = Computational{ 153 | .year = 100 * C + Z, 154 | .month = div(N_3, 1 << 16), 155 | .day = div(mod(N_3, (1 << 16)), a3), 156 | }; 157 | 158 | return computational.toGregorian(N_Y); 159 | } 160 | 161 | pub fn toEpoch(self: Self) EpochDays { 162 | // This function is Figure 13 of the paper. 163 | const c = Computational.fromGregorian(self); 164 | const C = c.year / 100; 165 | 166 | const y_star = days_in_year.numerator * c.year / 4 - C + C / 4; 167 | const days_in_5mo = 31 + 30 + 31 + 30 + 31; 168 | const m_star = (days_in_5mo * @as(UEpochDays, c.month) - 457) / 5; 169 | const N = y_star + m_star + c.day; 170 | 171 | return @intCast(@as(IEpochDays, @bitCast(N)) - K); 172 | } 173 | 174 | pub const Duration = struct { 175 | years: Year = 0, 176 | months: Duration.Months = 0, 177 | days: Duration.Days = 0, 178 | 179 | pub const Days = std.meta.Int(.signed, @typeInfo(EpochDays).int.bits); 180 | pub const Months = std.meta.Int(.signed, @typeInfo(Duration.Days).int.bits - std.math.log2_int(u16, 12)); 181 | 182 | pub fn init(years: Year, months: Duration.Months, days: Duration.Days) Duration { 183 | return Duration{ .years = years, .months = months, .days = days }; 184 | } 185 | }; 186 | 187 | pub fn add(self: Self, duration: Duration) Self { 188 | const m = duration.months + self.month.numeric() - 1; 189 | const y = self.year + duration.years + @divFloor(m, 12); 190 | 191 | const ym_epoch_day = Self{ 192 | .year = @intCast(y), 193 | .month = @enumFromInt(std.math.comptimeMod(m, 12) + 1), 194 | .day = 1, 195 | }; 196 | 197 | var epoch_days = ym_epoch_day.toEpoch(); 198 | epoch_days += duration.days + self.day - 1; 199 | 200 | return fromEpoch(epoch_days); 201 | } 202 | 203 | pub const Weekday = WeekdayT; 204 | pub fn weekday(self: Self) Weekday { 205 | const epoch_days = self.toEpoch() +% epoch.weekday().numeric() -% 1; 206 | return @enumFromInt(std.math.comptimeMod(epoch_days, 7) +% 1); 207 | } 208 | 209 | pub fn parseRfc3339(str: *const [10]u8) !Self { 210 | if (str[4] != '-' or str[7] != '-') return error.Parsing; 211 | 212 | const year = try std.fmt.parseInt(IntFittingRange(0, 9999), str[0..4], 10); 213 | const month = try std.fmt.parseInt(Month.Int, str[5..7], 10); 214 | if (month < 1 or month > 12) return error.Parsing; 215 | const m: Month = @enumFromInt(month); 216 | const day = try std.fmt.parseInt(Day, str[8..10], 10); 217 | if (day < 1 or day > m.days(isLeap(year))) return error.Parsing; 218 | 219 | return .{ 220 | .year = @intCast(year), // if YearT is `i8` or `u8` this may fail. increase it to not fail. 221 | .month = m, 222 | .day = day, 223 | }; 224 | } 225 | 226 | fn fmtRfc3339(self: Self, writer: anytype) !void { 227 | if (self.year < 0 or self.year > 9999) return error.Range; 228 | if (self.day < 1 or self.day > 99) return error.Range; 229 | if (self.month.numeric() < 1 or self.month.numeric() > 12) return error.Range; 230 | try writer.print("{d:0>4}-{d:0>2}-{d:0>2}", .{ 231 | @as(IntFittingRange(0, 9999), @intCast(self.year)), 232 | self.month.numeric(), 233 | self.day, 234 | }); 235 | } 236 | 237 | pub fn format( 238 | self: Self, 239 | comptime fmt: []const u8, 240 | options: std.fmt.FormatOptions, 241 | writer: anytype, 242 | ) (@TypeOf(writer).Error || error{Range})!void { 243 | _ = options; 244 | 245 | if (std.mem.eql(u8, "rfc3339", fmt)) { 246 | try self.fmtRfc3339(writer); 247 | } else { 248 | try writer.print( 249 | "Date{{ .year = {d}, .month = .{s}, .day = .{d} }}", 250 | .{ self.year, @tagName(self.month), self.day }, 251 | ); 252 | } 253 | } 254 | }; 255 | } 256 | 257 | pub fn Gregorian(comptime Year: type, comptime epoch: Comptime) type { 258 | const shift = solveShift(Year, epoch) catch unreachable; 259 | return Advanced(Year, epoch, shift); 260 | } 261 | 262 | fn testFromToEpoch(comptime T: type) !void { 263 | const d1 = T{ .year = 1970, .month = .jan, .day = 1 }; 264 | const d2 = T{ .year = 1980, .month = .jan, .day = 1 }; 265 | 266 | try expectEqual(3_652, d2.toEpoch() - d1.toEpoch()); 267 | 268 | // We don't have time to test converting there and back again for every possible i64/u64. 269 | // The paper has already proven it and written tests for i32 and u32. 270 | // Instead let's cycle through the first and last 1 << 16 part of each range. 271 | const min_epoch_day: i128 = T.min_epoch_day; 272 | const max_epoch_day: i128 = T.max_epoch_day; 273 | const diff = max_epoch_day - min_epoch_day; 274 | const range: usize = if (max_epoch_day - min_epoch_day > 1 << 16) 1 << 16 else @intCast(diff); 275 | for (0..range) |i| { 276 | const ii: T.IEpochDays = @intCast(i); 277 | 278 | const d3: T.EpochDays = @intCast(min_epoch_day + ii); 279 | try expectEqual(d3, T.fromEpoch(d3).toEpoch()); 280 | 281 | const d4: T.EpochDays = @intCast(max_epoch_day - ii); 282 | try expectEqual(d4, T.fromEpoch(d4).toEpoch()); 283 | } 284 | } 285 | 286 | test "Gregorian from and to epoch" { 287 | try testFromToEpoch(Gregorian(i16, epoch_mod.unix)); 288 | try testFromToEpoch(Gregorian(i32, epoch_mod.unix)); 289 | try testFromToEpoch(Gregorian(i64, epoch_mod.unix)); 290 | try testFromToEpoch(Gregorian(u16, epoch_mod.unix)); 291 | try testFromToEpoch(Gregorian(u32, epoch_mod.unix)); 292 | try testFromToEpoch(Gregorian(u64, epoch_mod.unix)); 293 | 294 | try testFromToEpoch(Gregorian(i16, epoch_mod.windows)); 295 | try testFromToEpoch(Gregorian(i32, epoch_mod.windows)); 296 | try testFromToEpoch(Gregorian(i64, epoch_mod.windows)); 297 | try testFromToEpoch(Gregorian(u16, epoch_mod.windows)); 298 | try testFromToEpoch(Gregorian(u32, epoch_mod.windows)); 299 | try testFromToEpoch(Gregorian(u64, epoch_mod.windows)); 300 | } 301 | 302 | test Gregorian { 303 | const T = Gregorian(i16, epoch_mod.unix); 304 | const d1 = T.init(1960, .jan, 1); 305 | const epoch = T.init(1970, .jan, 1); 306 | 307 | try expectEqual(365, T.init(1971, .jan, 1).toEpoch()); 308 | try expectEqual(epoch, T.fromEpoch(0)); 309 | try expectEqual(3_653, epoch.toEpoch() - d1.toEpoch()); 310 | 311 | // overflow 312 | // $ TZ=UTC0 date -d '1970-01-01 +1 year +13 months +32 days' --iso-8601=seconds 313 | try expectEqual( 314 | T.init(1972, .mar, 4), 315 | T.init(1970, .jan, 1).add(T.Duration.init(1, 13, 32)), 316 | ); 317 | // underflow 318 | // $ TZ=UTC0 date -d '1972-03-04 -10 year -13 months -32 days' --iso-8601=seconds 319 | try expectEqual( 320 | T.init(1961, .jan, 3), 321 | T.init(1972, .mar, 4).add(T.Duration.init(-10, -13, -32)), 322 | ); 323 | 324 | // $ date -d '1970-01-01' 325 | try expectEqual(.thu, epoch.weekday()); 326 | try expectEqual(.thu, epoch.add(T.Duration.init(0, 0, 7)).weekday()); 327 | try expectEqual(.thu, epoch.add(T.Duration.init(0, 0, -7)).weekday()); 328 | // $ date -d '1980-01-01' 329 | try expectEqual(.tue, T.init(1980, .jan, 1).weekday()); 330 | // $ date -d '1960-01-01' 331 | try expectEqual(.fri, d1.weekday()); 332 | 333 | try expectEqual(d1, try T.parseRfc3339("1960-01-01")); 334 | try std.testing.expectError(error.Parsing, T.parseRfc3339("2000T01-01")); 335 | try std.testing.expectError(error.InvalidCharacter, T.parseRfc3339("2000-01-AD")); 336 | 337 | var buf: [32]u8 = undefined; 338 | var stream = std.io.fixedBufferStream(&buf); 339 | try d1.fmtRfc3339(stream.writer()); 340 | try std.testing.expectEqualStrings("1960-01-01", stream.getWritten()); 341 | } 342 | 343 | const WeekdayInt = IntFittingRange(1, 7); 344 | pub const WeekdayT = enum(WeekdayInt) { 345 | mon = 1, 346 | tue = 2, 347 | wed = 3, 348 | thu = 4, 349 | fri = 5, 350 | sat = 6, 351 | sun = 7, 352 | 353 | pub const Int = WeekdayInt; 354 | 355 | /// Convenient conversion to `WeekdayInt`. mon = 1, sun = 7 356 | pub fn numeric(self: @This()) Int { 357 | return @intFromEnum(self); 358 | } 359 | }; 360 | 361 | const MonthInt = IntFittingRange(1, 12); 362 | pub const MonthT = enum(MonthInt) { 363 | jan = 1, 364 | feb = 2, 365 | mar = 3, 366 | apr = 4, 367 | may = 5, 368 | jun = 6, 369 | jul = 7, 370 | aug = 8, 371 | sep = 9, 372 | oct = 10, 373 | nov = 11, 374 | dec = 12, 375 | 376 | pub const Int = MonthInt; 377 | pub const Days = IntFittingRange(28, 31); 378 | 379 | /// Convenient conversion to `MonthInt`. jan = 1, dec = 12 380 | pub fn numeric(self: @This()) Int { 381 | return @intFromEnum(self); 382 | } 383 | 384 | pub fn days(self: @This(), is_leap_year: bool) Days { 385 | const m: Days = @intCast(self.numeric()); 386 | return if (m != 2) 387 | 30 | (m ^ (m >> 3)) 388 | else if (is_leap_year) 389 | 29 390 | else 391 | 28; 392 | } 393 | }; 394 | pub const DayT = IntFittingRange(1, 31); 395 | 396 | test MonthT { 397 | try expectEqual(31, MonthT.jan.days(false)); 398 | try expectEqual(29, MonthT.feb.days(true)); 399 | try expectEqual(28, MonthT.feb.days(false)); 400 | try expectEqual(31, MonthT.mar.days(false)); 401 | try expectEqual(30, MonthT.apr.days(false)); 402 | try expectEqual(31, MonthT.may.days(false)); 403 | try expectEqual(30, MonthT.jun.days(false)); 404 | try expectEqual(31, MonthT.jul.days(false)); 405 | try expectEqual(31, MonthT.aug.days(false)); 406 | try expectEqual(30, MonthT.sep.days(false)); 407 | try expectEqual(31, MonthT.oct.days(false)); 408 | try expectEqual(30, MonthT.nov.days(false)); 409 | try expectEqual(31, MonthT.dec.days(false)); 410 | } 411 | 412 | pub fn isLeap(year: anytype) bool { 413 | return if (@mod(year, 25) != 0) 414 | year & (4 - 1) == 0 415 | else 416 | year & (16 - 1) == 0; 417 | } 418 | 419 | test isLeap { 420 | try expectEqual(false, isLeap(2095)); 421 | try expectEqual(true, isLeap(2096)); 422 | try expectEqual(false, isLeap(2100)); 423 | try expectEqual(true, isLeap(2400)); 424 | } 425 | 426 | /// Useful for epoch math. 427 | pub const Comptime = struct { 428 | year: comptime_int, 429 | month: Month, 430 | day: Day, 431 | 432 | pub const Month = std.math.IntFittingRange(1, 12); 433 | pub const Day = std.math.IntFittingRange(1, 31); 434 | 435 | pub fn init(year: comptime_int, month: Month, day: Day) Comptime { 436 | return .{ .year = year, .month = month, .day = day }; 437 | } 438 | 439 | pub fn daysUntil(from: Comptime, to: Comptime) comptime_int { 440 | @setEvalBranchQuota(5000); 441 | const eras = @divFloor(to.year - from.year, era.years); 442 | comptime var res: comptime_int = eras * era.days; 443 | 444 | var i = from.year + eras * era.years; 445 | while (i < to.year) : (i += 1) { 446 | res += if (isLeap(i)) 366 else 365; 447 | } 448 | 449 | res += @intCast(daysSinceJan01(to)); 450 | res -= @intCast(daysSinceJan01(from)); 451 | 452 | return res; 453 | } 454 | 455 | fn daysSinceJan01(d: Comptime) u16 { 456 | const leap = isLeap(d.year); 457 | var res: u16 = d.day - 1; 458 | for (1..d.month) |j| { 459 | const m: MonthT = @enumFromInt(j); 460 | res += m.days(leap); 461 | } 462 | 463 | return res; 464 | } 465 | 466 | pub fn weekday(d: Comptime) WeekdayT { 467 | // 1970-01-01 is a Thursday. 468 | const known_date = epoch_mod.unix; 469 | const known_date_weekday: comptime_int = @intFromEnum(WeekdayT.thu); 470 | const start_of_week: comptime_int = @intFromEnum(WeekdayT.mon); 471 | 472 | const epoch_days = known_date.daysUntil(d) +% known_date_weekday -% start_of_week; 473 | return @enumFromInt(std.math.comptimeMod(epoch_days, 7) +% start_of_week); 474 | } 475 | }; 476 | 477 | test Comptime { 478 | try expectEqual(1, Comptime.init(2000, 1, 1).daysUntil(Comptime.init(2000, 1, 2))); 479 | try expectEqual(366, Comptime.init(2000, 1, 1).daysUntil(Comptime.init(2001, 1, 1))); 480 | try expectEqual(146_097, Comptime.init(0, 1, 1).daysUntil(Comptime.init(400, 1, 1))); 481 | try expectEqual(146_097 + 366, Comptime.init(0, 1, 1).daysUntil(Comptime.init(401, 1, 1))); 482 | const from = Comptime.init(std.math.minInt(i16), 1, 1); 483 | const to = Comptime.init(std.math.maxInt(i16) + 1, 1, 1); 484 | try expectEqual(23_936_532, from.daysUntil(to)); 485 | 486 | try expectEqual(WeekdayT.thu, Comptime.init(1970, 1, 1).weekday()); 487 | const d1 = Comptime.init(2024, 4, 27); 488 | try expectEqual(19_840, epoch_mod.unix.daysUntil(d1)); 489 | try expectEqual(WeekdayT.sat, d1.weekday()); 490 | 491 | try expectEqual(WeekdayT.wed, Comptime.init(1969, 12, 31).weekday()); 492 | const d2 = Comptime.init(1960, 1, 1); 493 | try expectEqual(-3653, epoch_mod.unix.daysUntil(d2)); 494 | try expectEqual(WeekdayT.fri, d2.weekday()); 495 | } 496 | 497 | /// The Gregorian calendar repeats every 400 years. 498 | const era = struct { 499 | pub const years = 400; 500 | pub const days = 146_097; 501 | }; 502 | 503 | /// Number of days between two consecutive March equinoxes 504 | const days_in_year = struct { 505 | const actual = 365.2424; 506 | // .0001 days per year of error. 507 | const numerator = 1_461; 508 | const denominator = 4; 509 | }; 510 | 511 | fn UIntFitting(to: comptime_int) type { 512 | return IntFittingRange(0, to); 513 | } 514 | 515 | /// Finds minimum epoch shift that covers the range: 516 | /// [std.math.minInt(Year), std.math.maxInt(Year)] 517 | fn solveShift(comptime Year: type, comptime epoch: Comptime) !comptime_int { 518 | // TODO: linear system of equations solver 519 | _ = epoch; 520 | return @divFloor(std.math.maxInt(Year), era.years) + 1; 521 | } 522 | 523 | test solveShift { 524 | const epoch = epoch_mod.unix; 525 | try expectEqual(82, try solveShift(i16, epoch)); 526 | try expectEqual(5_368_710, try solveShift(i32, epoch)); 527 | try expectEqual(23_058_430_092_136_940, try solveShift(i64, epoch)); 528 | } 529 | 530 | fn ComptimeDiv(comptime Num: type, comptime divisor: comptime_int) type { 531 | const info = @typeInfo(Num).int; 532 | return std.meta.Int(info.signedness, info.bits - std.math.log2(divisor)); 533 | } 534 | 535 | /// Return the quotient of `num` with the smallest integer type 536 | fn comptimeDivFloor(num: anytype, comptime divisor: comptime_int) ComptimeDiv(@TypeOf(num), divisor) { 537 | return @intCast(@divFloor(num, divisor)); 538 | } 539 | 540 | test comptimeDivFloor { 541 | try std.testing.expectEqual(@as(u13, 100), comptimeDivFloor(@as(u16, 1000), 10)); 542 | } 543 | -------------------------------------------------------------------------------- /src/datetime.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const date_mod = @import("./date.zig"); 3 | const time_mod = @import("./time.zig"); 4 | const epoch_mod = date_mod.epoch; 5 | const s_per_day = time_mod.s_per_day; 6 | const s_per_hour = std.time.s_per_hour; 7 | const s_per_min = std.time.s_per_min; 8 | 9 | pub fn Advanced(comptime DateT: type, comptime TimeT: type, comptime has_offset: bool) type { 10 | return struct { 11 | date: Date, 12 | time: Time = .{}, 13 | offset: OffsetSeconds = 0, 14 | 15 | pub const Date = DateT; 16 | pub const Time = TimeT; 17 | pub const OffsetSeconds = if (has_offset) std.math.IntFittingRange(-s_per_day / 2, s_per_day / 2) else u0; 18 | /// Fractional epoch seconds based on `TimeT.precision`: 19 | /// 0 = seconds 20 | /// 3 = milliseconds 21 | /// 6 = microseconds 22 | /// 9 = nanoseconds 23 | pub const EpochSubseconds = std.meta.Int( 24 | @typeInfo(Date.EpochDays).int.signedness, 25 | @typeInfo(Date.EpochDays).int.bits + std.math.log2_int_ceil(usize, Time.subseconds_per_day), 26 | ); 27 | 28 | const Self = @This(); 29 | const subseconds_per_day = s_per_day * Time.subseconds_per_s; 30 | 31 | pub fn init( 32 | year: Date.Year, 33 | month: Date.Month, 34 | day: Date.Day, 35 | hour: Time.Hour, 36 | minute: Time.Minute, 37 | second: Time.Second, 38 | subsecond: Time.Subsecond, 39 | offset: OffsetSeconds, 40 | ) Self { 41 | return .{ 42 | .date = Date.init(year, month, day), 43 | .time = Time.init(hour, minute, second, subsecond), 44 | .offset = offset, 45 | }; 46 | } 47 | 48 | pub fn now() Self { 49 | return switch (Time.precision) { 50 | 0 => fromEpoch(@intCast(std.time.timestamp())), 51 | 3 => fromEpoch(@intCast(@divFloor(std.time.milliTimestamp(), Time.subseconds_per_s / 1_000))), 52 | 6 => fromEpoch(@intCast(@divFloor(std.time.microTimestamp(), Time.subseconds_per_s / 1_000_000))), 53 | else => fromEpoch(@intCast(@divFloor(std.time.nanoTimestamp(), Time.subseconds_per_s / 1_000_000_000))), 54 | }; 55 | } 56 | 57 | /// New date time from fractional seconds since `Date.epoch`. 58 | pub fn fromEpoch(subseconds: EpochSubseconds) Self { 59 | const days = @divFloor(subseconds, subseconds_per_day); 60 | const new_date = Date.fromEpoch(@intCast(days)); 61 | const day_seconds = std.math.comptimeMod(subseconds, subseconds_per_day); 62 | const new_time = Time.fromDaySeconds(day_seconds); 63 | return .{ .date = new_date, .time = new_time }; 64 | } 65 | 66 | /// Returns fractional seconds since `Date.epoch`. 67 | pub fn toEpoch(self: Self) EpochSubseconds { 68 | var res: EpochSubseconds = 0; 69 | res += @as(EpochSubseconds, self.date.toEpoch()) * subseconds_per_day; 70 | res += self.time.toDaySeconds(); 71 | res += @as(EpochSubseconds, self.offset) * subseconds_per_day; 72 | return res; 73 | } 74 | 75 | pub const Duration = struct { 76 | years: Date.Year = 0, 77 | months: Date.Duration.Months = 0, 78 | days: Date.Duration.Days = 0, 79 | hours: i64 = 0, 80 | minutes: i64 = 0, 81 | seconds: i64 = 0, 82 | subseconds: Time.Duration.Subseconds = 0, 83 | 84 | pub fn init( 85 | years: Date.Year, 86 | months: Date.Duration.Months, 87 | days: Date.Duration.Days, 88 | hours: i64, 89 | minutes: i64, 90 | seconds: i64, 91 | subseconds: Time.Duration.Subseconds, 92 | ) Duration { 93 | return Duration{ 94 | .years = years, 95 | .months = months, 96 | .days = days, 97 | .hours = hours, 98 | .minutes = minutes, 99 | .seconds = seconds, 100 | .subseconds = subseconds, 101 | }; 102 | } 103 | }; 104 | 105 | pub fn add(self: Self, duration: Duration) Self { 106 | const time = self.time.addWithOverflow(.{ 107 | .hours = duration.hours, 108 | .minutes = duration.minutes, 109 | .seconds = duration.seconds, 110 | }); 111 | const date = self.date.add(.{ 112 | .years = duration.years, 113 | .months = duration.months, 114 | .days = duration.days + @as(Date.Duration.Days, @intCast(time[1])), 115 | }); 116 | return .{ .date = date, .time = time[0] }; 117 | } 118 | 119 | pub fn parseRfc3339(str: []const u8) !Self { 120 | if (str.len < "yyyy-MM-ddThh:mm:ssZ".len) return error.Parsing; 121 | if (std.ascii.toUpper(str[10]) != 'T') return error.Parsing; 122 | 123 | const date = try Date.parseRfc3339(str[0..10]); 124 | const time_end = std.mem.indexOfAnyPos(u8, str, 11, &[_]u8{ 'Z', '+', '-' }) orelse 125 | return error.Parsing; 126 | const time = try Time.parseRfc3339(str[11..time_end]); 127 | 128 | var offset: OffsetSeconds = 0; 129 | if (comptime has_offset) brk: { 130 | var i = time_end; 131 | const sign: OffsetSeconds = switch (str[i]) { 132 | 'Z' => break :brk, 133 | '-' => -1, 134 | '+' => 1, 135 | else => return error.Parsing, 136 | }; 137 | i += 1; 138 | 139 | const offset_hour = try std.fmt.parseInt(OffsetSeconds, str[i..][0..2], 10); 140 | if (str[i + 2] != ':') return error.Parsing; 141 | const offset_minute = try std.fmt.parseInt(OffsetSeconds, str[i + 3 ..][0..2], 10); 142 | 143 | offset = sign * (offset_hour * s_per_hour + offset_minute * s_per_min); 144 | } 145 | 146 | return .{ .date = date, .time = time, .offset = offset }; 147 | } 148 | 149 | fn fmtRfc3339(self: Self, writer: anytype) !void { 150 | try writer.print("{rfc3339}T{rfc3339}", .{ self.date, self.time }); 151 | if (self.offset == 0) { 152 | try writer.writeByte('Z'); 153 | } else { 154 | const hour_offset = @divTrunc(self.offset, s_per_hour); 155 | const minute_offset = @divTrunc(self.offset - hour_offset * s_per_hour, s_per_min); 156 | try writer.writeByte(if (self.offset < 0) '-' else '+'); 157 | try writer.print("{d:0>2}:{d:0>2}", .{ @abs(hour_offset), @abs(minute_offset) }); 158 | } 159 | } 160 | 161 | pub fn format( 162 | self: Self, 163 | comptime fmt: []const u8, 164 | options: std.fmt.FormatOptions, 165 | writer: anytype, 166 | ) (@TypeOf(writer).Error || error{Range})!void { 167 | _ = options; 168 | 169 | if (std.mem.eql(u8, "rfc3339", fmt)) { 170 | try self.fmtRfc3339(writer); 171 | } else { 172 | try writer.print("DateTime{{ .date = {}, .time = {} }}", .{ self.date, self.time }); 173 | } 174 | } 175 | }; 176 | } 177 | 178 | test Advanced { 179 | const T = Advanced(date_mod.Date, time_mod.Milli, true); 180 | const expectEqual = std.testing.expectEqual; 181 | 182 | const a = T.init(1970, .jan, 1, 0, 0, 0, 0, 0); 183 | const duration = T.Duration.init(1, 1, 1, 25, 1, 1, 0); 184 | try expectEqual(T.init(1971, .feb, 3, 1, 1, 1, 0, 0), a.add(duration)); 185 | 186 | // RFC 3339 section 5.8" 187 | try expectEqual(T.init(1985, .apr, 12, 23, 20, 50, 520, 0), try T.parseRfc3339("1985-04-12T23:20:50.52Z")); 188 | try expectEqual(T.init(1996, .dec, 19, 16, 39, 57, 0, -8 * s_per_hour), try T.parseRfc3339("1996-12-19T16:39:57-08:00")); 189 | try expectEqual(T.init(1990, .dec, 31, 23, 59, 60, 0, 0), try T.parseRfc3339("1990-12-31T23:59:60Z")); 190 | try expectEqual(T.init(1990, .dec, 31, 15, 59, 60, 0, -8 * s_per_hour), try T.parseRfc3339("1990-12-31T15:59:60-08:00")); 191 | try expectEqual(T.init(1937, .jan, 1, 12, 0, 27, 870, 20 * s_per_min), try T.parseRfc3339("1937-01-01T12:00:27.87+00:20")); 192 | 193 | // negative offset 194 | try expectEqual(T.init(1985, .apr, 12, 23, 20, 50, 520, -20 * s_per_min), try T.parseRfc3339("1985-04-12T23:20:50.52-00:20")); 195 | try expectEqual(T.init(1985, .apr, 12, 23, 20, 50, 520, -10 * s_per_hour - 20 * s_per_min), try T.parseRfc3339("1985-04-12T23:20:50.52-10:20")); 196 | 197 | var buf: [32]u8 = undefined; 198 | var stream = std.io.fixedBufferStream(&buf); 199 | try T.init(1937, .jan, 1, 12, 0, 27, 870, 20 * s_per_min).fmtRfc3339(stream.writer()); 200 | try std.testing.expectEqualStrings("1937-01-01T12:00:27.870+00:20", stream.getWritten()); 201 | 202 | // negative offset 203 | stream.reset(); 204 | try T.init(1937, .jan, 1, 12, 0, 27, 870, -20 * s_per_min).fmtRfc3339(stream.writer()); 205 | try std.testing.expectEqualStrings("1937-01-01T12:00:27.870-00:20", stream.getWritten()); 206 | 207 | stream.reset(); 208 | try T.init(1937, .jan, 1, 12, 0, 27, 870, -1 * s_per_hour - 20 * s_per_min).fmtRfc3339(stream.writer()); 209 | try std.testing.expectEqualStrings("1937-01-01T12:00:27.870-01:20", stream.getWritten()); 210 | } 211 | -------------------------------------------------------------------------------- /src/root.zig: -------------------------------------------------------------------------------- 1 | //! Gregorian types. 2 | const std = @import("std"); 3 | pub const date = @import("./date.zig"); 4 | pub const time = @import("./time.zig"); 5 | pub const datetime = @import("./datetime.zig"); 6 | 7 | /// Supports dates between years -32_768 and 32_768. 8 | pub const Date = date.Date; 9 | pub const Month = Date.Month; 10 | pub const Day = Date.Day; 11 | pub const Weekday = Date.Weekday; 12 | 13 | pub const Time = time.Sec; 14 | 15 | /// * Years between -32_768 and 32_768 (inclusive) 16 | /// * Second resolution. 17 | /// * No timezones. 18 | pub const DateTime = datetime.Advanced(Date, time.Sec, false); 19 | 20 | fn fmtRfc3339Impl( 21 | date_time_or_datetime: Date, 22 | comptime fmt: []const u8, 23 | options: std.fmt.FormatOptions, 24 | writer: anytype, 25 | ) !void { 26 | _ = fmt; 27 | _ = options; 28 | try date_time_or_datetime.toRfc3339(writer); 29 | } 30 | 31 | /// Return a RFC 3339 formatter for a Date, Time, or DateTime type. 32 | pub fn fmtRfc3339(date_time_or_datetime: anytype) std.fmt.Formatter(fmtRfc3339Impl) { 33 | return .{ .data = date_time_or_datetime }; 34 | } 35 | 36 | /// Tests EpochSeconds -> DateTime and DateTime -> EpochSeconds 37 | fn testEpoch(secs: DateTime.EpochSubseconds, dt: DateTime) !void { 38 | const actual_dt = DateTime.fromEpoch(secs); 39 | try std.testing.expectEqual(dt, actual_dt); 40 | try std.testing.expectEqual(secs, dt.toEpoch()); 41 | } 42 | 43 | test DateTime { 44 | // $ date -d @31535999 --iso-8601=seconds 45 | try std.testing.expectEqual(8, @sizeOf(DateTime)); 46 | try testEpoch(0, .{ .date = DateTime.Date.init(1970, .jan, 1) }); 47 | try testEpoch(31535999, .{ 48 | .date = .{ .year = 1970, .month = .dec, .day = 31 }, 49 | .time = .{ .hour = 23, .minute = 59, .second = 59 }, 50 | }); 51 | try testEpoch(1622924906, .{ 52 | .date = .{ .year = 2021, .month = .jun, .day = 5 }, 53 | .time = .{ .hour = 20, .minute = 28, .second = 26 }, 54 | }); 55 | try testEpoch(1625159473, .{ 56 | .date = .{ .year = 2021, .month = .jul, .day = 1 }, 57 | .time = .{ .hour = 17, .minute = 11, .second = 13 }, 58 | }); 59 | // Washington bday, proleptic 60 | try testEpoch(-7506041400, .{ 61 | .date = .{ .year = 1732, .month = .feb, .day = 22 }, 62 | .time = .{ .hour = 12, .minute = 30 }, 63 | }); 64 | // minimum date 65 | try testEpoch(-1096225401600, .{ 66 | .date = .{ .year = std.math.minInt(i16), .month = .jan, .day = 1 }, 67 | }); 68 | // maximum date 69 | // $ date -d '32767-12-31 UTC' +%s 70 | try testEpoch(971890876800, .{ 71 | .date = .{ .year = std.math.maxInt(i16), .month = .dec, .day = 31 }, 72 | }); 73 | } 74 | 75 | test { 76 | _ = date; 77 | _ = time; 78 | } 79 | -------------------------------------------------------------------------------- /src/time.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const IntFittingRange = std.math.IntFittingRange; 3 | pub const s_per_day = std.time.s_per_day; 4 | const ns_per_day = s_per_day * 1_000_000_000; 5 | 6 | /// A time of day with a subsecond field capable of holding values 7 | /// between 0 and 10 ^ `decimal_precision`. 8 | /// 9 | /// TimeAdvanced(0) = seconds 10 | /// TimeAdvanced(3) = milliseconds 11 | /// TimeAdvanced(6) = microseconds 12 | /// TimeAdvanced(9) = nanoseconds 13 | pub fn Advanced(decimal_precision: comptime_int) type { 14 | return struct { 15 | hour: Hour = 0, 16 | minute: Minute = 0, 17 | /// Allows leap seconds. 18 | second: Second = 0, 19 | /// Milliseconds, microseconds, or nanoseconds. 20 | subsecond: Subsecond = 0, 21 | 22 | pub const Hour = IntFittingRange(0, 23); 23 | pub const Minute = IntFittingRange(0, 59); 24 | pub const Second = IntFittingRange(0, 60); 25 | pub const Subsecond = IntFittingRange(0, if (decimal_precision == 0) 0 else subseconds_per_s); 26 | pub const DaySubseconds = IntFittingRange(0, s_per_day * subseconds_per_s); 27 | const IDaySubseconds = std.meta.Int(.signed, @typeInfo(DaySubseconds).int.bits + 1); 28 | 29 | const Self = @This(); 30 | 31 | pub const precision = decimal_precision; 32 | pub const subseconds_per_s: comptime_int = std.math.powi(u32, 10, decimal_precision) catch unreachable; 33 | pub const subseconds_per_min = 60 * subseconds_per_s; 34 | pub const subseconds_per_hour = 60 * subseconds_per_min; 35 | pub const subseconds_per_day = 24 * subseconds_per_hour; 36 | 37 | pub fn init(hour: Hour, minute: Minute, second: Second, subsecond: Subsecond) Self { 38 | return .{ .hour = hour, .minute = minute, .second = second, .subsecond = subsecond }; 39 | } 40 | 41 | pub fn now() Self { 42 | switch (decimal_precision) { 43 | 0 => { 44 | const day_seconds = std.math.comptimeMod(std.time.timestamp(), s_per_day); 45 | return fromDaySeconds(day_seconds); 46 | }, 47 | 1...9 => { 48 | const day_nanos = std.math.comptimeMod(std.time.nanoTimestamp(), ns_per_day); 49 | const day_subseconds = @divFloor(day_nanos, subseconds_per_s / 1_000_000_000); 50 | return fromDaySeconds(day_subseconds); 51 | }, 52 | else => @compileError("zig's highest precision timestamp is nanoTimestamp"), 53 | } 54 | } 55 | 56 | pub fn fromDaySeconds(seconds: DaySubseconds) Self { 57 | var subseconds = std.math.comptimeMod(seconds, subseconds_per_day); 58 | 59 | const hour = @divFloor(subseconds, subseconds_per_hour); 60 | subseconds -= hour * subseconds_per_hour; 61 | 62 | const minute = @divFloor(subseconds, subseconds_per_min); 63 | subseconds -= minute * subseconds_per_min; 64 | 65 | const second = @divFloor(subseconds, subseconds_per_s); 66 | subseconds -= second * subseconds_per_s; 67 | 68 | return .{ 69 | .hour = @intCast(hour), 70 | .minute = @intCast(minute), 71 | .second = @intCast(second), 72 | .subsecond = @intCast(subseconds), 73 | }; 74 | } 75 | 76 | pub fn toDaySeconds(self: Self) DaySubseconds { 77 | var sec: IDaySubseconds = 0; 78 | sec += @as(IDaySubseconds, self.hour) * subseconds_per_hour; 79 | sec += @as(IDaySubseconds, self.minute) * subseconds_per_min; 80 | sec += @as(IDaySubseconds, self.second) * subseconds_per_s; 81 | sec += @as(IDaySubseconds, self.subsecond); 82 | 83 | return std.math.comptimeMod(sec, s_per_day * subseconds_per_s); 84 | } 85 | 86 | pub const Duration = struct { 87 | hours: i64 = 0, 88 | minutes: i64 = 0, 89 | seconds: i64 = 0, 90 | subseconds: Duration.Subseconds = 0, 91 | 92 | pub const Subseconds = if (precision == 0) u0 else i64; 93 | 94 | pub fn init(hour: i64, minute: i64, second: i64, subsecond: Duration.Subseconds) Duration { 95 | return Duration{ .hours = hour, .minutes = minute, .seconds = second, .subseconds = subsecond }; 96 | } 97 | }; 98 | 99 | /// Does not handle leap seconds. 100 | /// Returns value and how many days overflowed. 101 | pub fn addWithOverflow(self: Self, duration: Duration) struct { Self, i64 } { 102 | const fs = duration.subseconds + self.subsecond; 103 | const s = duration.seconds + self.second + @divFloor(@as(i64, fs), 1000); 104 | const m = duration.minutes + self.minute + @divFloor(s, 60); 105 | const h = duration.hours + self.hour + @divFloor(m, 60); 106 | const overflow = @divFloor(h, 24); 107 | 108 | return .{ 109 | Self{ 110 | .subsecond = if (Duration.Subseconds == u0) 0 else std.math.comptimeMod(fs, 1000), 111 | .second = std.math.comptimeMod(s, 60), 112 | .minute = std.math.comptimeMod(m, 60), 113 | .hour = std.math.comptimeMod(h, 24), 114 | }, 115 | overflow, 116 | }; 117 | } 118 | 119 | /// Does not handle leap seconds nor overflow. 120 | pub fn add(self: Self, duration: Duration) Self { 121 | return self.addWithOverflow(duration)[0]; 122 | } 123 | 124 | pub fn parseRfc3339(str: []const u8) !Self { 125 | if (str.len < "hh:mm:ss".len) return error.Parsing; 126 | if (str[2] != ':' or str[5] != ':') return error.Parsing; 127 | 128 | const hour = try std.fmt.parseInt(Hour, str[0..2], 10); 129 | const minute = try std.fmt.parseInt(Minute, str[3..5], 10); 130 | const second = try std.fmt.parseInt(Second, str[6..8], 10); 131 | 132 | var subsecond: Subsecond = 0; 133 | if (str.len > 9 and str[8] == '.') { 134 | const subsecond_str = str[9..]; 135 | // Choose largest performant type. 136 | // Ideally, this would allow infinite precision. 137 | const T = f64; 138 | var subsecondf = try std.fmt.parseFloat(T, subsecond_str); 139 | const actual_precision: T = @floatFromInt(subsecond_str.len); 140 | subsecondf *= std.math.pow(T, 10, precision - actual_precision); 141 | 142 | subsecond = @intFromFloat(subsecondf); 143 | } 144 | 145 | return .{ .hour = hour, .minute = minute, .second = second, .subsecond = subsecond }; 146 | } 147 | 148 | fn fmtRfc3339(self: Self, writer: anytype) !void { 149 | if (self.hour > 24 or self.minute > 59 or self.second > 60) return error.Range; 150 | try writer.print("{d:0>2}:{d:0>2}:{d:0>2}", .{ self.hour, self.minute, self.second }); 151 | if (self.subsecond != 0) { 152 | // We could trim trailing zeros here to save space. 153 | try writer.print(".{d}", .{self.subsecond}); 154 | } 155 | } 156 | 157 | pub fn format( 158 | self: Self, 159 | comptime fmt: []const u8, 160 | options: std.fmt.FormatOptions, 161 | writer: anytype, 162 | ) (@TypeOf(writer).Error || error{Range})!void { 163 | _ = options; 164 | 165 | if (std.mem.eql(u8, "rfc3339", fmt)) { 166 | try self.fmtRfc3339(writer); 167 | } else { 168 | try writer.print( 169 | "Time{{ .hour = {d}, .minute = .{d}, .second = .{d} }}", 170 | .{ self.hour, self.minute, self.second }, 171 | ); 172 | } 173 | } 174 | }; 175 | } 176 | 177 | test Advanced { 178 | const t1 = Milli{}; 179 | const expectEqual = std.testing.expectEqual; 180 | // no overflow 181 | try expectEqual(Milli.init(2, 2, 2, 1), t1.add(Milli.Duration.init(2, 2, 2, 1))); 182 | // cause each place to overflow 183 | try expectEqual( 184 | .{ Milli.init(2, 2, 2, 1), @as(i64, 1) }, 185 | t1.addWithOverflow(Milli.Duration.init(25, 61, 61, 1001)), 186 | ); 187 | // cause each place to underflow 188 | try expectEqual( 189 | .{ Milli.init(21, 57, 57, 999), @as(i64, -2) }, 190 | t1.addWithOverflow(Milli.Duration.init(-25, -61, -61, -1001)), 191 | ); 192 | 193 | try expectEqual(Milli.init(22, 30, 0, 0), try Milli.parseRfc3339("22:30:00")); 194 | try expectEqual(Milli.init(22, 30, 0, 0), try Milli.parseRfc3339("22:30:00.0000")); 195 | try expectEqual(Milli.init(22, 30, 20, 100), try Milli.parseRfc3339("22:30:20.1")); 196 | 197 | const expectError = std.testing.expectError; 198 | try expectError(error.InvalidCharacter, Milli.parseRfc3339("22:30:20.1a00")); 199 | try expectError(error.Parsing, Milli.parseRfc3339("02:00:0")); // missing second digit 200 | 201 | var buf: [32]u8 = undefined; 202 | var stream = std.io.fixedBufferStream(&buf); 203 | const time = Milli.init(22, 30, 20, 100); 204 | try time.fmtRfc3339(stream.writer()); 205 | try std.testing.expectEqualStrings("22:30:20.100", stream.getWritten()); 206 | 207 | stream.reset(); 208 | const time2 = Milli.init(22, 30, 20, 100); 209 | try time2.fmtRfc3339(stream.writer()); 210 | try std.testing.expectEqualStrings("22:30:20.100", stream.getWritten()); 211 | } 212 | 213 | /// Time with second precision. 214 | pub const Sec = Advanced(0); 215 | /// Time with millisecond precision. 216 | pub const Milli = Advanced(3); 217 | /// Time with microsecond precision. 218 | /// Note: This is the same size `TimeNano`. If you want the extra precision use that instead. 219 | pub const Micro = Advanced(6); 220 | /// Time with nanosecond precision. 221 | pub const Nano = Advanced(9); 222 | --------------------------------------------------------------------------------