Skip to content

Instantly share code, notes, and snippets.

@amasanelli
Created January 16, 2024 03:37
Show Gist options
  • Save amasanelli/b6f28f61e8f00446bdc10ede1ca27978 to your computer and use it in GitHub Desktop.
Save amasanelli/b6f28f61e8f00446bdc10ede1ca27978 to your computer and use it in GitHub Desktop.
cron expression parser and next occurrence calculator
import * as moment from 'moment-timezone';
interface FieldBounds {
min: number;
max: number;
}
const minuteBound: FieldBounds = {
min: 0,
max: 59,
};
const hourBound: FieldBounds = {
min: 0,
max: 23,
};
const domBound: FieldBounds = {
min: 1,
max: 31,
};
const monthBound: FieldBounds = {
min: 1,
max: 12,
};
const dowBound: FieldBounds = {
min: 0,
max: 6,
};
export class InvalidCronExpression extends Error {
constructor() {
super('invalid cron expression');
}
}
export class CronParser {
private readonly minutes: number[];
private readonly hours: number[];
private readonly dom: number[];
private readonly months: number[];
private readonly dow: number[];
private readonly timezone: string;
constructor(expression: string, timezone: string) {
const fields = expression.trim().split(' ');
if (fields.length !== 5) {
throw new InvalidCronExpression();
}
if (moment.tz.zone(timezone) === null) {
throw new Error('invalid timezone');
}
this.minutes = CronParser.parseField(fields[0], minuteBound);
this.hours = CronParser.parseField(fields[1], hourBound);
this.dom = CronParser.parseField(fields[2], domBound);
this.months = CronParser.parseField(fields[3], monthBound);
this.dow = CronParser.parseField(fields[4], dowBound);
this.timezone = timezone;
}
static isValid(expression: string): boolean {
try {
const fields = expression.trim().split(' ');
if (fields.length !== 5) {
throw new InvalidCronExpression();
}
this.parseField(fields[0], minuteBound);
this.parseField(fields[1], hourBound);
this.parseField(fields[2], domBound);
this.parseField(fields[3], monthBound);
this.parseField(fields[4], dowBound);
return true;
} catch (error) {
return false;
}
}
static parseField(field: string, bounds: FieldBounds): number[] {
const bitset: number[] = [];
for (let i = 0; i <= bounds.max; i++) {
bitset.push(0);
}
const fieldParts = field.split(',');
for (const fieldPart of fieldParts) {
const results = this.parseFieldPart(fieldPart, bounds);
for (const result of results) {
bitset[result] = 1;
}
}
return bitset;
}
static parseFieldPart(fieldPart: string, bounds: FieldBounds): number[] {
fieldPart = fieldPart.replace('*', `${bounds.min}-${bounds.max}`);
const rangeAndStep = fieldPart.split('/');
if (rangeAndStep.length > 2) {
throw new InvalidCronExpression();
}
const hasStep = rangeAndStep.length === 2;
const lowAndHigh = rangeAndStep[0].split('-');
if (lowAndHigh.length > 2) {
throw new InvalidCronExpression();
}
const begin = parseInt(lowAndHigh[0]);
if (Number.isNaN(begin)) {
throw new InvalidCronExpression();
}
let end: number;
if (lowAndHigh.length === 1 && hasStep) {
end = bounds.max;
} else if (lowAndHigh.length === 1 && !hasStep) {
end = begin;
} else {
end = parseInt(lowAndHigh[1]);
}
if (Number.isNaN(end)) {
throw new InvalidCronExpression();
}
if (begin > bounds.max || begin < bounds.min) {
throw new InvalidCronExpression();
}
if (end > bounds.max || end < bounds.min) {
throw new InvalidCronExpression();
}
if (begin > end) {
throw new InvalidCronExpression();
}
let step = 1;
if (hasStep) {
step = parseInt(rangeAndStep[1]);
}
if (Number.isNaN(step)) {
throw new InvalidCronExpression();
}
return this.createMap(begin, end, step);
}
static createMap(begin: number, end: number, step: number): number[] {
const arr: number[] = [];
for (let i = begin; i <= end; i += step) {
arr.push(i);
}
return arr;
}
next(date: Date): Date | null {
let t = moment(date)
.tz(this.timezone)
.second(0)
.millisecond(0)
.add(1, 'minute');
const maxYear = moment(t).add(5, 'years').year();
while (t.year() <= maxYear) {
if (this.months[t.month() + 1] === 0) {
let i = t.month() + 1 + 1;
for (i; i < this.months.length; i++) {
if (this.months[i] === 1) {
break;
}
}
if (i >= this.months.length) {
t = t.month(1).date(1).hour(0).minute(0).add(1, 'year');
continue;
}
t = t.date(1).hour(0).minute(0);
const diff = i - (t.month() + 1);
t.add(diff, 'months');
}
const month = t.month() + 1;
if (this.dom[t.date()] === 0 || this.dow[t.weekday()] === 0) {
const daysInMonth = t.daysInMonth();
let i = t.date() + 1;
for (i; i <= daysInMonth; i++) {
if (this.dom[i] === 1) {
break;
}
}
if (i >= daysInMonth) {
t = t.date(1).hour(0).minute(0).add(1, 'month');
continue;
}
t = t.hour(0).minute(0);
const diff = i - t.date();
t.add(diff, 'days');
if (t.month() + 1 > month) {
t = t.date(1);
continue;
}
if (this.dow[t.weekday()] === 0) {
continue;
}
}
if (this.hours[t.hour()] === 0) {
let i = t.hour() + 1;
for (i; i < this.hours.length; i++) {
if (this.hours[i] === 1) {
break;
}
}
if (i >= this.hours.length) {
t = t.hour(0).minute(0).add(1, 'day');
continue;
}
t = t.minute(0);
const diff = i - t.hour();
t.add(diff, 'hours');
}
if (this.minutes[t.minute()] === 0) {
let i = t.minute() + 1;
for (i; i < this.minutes.length; i++) {
if (this.minutes[i] === 1) {
break;
}
}
if (i >= this.minutes.length) {
t = t.minute(0).add(1, 'hour');
continue;
}
const diff = i - t.minute();
t.add(diff, 'minutes');
}
return t.toDate();
}
return null;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment