this repo has no description
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}