Skip to content

Instantly share code, notes, and snippets.

@mnixry
Created June 26, 2025 16:24
Show Gist options
  • Save mnixry/53a1f0bfa47136b25a1eee8418ba2110 to your computer and use it in GitHub Desktop.
Save mnixry/53a1f0bfa47136b25a1eee8418ba2110 to your computer and use it in GitHub Desktop.
crontab.guru style cron syntax parser and generates explanation.
class CronTabParsingError extends Error {
static assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new CronTabParsingError(message);
}
}
}
const ordinal = (n: number): string => {
if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
switch (n % 10) {
case 1:
return `${n}st`;
case 2:
return `${n}nd`;
case 3:
return `${n}rd`;
default:
return `${n}th`;
}
};
const formatList = (items: unknown[]): string => {
const itemsStr = items.map((item) => String(item));
switch (items.length) {
case 1:
return itemsStr[0];
case 2:
return `${itemsStr[0]} and ${itemsStr[1]}`;
default:
return `${itemsStr.slice(0, -1).join(', ')}, and ${itemsStr[itemsStr.length - 1]}`;
}
};
const BOUNDS = {
minute: [0, 59],
hour: [0, 23],
day_of_month: [1, 31],
month: [1, 12],
day_of_week: [0, 6],
} as const;
const BOUND_NAMES = {
minute: 'minute',
hour: 'hour',
day_of_month: 'day-of-month',
month: 'month',
day_of_week: 'day-of-week',
} as const;
const MONTH_NAMES = {
jan: 1,
feb: 2,
mar: 3,
apr: 4,
may: 5,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
oct: 10,
nov: 11,
dec: 12,
} as const;
const REVERSE_MONTH_NAMES = {
1: 'January',
2: 'February',
3: 'March',
4: 'April',
5: 'May',
6: 'June',
7: 'July',
8: 'August',
9: 'September',
10: 'October',
11: 'November',
12: 'December',
} as const;
const DAY_NAMES = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6,
} as const;
const REVERSE_DAY_NAMES = {
0: 'Sunday',
1: 'Monday',
2: 'Tuesday',
3: 'Wednesday',
4: 'Thursday',
5: 'Friday',
6: 'Saturday',
} as const;
const SPECIAL_STRINGS = {
'@yearly': '0 0 1 1 *',
'@annually': '0 0 1 1 *',
'@monthly': '0 0 1 * *',
'@weekly': '0 0 * * 0',
'@daily': '0 0 * * *',
'@midnight': '0 0 * * *',
'@hourly': '0 * * * *',
} as const;
export class CronTab {
public readonly description: string;
// protected readonly originalString: string;
protected readonly minutes: number[];
protected readonly hours: number[];
protected readonly daysOfMonth: number[];
protected readonly months: number[];
protected readonly daysOfWeek: number[];
protected readonly parts: string[];
constructor(protected readonly cronString: string) {
const sLower = cronString.trim().toLowerCase();
if (sLower === '@reboot') {
this.description = 'After rebooting.';
// Initialize arrays to empty for consistency
[this.parts, this.minutes, this.hours, this.daysOfMonth, this.months, this.daysOfWeek] = [
[],
[],
[],
[],
[],
[],
];
return;
}
this.parts = this.preNormalize(cronString);
this.minutes = this.parsePart(this.parts[0], 'minute');
this.hours = this.parsePart(this.parts[1], 'hour');
this.daysOfMonth = this.parsePart(this.parts[2], 'day_of_month');
this.months = this.parsePart(this.parts[3], 'month');
const rawDaysOfWeek = this.parsePart(this.parts[4], 'day_of_week');
// Handle non-standard '7' for Sunday and remove duplicates
const weekDaySet = new Set(rawDaysOfWeek.map((d) => (d === 7 ? 0 : d)));
this.daysOfWeek = Array.from(weekDaySet).sort((a, b) => a - b);
this.description = this.describe();
}
protected preNormalize(cronString: string): string[] {
let s = cronString.trim().toLowerCase();
s = SPECIAL_STRINGS[s as keyof typeof SPECIAL_STRINGS] ?? s;
const parts = s.split(/\s+/);
CronTabParsingError.assert(
parts.length === 5,
`Invalid cron string: Expected 5 parts, but found ${parts.length}.`
);
parts[3] = this.replaceNames(parts[3], MONTH_NAMES);
parts[4] = this.replaceNames(parts[4], DAY_NAMES);
return parts;
}
protected replaceNames(partString: string, namesMap: Record<string, number>): string {
const regex = new RegExp(`\\b${Object.keys(namesMap).join('|')}\\b`, 'gi');
return partString.replace(regex, (match) => namesMap[match].toString());
}
protected parsePart(partString: string, partName: keyof typeof BOUNDS): number[] {
const [minVal, maxVal] = BOUNDS[partName];
const processedPartString = partString.replace(/\*/g, `${minVal}-${maxVal}`);
const fullSet = new Set<number>();
for (const subPart of processedPartString.split(',')) {
CronTabParsingError.assert(
subPart,
`Invalid format in '${BOUND_NAMES[partName]}': empty part due to extra comma.`
);
const rangeStepMatch = subPart.match(/^(\d+)-(\d+)\/(\d+)$/);
if (rangeStepMatch) {
const [, startStr, endStr, stepStr] = rangeStepMatch;
const [start, end, step] = [+startStr, +endStr, +stepStr];
CronTabParsingError.assert(
start <= end,
`Invalid range in '${BOUND_NAMES[partName]}': start '${start}' is greater than end '${end}'.`
);
CronTabParsingError.assert(
step !== 0,
`Invalid step in '${BOUND_NAMES[partName]}': step cannot be 0.`
);
for (let i = start; i <= end; i += step) fullSet.add(i);
continue;
}
const rangeMatch = subPart.match(/^(\d+)-(\d+)$/);
if (rangeMatch) {
const [, startStr, endStr] = rangeMatch;
const [start, end] = [+startStr, +endStr];
CronTabParsingError.assert(
start <= end,
`Invalid range in '${BOUND_NAMES[partName]}': start '${start}' is greater than end '${end}'.`
);
for (let i = start; i <= end; i++) fullSet.add(i);
continue;
}
if (/^\d+$/.test(subPart)) {
fullSet.add(+subPart);
continue;
}
throw new CronTabParsingError(
`Invalid format in '${BOUND_NAMES[partName]}' field: '${subPart}'`
);
}
for (const num of fullSet) {
CronTabParsingError.assert(
num >= minVal && num <= maxVal && !(partName === 'day_of_week' && num === 7),
`Value '${num}' out of bounds for ${BOUND_NAMES[partName]} (must be ${minVal}-${maxVal})`
);
}
return Array.from(fullSet).sort((a, b) => a - b);
}
protected describePart(
partStr: string,
name: keyof typeof BOUNDS,
values: number[],
nameMap?: Record<number, string>
): string {
const [minVal, maxVal] = BOUNDS[name];
if (values.length === maxVal - minVal + 1) {
return `every ${name.replace(/_/g, ' ')}`;
}
const stepMatch = partStr.match(/^\*\/(\d+)/);
if (stepMatch) {
const step = +stepMatch[1];
return `every ${ordinal(step)} ${name.replace(/_/g, ' ')}`;
}
const stringValues = nameMap ? values.map((v) => nameMap[v] || v) : values;
return formatList(stringValues);
}
protected describe(): string {
const isSpecificTime =
this.minutes.length === 1 &&
this.hours.length === 1 &&
/^\d+$/.test(this.parts[0]) &&
/^\d+$/.test(this.parts[1]);
let timeDesc = '';
if (isSpecificTime) {
timeDesc = `At ${String(this.hours[0]).padStart(2, '0')}:${String(this.minutes[0]).padStart(2, '0')}`;
} else {
const minuteDesc = this.describePart(this.parts[0], 'minute', this.minutes);
const hourDesc = this.describePart(this.parts[1], 'hour', this.hours);
if (minuteDesc.includes('every minute')) {
timeDesc = `At ${minuteDesc}`;
if (!hourDesc.includes('every hour')) {
timeDesc += ` past ${hourDesc}`;
}
} else {
timeDesc = `At minute ${minuteDesc}`;
if (!hourDesc.includes('every hour')) {
timeDesc += ` past ${hourDesc}`;
}
}
}
const fullDesc: string[] = [timeDesc];
const dayOfMonthStr = this.describePart(this.parts[2], 'day_of_month', this.daysOfMonth);
const dayOfWeekStr = this.describePart(
this.parts[4],
'day_of_week',
this.daysOfWeek,
REVERSE_DAY_NAMES
);
const hasDom = this.parts[2] !== '*';
const hasDow = this.parts[4] !== '*';
const domDowParts: string[] = [];
if (hasDom) domDowParts.push(`on day-of-month ${dayOfMonthStr}`);
if (hasDow) {
const conjunction = !hasDom || !hasDow ? 'and' : "if it's";
if (domDowParts.length > 0) domDowParts.push(conjunction);
domDowParts.push(`on ${dayOfWeekStr}`);
}
if (domDowParts.length > 0) fullDesc.push(domDowParts.join(' '));
const monthDesc = this.describePart(this.parts[3], 'month', this.months, REVERSE_MONTH_NAMES);
if (this.parts[3] !== '*') fullDesc.push(`in ${monthDesc}`);
return fullDesc.join(' ').replace(/\s+/g, ' ').trim() + '.';
}
}
export function describeCron(
cronString: string
): { description: string; error?: never } | { description?: never; error: string } {
try {
const { description } = new CronTab(cronString);
return { description };
} catch (error) {
if (error instanceof CronTabParsingError) return { error: error.message };
throw error;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment