Created
June 26, 2025 16:24
-
-
Save mnixry/53a1f0bfa47136b25a1eee8418ba2110 to your computer and use it in GitHub Desktop.
crontab.guru style cron syntax parser and generates explanation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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