| git.druid.rocks | index | druid520 | radium | radium-lang-cron.el |
radium-lang-cron.el
;;; radium-lang-cron.el, crontab editing + next-run preview -*- lexical-binding: t; -*-
(defun radium-cron-parse-field (str lo hi)
"i parse cron field STR into the sorted list of valid ints in [LO,HI]
(it supports lists \"a,b\", ranges \"a-b\", steps \"a-b/n\" or \"*/n\")."
(let (result)
(dolist (part (split-string str ","))
(let (base step)
(if (string-match "\\`\\(.+\\)/\\([0-9]+\\)\\'" part)
(setq base (match-string 1 part) step (string-to-number (match-string 2 part)))
(setq base part step 1))
(let (range-lo range-hi)
(cond
((string= base "*") (setq range-lo lo range-hi hi))
((string-match "\\`\\([0-9]+\\)-\\([0-9]+\\)\\'" base)
(setq range-lo (string-to-number (match-string 1 base))
range-hi (string-to-number (match-string 2 base))))
(t (setq range-lo (string-to-number base) range-hi range-lo)))
(let ((v range-lo))
(while (<= v range-hi)
(push v result)
(setq v (+ v step)))))))
(sort (delete-dups result) #'<)))
(defun radium-cron-days-in-month (y m)
(let ((days [31 28 31 30 31 30 31 31 30 31 30 31]))
(if (and (= m 2) (or (zerop (mod y 400))
(and (zerop (mod y 4)) (not (zerop (mod y 100))))))
29
(aref days (1- m)))))
(defconst radium-cron-search-years 100
"how many years ahead radium-cron-next-runs searches before giving
up - matters only for schedules sparse enough to need more than a
handful of years to find n runs (day=29 month=2 fires roughly once
every 4-8 years, the sparsest a real 5-field schedule gets).")
(defun radium-cron-next-runs (line n)
"i compute the next N times cron LINE (a 5-field schedule, with an
optional trailing command which i ignore) will fire, starting after
now - searching at most radium-cron-search-years years ahead. may
return fewer than N: either the schedule matches no real date at all
\(a bogus day=30 month=2, say - true forever, not a search-window
artifact) or genuinely needs longer than radium-cron-search-years to
find N runs - this function alone can't tell the two apart, so callers
that care should compare the result length against N themselves."
(let* ((fields (split-string (string-trim line) "[ \t]+" t)))
(unless (>= (length fields) 5)
(user-error "err: not a 5-field cron schedule: %S." line))
(let* ((mins (radium-cron-parse-field (nth 0 fields) 0 59))
(hrs (radium-cron-parse-field (nth 1 fields) 0 23))
(doms (radium-cron-parse-field (nth 2 fields) 1 31))
(mons (radium-cron-parse-field (nth 3 fields) 1 12))
(dows (mapcar (lambda (d) (if (= d 7) 0 d)) (radium-cron-parse-field (nth 4 fields) 0 7)))
(dom-star (string= (nth 2 fields) "*"))
(dow-star (string= (nth 4 fields) "*"))
(now (current-time))
(year0 (nth 5 (decode-time now)))
(results '()))
(catch 'enough
(dolist (y (number-sequence year0 (+ year0 radium-cron-search-years)))
(dolist (mo mons)
(dolist (d (number-sequence 1 (radium-cron-days-in-month y mo)))
(let ((dow (nth 6 (decode-time (encode-time 0 0 0 d mo y)))))
(when (cond ((and dom-star dow-star) t)
(dom-star (memq dow dows))
(dow-star (memq d doms))
(t (or (memq d doms) (memq dow dows))))
(dolist (h hrs)
(dolist (mi mins)
(let ((candidate (encode-time 0 mi h d mo y)))
(when (time-less-p now candidate)
(push candidate results)
(when (>= (length results) n)
(throw 'enough nil))))))))))))
(nreverse results))))
(defun radium-cron-preview-next-runs ()
"i show the next 5 times the cron schedule on the current line will fire."
(interactive)
(let* ((line (thing-at-point 'line t))
(times (radium-cron-next-runs line 5)))
(message "ok: next 5 runs: %s.%s"
(mapconcat (lambda (tm) (format-time-string "%Y-%m-%d %H:%M %a" tm)) times ", ")
(if (< (length times) 5)
(format " (only %d found within %dy - a sparse schedule, or check for a bogus date like feb 30)"
(length times) radium-cron-search-years)
""))))
(use-package crontab-mode
:mode (("\\.cron\\(tab\\)?\\'" . crontab-mode)
("cron\\.d/" . crontab-mode))
:bind (:map crontab-mode-map ("C-c C-r" . radium-cron-preview-next-runs)))
(provide 'radium-lang-cron)