Files
bun.sh/src/bun.js/api/cron_parser.zig
2026-02-13 21:46:59 +00:00

315 lines
12 KiB
Zig

/// Cron expression parser and next-occurrence calculator.
///
/// Parses standard 5-field cron expressions (minute hour day month weekday)
/// into a bitset representation, and computes the next matching UTC time.
///
/// Supports:
/// - Wildcards: *
/// - Lists: 1,3,5
/// - Ranges: 1-5
/// - Steps: */15, 1-30/2
/// - Named days: SUN-SAT, Sun-Sat, Sunday-Saturday (case-insensitive)
/// - Named months: JAN-DEC, Jan-Dec, January-December (case-insensitive)
/// - Sunday as 7: weekday field accepts 7 as alias for 0
/// - Nicknames: @yearly, @annually, @monthly, @weekly, @daily, @midnight, @hourly
pub const CronExpression = struct {
minutes: u64, // bits 0-59
hours: u32, // bits 0-23
days: u32, // bits 1-31
months: u16, // bits 1-12
weekdays: u8, // bits 0-6 (0=Sunday)
days_is_wildcard: bool, // true if day-of-month field was *
weekdays_is_wildcard: bool, // true if weekday field was *
pub const Error = error{
InvalidField,
InvalidStep,
InvalidRange,
InvalidNumber,
TooManyFields,
TooFewFields,
};
/// Parse a 5-field cron expression or predefined nickname into a CronExpression.
pub fn parse(input: []const u8) Error!CronExpression {
const expr = bun.strings.trim(input, " \t");
// Check for predefined nicknames
if (expr.len > 0 and expr[0] == '@') {
return parseNickname(expr) orelse error.InvalidField;
}
var count: usize = 0;
var fields: [5][]const u8 = undefined;
var iter = std.mem.tokenizeAny(u8, expr, " \t");
while (iter.next()) |field| {
if (count >= 5) return error.TooManyFields;
fields[count] = field;
count += 1;
}
if (count != 5) return error.TooFewFields;
return .{
.minutes = try parseField(u64, fields[0], 0, 59, .none),
.hours = try parseField(u32, fields[1], 0, 23, .none),
.days = try parseField(u32, fields[2], 1, 31, .none),
.months = try parseField(u16, fields[3], 1, 12, .month),
.weekdays = try parseField(u8, fields[4], 0, 6, .weekday),
.days_is_wildcard = bun.strings.eql(fields[2], "*"),
.weekdays_is_wildcard = bun.strings.eql(fields[4], "*"),
};
}
/// Validate a cron expression string without allocating.
pub fn validate(expr: []const u8) bool {
_ = parse(expr) catch return false;
return true;
}
/// Format the expression as a normalized numeric "M H D Mo W" string
/// suitable for crontab. Returns the written slice of `buf`.
pub fn formatNumeric(self: CronExpression, buf: *[512]u8) []const u8 {
var stream = std.io.fixedBufferStream(buf);
const w = stream.writer();
formatBitfield(w, u64, self.minutes, 0, 59);
w.writeByte(' ') catch unreachable;
formatBitfield(w, u32, self.hours, 0, 23);
w.writeByte(' ') catch unreachable;
formatBitfield(w, u32, self.days, 1, 31);
w.writeByte(' ') catch unreachable;
formatBitfield(w, u16, self.months, 1, 12);
w.writeByte(' ') catch unreachable;
formatBitfield(w, u8, self.weekdays, 0, 6);
return stream.getWritten();
}
/// Compute the next UTC time (in ms since epoch) that matches this expression,
/// starting from `from_ms`. Returns null if no match found within ~4 years.
pub fn next(self: CronExpression, globalObject: *jsc.JSGlobalObject, from_ms: f64) bun.JSError!?f64 {
var dt = globalObject.msToGregorianDateTimeUTC(from_ms);
// Advance by 1 minute, zero out seconds
dt.minute += 1;
if (dt.minute > 59) {
dt.minute = 0;
dt.hour += 1;
if (dt.hour > 23) {
dt.hour = 0;
dt.day += 1;
}
}
dt.second = 0;
// Loop up to ~4 years to prevent infinite iteration
var iterations: u32 = 0;
const max_iterations: u32 = 1500 * 24 * 60;
while (iterations < max_iterations) : (iterations += 1) {
// Normalize via round-trip to handle overflows and compute weekday
{
const ms = try globalObject.gregorianDateTimeToMSUTC(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, 0);
dt = globalObject.msToGregorianDateTimeUTC(ms);
}
// Check month
if (!bitSet(u16, self.months, @intCast(dt.month))) {
dt.month += 1;
dt.day = 1;
dt.hour = 0;
dt.minute = 0;
continue;
}
// POSIX cron day-of-month / day-of-week logic:
// - If both are restricted (neither was *): OR — either matching is enough
// - If only one is restricted: only that one matters (the * field matches all)
const day_ok = bitSet(u32, self.days, @intCast(dt.day));
const weekday_ok = bitSet(u8, self.weekdays, @intCast(dt.weekday));
const both_restricted = !self.days_is_wildcard and !self.weekdays_is_wildcard;
const day_match = if (both_restricted) (day_ok or weekday_ok) else (day_ok and weekday_ok);
if (!day_match) {
dt.day += 1;
dt.hour = 0;
dt.minute = 0;
continue;
}
// Check hour
if (!bitSet(u32, self.hours, @intCast(dt.hour))) {
dt.hour += 1;
dt.minute = 0;
continue;
}
// Check minute
if (!bitSet(u64, self.minutes, @intCast(dt.minute))) {
dt.minute += 1;
continue;
}
// All fields match
return try globalObject.gregorianDateTimeToMSUTC(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, 0);
}
return null;
}
};
// ============================================================================
// Name lookup tables
// ============================================================================
const all_hours: u32 = (1 << 24) - 1;
const all_days: u32 = ((1 << 32) - 1) & ~@as(u32, 1);
const all_months: u16 = ((1 << 13) - 1) & ~@as(u16, 1);
const all_weekdays: u8 = (1 << 7) - 1;
fn parseNickname(expr: []const u8) ?CronExpression {
const eql = bun.strings.eqlCaseInsensitiveASCIIICheckLength;
if (eql(expr, "@yearly") or eql(expr, "@annually"))
return .{ .minutes = 1, .hours = 1, .days = 1 << 1, .months = 1 << 1, .weekdays = all_weekdays, .days_is_wildcard = false, .weekdays_is_wildcard = true };
if (eql(expr, "@monthly"))
return .{ .minutes = 1, .hours = 1, .days = 1 << 1, .months = all_months, .weekdays = all_weekdays, .days_is_wildcard = false, .weekdays_is_wildcard = true };
if (eql(expr, "@weekly"))
return .{ .minutes = 1, .hours = 1, .days = all_days, .months = all_months, .weekdays = 1, .days_is_wildcard = true, .weekdays_is_wildcard = false };
if (eql(expr, "@daily") or eql(expr, "@midnight"))
return .{ .minutes = 1, .hours = 1, .days = all_days, .months = all_months, .weekdays = all_weekdays, .days_is_wildcard = true, .weekdays_is_wildcard = true };
if (eql(expr, "@hourly"))
return .{ .minutes = 1, .hours = all_hours, .days = all_days, .months = all_months, .weekdays = all_weekdays, .days_is_wildcard = true, .weekdays_is_wildcard = true };
return null;
}
const weekday_map = bun.ComptimeStringMap(u7, .{
.{ "sun", 0 }, .{ "mon", 1 }, .{ "tue", 2 },
.{ "wed", 3 }, .{ "thu", 4 }, .{ "fri", 5 },
.{ "sat", 6 }, .{ "sunday", 0 }, .{ "monday", 1 },
.{ "tuesday", 2 }, .{ "wednesday", 3 }, .{ "thursday", 4 },
.{ "friday", 5 }, .{ "saturday", 6 },
});
const month_map = bun.ComptimeStringMap(u7, .{
.{ "jan", 1 }, .{ "feb", 2 }, .{ "mar", 3 },
.{ "apr", 4 }, .{ "may", 5 }, .{ "jun", 6 },
.{ "jul", 7 }, .{ "aug", 8 }, .{ "sep", 9 },
.{ "oct", 10 }, .{ "nov", 11 }, .{ "dec", 12 },
.{ "january", 1 }, .{ "february", 2 }, .{ "march", 3 },
.{ "april", 4 }, .{ "may", 5 }, .{ "june", 6 },
.{ "july", 7 }, .{ "august", 8 }, .{ "september", 9 },
.{ "october", 10 }, .{ "november", 11 }, .{ "december", 12 },
});
// ============================================================================
// Field parsing
// ============================================================================
const NameKind = enum { none, weekday, month };
/// Parse a single cron field (e.g. "1,5-10,*/3") into a bitset.
fn parseField(comptime T: type, field: []const u8, min: u7, max: u7, kind: NameKind) CronExpression.Error!T {
if (field.len == 0) return error.InvalidField;
var result: T = 0;
var parts = std.mem.splitScalar(u8, field, ',');
while (parts.next()) |part| {
if (part.len == 0) return error.InvalidField;
// Split by / for step
var step_iter = std.mem.splitScalar(u8, part, '/');
const base = step_iter.next() orelse return error.InvalidField;
const step_str = step_iter.next();
if (step_iter.next() != null) return error.InvalidStep;
const step: u7 = if (step_str) |s| blk: {
if (s.len == 0) return error.InvalidStep;
break :blk std.fmt.parseInt(u7, s, 10) catch return error.InvalidStep;
} else 1;
if (step == 0) return error.InvalidStep;
var range_min: u7 = undefined;
var range_max: u7 = undefined;
if (bun.strings.eql(base, "*")) {
range_min = min;
range_max = max;
} else {
if (splitRange(base)) |range_parts| {
const lo = parseValue(range_parts[0], min, max, kind) catch return error.InvalidNumber;
const hi = parseValue(range_parts[1], min, max, kind) catch return error.InvalidNumber;
if (lo > hi) return error.InvalidRange;
range_min = lo;
range_max = hi;
} else {
const lo = parseValue(base, min, max, kind) catch return error.InvalidNumber;
range_min = lo;
range_max = if (step_str != null) max else lo;
}
}
// Set bits
var i: u7 = range_min;
while (i <= range_max) : (i += step) {
result |= @as(T, 1) << @intCast(i);
if (@as(u8, i) + @as(u8, step) > range_max) break;
}
}
return result;
}
/// Split a base expression on '-' for ranges, returning null if not a range.
fn splitRange(base: []const u8) ?[2][]const u8 {
const idx = bun.strings.indexOfChar(base, '-') orelse return null;
if (idx == 0 or idx == base.len - 1) return null;
const rest = base[idx + 1 ..];
if (bun.strings.indexOfChar(rest, '-') != null) return null;
return .{ base[0..idx], rest };
}
/// Parse a single value (number or name), validating range.
/// For weekday fields, 7 is normalized to 0 (Sunday).
fn parseValue(str: []const u8, min: u7, max: u7, kind: NameKind) error{InvalidNumber}!u7 {
// Try named value first via ComptimeStringMap case-insensitive lookup
switch (kind) {
.weekday => if (weekday_map.getASCIIICaseInsensitive(str)) |v| return v,
.month => if (month_map.getASCIIICaseInsensitive(str)) |v| return v,
.none => {},
}
const val = std.fmt.parseInt(u8, str, 10) catch return error.InvalidNumber;
if (kind == .weekday and val == 7) return 0;
if (val < min or val > max) return error.InvalidNumber;
return @intCast(val);
}
// ============================================================================
// Helpers
// ============================================================================
inline fn bitSet(comptime T: type, set: T, pos: std.math.Log2Int(T)) bool {
return (set >> pos) & 1 != 0;
}
/// Write a bitfield as a cron field string: "*" if all bits set, or comma-separated values.
fn formatBitfield(w: anytype, comptime T: type, bits: T, min: u8, max: u8) void {
var all_set = true;
for (min..max + 1) |i| {
if ((bits >> @intCast(i)) & 1 == 0) {
all_set = false;
break;
}
}
if (all_set) {
w.writeByte('*') catch unreachable;
return;
}
var first = true;
for (min..max + 1) |i| {
if ((bits >> @intCast(i)) & 1 != 0) {
if (!first) w.writeByte(',') catch unreachable;
std.fmt.format(w, "{d}", .{i}) catch unreachable;
first = false;
}
}
}
const std = @import("std");
const bun = @import("bun");
const jsc = bun.jsc;