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.