Skip to content

Instantly share code, notes, and snippets.

@jorgegonzalez
Created August 27, 2025 17:36
Show Gist options
  • Select an option

  • Save jorgegonzalez/1b9c6ab538d1a0a6cabd0765cbfaa249 to your computer and use it in GitHub Desktop.

Select an option

Save jorgegonzalez/1b9c6ab538d1a0a6cabd0765cbfaa249 to your computer and use it in GitHub Desktop.
crontab-guru implementation
/**
* Cron Expression Parser Library
* Converts cron expressions to human-readable descriptions and calculates next execution times
*/
class CronParser {
constructor() {
this.monthNames = [
null, "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
this.weekdayNames = [
"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"
];
this.monthMap = {
jan: "1", feb: "2", mar: "3", apr: "4", may: "5", jun: "6",
jul: "7", aug: "8", sep: "9", oct: "10", nov: "11", dec: "12"
};
this.weekdayMap = {
sun: "0", mon: "1", tue: "2", wed: "3", thu: "4", fri: "5", sat: "6"
};
this.specialStrings = {
"@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", "*", "*", "*", "*"]
};
}
/**
* Parse a cron expression and return human-readable description
* @param {string} expression - Cron expression (e.g., "0 7,18 * * *")
* @returns {Object} Parsed result with description and next execution time
*/
parse(expression) {
const normalized = this.prenormalize(expression);
const schedule = this.normalize(normalized);
const description = this.describe(normalized);
const nextDate = this.getNextDate(schedule);
return {
expression: expression,
description: description,
schedule: schedule,
nextDate: nextDate,
errors: schedule.errors || null
};
}
/**
* Pre-normalize the cron expression (handle special strings and named values)
*/
prenormalize(expression) {
const parts = expression.trim().split(/\s+/).filter(p => p);
// Handle @reboot special case
if (parts.length === 1 && parts[0] === "@reboot") {
return {
originalParts: parts,
parts: [],
special: "After rebooting."
};
}
// Handle other special strings
if (parts.length === 1 && this.specialStrings[parts[0]]) {
const expandedParts = this.specialStrings[parts[0]];
return this.processParts(expandedParts, parts);
}
return this.processParts(parts, parts);
}
/**
* Process cron parts and replace named months/weekdays
*/
processParts(parts, originalParts) {
const processed = parts.map((part, index) => {
// Replace month names (index 3)
if (index === 3) {
return this.replaceNames(part, this.monthMap);
}
// Replace weekday names (index 4)
if (index === 4) {
return this.replaceNames(part, this.weekdayMap);
}
return part;
});
return {
originalParts: originalParts,
parts: processed,
daysAnded: (processed[2] && processed[2][0] === "*") || (processed[4] && processed[4][0] === "*")
};
}
/**
* Replace named values with numeric equivalents
*/
replaceNames(str, map) {
let result = str;
Object.keys(map).forEach(name => {
const regex = new RegExp(`(^|[,-/])${name}($|[,-/])`, 'gi');
result = result.replace(regex, `$1${map[name]}$2`);
});
return result;
}
/**
* Normalize and validate the cron expression parts
*/
normalize(prenormalized) {
if (!prenormalized.parts || prenormalized.parts.length === 0) {
return prenormalized.special ? { special: prenormalized.special } : {};
}
const parts = prenormalized.parts;
const result = {
errors: [],
warnings: [],
daysAnded: prenormalized.daysAnded
};
if (parts.length !== 5) {
result.errors.push("fields");
return result;
}
// Parse each field
result.minutes = this.parseField(parts[0], 0, 59, "minutes", result);
result.hours = this.parseField(parts[1], 0, 23, "hours", result);
result.dates = this.parseField(parts[2], 1, 31, "dates", result);
result.months = this.parseField(parts[3], 1, 12, "months", result);
result.weekdays = this.parseWeekdayField(parts[4], result);
// Clean up empty arrays
if (result.errors.length === 0) delete result.errors;
if (result.warnings.length === 0) delete result.warnings;
return result;
}
/**
* Parse a single cron field
*/
parseField(field, min, max, name, result) {
if (!field || field.length === 0) {
result.errors.push(name);
return [];
}
// Replace * with range
field = field.replace(/(^|[,-/])\*($|[,-/])/g, `$1${min}-${max}$2`);
const values = [];
const parts = field.split(',');
for (const part of parts) {
const parsed = this.parsePart(part, min, max);
if (parsed.error) {
result.errors.push(name);
return [];
}
if (parsed.warning) {
result.warnings.push(name);
}
values.push(...parsed.values);
}
// Remove duplicates and sort
return [...new Set(values)].sort((a, b) => a - b);
}
/**
* Parse weekday field (special handling for 7 = Sunday)
*/
parseWeekdayField(field, result) {
const values = this.parseField(field, 0, 7, "weekdays", result);
// Convert 7 to 0 for Sunday
return [...new Set(values.map(v => v === 7 ? 0 : v))].sort((a, b) => a - b);
}
/**
* Parse a single part (number, range, or step)
*/
parsePart(part, min, max) {
const tokens = part.match(/\d+|./g);
if (!tokens) return { error: true, values: [] };
const nums = tokens.map(t => isNaN(t) ? t : Number(t));
// Single number
if (nums.length === 1 && Number.isInteger(nums[0])) {
return { values: [nums[0]] };
}
// Range: n-m
if (nums.length === 3 && nums[1] === '-' && Number.isInteger(nums[0]) && Number.isInteger(nums[2])) {
return { values: this.range(nums[0], nums[2], 1) };
}
// Step: n/m or */m
if (nums.length === 3 && nums[1] === '/' && Number.isInteger(nums[2])) {
if (Number.isInteger(nums[0])) {
return {
values: this.range(nums[0], max, nums[2]),
warning: true
};
}
}
// Range with step: n-m/s
if (nums.length === 5 && nums[1] === '-' && nums[3] === '/' &&
Number.isInteger(nums[0]) && Number.isInteger(nums[2]) && Number.isInteger(nums[4])) {
return { values: this.range(nums[0], nums[2], nums[4]) };
}
return { error: true, values: [] };
}
/**
* Generate a range of numbers
*/
range(start, end, step = 1) {
const result = [];
for (let i = start; i <= end; i += step) {
result.push(i);
}
return result;
}
/**
* Generate human-readable description
*/
describe(prenormalized) {
if (prenormalized.special) {
return prenormalized.special;
}
if (!prenormalized.parts || prenormalized.parts.length !== 5) {
return "Invalid cron expression";
}
const parts = prenormalized.parts;
// Check if it's a specific time (e.g., "30 14 * * *" = "At 14:30")
if (this.isSpecificTime(parts[0], parts[1])) {
return this.describeSpecificTime(parts);
}
// General description
return this.describeGeneral(parts, prenormalized.daysAnded);
}
/**
* Check if minutes and hours represent a specific time
*/
isSpecificTime(minutes, hours) {
return /^0*\d\d?$/.test(minutes) && /^0*\d\d?$/.test(hours);
}
/**
* Describe a specific time
*/
describeSpecificTime(parts) {
const minute = parts[0].padStart(2, '0');
const hour = parts[1].padStart(2, '0');
let description = `At ${hour}:${minute}`;
const dateDesc = this.describeField(parts[2], "day-of-month", null, 31);
const monthDesc = this.describeField(parts[3], "month", this.monthNames, 12);
const weekdayDesc = this.describeField(parts[4], "day-of-week", this.weekdayNames, 7);
if (dateDesc !== "") description += " on " + dateDesc;
if (weekdayDesc !== "") description += " on " + weekdayDesc;
if (monthDesc !== "") description += " in " + monthDesc;
return description.replace(/\s+/g, ' ').trim() + ".";
}
/**
* General description for complex expressions
*/
describeGeneral(parts, daysAnded) {
let description = "At";
const minuteDesc = this.describeField(parts[0], "minute", null, 59);
const hourDesc = this.describeField(parts[1], "hour", null, 23);
const dateDesc = this.describeField(parts[2], "day-of-month", null, 31);
const monthDesc = this.describeField(parts[3], "month", this.monthNames, 12);
const weekdayDesc = this.describeField(parts[4], "day-of-week", this.weekdayNames, 7);
if (minuteDesc) description += " " + minuteDesc;
if (hourDesc) description += " past " + hourDesc;
if (dateDesc) description += " on " + dateDesc;
if (dateDesc && weekdayDesc) {
description += daysAnded ? " if it's" : " and";
}
if (weekdayDesc) description += " on " + weekdayDesc;
if (monthDesc) description += " in " + monthDesc;
return description.replace(/\s+/g, ' ').trim() + ".";
}
/**
* Describe a single field
*/
describeField(field, unit, names, max) {
if (field === "*") return "";
const parts = field.split(',');
const descriptions = parts.map(part => this.describePart(part, unit, names, max));
return this.joinDescriptions(descriptions);
}
/**
* Describe a single part of a field
*/
describePart(part, unit, names, max) {
// Handle single values
if (/^\d+$/.test(part)) {
const num = parseInt(part);
return names ? names[num] || num : this.ordinal(num);
}
// Handle ranges
if (part.includes('-')) {
const [start, end] = part.split('-').map(Number);
const startName = names ? names[start] || start : start;
const endName = names ? names[end] || end : end;
return `every ${unit} from ${startName} through ${endName}`;
}
// Handle steps
if (part.includes('/')) {
const [range, step] = part.split('/');
if (range === '*') {
return `every ${this.ordinal(step)} ${unit}`;
}
const [start, end] = range.split('-').map(Number);
const startName = names ? names[start] || start : start;
const endName = names ? names[end] || end : end;
return `every ${this.ordinal(step)} ${unit} from ${startName} through ${endName}`;
}
return part;
}
/**
* Convert number to ordinal (1st, 2nd, 3rd, etc.)
*/
ordinal(num) {
const n = parseInt(num);
const s = n > 20 ? n % 10 : n;
const suffix = s === 1 ? 'st' : s === 2 ? 'nd' : s === 3 ? 'rd' : 'th';
return n + suffix;
}
/**
* Join field descriptions
*/
joinDescriptions(descriptions) {
if (descriptions.length === 0) return "";
if (descriptions.length === 1) return descriptions[0];
if (descriptions.length === 2) return descriptions[0] + " and " + descriptions[1];
return descriptions.slice(0, -1).join(", ") + ", and " + descriptions[descriptions.length - 1];
}
/**
* Calculate the next execution date
*/
getNextDate(schedule, fromDate = new Date()) {
if (schedule.errors || schedule.special) return null;
// Round up to next minute
const date = new Date(fromDate);
date.setSeconds(0);
date.setMilliseconds(0);
date.setMinutes(date.getMinutes() + 1);
// Try to find next valid date (max 1000 iterations to prevent infinite loop)
for (let i = 0; i < 1000; i++) {
if (this.dateMatches(date, schedule)) {
return date;
}
// Increment by 1 minute
date.setMinutes(date.getMinutes() + 1);
}
return null;
}
/**
* Check if a date matches the cron schedule
*/
dateMatches(date, schedule) {
const minute = date.getMinutes();
const hour = date.getHours();
const dayOfMonth = date.getDate();
const month = date.getMonth() + 1;
const dayOfWeek = date.getDay();
if (!schedule.minutes.includes(minute)) return false;
if (!schedule.hours.includes(hour)) return false;
if (!schedule.months.includes(month)) return false;
const dateMatch = schedule.dates.includes(dayOfMonth);
const weekdayMatch = schedule.weekdays.includes(dayOfWeek);
if (schedule.daysAnded) {
return dateMatch && weekdayMatch;
} else {
return dateMatch || weekdayMatch;
}
}
}
// Export for different module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = CronParser;
} else if (typeof define === 'function' && define.amd) {
define([], function() { return CronParser; });
} else {
window.CronParser = CronParser;
}
// Usage example:
/*
const parser = new CronParser();
const result = parser.parse("0 7,18 * * *");
console.log(result.description); // "At 07:00 and 18:00."
console.log(result.nextDate); // Next execution date
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment