mirror of
https://github.com/oven-sh/bun
synced 2026-02-17 06:12:08 +00:00
315 lines
12 KiB
Zig
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;
|