Writing a cron parser: five fields, the union rule, and February 30th

The syntax takes half an hour. The semantics are the work: day-of-month and day-of-week unite rather than intersect, impossible dates need a stopping condition, and 7 and 0 are both Sunday.

The cron tool answers one question: when does this expression run next, five times over. Parsing the syntax is less than half the work. The rest is semantics.

Syntax: five fields, each a set

Minute, hour, day of month, month, day of week. Each field accepts *, a single value, a range a-b, steps as */s and a/s, and comma lists, with names such as JAN and MON for months and weekdays. 7 is equivalent to 0, both Sunday, a POSIX compatibility leftover.

Do not keep an expression tree. Expand every field into a numeric set, so finding the next run is a has() call:

function parseField(text, min, max, names) {
  const out = new Set();
  for (const part of text.split(',')) {
    const [range, stepText] = part.split('/');
    const step = stepText === undefined ? 1 : Number(stepText);
    if (!Number.isInteger(step) || step <= 0) return { error: 'step' };
    let from = min;
    let to = max;
    if (range !== '*') {
      const [a, b] = range.split('-');
      from = value(a, names);
      to = b === undefined ? (stepText === undefined ? from : max) : value(b, names);
    }
    if (from < min || to > max || from > to) return { error: 'range' };
    for (let v = from; v <= to; v += step) out.add(v);
  }
  return { values: out };
}

Mind the meaning of a/s: 5/20 means start at 5 and step by 20 up to the field maximum, giving 5, 25, 45, not the fifth value of every twenty. Both interpretations exist in the wild; POSIX chose the first.

Day of month and day of week unite

This is the most counter-intuitive rule in cron: when both day fields are restricted, either matching is enough (a union). Only when one of them is written as * does the other constrain the schedule. Implementing it as an intersection is a common mistake, and it makes jobs run far less often than intended.

Dates that do not exist

0 0 30 2 *, February 30th, never runs again. The implementation needs a stopping condition. Mine advances day by day, capped at 366 times 5 days, and returns an empty list beyond that. Without the cap the UI spins forever instead of honestly saying never.

Daylight saving also creates missing and repeated times. This site treats them as local clock semantics and does not try to deduplicate.

Advancing day by day

Advancing by day is much simpler than by minute: find a matching date, where day, month and weekday all pass has(), then take the first matching time on that day, and jump to the next day if that moment has passed. Minute stepping loops hundreds of thousands of times; day stepping loops dozens.

One small extra

History: recently used expressions are stored in localStorage, at most eight, newest first and deduplicated. That is not cron functionality, but it turns rerunning the expression from a moment ago into two clicks.

Parsing is half an hour. The semantics, union, step origins and impossible dates, are the entire value of the tool.

← Back to all posts

Comments

…