From ad2ff533da93bc7899d6b0f79b29969cafcc0011 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Wed, 30 Oct 2024 14:42:29 +0100 Subject: [PATCH 01/13] init next dev session --- lib/Datetime.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/Datetime.zig b/lib/Datetime.zig index bf17eb2..892bf09 100644 --- a/lib/Datetime.zig +++ b/lib/Datetime.zig @@ -627,8 +627,8 @@ pub fn diff(this: Datetime, other: Datetime) Duration { return .{ .__sec = s, .__nsec = @intCast(ns) }; } -/// Calculate wall time difference between two timezone-aware datetimes. -/// If one of the datetimes is naive (no tz specified), this is considered an error. +/// Calculate wall time difference between two aware datetimes. +/// If one of the datetimes is naive (no time zone specified), this is considered an error. /// /// Result is ('this' wall time - 'other' wall time) as a Duration. pub fn diffWall(this: Datetime, other: Datetime) !Duration { @@ -780,9 +780,9 @@ pub fn fromString(string: []const u8, directives: []const u8) !Datetime { return try str.tokenizeAndParse(string, directives); } -/// Make a datetime from a string with an ISO8601-compatibel format. +/// Make a datetime from a string with an ISO8601-compatible format. pub fn fromISO8601(string: []const u8) !Datetime { - // 9 digits of fractional seconds and hh:mm:ss UTC offset: 38 characters + // 9 digits of fractional seconds and ±hh:mm:ss UTC offset: 38 characters if (string.len > 38) return error.InvalidFormat; // last character must be Z (UTC) or a digit From 603f7539306eb45f80cb0f55b724b97949263be1 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Wed, 30 Oct 2024 15:30:40 +0100 Subject: [PATCH 02/13] not --- lib/string.zig | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/string.zig b/lib/string.zig index a17d9ba..ccbca5d 100644 --- a/lib/string.zig +++ b/lib/string.zig @@ -450,15 +450,17 @@ fn printIntoWriter( fn parseDigits(comptime T: type, string: []const u8, idx_ptr: *usize, maxDigits: usize) !T { const start_idx = idx_ptr.*; - if (!std.ascii.isDigit(string[start_idx])) return error.InvalidFormat; - - idx_ptr.* += 1; - while (idx_ptr.* < string.len and // check first if string depleted - idx_ptr.* < start_idx + maxDigits and - std.ascii.isDigit(string[idx_ptr.*])) : (idx_ptr.* += 1) - {} - - return try std.fmt.parseInt(T, string[start_idx..idx_ptr.*], 10); + // must start with a digit... otherwise format is invalid. + if (std.ascii.isDigit(string[start_idx])) { + idx_ptr.* += 1; + while (idx_ptr.* < string.len and // check first if string depleted + idx_ptr.* < start_idx + maxDigits and + std.ascii.isDigit(string[idx_ptr.*])) : (idx_ptr.* += 1) + {} + + return try std.fmt.parseInt(T, string[start_idx..idx_ptr.*], 10); + } + return error.InvalidFormat; } // AM or PM string, no matter if upper or lower case. From e193fcd7e37bbe6947a49cd3417787e82e4d725f Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Wed, 30 Oct 2024 19:38:10 +0100 Subject: [PATCH 03/13] more 'not' cosmetics --- lib/Datetime.zig | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/Datetime.zig b/lib/Datetime.zig index 892bf09..c7939e8 100644 --- a/lib/Datetime.zig +++ b/lib/Datetime.zig @@ -195,8 +195,8 @@ const tzOpts = enum { utc_offset, }; -// helper to specify either a time zone or a UTC offset -const tz_options = union(tzOpts) { +/// helper to specify either a time zone or a UTC offset: +pub const tz_options = union(tzOpts) { tz: *const Timezone, utc_offset: UTCoffset, }; @@ -349,12 +349,13 @@ pub fn fromFields(fields: Fields) ZdtError!Datetime { } } - // If both guesses did not succeed, we have a non-existent datetime. - // this should give an error. - if (!dt_eq_guess_1 and !dt_eq_guess_2) return ZdtError.NonexistentDatetime; + // If we came here, either guess 1 or guess 2 is correct; guess 1 takes precedence. + if (dt_eq_guess_1) return dt_guess_1; + if (dt_eq_guess_2) return dt_guess_2; - // If we came here, either guess 1 or guess 2 is correct. - if (dt_eq_guess_1) return dt_guess_1 else return dt_guess_2; + // If both guesses did NOT succeed, we have a non-existent datetime. + // this should give an error. + return ZdtError.NonexistentDatetime; } /// Make a fields struct from a datetime. @@ -485,7 +486,7 @@ pub fn isAware(dt: *const Datetime) bool { /// true if no offset from UTC is defined pub fn isNaive(dt: *const Datetime) bool { - return !dt.isAware(); + return dt.utc_offset == null; } /// returns true if a datetime is located in daylight saving time. @@ -647,10 +648,11 @@ pub fn diffWall(this: Datetime, other: Datetime) !Duration { } /// Validate a datetime in terms of leap seconds; -/// checks if the datetime could be a leap second if .second is == 60. +/// Returns an error if the datetime has seconds == 60 but is NOT a leap second datetime. pub fn validateLeap(this: *const Datetime) !void { if (this.second != 60) return; - if (!cal.mightBeLeap(this.unix_sec)) return error.SecondOutOfRange; + if (cal.mightBeLeap(this.unix_sec)) return; + return error.SecondOutOfRange; } /// Difference in leap seconds between two datetimes. @@ -785,12 +787,12 @@ pub fn fromISO8601(string: []const u8) !Datetime { // 9 digits of fractional seconds and ±hh:mm:ss UTC offset: 38 characters if (string.len > 38) return error.InvalidFormat; - // last character must be Z (UTC) or a digit - if (string[string.len - 1] != 'Z' and !std.ascii.isDigit(string[string.len - 1])) { - return error.InvalidFormat; + // last character must be Z (UTC) or a digit, otherwise the input is not ISO8601-compatible + if (string[string.len - 1] == 'Z' or std.ascii.isDigit(string[string.len - 1])) { + var idx: usize = 0; // assume datetime starts at beginning of string + return try Datetime.fromFields(try str.parseISO8601(string, &idx)); } - var idx: usize = 0; // assume datetime starts at beginning of string - return try Datetime.fromFields(try str.parseISO8601(string, &idx)); + return error.InvalidFormat; } /// Format a datetime into a string From 1c8fe8295c7e94312f253d9e667fe1512593393d Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Wed, 30 Oct 2024 20:43:59 +0100 Subject: [PATCH 04/13] iso duration parser --- lib/Duration.zig | 221 +++++++++++++++++++++++++++++++++++++++- tests/test_duration.zig | 72 +++++++++++++ 2 files changed, 290 insertions(+), 3 deletions(-) diff --git a/lib/Duration.zig b/lib/Duration.zig index 4155893..6b4b496 100644 --- a/lib/Duration.zig +++ b/lib/Duration.zig @@ -4,6 +4,9 @@ const std = @import("std"); const Duration = @This(); +// max. digits per quantity in an ISO8601 duration string +const maxDigits: usize = 99; + /// Any duration represented in seconds. /// Do not modify directly. __sec: i64 = 0, @@ -21,6 +24,23 @@ pub fn fromTimespanMultiple(n: i128, timespan: Timespan) Duration { }; } +/// Create a duration from an ISO8601 duration string. +/// +/// Since the Duration type represents and absolute difference in time, +/// 'years' and 'months' fields of the duration string must be zero, +/// if not, this is considered an error due to the ambiguity of months and years. +pub fn fromISO8601Duration(string: []const u8) !Duration { + const fields: RelativeDeltaFields = try parseIsoDur(string); + if (fields.years != 0 or fields.months != 0) return error.InvalidFormat; + return .{ + .__sec = @as(i64, fields.days) * 86400 + // + @as(i64, fields.hours) * 3600 + // + @as(i64, fields.minutes) * 60 + // + @as(i64, fields.seconds), + .__nsec = fields.nanoseconds, + }; +} + /// Convert a Duration to the smallest multiple of the given timespan /// that can contain the duration pub fn toTimespanMultiple(duration: Duration, timespan: Timespan) i128 { @@ -68,7 +88,8 @@ pub fn asNanoseconds(duration: Duration) i128 { return duration.__sec * 1_000_000_000 + duration.__nsec; } -// Formatted printing for Duration type. Defaults to ISO8601 duration format. +// Formatted printing for Duration type. Defaults to ISO8601 duration format, +// years/months/days excluded due to the ambiguity of months and years. pub fn format( duration: Duration, comptime fmt: []const u8, @@ -84,16 +105,19 @@ pub fn format( if (is_negative and duration.__nsec > 0) s -= 1; const ns = if (is_negative) 1_000_000_000 - duration.__nsec else duration.__nsec; + // TODO : truncate zeros from ns + const hours = @divFloor(s, 3600); const remainder = @rem(s, 3600); const minutes = @divFloor(remainder, 60); const seconds = @rem(remainder, 60); if (is_negative) try writer.print("-", .{}); - try writer.print("PT{d:0>2}H{d:0>2}M{d:0>2}", .{ hours, minutes, seconds }); + + try writer.print("PT{d}H{d}M{d}", .{ hours, minutes, seconds }); if (duration.__nsec > 0) { - try writer.print(".{d:0>9}S", .{ns}); + try writer.print(".{d}S", .{ns}); } else { try writer.print("S", .{}); } @@ -135,3 +159,194 @@ pub const Timespan = enum(u64) { // pub fn toISO8601(duration: Duration, writer: anytype) void { // // }; + +/// Fields of a duration that is relative to a datetime. +pub const RelativeDeltaFields = struct { + years: i32 = 0, + months: i32 = 0, + days: i32 = 0, + hours: i32 = 0, + minutes: i32 = 0, + seconds: i32 = 0, + nanoseconds: u32 = 0, + + // /// TODO : to Duration (absoulte) - truncate months and years + // pub fn toDurationTruncate(fields: RelativeDeltaFields) Duration { + // + // } + // + // /// TODO : to Duration (absoulte) - return error if years or months are != 0 + // pub fn toDuration(fields: RelativeDeltaFields) !Duration { + // + // } +}; + +/// convert ISO8601 duration from string to RelativeDeltaFields. +pub fn parseIsoDur(string: []const u8) !RelativeDeltaFields { + var result: RelativeDeltaFields = .{}; + + // at least 3 characters, e.g. P0D + if (string.len < 3) return error.InvalidFormat; + + // must end with a character + if (!std.ascii.isAlphabetic(string[string.len - 1])) return error.InvalidFormat; + + var stop: usize = 0; + var invert: bool = false; + + if (string[stop] == '-') { + invert = true; + stop += 1; + } + + //log.info("invert: {any}", .{invert}); + + // must start with P (ignore sign) + if (string[stop] != 'P') return error.InvalidFormat; + stop += 1; + + if (string[stop] == 'T') stop += 1; + + var idx: usize = string.len - 1; + + // need flags to keep track of what has been parsed already, + // and in which order: + // quantity: Y m d T H M S + // bit/order: - - 4 3 2 1 0 + var flags: u8 = 0; + + while (idx > stop) { + //log.info("flags: {b}", .{flags}); + switch (string[idx]) { + 'S' => { + // seconds come last, so no other quantity must have been parsed yet + if (flags > 0) return error.InvalidFormat; + // log.info("parse seconds!", .{}); + idx -= 1; + flags |= 0b1; + _ = try parseAndAdvanceS(string, &idx, &result.seconds, &result.nanoseconds); + if (invert) result.seconds *= -1; + // log.info("seconds: {d}", .{quantity}); + }, + 'M' => { + // 'M' may appear twice; its either minutes or months; + // depending on if the 'T' has been seen => + // minutes if (flags & 0b1000 == 0), otherwise months + if (flags & 0b1000 == 0) { + // minutes come second to last, so only seconds may have been parsed yet + // log.info("parse minutes!", .{}); + if (flags > 1) return error.InvalidFormat; + idx -= 1; + flags |= 0b10; + var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); + if (invert) quantity *= -1; + result.minutes = quantity; + // log.info("minutes: {d}", .{quantity}); + } else { + // log.info("parse months!", .{}); + if (flags > 0b11111) return error.InvalidFormat; + idx -= 1; + flags |= 0b100000; + var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); + if (invert) quantity *= -1; + result.months = quantity; + // log.info("months: {d}", .{quantity}); + } + }, + 'H' => { // hours are the third-to-last component, + // so only seconds and minutes may have been parsed yet + if (flags > 0b11) return error.InvalidFormat; + // log.info("parse hours!", .{}); + idx -= 1; + flags |= 0b100; + var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); + if (invert) quantity *= -1; + result.hours = quantity; + // log.info("hours: {d}", .{quantity}); + }, + 'T' => { // date/time separator must only appear once + if (flags > 0b111) return error.InvalidFormat; + // log.info("date/time sep!", .{}); + idx -= 1; + flags |= 0b1000; + }, + 'D' => { + if (flags > 0b1111) return error.InvalidFormat; + // log.info("parse days!", .{}); + idx -= 1; + flags |= 0b10000; + var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); + if (invert) quantity *= -1; + result.days = quantity; + // log.info("days: {d}", .{quantity}); + }, + 'Y' => { + if (flags > 0b111111) return error.InvalidFormat; + // log.info("parse years!", .{}); + idx -= 1; + flags |= 0b1000000; + var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); + if (invert) quantity *= -1; + result.years = quantity; + // log.info("years: {d}", .{quantity}); + }, + else => return error.InvalidFormat, + } + } + // log.info("done. idx: {d}", .{idx}); + return result; +} + +/// Backwards-looking parse chars from 'string' seconds and nanoseconds (sum), +/// end index is the value of 'idx_ptr' when the function is called. +/// start index is determined automatically. +/// +/// This is a procedure; it modifies input pointer 'sec' and 'nsec' values in-place. +/// This way, we can work around the fact that there are no multiple-return functions +/// and save arithmetic operations (compared to using a single return value). +fn parseAndAdvanceS(string: []const u8, idx_ptr: *usize, sec: *i32, nsec: *u32) !void { + const end_idx = idx_ptr.*; + var have_fraction: bool = false; + while (idx_ptr.* > 0 and + end_idx - idx_ptr.* < maxDigits and + !std.ascii.isAlphabetic(string[idx_ptr.*])) : (idx_ptr.* -= 1) + { + if (string[idx_ptr.*] == '.') have_fraction = true; + } + + // short cut: there is no fraction + if (!have_fraction) { + sec.* = try std.fmt.parseInt(i32, string[idx_ptr.* + 1 .. end_idx + 1], 10); + return; + } + + // fractional seconds are specified. need to convert them to nanoseconds, + // and truncate anything behind the nineth digit. + const substr = string[idx_ptr.* + 1 .. end_idx + 1]; + const idx_dot = std.mem.indexOfScalar(u8, substr, '.'); + if (idx_dot == null) return error.InvalidFormat; + + sec.* = try std.fmt.parseInt(i32, substr[0..idx_dot.?], 10); + + var substr_nanos = substr[idx_dot.? + 1 ..]; + if (substr_nanos.len > 9) substr_nanos = substr_nanos[0..9]; + const nanos = try std.fmt.parseInt(u32, substr_nanos, 10); + + // nanos might actually be another unit; if there is e.g. 3 digits of fractional + // seconds, we have milliseconds (1/10^3 s) and need to multiply by 10^(9-3) to get ns. + const missing = 9 - substr_nanos.len; + const f: u32 = try std.math.powi(u32, 10, @as(u32, @intCast(missing))); + nsec.* = nanos * f; +} + +/// backwards-looking parse chars from 'string' to int (base 10), +/// end index is the value of 'idx_ptr' when the function is called. +/// start index is determined automatically. +fn parseAndAdvanceYMDHM(comptime T: type, string: []const u8, idx_ptr: *usize) !T { + const end_idx = idx_ptr.*; + while (idx_ptr.* > 0 and + end_idx - idx_ptr.* < maxDigits and + !std.ascii.isAlphabetic(string[idx_ptr.*])) : (idx_ptr.* -= 1) + {} + return try std.fmt.parseInt(T, string[idx_ptr.* + 1 .. end_idx + 1], 10); +} diff --git a/tests/test_duration.zig b/tests/test_duration.zig index 5eb241e..58456d8 100644 --- a/tests/test_duration.zig +++ b/tests/test_duration.zig @@ -9,6 +9,12 @@ const Duration = zdt.Duration; const log = std.log.scoped(.test_duration); +const TestCaseISODur = struct { + string: []const u8 = "", + fields: Duration.RelativeDeltaFields = .{}, + duration: Duration = .{}, +}; + test "from timespan" { var td = Duration.fromTimespanMultiple(5, Duration.Timespan.nanosecond); try testing.expectEqual(@as(i128, 5), td.asNanoseconds()); @@ -169,3 +175,69 @@ test "leap second difference" { try testing.expectEqual(@as(i64, -86400), diff.__sec); try testing.expectEqual(@as(u32, 0), diff.__nsec); } + +test "iso duration parser, full valid input" { + const cases = [_]TestCaseISODur{ + .{ + .string = "P1Y2M3DT4H5M6.789S", + .fields = .{ .years = 1, .months = 2, .days = 3, .hours = 4, .minutes = 5, .seconds = 6, .nanoseconds = 789000000 }, + }, + .{ + .string = "P-2Y3M4DT5H6M7.89S", + .fields = .{ .years = -2, .months = 3, .days = 4, .hours = 5, .minutes = 6, .seconds = 7, .nanoseconds = 890000000 }, + }, + .{ + .string = "P999Y0M0DT0H0M0.123S", + .fields = .{ .years = 999, .months = 0, .days = 0, .hours = 0, .minutes = 0, .seconds = 0, .nanoseconds = 123000000 }, + }, + .{ + .string = "P3Y4M5DT6H7M8.9S", + .fields = .{ .years = 3, .months = 4, .days = 5, .hours = 6, .minutes = 7, .seconds = 8, .nanoseconds = 900000000 }, + }, + .{ + .string = "P-5Y6M7DT8H9M10.111000001S", + .fields = .{ .years = -5, .months = 6, .days = 7, .hours = 8, .minutes = 9, .seconds = 10, .nanoseconds = 111000001 }, + }, + .{ + .string = "P7Y8M9DT10H11M12.123S", + .fields = .{ .years = 7, .months = 8, .days = 9, .hours = 10, .minutes = 11, .seconds = 12, .nanoseconds = 123000000 }, + }, + .{ + .string = "P-8Y9M10DT11H12M13.145S", + .fields = .{ .years = -8, .months = 9, .days = 10, .hours = 11, .minutes = 12, .seconds = 13, .nanoseconds = 145000000 }, + }, + .{ + .string = "-P9Y10M11DT12H13M14.156S", + .fields = .{ .years = -9, .months = -10, .days = -11, .hours = -12, .minutes = -13, .seconds = -14, .nanoseconds = 156000000 }, + }, + }; + + for (cases) |case| { + const fields = try Duration.parseIsoDur(case.string); + try testing.expectEqual(case.fields, fields); + + // all test cases have year or month != 0, so conversion to duration fails: + const err = Duration.fromISO8601Duration(case.string); + try testing.expectError(error.InvalidFormat, err); + } +} + +test "iso duration to Duration type round-trip" { + const cases = [_]TestCaseISODur{ + .{ + .string = "PT4H5M6.789S", + .duration = .{ .__sec = 4 * 3600 + 5 * 60 + 6, .__nsec = 789000000 }, + }, + }; + + for (cases) |case| { + const dur = try Duration.fromISO8601Duration(case.string); + try testing.expectEqual(case.duration, dur); + + var buf = std.ArrayList(u8).init(testing.allocator); + defer buf.deinit(); + try dur.format("{s}", .{}, buf.writer()); + try testing.expectEqualStrings(case.string, buf.items); + buf.clearAndFree(); + } +} From 6ff82677c39f3c8ea1f77819ec9c0f2284ff0a86 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Wed, 30 Oct 2024 20:45:33 +0100 Subject: [PATCH 05/13] bump --- build.zig | 2 +- build.zig.zon | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index e44f8e3..153bb4c 100644 --- a/build.zig +++ b/build.zig @@ -10,7 +10,7 @@ const std = @import("std"); const builtin = @import("builtin"); const log = std.log.scoped(.zdt_build); -const zdt_version = std.SemanticVersion{ .major = 0, .minor = 4, .patch = 1 }; +const zdt_version = std.SemanticVersion{ .major = 0, .minor = 4, .patch = 2 }; const example_files = [_][]const u8{ "ex_demo", diff --git a/build.zig.zon b/build.zig.zon index afe9e28..75630da 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "zdt", - .version = "0.4.1", + .version = "0.4.2", .paths = .{ "zdt.zig", "lib", // anything from lib From 1fed30f8526630e48d0f5d73762cc8f1b4250390 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Thu, 31 Oct 2024 16:50:15 +0100 Subject: [PATCH 06/13] update demo --- examples/ex_demo.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/ex_demo.zig b/examples/ex_demo.zig index 0e10dc8..b59c1e7 100644 --- a/examples/ex_demo.zig +++ b/examples/ex_demo.zig @@ -47,6 +47,6 @@ pub fn main() !void { "Wall clock time difference: {s}\nAbsolute time difference: {s}\n", .{ wall_diff, abs_diff }, ); - // Wall clock time difference: PT09H00M00S - // Absolute time difference: PT00H00M00S + // Wall clock time difference: PT9H0M0S + // Absolute time difference: PT0H0M0S } From 8ee89d0eb2f47664179761ed5ebedf205d8fa9b3 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Thu, 31 Oct 2024 16:50:34 +0100 Subject: [PATCH 07/13] truncate zeros from fractional part --- lib/Duration.zig | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/Duration.zig b/lib/Duration.zig index 6b4b496..4f11031 100644 --- a/lib/Duration.zig +++ b/lib/Duration.zig @@ -103,9 +103,12 @@ pub fn format( // account for fraction always being positive: if (is_negative and duration.__nsec > 0) s -= 1; - const ns = if (is_negative) 1_000_000_000 - duration.__nsec else duration.__nsec; - // TODO : truncate zeros from ns + var frac = if (is_negative) 1_000_000_000 - duration.__nsec else duration.__nsec; + // truncate zeros from fractional part + if (frac > 0) { + while (frac % 10 == 0) : (frac /= 10) {} + } const hours = @divFloor(s, 3600); const remainder = @rem(s, 3600); @@ -117,7 +120,7 @@ pub fn format( try writer.print("PT{d}H{d}M{d}", .{ hours, minutes, seconds }); if (duration.__nsec > 0) { - try writer.print(".{d}S", .{ns}); + try writer.print(".{d}S", .{frac}); } else { try writer.print("S", .{}); } From 732e1ddac02c0ec46d147a4ee755c8e8e80f93d1 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Thu, 31 Oct 2024 17:06:21 +0100 Subject: [PATCH 08/13] fix negative duration format --- lib/Duration.zig | 15 ++++++--------- tests/test_duration.zig | 8 ++++++++ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/Duration.zig b/lib/Duration.zig index 4f11031..dbe2c68 100644 --- a/lib/Duration.zig +++ b/lib/Duration.zig @@ -88,8 +88,9 @@ pub fn asNanoseconds(duration: Duration) i128 { return duration.__sec * 1_000_000_000 + duration.__nsec; } -// Formatted printing for Duration type. Defaults to ISO8601 duration format, -// years/months/days excluded due to the ambiguity of months and years. +// Formatted printing for Duration type. Defaults to 'ISO8601 duration'-like +// format, with years/months/days excluded due to the ambiguity of months and years. +// If a component is zero (e.g. hours = 0), this is also reported ("0H"). pub fn format( duration: Duration, comptime fmt: []const u8, @@ -99,12 +100,8 @@ pub fn format( _ = options; _ = fmt; const is_negative = duration.__sec < 0; - var s: u64 = if (is_negative) @intCast(duration.__sec * -1) else @intCast(duration.__sec); - - // account for fraction always being positive: - if (is_negative and duration.__nsec > 0) s -= 1; - - var frac = if (is_negative) 1_000_000_000 - duration.__nsec else duration.__nsec; + const s: u64 = if (is_negative) @intCast(duration.__sec * -1) else @intCast(duration.__sec); + var frac = duration.__nsec; // truncate zeros from fractional part if (frac > 0) { while (frac % 10 == 0) : (frac /= 10) {} @@ -119,7 +116,7 @@ pub fn format( try writer.print("PT{d}H{d}M{d}", .{ hours, minutes, seconds }); - if (duration.__nsec > 0) { + if (frac > 0) { try writer.print(".{d}S", .{frac}); } else { try writer.print("S", .{}); diff --git a/tests/test_duration.zig b/tests/test_duration.zig index 58456d8..2e5cdea 100644 --- a/tests/test_duration.zig +++ b/tests/test_duration.zig @@ -228,6 +228,14 @@ test "iso duration to Duration type round-trip" { .string = "PT4H5M6.789S", .duration = .{ .__sec = 4 * 3600 + 5 * 60 + 6, .__nsec = 789000000 }, }, + .{ + .string = "PT37H0M12.789000001S", // default formatter prints components that are zero + .duration = .{ .__sec = 37 * 3600 + 12, .__nsec = 789000001 }, + }, + .{ + .string = "-PT0H46M59.789S", + .duration = .{ .__sec = -(46 * 60 + 59), .__nsec = 789000000 }, + }, }; for (cases) |case| { From 889c4a0d2a9e13f162acf47510f7f4180f00c3a8 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Thu, 31 Oct 2024 17:06:25 +0100 Subject: [PATCH 09/13] update changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7e137..76289f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ Types of changes ## Unreleased +### Added + +- ISO8601 duration parser + +### Fixed + +- default formatter of the Duration type to 'ISO8601 duration'-like string + ## 2024-10-28, v0.4.1 ### Added From 11dd8e8426a6a425be4a1473fd84bc9352c65569 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Thu, 31 Oct 2024 19:08:12 +0100 Subject: [PATCH 10/13] cleanup, test invalid strings --- lib/Duration.zig | 58 ++++++++++++++++------------------------- tests/test_duration.zig | 22 ++++++++++++++++ 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/lib/Duration.zig b/lib/Duration.zig index dbe2c68..57cadb6 100644 --- a/lib/Duration.zig +++ b/lib/Duration.zig @@ -199,14 +199,15 @@ pub fn parseIsoDur(string: []const u8) !RelativeDeltaFields { stop += 1; } - //log.info("invert: {any}", .{invert}); - // must start with P (ignore sign) if (string[stop] != 'P') return error.InvalidFormat; stop += 1; if (string[stop] == 'T') stop += 1; + // 'P' or 'T' must be followed by a digit or minus sign + if (!(std.ascii.isDigit(string[stop]) or string[stop] == '-')) return error.InvalidFormat; + var idx: usize = string.len - 1; // need flags to keep track of what has been parsed already, @@ -221,12 +222,10 @@ pub fn parseIsoDur(string: []const u8) !RelativeDeltaFields { 'S' => { // seconds come last, so no other quantity must have been parsed yet if (flags > 0) return error.InvalidFormat; - // log.info("parse seconds!", .{}); idx -= 1; flags |= 0b1; _ = try parseAndAdvanceS(string, &idx, &result.seconds, &result.nanoseconds); if (invert) result.seconds *= -1; - // log.info("seconds: {d}", .{quantity}); }, 'M' => { // 'M' may appear twice; its either minutes or months; @@ -234,66 +233,54 @@ pub fn parseIsoDur(string: []const u8) !RelativeDeltaFields { // minutes if (flags & 0b1000 == 0), otherwise months if (flags & 0b1000 == 0) { // minutes come second to last, so only seconds may have been parsed yet - // log.info("parse minutes!", .{}); if (flags > 1) return error.InvalidFormat; idx -= 1; flags |= 0b10; var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); if (invert) quantity *= -1; result.minutes = quantity; - // log.info("minutes: {d}", .{quantity}); } else { - // log.info("parse months!", .{}); if (flags > 0b11111) return error.InvalidFormat; idx -= 1; flags |= 0b100000; var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); if (invert) quantity *= -1; result.months = quantity; - // log.info("months: {d}", .{quantity}); } }, 'H' => { // hours are the third-to-last component, // so only seconds and minutes may have been parsed yet if (flags > 0b11) return error.InvalidFormat; - // log.info("parse hours!", .{}); idx -= 1; flags |= 0b100; var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); if (invert) quantity *= -1; result.hours = quantity; - // log.info("hours: {d}", .{quantity}); }, 'T' => { // date/time separator must only appear once if (flags > 0b111) return error.InvalidFormat; - // log.info("date/time sep!", .{}); idx -= 1; flags |= 0b1000; }, 'D' => { if (flags > 0b1111) return error.InvalidFormat; - // log.info("parse days!", .{}); idx -= 1; flags |= 0b10000; var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); if (invert) quantity *= -1; result.days = quantity; - // log.info("days: {d}", .{quantity}); }, 'Y' => { if (flags > 0b111111) return error.InvalidFormat; - // log.info("parse years!", .{}); idx -= 1; flags |= 0b1000000; var quantity = try parseAndAdvanceYMDHM(i32, string, &idx); if (invert) quantity *= -1; result.years = quantity; - // log.info("years: {d}", .{quantity}); }, else => return error.InvalidFormat, } } - // log.info("done. idx: {d}", .{idx}); return result; } @@ -314,29 +301,28 @@ fn parseAndAdvanceS(string: []const u8, idx_ptr: *usize, sec: *i32, nsec: *u32) if (string[idx_ptr.*] == '.') have_fraction = true; } - // short cut: there is no fraction - if (!have_fraction) { + if (have_fraction) { + // fractional seconds are specified. need to convert them to nanoseconds, + // and truncate anything behind the nineth digit. + const substr = string[idx_ptr.* + 1 .. end_idx + 1]; + const idx_dot = std.mem.indexOfScalar(u8, substr, '.'); + if (idx_dot == null) return error.InvalidFormat; + + sec.* = try std.fmt.parseInt(i32, substr[0..idx_dot.?], 10); + + var substr_nanos = substr[idx_dot.? + 1 ..]; + if (substr_nanos.len > 9) substr_nanos = substr_nanos[0..9]; + const nanos = try std.fmt.parseInt(u32, substr_nanos, 10); + + // nanos might actually be another unit; if there is e.g. 3 digits of fractional + // seconds, we have milliseconds (1/10^3 s) and need to multiply by 10^(9-3) to get ns. + const missing = 9 - substr_nanos.len; + const f: u32 = try std.math.powi(u32, 10, @as(u32, @intCast(missing))); + nsec.* = nanos * f; + } else { // short cut: there is no fraction sec.* = try std.fmt.parseInt(i32, string[idx_ptr.* + 1 .. end_idx + 1], 10); return; } - - // fractional seconds are specified. need to convert them to nanoseconds, - // and truncate anything behind the nineth digit. - const substr = string[idx_ptr.* + 1 .. end_idx + 1]; - const idx_dot = std.mem.indexOfScalar(u8, substr, '.'); - if (idx_dot == null) return error.InvalidFormat; - - sec.* = try std.fmt.parseInt(i32, substr[0..idx_dot.?], 10); - - var substr_nanos = substr[idx_dot.? + 1 ..]; - if (substr_nanos.len > 9) substr_nanos = substr_nanos[0..9]; - const nanos = try std.fmt.parseInt(u32, substr_nanos, 10); - - // nanos might actually be another unit; if there is e.g. 3 digits of fractional - // seconds, we have milliseconds (1/10^3 s) and need to multiply by 10^(9-3) to get ns. - const missing = 9 - substr_nanos.len; - const f: u32 = try std.math.powi(u32, 10, @as(u32, @intCast(missing))); - nsec.* = nanos * f; } /// backwards-looking parse chars from 'string' to int (base 10), diff --git a/tests/test_duration.zig b/tests/test_duration.zig index 2e5cdea..1367937 100644 --- a/tests/test_duration.zig +++ b/tests/test_duration.zig @@ -249,3 +249,25 @@ test "iso duration to Duration type round-trip" { buf.clearAndFree(); } } + +test "iso duration fail cases" { + const cases = [_][]const u8{ + "-PT;0H46M59.789S", + "yPT0H46M59.789S", + "-P--T0H46M59.789S", + "p1y2m3dt4k5m6.789s", + "px2y3m4dt5h6m7.89s", + "p999y0m0d 0h0m0.123s", + "p+999y0m0dt0h0m0.123s", + "p3y4m5dt6h7m8.9>", + "p-+5y6m7dt8h9m10.111000001s", + "p7y8m9dt10h11m12;123s", + "p-8y9m10xt11h12m13.145s", + "-p9y10m11dt12h13m14.156s", + }; + + for (cases) |case| { + const err = Duration.parseIsoDur(case) catch error.InvalidFormat; + try testing.expectError(error.InvalidFormat, err); + } +} From 1df1215c55d5834c98fcbddb5b15f74224ec4d62 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Thu, 31 Oct 2024 21:43:30 +0100 Subject: [PATCH 11/13] update example and tests --- examples/ex_duration.zig | 9 +++++++++ tests/test_duration.zig | 18 +++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/examples/ex_duration.zig b/examples/ex_duration.zig index ebd992b..b195c56 100644 --- a/examples/ex_duration.zig +++ b/examples/ex_duration.zig @@ -18,17 +18,26 @@ pub fn main() !void { const now_utc = zdt.Datetime.nowUTC(); println("now, UTC : {s}", .{now_utc}); const past_midnight = try now_utc.floorTo(zdt.Duration.Timespan.day); + + // difference between two datetimes expressed as Duration: println( "{d:.3} seconds have passed since midnight ({s})\n", .{ now_utc.diff(past_midnight).totalSeconds(), past_midnight }, ); + // Durations from Timespans: const tomorrow = try now_utc.add(zdt.Duration.fromTimespanMultiple(1, zdt.Duration.Timespan.day)); println("tomorrow, same time : {s}", .{tomorrow}); println("tomorrow, same time, is {d} seconds away from now\n", .{tomorrow.diff(now_utc).asSeconds()}); + // Timespan units range from nanoseconds to weeks: const two_weeks_ago = try now_utc.sub(zdt.Duration.fromTimespanMultiple(2, zdt.Duration.Timespan.week)); println("two weeks ago : {s}", .{two_weeks_ago}); + + // ISO8601-duration parser on-board: + const one_wk_one_h = try zdt.Duration.fromISO8601Duration("P7DT1H"); + const in_a_week = try now_utc.add(one_wk_one_h); + println("in a week and an hour : {s}", .{in_a_week}); } fn println(comptime fmt: []const u8, args: anytype) void { diff --git a/tests/test_duration.zig b/tests/test_duration.zig index 1367937..5e33b69 100644 --- a/tests/test_duration.zig +++ b/tests/test_duration.zig @@ -255,15 +255,15 @@ test "iso duration fail cases" { "-PT;0H46M59.789S", "yPT0H46M59.789S", "-P--T0H46M59.789S", - "p1y2m3dt4k5m6.789s", - "px2y3m4dt5h6m7.89s", - "p999y0m0d 0h0m0.123s", - "p+999y0m0dt0h0m0.123s", - "p3y4m5dt6h7m8.9>", - "p-+5y6m7dt8h9m10.111000001s", - "p7y8m9dt10h11m12;123s", - "p-8y9m10xt11h12m13.145s", - "-p9y10m11dt12h13m14.156s", + "P1Y2M3DT4K5M6.789S", + "PX2Y3M4DT5H6M7.89S", + "P999Y0M0D 0H0M0.123S", + "P+999Y0M0DT0H0M0.123S", + "P3Y4M5DT6H7M8.9>", + "P-+5Y6M7DT8H9M10.111000001S", + "P7Y8M9DT10H11M12;123S", + "P-8Y9M10XT11H12M13.145S", + "-P9Y10M11DT12H13M14.156s", }; for (cases) |case| { From d436bdf3034d82dd69e596062608c2f0561f6043 Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Thu, 31 Oct 2024 21:48:00 +0100 Subject: [PATCH 12/13] zero duration --- lib/Duration.zig | 3 +++ tests/test_duration.zig | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/lib/Duration.zig b/lib/Duration.zig index 57cadb6..cb36f10 100644 --- a/lib/Duration.zig +++ b/lib/Duration.zig @@ -99,6 +99,9 @@ pub fn format( ) !void { _ = options; _ = fmt; + + if (duration.__sec == 0 and duration.__nsec == 0) return try writer.print("PT0S", .{}); + const is_negative = duration.__sec < 0; const s: u64 = if (is_negative) @intCast(duration.__sec * -1) else @intCast(duration.__sec); var frac = duration.__nsec; diff --git a/tests/test_duration.zig b/tests/test_duration.zig index 5e33b69..c9a2815 100644 --- a/tests/test_duration.zig +++ b/tests/test_duration.zig @@ -224,6 +224,14 @@ test "iso duration parser, full valid input" { test "iso duration to Duration type round-trip" { const cases = [_]TestCaseISODur{ + .{ + .string = "PT0S", + .duration = .{}, + }, + .{ + .string = "-PT0H46M59.789S", + .duration = .{ .__sec = -(46 * 60 + 59), .__nsec = 789000000 }, + }, .{ .string = "PT4H5M6.789S", .duration = .{ .__sec = 4 * 3600 + 5 * 60 + 6, .__nsec = 789000000 }, From 4fcc52f507bbb3e1151b14b71e76ae811a3d60ae Mon Sep 17 00:00:00 2001 From: FObersteiner Date: Mon, 4 Nov 2024 09:08:25 +0100 Subject: [PATCH 13/13] test random --- tests/test_duration.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_duration.zig b/tests/test_duration.zig index c9a2815..f8d67b4 100644 --- a/tests/test_duration.zig +++ b/tests/test_duration.zig @@ -241,7 +241,7 @@ test "iso duration to Duration type round-trip" { .duration = .{ .__sec = 37 * 3600 + 12, .__nsec = 789000001 }, }, .{ - .string = "-PT0H46M59.789S", + .string = "-PT0H46M59.789S", // default formatter normalizes, e.g. seconds >= 59 .duration = .{ .__sec = -(46 * 60 + 59), .__nsec = 789000000 }, }, }; @@ -272,6 +272,10 @@ test "iso duration fail cases" { "P7Y8M9DT10H11M12;123S", "P-8Y9M10XT11H12M13.145S", "-P9Y10M11DT12H13M14.156s", + "UwKMxofSAIQSil8gW", + "gikNDeWiEh4yRt01haAPWqxQWOUSG2hC3EWSiAn3BNnt", + "0", + "", }; for (cases) |case| {