cron パーサを書く:5 フィールド、和集合の規則、そして 2 月 30 日
構文は 30 分で書けます。難しいのは意味論です。日と曜日を両方指定したときは積ではなく和、存在しない日時には終了条件が要り、7 も 0 も日曜です。
cron ツール が答えるのは 1 つ、この式が次にいつ動くかを 5 回分です。構文の解析は作業の半分以下で、残りは意味論の細部です。
構文:5 フィールド、それぞれ集合
分、時、日、月、曜日。各フィールドは *、単一値、範囲 a-b、ステップ */s と a/s、カンマ区切りの列に対応し、月と曜日は名前(JAN、MON)も受け付けます。7 は 0 と等価(どちらも日曜)で、POSIX 由来の互換項目です。
式木は保持せず、各フィールドを数値集合に展開します。次の実行を探すのは has() の呼び出しになります。
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 };
}
a/s の意味に注意します。5/20 は 5 から 20 刻みで上限まで、つまり 5, 25, 45 です。「20 個ごとの 5 番目」ではありません。どちらの解釈も実装に存在しますが、POSIX は前者です。
日と曜日は和集合
cron で最も直感に反する規則です。日と曜日の両方を限定したときは、どちらかが一致すれば実行(和集合)します。片方が * のときだけ、もう片方が制約になります。積として実装するのはよくある誤りで、実行回数が意図より大幅に減ります。
存在しない日時
0 0 30 2 *(2 月 30 日)は次が永遠に来ません。実装には終了条件が必要です。私の実装は日単位で進め、最大 366×5 日で打ち切り、それを超えたら空の配列を返します。上限がないと画面は回り続け、「永遠に来ない」と正直に答えてくれません。
夏時間も存在しない時刻と重複する時刻を生みます。このサイトはローカル時計の意味として扱い、重複の排除はしません。
日単位で進める
分単位より日単位の方がずっと簡単です。まず日・月・曜日の 3 集合すべてが has を通る日を探し、その日の最初の一致時刻を取り、その時刻が過ぎていれば翌日へ進みます。分単位では数十万回のループが必要ですが、日単位なら数十回です。
ついでに足した小さな機能
履歴です。最近使った式を localStorage に最大 8 件、新しい順に重複を除いて保存します。cron の機能ではありませんが、「さっきの式をもう一度」が 10 秒から 2 クリックになります。
構文の解析は 30 分。和集合、ステップの起点、存在しない日時といった意味論の細部こそが、このツールの価値のすべてです。

コメント
…