this repo has no description
at main 638 lines 22 kB view raw
1//! Cron expression parser and iterator 2//! 3//! Supports standard 5-field cron expressions: minute hour day month day-of-week 4//! 5//! Example: "0 0 * * *" = midnight daily 6//! "*/15 * * * *" = every 15 minutes 7//! "0 9 * * 1-5" = 9am weekdays 8//! 9//! ## Type System Notes 10//! 11//! This library demonstrates pragmatic Zig type system usage: 12//! 13//! - **Error unions** (`CronError!Cron`): Zig's way of handling fallible operations. 14//! Forces callers to handle errors explicitly. No hidden exceptions. 15//! 16//! - **Enums with explicit backing type** (`Field = enum(u3)`): Self-documenting, 17//! type-safe field indices. Compiler catches invalid field usage. 18//! 19//! - **StaticBitSet**: Perfect for bounded integer domains. O(1) lookup, compact 20//! representation, no allocation. Better than HashMap or []bool for this use case. 21//! 22//! - **Options struct**: Idiomatic configuration pattern from std.json, std.fmt. 23//! Cleaner than multiple overloads or optional parameters. 24//! 25//! - **Iterator with ?T return**: The standard Zig iterator pattern. Works naturally 26//! with `while (iter.next()) |item|` syntax. 27//! 28//! What we DON'T use and why: 29//! 30//! - **Comptime parsing**: Cron expressions typically come from config files, 31//! databases, or user input - all runtime values. Comptime validation would 32//! require expressions to be known at compile time, which defeats the purpose. 33//! 34//! - **Type-returning functions**: Could wrap Cron in `fn(comptime expr) type`, 35//! but it adds complexity without solving a real problem. Runtime parsing with 36//! good error messages is more practical. 37 38const std = @import("std"); 39 40/// Parsing errors with specific failure modes 41pub const CronError = error{ 42 /// Expression doesn't have exactly 5 space-separated fields 43 InvalidExpression, 44 /// Field contains invalid syntax (bad step, unknown name, etc.) 45 InvalidField, 46 /// Range start is greater than end (e.g., "10-5") 47 InvalidRange, 48 /// Value exceeds field bounds (e.g., minute 60) 49 OutOfRange, 50}; 51 52/// Field indices as a typed enum. 53/// Using enum(u3) because we have 5 fields (fits in 3 bits). 54/// This is better than raw integers because the compiler prevents mistakes. 55pub const Field = enum(u3) { 56 minute = 0, 57 hour = 1, 58 day = 2, 59 month = 3, 60 dow = 4, 61 62 /// Get the valid range for this field 63 pub fn range(self: Field) struct { min: u8, max: u8 } { 64 return switch (self) { 65 .minute => .{ .min = 0, .max = 59 }, 66 .hour => .{ .min = 0, .max = 23 }, 67 .day => .{ .min = 1, .max = 31 }, 68 .month => .{ .min = 1, .max = 12 }, 69 .dow => .{ .min = 0, .max = 6 }, 70 }; 71 } 72}; 73 74/// Days in each month (non-leap year) 75const days_in_month = [12]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; 76 77/// Month name abbreviations 78const month_names = [_][]const u8{ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec" }; 79 80/// Day of week abbreviations (0=Sunday) 81const dow_names = [_][]const u8{ "sun", "mon", "tue", "wed", "thu", "fri", "sat" }; 82 83/// Bitset for a cron field. 84/// StaticBitSet(64) is ideal here: 85/// - All cron fields fit in 64 bits (largest is minute: 0-59) 86/// - O(1) set/test operations 87/// - No allocation needed 88/// - Compact memory representation (8 bytes) 89pub const FieldSet = std.StaticBitSet(64); 90 91/// Configuration options for parsing. 92/// Using an options struct is idiomatic Zig - see std.json.ParseOptions. 93pub const ParseOptions = struct { 94 /// Day/DOW combination logic. 95 /// true (default): day OR dow - standard cron behavior 96 /// false: day AND dow - fcron behavior 97 day_or: bool = true, 98}; 99 100/// Parsed cron expression 101pub const Cron = struct { 102 minute: FieldSet, 103 hour: FieldSet, 104 day: FieldSet, 105 month: FieldSet, 106 dow: FieldSet, 107 day_any: bool, // true if day field was "*" (unrestricted) 108 dow_any: bool, // true if dow field was "*" (unrestricted) 109 day_or: bool, // day/dow combination logic 110 111 /// Parse a cron expression with default options. 112 pub fn parse(expr: []const u8) CronError!Cron { 113 return parseWithOptions(expr, .{}); 114 } 115 116 /// Parse a cron expression with custom options. 117 pub fn parseWithOptions(expr: []const u8, options: ParseOptions) CronError!Cron { 118 var it = std.mem.splitScalar(u8, expr, ' '); 119 var fields: [5][]const u8 = undefined; 120 var count: usize = 0; 121 122 while (it.next()) |field| { 123 if (field.len == 0) continue; 124 if (count >= 5) return CronError.InvalidExpression; 125 fields[count] = field; 126 count += 1; 127 } 128 129 if (count != 5) return CronError.InvalidExpression; 130 131 return Cron{ 132 .minute = try parseField(fields[0], .minute), 133 .hour = try parseField(fields[1], .hour), 134 .day = try parseField(fields[2], .day), 135 .month = try parseField(fields[3], .month), 136 .dow = try parseField(fields[4], .dow), 137 .day_any = isWildcard(fields[2]), 138 .dow_any = isWildcard(fields[4]), 139 .day_or = options.day_or, 140 }; 141 } 142 143 /// Get the next occurrence after the given timestamp (microseconds since epoch). 144 /// Returns null if no match is found within 4 years. 145 pub fn next(self: *const Cron, from_micros: i64) ?i64 { 146 const from_secs = @divFloor(from_micros, 1_000_000); 147 var dt = DateTime.fromTimestamp(from_secs); 148 149 // Start from next minute 150 dt.second = 0; 151 dt.minute += 1; 152 dt.normalize(); 153 154 // Search up to 4 years (covers leap year cycles) 155 const max_iterations: u32 = 4 * 366 * 24 * 60; 156 var iterations: u32 = 0; 157 158 while (iterations < max_iterations) : (iterations += 1) { 159 // Check month first (coarsest granularity) 160 if (!self.month.isSet(dt.month)) { 161 dt.month += 1; 162 dt.day = 1; 163 dt.hour = 0; 164 dt.minute = 0; 165 dt.normalize(); 166 continue; 167 } 168 169 // Check day/dow combination 170 if (!self.checkDayDow(&dt)) { 171 dt.day += 1; 172 dt.hour = 0; 173 dt.minute = 0; 174 dt.normalize(); 175 continue; 176 } 177 178 if (!self.hour.isSet(dt.hour)) { 179 dt.hour += 1; 180 dt.minute = 0; 181 dt.normalize(); 182 continue; 183 } 184 185 if (!self.minute.isSet(dt.minute)) { 186 dt.minute += 1; 187 dt.normalize(); 188 continue; 189 } 190 191 return dt.toTimestamp() * 1_000_000; 192 } 193 194 return null; 195 } 196 197 /// Get multiple next occurrences. 198 pub fn nextN(self: *const Cron, from_micros: i64, count: usize, buf: []i64) usize { 199 var current = from_micros; 200 var written: usize = 0; 201 202 while (written < count and written < buf.len) { 203 if (self.next(current)) |ts| { 204 buf[written] = ts; 205 written += 1; 206 current = ts; 207 } else break; 208 } 209 210 return written; 211 } 212 213 /// Create an iterator for successive occurrences. 214 /// Usage: `while (cron.iter(start).next()) |ts| { ... }` 215 pub fn iter(self: *const Cron, start_micros: i64) Iterator { 216 return .{ .cron = self, .current = start_micros }; 217 } 218 219 /// Check if a timestamp matches this cron expression. 220 pub fn matches(self: *const Cron, micros: i64) bool { 221 const secs = @divFloor(micros, 1_000_000); 222 const dt = DateTime.fromTimestamp(secs); 223 224 if (!self.minute.isSet(dt.minute)) return false; 225 if (!self.hour.isSet(dt.hour)) return false; 226 if (!self.month.isSet(dt.month)) return false; 227 228 return self.checkDayDow(&dt); 229 } 230 231 fn checkDayDow(self: *const Cron, dt: *const DateTime) bool { 232 const day_match = self.day.isSet(dt.day); 233 const dow_match = self.dow.isSet(dt.dayOfWeek()); 234 235 // Standard cron behavior: 236 // - If day is "*", only check dow 237 // - If dow is "*", only check day 238 // - If both are restricted, use day_or logic 239 if (self.day_any and self.dow_any) return true; 240 if (self.day_any) return dow_match; 241 if (self.dow_any) return day_match; 242 return if (self.day_or) day_match or dow_match else day_match and dow_match; 243 } 244}; 245 246/// Iterator over cron occurrences. 247/// Demonstrates the standard Zig iterator pattern: next() returns ?T. 248pub const Iterator = struct { 249 cron: *const Cron, 250 current: i64, 251 252 /// Get next occurrence, advancing internal state. 253 /// Returns null when no more occurrences (within 4-year search window). 254 pub fn next(self: *Iterator) ?i64 { 255 if (self.cron.next(self.current)) |ts| { 256 self.current = ts; 257 return ts; 258 } 259 return null; 260 } 261 262 /// Peek at next occurrence without advancing. 263 pub fn peek(self: *const Iterator) ?i64 { 264 return self.cron.next(self.current); 265 } 266 267 /// Reset iterator to a new starting point. 268 pub fn reset(self: *Iterator, start_micros: i64) void { 269 self.current = start_micros; 270 } 271}; 272 273// ============================================================================ 274// Field Parsing 275// ============================================================================ 276 277fn isWildcard(field: []const u8) bool { 278 return std.mem.eql(u8, field, "*"); 279} 280 281fn parseField(field: []const u8, idx: Field) CronError!FieldSet { 282 var set = FieldSet.initEmpty(); 283 const r = idx.range(); 284 285 // Handle comma-separated values 286 var parts = std.mem.splitScalar(u8, field, ','); 287 while (parts.next()) |part| { 288 try parsePart(part, &set, r.min, r.max, idx); 289 } 290 291 return set; 292} 293 294fn parsePart(part: []const u8, set: *FieldSet, min: u8, max: u8, idx: Field) CronError!void { 295 var step: u8 = 1; 296 var range_part = part; 297 298 // Handle step syntax: */5, 0-30/5 299 if (std.mem.indexOf(u8, part, "/")) |slash_pos| { 300 range_part = part[0..slash_pos]; 301 step = std.fmt.parseInt(u8, part[slash_pos + 1 ..], 10) catch return CronError.InvalidField; 302 if (step == 0) return CronError.InvalidField; 303 } 304 305 // Handle wildcard 306 if (std.mem.eql(u8, range_part, "*")) { 307 var v = min; 308 while (v <= max) : (v += step) { 309 set.set(v); 310 if (v == max or @addWithOverflow(v, step)[1] != 0) break; 311 } 312 return; 313 } 314 315 // Handle range: 1-5 316 if (std.mem.indexOf(u8, range_part, "-")) |dash_pos| { 317 const start = try parseValue(range_part[0..dash_pos], min, max, idx); 318 const end = try parseValue(range_part[dash_pos + 1 ..], min, max, idx); 319 320 if (start > end) return CronError.InvalidRange; 321 322 var v = start; 323 while (v <= end) : (v += step) { 324 set.set(v); 325 if (v == end or @addWithOverflow(v, step)[1] != 0) break; 326 } 327 return; 328 } 329 330 // Single value 331 const value = try parseValue(range_part, min, max, idx); 332 set.set(value); 333} 334 335fn parseValue(s: []const u8, min: u8, max: u8, idx: Field) CronError!u8 { 336 // Try numeric first 337 if (std.fmt.parseInt(u8, s, 10)) |v| { 338 if (v < min or v > max) return CronError.OutOfRange; 339 return v; 340 } else |_| {} 341 342 // Try name lookup for month/dow 343 const lower = blk: { 344 var buf: [3]u8 = undefined; 345 const len = @min(s.len, 3); 346 for (0..len) |i| { 347 buf[i] = std.ascii.toLower(s[i]); 348 } 349 break :blk buf[0..len]; 350 }; 351 352 if (idx == .month) { 353 for (month_names, 1..) |name, i| { 354 if (std.mem.eql(u8, lower, name)) return @intCast(i); 355 } 356 } else if (idx == .dow) { 357 for (dow_names, 0..) |name, i| { 358 if (std.mem.eql(u8, lower, name)) return @intCast(i); 359 } 360 } 361 362 return CronError.InvalidField; 363} 364 365// ============================================================================ 366// DateTime - Simple datetime for cron calculations 367// ============================================================================ 368 369/// Simple datetime struct for timestamp manipulation. 370/// Not timezone-aware - assumes UTC for simplicity. 371pub const DateTime = struct { 372 year: i32, 373 month: u8, // 1-12 374 day: u8, // 1-31 375 hour: u8, // 0-23 376 minute: u8, // 0-59 377 second: u8 = 0, 378 379 pub fn fromTimestamp(secs: i64) DateTime { 380 var remaining = secs; 381 382 // Years since 1970 383 var year: i32 = 1970; 384 while (true) { 385 const days_this_year: i64 = if (isLeapYear(year)) 366 else 365; 386 const secs_this_year = days_this_year * 86400; 387 if (remaining < secs_this_year) break; 388 remaining -= secs_this_year; 389 year += 1; 390 } 391 392 // Month and day 393 var month: u8 = 1; 394 while (month <= 12) { 395 var days_this_month = days_in_month[month - 1]; 396 if (month == 2 and isLeapYear(year)) days_this_month = 29; 397 const secs_this_month: i64 = @as(i64, days_this_month) * 86400; 398 if (remaining < secs_this_month) break; 399 remaining -= secs_this_month; 400 month += 1; 401 } 402 403 const day: u8 = @intCast(@divFloor(remaining, 86400) + 1); 404 remaining = @mod(remaining, 86400); 405 const hour: u8 = @intCast(@divFloor(remaining, 3600)); 406 remaining = @mod(remaining, 3600); 407 const minute: u8 = @intCast(@divFloor(remaining, 60)); 408 const second: u8 = @intCast(@mod(remaining, 60)); 409 410 return .{ .year = year, .month = month, .day = day, .hour = hour, .minute = minute, .second = second }; 411 } 412 413 pub fn toTimestamp(self: *const DateTime) i64 { 414 var secs: i64 = 0; 415 416 // Years 417 var y: i32 = 1970; 418 while (y < self.year) : (y += 1) { 419 secs += if (isLeapYear(y)) 366 * 86400 else 365 * 86400; 420 } 421 422 // Months 423 var m: u8 = 1; 424 while (m < self.month) : (m += 1) { 425 var days = days_in_month[m - 1]; 426 if (m == 2 and isLeapYear(self.year)) days = 29; 427 secs += @as(i64, days) * 86400; 428 } 429 430 secs += @as(i64, self.day - 1) * 86400; 431 secs += @as(i64, self.hour) * 3600; 432 secs += @as(i64, self.minute) * 60; 433 secs += self.second; 434 435 return secs; 436 } 437 438 pub fn normalize(self: *DateTime) void { 439 while (self.minute >= 60) { 440 self.minute -= 60; 441 self.hour += 1; 442 } 443 444 while (self.hour >= 24) { 445 self.hour -= 24; 446 self.day += 1; 447 } 448 449 while (true) { 450 var max_day = days_in_month[self.month - 1]; 451 if (self.month == 2 and isLeapYear(self.year)) max_day = 29; 452 if (self.day <= max_day) break; 453 self.day -= max_day; 454 self.month += 1; 455 if (self.month > 12) { 456 self.month = 1; 457 self.year += 1; 458 } 459 } 460 461 while (self.month > 12) { 462 self.month -= 12; 463 self.year += 1; 464 } 465 } 466 467 pub fn dayOfWeek(self: *const DateTime) u8 { 468 // Zeller's congruence 469 var y = self.year; 470 var m: i32 = self.month; 471 472 if (m < 3) { 473 m += 12; 474 y -= 1; 475 } 476 477 const k = @mod(y, 100); 478 const j = @divFloor(y, 100); 479 480 var h = self.day + @divFloor(13 * (m + 1), 5) + k + @divFloor(k, 4) + @divFloor(j, 4) - 2 * j; 481 h = @mod(h, 7); 482 483 // Convert from Saturday=0 to Sunday=0 484 return @intCast(@mod(h + 6, 7)); 485 } 486}; 487 488fn isLeapYear(year: i32) bool { 489 return @mod(year, 400) == 0 or (@mod(year, 4) == 0 and @mod(year, 100) != 0); 490} 491 492// ============================================================================ 493// Tests 494// ============================================================================ 495 496test "parse simple expressions" { 497 const cron = try Cron.parse("0 0 * * *"); 498 try std.testing.expect(cron.minute.isSet(0)); 499 try std.testing.expect(!cron.minute.isSet(1)); 500 try std.testing.expect(cron.hour.isSet(0)); 501 try std.testing.expect(!cron.hour.isSet(1)); 502 try std.testing.expect(cron.day.isSet(1)); 503 try std.testing.expect(cron.day.isSet(31)); 504} 505 506test "parse step expressions" { 507 const cron = try Cron.parse("*/15 * * * *"); 508 try std.testing.expect(cron.minute.isSet(0)); 509 try std.testing.expect(cron.minute.isSet(15)); 510 try std.testing.expect(cron.minute.isSet(30)); 511 try std.testing.expect(cron.minute.isSet(45)); 512 try std.testing.expect(!cron.minute.isSet(1)); 513 try std.testing.expect(!cron.minute.isSet(14)); 514} 515 516test "parse range expressions" { 517 const cron = try Cron.parse("0 9-17 * * 1-5"); 518 try std.testing.expect(cron.hour.isSet(9)); 519 try std.testing.expect(cron.hour.isSet(17)); 520 try std.testing.expect(!cron.hour.isSet(8)); 521 try std.testing.expect(!cron.hour.isSet(18)); 522 try std.testing.expect(cron.dow.isSet(1)); 523 try std.testing.expect(cron.dow.isSet(5)); 524 try std.testing.expect(!cron.dow.isSet(0)); 525 try std.testing.expect(!cron.dow.isSet(6)); 526} 527 528test "parse month/dow names" { 529 const cron = try Cron.parse("0 0 1 jan,jun,dec mon,fri"); 530 try std.testing.expect(cron.month.isSet(1)); 531 try std.testing.expect(cron.month.isSet(6)); 532 try std.testing.expect(cron.month.isSet(12)); 533 try std.testing.expect(!cron.month.isSet(2)); 534 try std.testing.expect(cron.dow.isSet(1)); 535 try std.testing.expect(cron.dow.isSet(5)); 536 try std.testing.expect(!cron.dow.isSet(0)); 537} 538 539test "DateTime conversion" { 540 const ts: i64 = 1705321800; // 2024-01-15 12:30:00 UTC 541 const dt = DateTime.fromTimestamp(ts); 542 try std.testing.expectEqual(@as(i32, 2024), dt.year); 543 try std.testing.expectEqual(@as(u8, 1), dt.month); 544 try std.testing.expectEqual(@as(u8, 15), dt.day); 545 try std.testing.expectEqual(@as(u8, 12), dt.hour); 546 try std.testing.expectEqual(@as(u8, 30), dt.minute); 547 try std.testing.expectEqual(ts, dt.toTimestamp()); 548} 549 550test "next occurrence - midnight daily" { 551 const cron = try Cron.parse("0 0 * * *"); 552 const from: i64 = 1705320000 * 1_000_000; // 2024-01-15 12:00:00 553 const next_ts = cron.next(from) orelse unreachable; 554 555 const dt = DateTime.fromTimestamp(@divFloor(next_ts, 1_000_000)); 556 try std.testing.expectEqual(@as(i32, 2024), dt.year); 557 try std.testing.expectEqual(@as(u8, 1), dt.month); 558 try std.testing.expectEqual(@as(u8, 16), dt.day); 559 try std.testing.expectEqual(@as(u8, 0), dt.hour); 560 try std.testing.expectEqual(@as(u8, 0), dt.minute); 561} 562 563test "next occurrence - every 15 minutes" { 564 const cron = try Cron.parse("*/15 * * * *"); 565 const from: i64 = 1705320420 * 1_000_000; // 2024-01-15 12:07:00 566 const next_ts = cron.next(from) orelse unreachable; 567 568 const dt = DateTime.fromTimestamp(@divFloor(next_ts, 1_000_000)); 569 try std.testing.expectEqual(@as(u8, 12), dt.hour); 570 try std.testing.expectEqual(@as(u8, 15), dt.minute); 571} 572 573test "day of week calculation" { 574 const dt = DateTime{ .year = 2024, .month = 1, .day = 15, .hour = 0, .minute = 0 }; 575 try std.testing.expectEqual(@as(u8, 1), dt.dayOfWeek()); // Monday 576 577 const dt2 = DateTime{ .year = 2024, .month = 1, .day = 14, .hour = 0, .minute = 0 }; 578 try std.testing.expectEqual(@as(u8, 0), dt2.dayOfWeek()); // Sunday 579 580 const dt3 = DateTime{ .year = 2024, .month = 1, .day = 20, .hour = 0, .minute = 0 }; 581 try std.testing.expectEqual(@as(u8, 6), dt3.dayOfWeek()); // Saturday 582} 583 584test "leap year" { 585 try std.testing.expect(isLeapYear(2024)); 586 try std.testing.expect(!isLeapYear(2023)); 587 try std.testing.expect(isLeapYear(2000)); 588 try std.testing.expect(!isLeapYear(1900)); 589} 590 591test "matches" { 592 const cron = try Cron.parse("30 9 * * 1-5"); 593 const monday_930: i64 = 1705311000 * 1_000_000; // Monday 09:30 594 try std.testing.expect(cron.matches(monday_930)); 595 596 const sunday_930: i64 = 1705224600 * 1_000_000; // Sunday 09:30 597 try std.testing.expect(!cron.matches(sunday_930)); 598} 599 600test "iterator" { 601 const cron = try Cron.parse("*/15 * * * *"); 602 var it = cron.iter(1705320000 * 1_000_000); // 2024-01-15 12:00:00 603 604 // First occurrence should be 12:15 605 const first = it.next() orelse unreachable; 606 var dt = DateTime.fromTimestamp(@divFloor(first, 1_000_000)); 607 try std.testing.expectEqual(@as(u8, 12), dt.hour); 608 try std.testing.expectEqual(@as(u8, 15), dt.minute); 609 610 // Second should be 12:30 611 const second = it.next() orelse unreachable; 612 dt = DateTime.fromTimestamp(@divFloor(second, 1_000_000)); 613 try std.testing.expectEqual(@as(u8, 12), dt.hour); 614 try std.testing.expectEqual(@as(u8, 30), dt.minute); 615 616 // Third should be 12:45 617 const third = it.next() orelse unreachable; 618 dt = DateTime.fromTimestamp(@divFloor(third, 1_000_000)); 619 try std.testing.expectEqual(@as(u8, 12), dt.hour); 620 try std.testing.expectEqual(@as(u8, 45), dt.minute); 621} 622 623test "day_or option" { 624 // Default: day OR dow (standard cron) 625 const or_cron = try Cron.parse("0 0 15 * 1"); // 15th OR Monday 626 const and_cron = try Cron.parseWithOptions("0 0 15 * 1", .{ .day_or = false }); // 15th AND Monday 627 628 // 2024-01-15 is Monday AND the 15th - both should match 629 const monday_15th: i64 = 1705276800 * 1_000_000; 630 try std.testing.expect(or_cron.matches(monday_15th)); 631 try std.testing.expect(and_cron.matches(monday_15th)); 632 633 // 2024-01-16 is Tuesday and 16th - OR matches (it's the 16th... wait no) 634 // Let's use 2024-01-22 which is Monday but not the 15th 635 const monday_22nd: i64 = 1705881600 * 1_000_000; 636 try std.testing.expect(or_cron.matches(monday_22nd)); // OR: Monday matches 637 try std.testing.expect(!and_cron.matches(monday_22nd)); // AND: not the 15th 638}