phplrt 4.0

Cron Expression

The five (or six) fields of a crontab line, the @daily shorthands and the extensions almost every scheduler ends up supporting:

1*/15 * * * *          every quarter of an hour
20 9-17 * * MON-FRI    hourly, during office hours
30 0 1,15 JAN,JUL *    the 1st and the 15th of January and July
40 12 L * 5#3          noon of the last day, and of the third Friday
50 0 1 1 * 2026        new year, in a year of its own
6@daily

The hard part of cron is not its shape - a field is a list of ranges with an optional step - but its spelling. L, 15W and 5#3 each carry a number inside them, so the lexer reads each as one token: splitting 5#3 into a number, a hash and a number would leave the parser to put it back together for no gain.

The optional year field needs no rule of its own either: Field(){5,6} is a repetition with bounds, so one line reads both the classic five field expression and the six field one.

Grammar

 1/**
 2 * -----------------------------------------------------------------------------
 3 *  Cron Expression
 4 * -----------------------------------------------------------------------------
 5 *
 6 * When a job is due: the minute, the hour, the day of the month, the month and
 7 * the day of the week it runs on.
 8 *
 9 * @see https://man7.org/linux/man-pages/man5/crontab.5.html
10 */
11
12%pragma root Expression
13
14%skip  T_WHITESPACE   \h++
15
16// @yearly, @monthly, @weekly, @daily, @hourly, @reboot
17%token T_NICKNAME     @(?i)(?:annually|yearly|monthly|weekly|daily|midnight|hourly|reboot)\b
18
19// JAN..DEC, MON..SUN
20%token T_NAME         (?i)(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|mon|tue|wed|thu|fri|sat|sun)\b
21
22/**
23 * The last day, the weekday nearest a day and the n-th weekday of a month are
24 * written next to the number they belong to, so each is read as a single
25 * token: "L", "5L", "15W", "5#3".
26 */
27%token T_NTH          [0-9]++#[0-9]++
28%token T_LAST         [0-9]*+(?i)L\b
29%token T_NEAREST      [0-9]++(?i)W\b
30
31%token T_NUMBER       [0-9]++
32%token T_ASTERISK     \*
33%token T_QUESTION     \?
34%token T_COMMA        ,
35%token T_DASH         -
36%token T_SLASH        /
37%token T_NEWLINE      \R
38
39Expression
40  : <T_NICKNAME> ::T_NEWLINE::?
41  | Field(){5,6} ::T_NEWLINE::?
42  ;
43
44// A field is written of the values it matches, separated by commas
45Field
46  : Value() (::T_COMMA:: Value())*
47  ;
48
49Value
50  : Range() Step()?
51  ;
52
53Range
54  : <T_ASTERISK>
55  | <T_QUESTION>
56  | Unit() (::T_DASH:: Unit())?
57  ;
58
59Unit
60  : <T_NTH>
61  | <T_LAST>
62  | <T_NEAREST>
63  | Number()
64  ;
65
66Number
67  : <T_NUMBER>
68  | <T_NAME>
69  ;
70
71// Every n-th value of the range: "*/15"
72Step
73  : ::T_SLASH:: <T_NUMBER>
74  ;

Usage

Nothing needs building here - the question is only whether the expression is one, which is what analyze() answers without running a single reducer:

 1use Phplrt\Compiler\Compiler;
 2use Phplrt\Parser\Analysis\Mode;
 3use Phplrt\Parser\Analysis\Result\SuccessfulResult;
 4use Phplrt\Source\File;
 5use Phplrt\Source\Source;
 6
 7$parser = new Compiler()
 8    ->load(new File(__DIR__ . '/grammar.pp3'))
 9    ->getParser();
10
11$parser->analyze(new Source('*/15 * * * *'), Mode::SyntaxCheck) instanceof SuccessfulResult; // true
12$parser->analyze(new Source('*/15 * *'), Mode::SyntaxCheck) instanceof SuccessfulResult;     // false

Mode::SyntaxCheck skips value building entirely, which is the cheapest way to ask "is this valid" of user input.

25+ more grammars. phplrt/grammars collects ready to read grammars for real languages - JSON5, TSV, semantic versions, DQL, PHQL, JMS types, PSR-5 and Doctrine annotations, Symfony expressions, Go! AOP pointcuts, Praspel contracts and more - each with sample inputs and a test that keeps it honest.