From f0419f34ab3c7e0f60aed1b9039cb02693a6511d Mon Sep 17 00:00:00 2001 From: "Nurul Huda (Apon)" Date: Wed, 28 Jan 2026 02:50:48 +0600 Subject: [PATCH] fix: instant/duration complete tc39 alignment --- src/Duration.zig | 221 ++++++++++++++++++++++++++++++++++-------- src/Instant.zig | 36 ++++--- src/Now.zig | 2 +- src/PlainDate.zig | 4 +- src/PlainDateTime.zig | 36 ++++++- src/ZonedDateTime.zig | 7 +- src/root.zig | 5 +- src/temporal.zig | 4 +- 8 files changed, 245 insertions(+), 70 deletions(-) diff --git a/src/Duration.zig b/src/Duration.zig index 75bee6d..f725fbd 100644 --- a/src/Duration.zig +++ b/src/Duration.zig @@ -2,15 +2,35 @@ const std = @import("std"); const abi = @import("abi.zig"); const temporal = @import("temporal.zig"); +const PlainDate = @import("PlainDate.zig"); +const PlainDateTime = @import("PlainDateTime.zig"); +const ZonedDateTime = @import("ZonedDateTime.zig"); + const Duration = @This(); -pub const RoundingOptions = temporal.RoundingOptions; pub const ToStringOptions = temporal.ToStringRoundingOptions; pub const ToStringRoundingOptions = temporal.ToStringRoundingOptions; pub const Unit = temporal.Unit; pub const RoundingMode = temporal.RoundingMode; pub const Sign = temporal.Sign; +pub const RoundingOptions = struct { + largest_unit: ?Unit = null, + smallest_unit: ?Unit = null, + rounding_mode: ?RoundingMode = null, + rounding_increment: ?u32 = null, + relative_to: ?RelativeTo = null, + + pub fn toCApi(self: RoundingOptions) abi.c.RoundingOptions { + return .{ + .largest_unit = abi.toUnitOption(temporal.toCUnit(self.largest_unit)), + .smallest_unit = abi.toUnitOption(temporal.toCUnit(self.smallest_unit)), + .rounding_mode = abi.toRoundingModeOption(temporal.toCRoundingMode(self.rounding_mode)), + .increment = abi.toOption(abi.c.OptionU32, self.rounding_increment), + }; + } +}; + /// Partial duration specification for creating Duration objects. /// This is a wrapper around the C API type to avoid exposing C types directly. pub const PartialDuration = struct { @@ -52,23 +72,30 @@ const ZonedDateTimeRef = struct { }; /// Relative-to context for duration operations. -pub const RelativeTo = struct { - plain_date: ?PlainDateRef = null, - zoned_date_time: ?ZonedDateTimeRef = null, +pub const RelativeTo = union(enum) { + plain_date: PlainDate, + plain_date_time: PlainDateTime, + zoned_date_time: ZonedDateTime, fn toCApi(self: RelativeTo) abi.c.RelativeTo { - return .{ - .date = if (self.plain_date) |pd| pd._inner else null, - .zoned = if (self.zoned_date_time) |zdt| zdt._inner else null, - }; + switch (self) { + .plain_date => |pd| return .{ .date = pd._inner }, + .zoned_date_time => |zdt| return .{ .zoned = zdt._inner }, + .plain_date_time => |pdt| return .{ .date = (pdt.toPlainDate() catch unreachable)._inner }, + } } }; + /// Options for Duration.total() providing unit and relative-to context. pub const TotalOptions = struct { unit: Unit, relative_to: ?RelativeTo = null, }; +pub const CompareOptions = struct { + relative_to: ?RelativeTo = null, +}; + _inner: *abi.c.Duration, /// Construct a Duration from years, months, weeks, days, hours, minutes, seconds, milliseconds, microseconds, and nanoseconds. @@ -99,18 +126,54 @@ pub fn init( )); } -/// Parse an ISO 8601 duration string (Temporal.Duration.from). -pub fn from(text: []const u8) !Duration { - const view = abi.toDiplomatStringView(text); - return wrapDuration(abi.c.temporal_rs_Duration_from_utf8(view)); +/// The Temporal.Duration.from() static method creates a new Temporal.Duration object from one of the following: +/// - A Temporal.Duration instance, which creates a copy of the instance. +/// - An ISO 8601 string representing a duration. +/// - A @Temporal.Duration.PartialDuration struct containing at least one of the following properties: +/// - days +/// - hours +/// - microseconds +/// - milliseconds +/// - minutes +/// - months +/// - nanoseconds +/// - seconds +/// - weeks +/// - years +/// The resulting duration must not have mixed signs, so all of these properties must have the same sign (or zero). Missing properties are treated as zero. +pub fn from(info: anytype) !Duration { + const T = @TypeOf(info); + + if (T == Duration) return info.clone(); + if (T == PartialDuration) return fromPartialDuration(info); + + // Handle string types (both literals and slices) + const type_info = @typeInfo(T); + switch (type_info) { + .pointer => { + const ptr = type_info.pointer; + const ChildType = switch (@typeInfo(ptr.child)) { + .array => |arr| arr.child, + else => ptr.child, + }; + + if (ChildType == u8) return fromUtf8(info); + if (ChildType == u16) return fromUtf16(info); + }, + else => @compileError("from() expects a Duration, []const u8, or []const u16, or Temporal.Duration.PartialDuration"), + } } -/// Parse an ISO 8601 UTF-16 duration string. -fn fromUtf16(text: []const u16) !Duration { +inline fn fromUtf16(text: []const u16) !Duration { const view = abi.toDiplomatString16View(text); return wrapDuration(abi.c.temporal_rs_Duration_from_utf16(view)); } +inline fn fromUtf8(text: []const u8) !Duration { + const view = abi.toDiplomatStringView(text); + return wrapDuration(abi.c.temporal_rs_Duration_from_utf8(view)); +} + /// Create a Duration from a partial duration (where some fields may be omitted). fn fromPartialDuration(partial: PartialDuration) !Duration { return wrapDuration(abi.c.temporal_rs_Duration_from_partial_duration(partial.toCApi())); @@ -204,8 +267,9 @@ pub fn subtract(self: Duration, other: Duration) !Duration { } /// Round the duration according to the specified options (Temporal.Duration.prototype.round). -pub fn round(self: Duration, options: RoundingOptions, relative_to: RelativeTo) !Duration { - return wrapDuration(abi.c.temporal_rs_Duration_round(self._inner, options.toCApi(), relative_to.toCApi())); +pub fn round(self: Duration, options: RoundingOptions) !Duration { + const rel = if (options.relative_to) |r| r.toCApi() else abi.c.RelativeTo{ .date = null, .zoned = null }; + return wrapDuration(abi.c.temporal_rs_Duration_round(self._inner, options.toCApi(), rel)); } /// Round the duration with an explicit provider. @@ -214,8 +278,9 @@ fn roundWithProvider(self: Duration, options: RoundingOptions, relative_to: Rela } /// Compare two durations (Temporal.Duration.compare). -pub fn compare(self: Duration, other: Duration, relative_to: RelativeTo) !i8 { - const res = abi.c.temporal_rs_Duration_compare(self._inner, other._inner, relative_to.toCApi()); +pub fn compare(self: Duration, other: Duration, options: CompareOptions) !i8 { + const rel = if (options.relative_to) |r| r.toCApi() else abi.c.RelativeTo{ .date = null, .zoned = null }; + const res = abi.c.temporal_rs_Duration_compare(self._inner, other._inner, rel); return try abi.extractResult(res); } @@ -304,32 +369,54 @@ test init { } test from { - // P1Y2M3DT4H5M6.789S = 1 year, 2 months, 3 days, 4 hours, 5 minutes, 6.789 seconds - const dur = try Duration.from("P1Y2M3DT4H5M6.789S"); - defer dur.deinit(); + { // P1Y2M3DT4H5M6.789S = 1 year, 2 months, 3 days, 4 hours, 5 minutes, 6.789 seconds + const dur = try Duration.from("P1Y2M3DT4H5M6.789S"); + defer dur.deinit(); - try std.testing.expectEqual(@as(i64, 1), dur.years()); - try std.testing.expectEqual(@as(i64, 2), dur.months()); - try std.testing.expectEqual(@as(i64, 3), dur.days()); - try std.testing.expectEqual(@as(i64, 4), dur.hours()); - try std.testing.expectEqual(@as(i64, 5), dur.minutes()); - try std.testing.expectEqual(@as(i64, 6), dur.seconds()); + try std.testing.expectEqual(@as(i64, 1), dur.years()); + try std.testing.expectEqual(@as(i64, 2), dur.months()); + try std.testing.expectEqual(@as(i64, 3), dur.days()); + try std.testing.expectEqual(@as(i64, 4), dur.hours()); + try std.testing.expectEqual(@as(i64, 5), dur.minutes()); + try std.testing.expectEqual(@as(i64, 6), dur.seconds()); - // from-time - // PT2H30M = 2 hours, 30 minutes - const dur_time = try Duration.from("PT2H30M"); - defer dur_time.deinit(); + // from-time + // PT2H30M = 2 hours, 30 minutes + const dur_time = try Duration.from("PT2H30M"); + defer dur_time.deinit(); - try std.testing.expectEqual(@as(i64, 0), dur_time.years()); - try std.testing.expectEqual(@as(i64, 2), dur_time.hours()); - try std.testing.expectEqual(@as(i64, 30), dur_time.minutes()); + try std.testing.expectEqual(@as(i64, 0), dur_time.years()); + try std.testing.expectEqual(@as(i64, 2), dur_time.hours()); + try std.testing.expectEqual(@as(i64, 30), dur_time.minutes()); - // from-negative - const dur_negative = try Duration.from("-P1D"); - defer dur_negative.deinit(); + // from-negative + const dur_negative = try Duration.from("-P1D"); + defer dur_negative.deinit(); - try std.testing.expectEqual(@as(i64, -1), dur_negative.days()); - try std.testing.expectEqual(Sign.negative, dur_negative.sign()); + try std.testing.expectEqual(@as(i64, -1), dur_negative.days()); + try std.testing.expectEqual(Sign.negative, dur_negative.sign()); + } + { + const partial = PartialDuration{ + .hours = 3, + .minutes = 45, + }; + + const dur = try Duration.from(partial); + defer dur.deinit(); + + try std.testing.expectEqual(@as(i64, 3), dur.hours()); + try std.testing.expectEqual(@as(i64, 45), dur.minutes()); + } + { + const dur1 = try Duration.from("PT1H"); + defer dur1.deinit(); + const dur2 = try Duration.from(dur1); + defer dur2.deinit(); + + try std.testing.expectEqual(@as(i64, 1), dur2.hours()); + try std.testing.expectEqual(@as(i64, 0), dur2.minutes()); + } } test blank { @@ -353,6 +440,17 @@ test add { try std.testing.expectEqual(@as(i64, 30), result.minutes()); } +test compare { + const dur1 = try Duration.from("PT1H"); + defer dur1.deinit(); + const dur2 = try Duration.from("PT30M"); + defer dur2.deinit(); + + const result = try dur1.compare(dur2, .{}); + + try std.testing.expectEqual(@as(i8, 1), result); +} + test subtract { const dur1 = try Duration.from("PT2H"); defer dur1.deinit(); @@ -432,6 +530,51 @@ test clone { try std.testing.expectEqual(dur.hours(), cloned.hours()); } +test round { + { + const dur = try Duration.from("PT1H30M"); + defer dur.deinit(); + + const rounded = try dur.round(.{ + .smallest_unit = .hour, + .relative_to = .{ + .plain_date_time = try PlainDateTime.init(2024, 1, 1, 12, 0, 0, 0, 0, 0), + }, + }); + defer rounded.deinit(); + + try std.testing.expectEqual(@as(i64, 2), rounded.hours()); + try std.testing.expectEqual(@as(i64, 0), rounded.minutes()); + } + + { + const dur = try Duration.from("PT1H30M"); + defer dur.deinit(); + + const rounded = try dur.round(.{ + .smallest_unit = .hour, + .relative_to = .{ + .plain_date = try PlainDate.init(2024, 1, 1), + }, + }); + defer rounded.deinit(); + + try std.testing.expectEqual(@as(i64, 2), rounded.hours()); + try std.testing.expectEqual(@as(i64, 0), rounded.minutes()); + } + + { + const dur = try Duration.from("PT1H30M"); + defer dur.deinit(); + + const rounded = try dur.round(.{ .smallest_unit = .hour }); + defer rounded.deinit(); + + try std.testing.expectEqual(@as(i64, 2), rounded.hours()); + try std.testing.expectEqual(@as(i64, 0), rounded.minutes()); + } +} + test total { { const dur = try Duration.init(0, 0, 0, 0, 1, 30, 0, 0, 0, 0); diff --git a/src/Instant.zig b/src/Instant.zig index 735a444..e204f53 100644 --- a/src/Instant.zig +++ b/src/Instant.zig @@ -48,8 +48,6 @@ pub const ToStringOptions = struct { }; _inner: *abi.c.Instant, -epoch_milliseconds: i64, -epoch_nanoseconds: i128, /// Construct from epoch nanoseconds (Temporal.Instant.fromEpochNanoseconds). pub fn init(epoch_ns: i128) !Instant { @@ -193,7 +191,15 @@ fn toZonedDateTimeIsoWithProvider(self: Instant, zone: TimeZone, provider: *cons /// Clone the underlying instant. fn clone(self: Instant) Instant { const ptr = abi.c.temporal_rs_Instant_clone(self._inner) orelse unreachable; - return .{ ._inner = ptr, .epoch_milliseconds = abi.c.temporal_rs_Instant_epoch_milliseconds(ptr), .epoch_nanoseconds = abi.fromI128Nanoseconds(abi.c.temporal_rs_Instant_epoch_nanoseconds(ptr)) }; + return .{ ._inner = ptr }; +} + +pub fn epochMilliseconds(self: Instant) i64 { + return abi.c.temporal_rs_Instant_epoch_milliseconds(self._inner); +} + +pub fn epochNanoseconds(self: Instant) i128 { + return abi.fromI128Nanoseconds(abi.c.temporal_rs_Instant_epoch_nanoseconds(self._inner)); } pub fn deinit(self: Instant) void { @@ -206,8 +212,6 @@ fn wrapInstant(res: anytype) !Instant { const ptr = (try abi.extractResult(res)) orelse return abi.TemporalError.Generic; return .{ ._inner = ptr, - .epoch_milliseconds = abi.c.temporal_rs_Instant_epoch_milliseconds(ptr), - .epoch_nanoseconds = abi.fromI128Nanoseconds(abi.c.temporal_rs_Instant_epoch_nanoseconds(ptr)), }; } @@ -258,7 +262,7 @@ test init { const inst = try Instant.init(epoch_ns); defer inst.deinit(); - try std.testing.expectEqual(epoch_ns, inst.epoch_nanoseconds); + try std.testing.expectEqual(epoch_ns, inst.epochNanoseconds()); } test fromEpochNanoseconds { @@ -271,8 +275,8 @@ test fromEpochNanoseconds { const min_inst = try Instant.fromEpochNanoseconds(min_ns); defer min_inst.deinit(); - try std.testing.expectEqual(max_ns, max_inst.epoch_nanoseconds); - try std.testing.expectEqual(min_ns, min_inst.epoch_nanoseconds); + try std.testing.expectEqual(max_ns, max_inst.epochNanoseconds()); + try std.testing.expectEqual(min_ns, min_inst.epochNanoseconds()); try std.testing.expectError(error.RangeError, Instant.fromEpochNanoseconds(max_ns + 1)); try std.testing.expectError(error.RangeError, Instant.fromEpochNanoseconds(min_ns - 1)); @@ -282,13 +286,13 @@ test from { // Test parsing from UTF-8 string const inst = try Instant.from("2024-03-15T14:30:45.123Z"); defer inst.deinit(); - try std.testing.expectEqual(@as(i64, 1_710_513_045_123), inst.epoch_milliseconds); + try std.testing.expectEqual(@as(i64, 1_710_513_045_123), inst.epochMilliseconds()); // Test creating from another Instant const inst2 = try Instant.from(inst); defer inst2.deinit(); - try std.testing.expectEqual(inst.epoch_milliseconds, inst2.epoch_milliseconds); - try std.testing.expectEqual(inst.epoch_nanoseconds, inst2.epoch_nanoseconds); + try std.testing.expectEqual(inst.epochMilliseconds(), inst2.epochMilliseconds()); + try std.testing.expectEqual(inst.epochNanoseconds(), inst2.epochNanoseconds()); try std.testing.expect(Instant.equals(inst, inst2)); } @@ -301,7 +305,7 @@ test fromUtf16 { const inst = try Instant.fromUtf16(utf16); defer inst.deinit(); - try std.testing.expectEqual(@as(i64, 1_710_513_045_123), inst.epoch_milliseconds); + try std.testing.expectEqual(@as(i64, 1_710_513_045_123), inst.epochMilliseconds()); } test subtract { @@ -313,11 +317,11 @@ test subtract { const added = try base.add(&dur); defer added.deinit(); - try std.testing.expectEqual(@as(i64, 5_400_000), added.epoch_milliseconds); + try std.testing.expectEqual(@as(i64, 5_400_000), added.epochMilliseconds()); const subbed = try added.subtract(&dur); defer subbed.deinit(); - try std.testing.expectEqual(@as(i64, 0), subbed.epoch_milliseconds); + try std.testing.expectEqual(@as(i64, 0), subbed.epochMilliseconds()); } test compare { @@ -410,7 +414,7 @@ test round { const rounded = try inst.round(opts); defer rounded.deinit(); - const ns = rounded.epoch_nanoseconds; + const ns = rounded.epochNanoseconds(); try std.testing.expectEqual(@as(i128, 1_609_459_245_000_000_000), ns); } @@ -421,7 +425,7 @@ test clone { const cloned = inst.clone(); defer cloned.deinit(); - try std.testing.expectEqual(inst.epoch_milliseconds, cloned.epoch_milliseconds); + try std.testing.expectEqual(inst.epochMilliseconds(), cloned.epochMilliseconds()); } test toString { diff --git a/src/Now.zig b/src/Now.zig index 94e85ab..7862e4f 100644 --- a/src/Now.zig +++ b/src/Now.zig @@ -98,7 +98,7 @@ fn currentParts() CurrentParts { test instant { const inst = try instant(); defer inst.deinit(); - try std.testing.expect(inst.epoch_nanoseconds > 0); + try std.testing.expect(inst.epochNanoseconds() > 0); } test plainDateISO { diff --git a/src/PlainDate.zig b/src/PlainDate.zig index f250bbe..aeb9b72 100644 --- a/src/PlainDate.zig +++ b/src/PlainDate.zig @@ -44,10 +44,10 @@ pub const ToZonedDateTimeOptions = struct { _inner: *abi.c.PlainDate, pub fn init(year_val: i32, month_val: u8, day_val: u8) !PlainDate { - return initWithCalendar(year_val, month_val, day_val, "iso8601"); + return calInit(year_val, month_val, day_val, "iso8601"); } -pub fn initWithCalendar(year_val: i32, month_val: u8, day_val: u8, calendar: []const u8) !PlainDate { +pub fn calInit(year_val: i32, month_val: u8, day_val: u8, calendar: []const u8) !PlainDate { const cal_view = abi.toDiplomatStringView(calendar); const cal_result = abi.c.temporal_rs_AnyCalendarKind_parse_temporal_calendar_string(cal_view); const cal_kind = try abi.extractResult(cal_result); diff --git a/src/PlainDateTime.zig b/src/PlainDateTime.zig index c7e5e4b..b607794 100644 --- a/src/PlainDateTime.zig +++ b/src/PlainDateTime.zig @@ -48,6 +48,7 @@ pub const WithOptions = struct { microsecond: ?u16 = null, nanosecond: ?u16 = null, }; +const PartialDateTime = struct {}; // Constructor - creates a PlainDateTime with all parameters pub fn init( @@ -75,7 +76,7 @@ pub fn init( )); } -pub fn initWithCalendar( +pub fn calInit( year_val: i32, month_val: u8, day_val: u8, @@ -105,8 +106,37 @@ pub fn initWithCalendar( } // Parse from string -pub fn from(s: []const u8) !PlainDateTime { - return fromUtf8(s); +pub fn from(info: anytype) !PlainDateTime { + const T = @TypeOf(info); + + if (T == PlainDateTime) return info.clone(); + if (T == PartialDateTime) return fromPartial(info); + + // Handle string types (both literals and slices) + const type_info = @typeInfo(T); + switch (type_info) { + .pointer => { + const ptr = type_info.pointer; + const ChildType = switch (@typeInfo(ptr.child)) { + .array => |arr| arr.child, + else => ptr.child, + }; + + if (ChildType == u8) return fromUtf8(info); + if (ChildType == u16) return fromUtf16(info); + }, + else => @compileError("from() expects a Duration, []const u8, or []const u16, or Temporal.Duration.PartialDuration"), + } +} + +fn fromPartial(info: PartialDateTime) !PlainDateTime { + _ = info; + return error.NotImplemented; + // return abi.c.temporal_rs_PlainDateTime_from_partial(.{ + // .date = .{ + // . + // } + // }, overflow: struct_ArithmeticOverflow_option) } fn fromUtf8(utf8: []const u8) !PlainDateTime { diff --git a/src/ZonedDateTime.zig b/src/ZonedDateTime.zig index fad0296..7660efd 100644 --- a/src/ZonedDateTime.zig +++ b/src/ZonedDateTime.zig @@ -202,10 +202,7 @@ pub fn subtract(self: ZonedDateTime, duration: Duration) !ZonedDateTime { /// Convert to Instant pub fn toInstant(self: ZonedDateTime) !Instant { const instant_ptr = abi.c.temporal_rs_ZonedDateTime_to_instant(self._inner) orelse return error.TemporalError; - const epoch_ms = abi.c.temporal_rs_Instant_epoch_milliseconds(instant_ptr); - const epoch_ns_parts = abi.c.temporal_rs_Instant_epoch_nanoseconds(instant_ptr); - const epoch_ns = abi.fromI128Nanoseconds(epoch_ns_parts); - return .{ ._inner = instant_ptr, .epoch_milliseconds = epoch_ms, .epoch_nanoseconds = epoch_ns }; + return .{ ._inner = instant_ptr }; } /// Convert to JSON string (ISO 8601 format) @@ -502,7 +499,7 @@ test toInstant { const instant = try zdt.toInstant(); defer instant.deinit(); - try std.testing.expectEqual(@as(i64, 1609459200000), instant.epoch_milliseconds); + try std.testing.expectEqual(@as(i64, 1609459200000), instant.epochMilliseconds()); } test toPlainDate { diff --git a/src/root.zig b/src/root.zig index c3d53ee..5db1b6c 100644 --- a/src/root.zig +++ b/src/root.zig @@ -78,6 +78,7 @@ test Duration { "ToStringRoundingOptions", "Sign", "TotalOptions", + "CompareOptions", }; try assertDecls(Duration, checks); @@ -142,7 +143,7 @@ test PlainDate { const checks = .{ // Constructor "init", - "initWithCalendar", + "calInit", // Static methods "compare", @@ -200,7 +201,7 @@ test PlainDateTime { const checks = .{ // Constructor "init", // Temporal.PlainDateTime() - "initWithCalendar", + "calInit", // Static methods "compare", diff --git a/src/temporal.zig b/src/temporal.zig index 3761a80..c76c4a5 100644 --- a/src/temporal.zig +++ b/src/temporal.zig @@ -159,10 +159,10 @@ pub const ToStringRoundingOptions = struct { } }; -fn toCUnit(opt: ?Unit) ?c.Unit { +pub fn toCUnit(opt: ?Unit) ?c.Unit { return if (opt) |u| @as(c.Unit, @intCast(u.toCApi())) else null; } -fn toCRoundingMode(opt: ?RoundingMode) ?c.RoundingMode { +pub fn toCRoundingMode(opt: ?RoundingMode) ?c.RoundingMode { return if (opt) |m| @as(c.RoundingMode, @intCast(m.toCApi())) else null; }