import { ImpossibleScheduleError, InvalidCronError } from "./errors.js"; /** * A single field of a five-field cron expression after alias resolution. * * `values` is the expanded set of allowed integers; `restricted` is false only * for the literal `*`, which matters for the cron dom/dow OR rule: when both * fields are restricted the day matches if EITHER matches, and when only one is * restricted it alone constrains the day. */ export interface CronField { readonly values: ReadonlySet; readonly restricted: boolean; } /** * Parsed five-field cron expression: minute hour day-of-month month * day-of-week. minute/hour/dow are zero-based; month is 1-12, dom 1-31. */ export interface ParsedCron { readonly minute: CronField; readonly hour: CronField; readonly dayOfMonth: CronField; readonly month: CronField; readonly dayOfWeek: CronField; } interface FieldBounds { readonly min: number; readonly max: number; /** alias -> value, applied before range parsing (MON, JAN, ...) */ readonly aliases?: Readonly>; } const MONTH_ALIASES: Readonly> = { JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12, }; const DOW_ALIASES: Readonly> = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6, }; const MINUTE: FieldBounds = { min: 0, max: 59 }; const HOUR: FieldBounds = { min: 0, max: 23 }; const DOM: FieldBounds = { min: 1, max: 31 }; const MONTH: FieldBounds = { min: 1, max: 12, aliases: MONTH_ALIASES }; const DOW: FieldBounds = { min: 0, max: 6, aliases: DOW_ALIASES }; export const FIELD_NAMES = ["minute", "hour", "day-of-month", "month", "day-of-week"] as const; /** Month lengths ignoring leap years (February counted as 29 to stay permissive). */ const MONTH_LENGTHS: readonly number[] = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; function applyAliases(token: string, bounds: FieldBounds): string { const aliases = bounds.aliases; if (!aliases) return token; const mapped = aliases[token.toUpperCase()]; return mapped === undefined ? token : String(mapped); } function parseValue(token: string, bounds: FieldBounds, fieldName: string): number { const mapped = applyAliases(token, bounds); if (!/^\d+$/.test(mapped)) { throw new InvalidCronError(`invalid value "${token}" in ${fieldName} field`); } const value = Number(mapped); if (value < bounds.min || value > bounds.max) { throw new InvalidCronError( `value ${value} out of range ${bounds.min}-${bounds.max} in ${fieldName} field`, ); } return value; } function parseStep(token: string, fieldName: string): number { const trimmed = token.trim(); if (!/^\d+$/.test(trimmed)) { throw new InvalidCronError(`invalid step "${token}" in ${fieldName} field`); } const step = Number(trimmed); if (!Number.isInteger(step) || step <= 0) { throw new InvalidCronError(`step must be a positive integer, got "${token}" in ${fieldName} field`); } return step; } function parseField(field: string, bounds: FieldBounds, fieldName: string): CronField { const raw = field.trim(); if (raw === "") { throw new InvalidCronError(`empty ${fieldName} field`); } const values = new Set(); for (const part of raw.split(",")) { const token = part.trim(); if (token === "") { throw new InvalidCronError(`empty list element in ${fieldName} field "${field}"`); } // Split off an optional /step suffix, being careful not to break on a bare value. const slash = token.indexOf("/"); const range = (slash === -1 ? token : token.slice(0, slash)).trim(); const stepPart = slash === -1 ? null : token.slice(slash + 1).trim(); if (range === "") { throw new InvalidCronError(`malformed element "${token}" in ${fieldName} field`); } if ((token.match(/\//g) ?? []).length > 1) { throw new InvalidCronError(`malformed element "${token}" in ${fieldName} field`); } const step = stepPart === null ? 1 : parseStep(stepPart, fieldName); let lo: number; let hi: number; if (range === "*") { lo = bounds.min; hi = bounds.max; } else if (range.includes("-")) { const dash = range.indexOf("-"); const loToken = range.slice(0, dash).trim(); const hiToken = range.slice(dash + 1).trim(); if (loToken === "" || hiToken === "") { throw new InvalidCronError(`malformed range "${range}" in ${fieldName} field`); } lo = parseValue(loToken, bounds, fieldName); hi = parseValue(hiToken, bounds, fieldName); if (lo > hi) { throw new InvalidCronError(`descending range "${range}" in ${fieldName} field (use ascending ranges)`); } } else { const value = parseValue(range, bounds, fieldName); if (stepPart === null) { values.add(value); continue; } // `n/step` means "starting at n, every step" — the vixie shorthand for n-max/step. lo = value; hi = bounds.max; } for (let v = lo; v <= hi; v += step) values.add(v); } if (values.size === 0) { throw new InvalidCronError(`${fieldName} field "${field}" selects no values`); } // Only the bare `*` leaves a field unrestricted; `*/15`, `1-5`, lists and // even `*,5` all constrain the field. return { values, restricted: raw !== "*" }; } /** * Parse a five-field cron expression. Supported syntax: `*`, integers, ranges * `a-b`, steps `n/step`, `a-b/step`, comma lists, and month/day-of-week names * (`JAN-DEC`, `MON-SUN`, case-insensitive). Anything else — more or fewer than * five fields, seconds/year fields, `@reboot` macros, `?`, `L`, `W`, `#` — is * rejected with `InvalidCronError` rather than silently misinterpreted. * * Throws `ImpossibleScheduleError` when the expression can never fire (for * example `0 0 31 2 *`). */ export function parseCron(expression: string): ParsedCron { if (typeof expression !== "string") { throw new InvalidCronError(`cron expression must be a string, got ${typeof expression}`); } const trimmed = expression.trim(); if (trimmed === "") { throw new InvalidCronError("cron expression must not be empty"); } if (trimmed.startsWith("@")) { throw new InvalidCronError( `cron macros like "${trimmed}" are not supported; use a five-field expression`, ); } for (const ch of ["?", "L", "W", "#"] as const) { if (trimmed.includes(ch)) { throw new InvalidCronError( `unsupported cron syntax "${ch}" in "${expression}"; this library implements the five-field syntax only`, ); } } const fields = trimmed.split(/\s+/); if (fields.length !== 5) { throw new InvalidCronError( `cron expression must have exactly 5 fields (minute hour day-of-month month day-of-week), got ${fields.length} in "${expression}"`, ); } const minute = parseField(fields[0]!, MINUTE, FIELD_NAMES[0]); const hour = parseField(fields[1]!, HOUR, FIELD_NAMES[1]); const dayOfMonth = parseField(fields[2]!, DOM, FIELD_NAMES[2]); const month = parseField(fields[3]!, MONTH, FIELD_NAMES[3]); const dayOfWeek = parseField(fields[4]!, DOW, FIELD_NAMES[4]); assertPossible(month, dayOfMonth); return { minute, hour, dayOfMonth, month, dayOfWeek }; } function assertPossible(month: CronField, dayOfMonth: CronField): void { for (const m of month.values) { const maxDay = MONTH_LENGTHS[m] ?? 0; for (const d of dayOfMonth.values) { if (d <= maxDay) return; } } throw new ImpossibleScheduleError( `expression can never fire: no allowed month is long enough for day-of-month ${[ ...dayOfMonth.values, ].join(", ")}`, ); } /** * Does this cron match the given local wall-clock day? Implements the vixie * dom/dow OR rule: if both fields are restricted the day matches when either * matches, otherwise the restricted field alone decides. */ export function cronMatchesDay(cron: ParsedCron, year: number, month: number, day: number): boolean { if (!cron.month.values.has(month)) return false; const domMatch = cron.dayOfMonth.values.has(day); const dowMatch = cron.dayOfWeek.values.has(dayOfWeekOf(year, month, day)); const domRestricted = cron.dayOfMonth.restricted; const dowRestricted = cron.dayOfWeek.restricted; // Vixie rule: neither restricted -> every day; exactly one restricted -> // that field alone; both restricted -> match if EITHER matches. if (domRestricted && dowRestricted) return domMatch || dowMatch; if (domRestricted) return domMatch; if (dowRestricted) return dowMatch; return true; } function dayOfWeekOf(year: number, month: number, day: number): number { return new Date(Date.UTC(year, month - 1, day)).getUTCDay(); } /** Does this cron match the given local time-of-day (hour/minute)? */ export function cronMatchesTime(cron: ParsedCron, hour: number, minute: number, second: number): boolean { if (second !== 0) return false; return cron.minute.values.has(minute) && cron.hour.values.has(hour); }