Grammar Syntax
A grammar file describes a language: the words it is made of, and the order they may appear in. Here is one in full:
1// The words 2%skip T_WHITESPACE \s++ 3%token T_DIGIT \d++ 4%token T_PLUS \+ 5 6// Where to start 7%pragma root Sum 8 9// The sentences 10Sum : <T_DIGIT> (::T_PLUS:: <T_DIGIT>)* ;
Save it as grammar.pp3 and it is ready to use:
1$parser = new Compiler() 2 ->load(new File(__DIR__ . '/grammar.pp3')) 3 ->getParser();
The syntax is a close relative of EBNF, so if you have written a grammar before, most of this will look familiar.
Comments
C-style, both kinds:
1// Everything to the end of the line 2 3/* 4 Everything between the markers 5 */
Declaring Tokens
%token T_DIGIT \d++
A name and a regular expression, separated by whitespace. The name is
whatever you like; by convention tokens are SCREAMING_CASE with a T_
prefix, which makes them obvious in a rule.
%skip declares a token the parser will never see. Use it for whitespace
and comments - they still get recognized, so offsets stay correct, but they
do not clutter the grammar:
1%skip T_WHITESPACE \s++ 2%skip T_COMMENT //[^\n]*+
Order matters. The lexer takes the first pattern that matches, not the longest one:
1%token T_STAR \* // matches first... 2%token T_POW \*\* // ...so this never matches
Put the longer one first:
1%token T_POW \*\* // ✔ 2%token T_STAR \*
Same story with keywords: declare if before your identifier pattern, or
if will be read as an identifier.
A declaration is one line. It is read by a lexer of its own, which starts
at %token and stops at the line break, and it expects exactly three things:
a name, the expression recognizing the token, and - optionally - an arrow with
what the token does.
1%token string:T_QUOTE " -> state(strings), channel(quotes) 2 ▲ ▲ ▲ ▲ 3 state name expr actions
A pattern cannot contain a literal space - whitespace is what separates
the parts of the declaration. Write it as \x20 or \s:
1%token T_TEXT [a-z ]++ // ✘ breaks 2%token T_TEXT [a-z\x20]++ // ✔ 3%token T_TEXT [a-z\s]++ // ✔
Anything else on the line is an error, which is how a .pp2 habit gets
noticed:
1error[UnexpectedTokenException]: Syntax error, unexpected "->" (T_PATTERN) 2 --> /app/grammar.pp3:1:20 3 | 41 | %token T_QUOTE " -> string 5 | ^^
Token Actions
A declaration may end with -> and say what the token does besides being
read. There are three actions, and each is written as a call:
| Action | What the token does |
|---|---|
channel(x) |
Emits the token to the channel x |
state(x) |
Hands the reading over to the lexer of the state x |
exit() |
Gives the control back to the lexer that entered this one |
channel(x)
A channel keeps a token out of the grammar without throwing it away - documentation comments are the usual reason:
%token T_DOC_COMMENT /\*\*.*?\*/ -> channel(docblocks)
The parser never sees it, but it is right there in the token stream for
anything that wants it. %skip is shorthand for the built-in Hidden
channel, so these two lines mean the same thing:
1%skip T_WHITESPACE \s++ 2%token T_WHITESPACE \s++ -> channel(Hidden)
state(x) and exit()
A token may hand the reading over to a lexer of its own, which is how a fragment written in different lexical rules is read - a string literal, a comment, an embedded language:
1%token T_QUOTE_OPEN " -> state(string) 2%token string:T_TEXT [^"]++ 3%token string:T_QUOTE_CLOSE " -> exit()
A token declared as state:NAME belongs to that state's lexer. Everything
that lexer reads is carried by the token that entered it, so T_TEXT never
reaches the outer stream. See Nested Lexers.
Several At Once
Actions are separated by commas, and the order does not matter:
%token T_QUOTE_OPEN " -> state(string), channel(strings)
A token is read once and therefore goes to exactly one place, so writing two
actions that both move the reading - state(x), exit() - is an error.
Tokens Belonging To Every State
Whitespace and comments are usually the same wherever they appear, and
repeating them in every state is how a grammar drifts out of sync with itself.
Write *: instead of a state name and the token is added to all of them:
1%skip *:T_WHITESPACE \s++ 2 3%token T_QUOTE_OPEN " -> state(string) 4%token string:T_TEXT [^"]++ 5%token string:T_QUOTE_CLOSE " -> exit()
Both the initial state and string now skip whitespace.
Three things worth knowing:
- the token is added to every state, including ones declared later or in a file included afterwards - the states are all known only once the whole grammar has been read;
- inside a state it is tried after the tokens that state declares itself, so a state with a catch-all pattern still wins;
- a lexer written by hand is left alone: what it recognizes is decided by that lexer, not by a declaration.
Lexers Written By Hand
Some fragments cannot be described by regular expressions at all - heredocs,
indentation-sensitive blocks, another language entirely. %lexer names a
state and gives the expression building the lexer that reads it:
1%token T_PHP_OPEN <\?php -> state(php) 2 3%lexer php -> { new \App\Lexer\PhpTokenLexer() }
The body is an expression, not a block of statements - whatever it
evaluates to has to be a LexerInterface. Note there is no return and no
semicolon.
Such a lexer decides on its own where its fragment ends: it stops, and control
returns to the lexer that called it, so it needs no token doing exit(). See
Nested Lexers.
Declaring Rules
A rule is a name, a colon, a body, and an optional semicolon:
Sum : <T_DIGIT> ::T_PLUS:: <T_DIGIT> ;
The colon is the only separator there is. By convention rules are
PascalCase, which tells them apart from tokens at a glance.
Long rules read better spread out:
1Expression 2 : Term() ((<T_PLUS> | <T_MINUS>) Term())* 3 ;
What Goes In A Rule Body
Tokens
Two spellings, and the difference is whether the token ends up in the result:
1Rule : <T_DIGIT> ; // read it and keep it 2Rule : ::T_COMMA:: ; // read it and throw it away
Keep the things that carry information (names, numbers, literals). Discard the punctuation that only holds the syntax together (commas, brackets, keywords).
1// A parenthesized expression: the brackets are required, but useless 2Group : ::T_PARENTHESIS_OPEN:: Expression() ::T_PARENTHESIS_CLOSE:: ;
Other Rules
Parentheses after the name - that is what tells a rule reference from a token:
Sum : Number() ::T_PLUS:: Number() ;
The rule may be declared anywhere, including in a file that has not been read yet. References are resolved after everything is loaded.
Inline Tokens
A rule may declare a token right where it reads it, without naming it. There are two spellings, and the difference is whether what you write is text or an expression:
| Written | Means |
|---|---|
"..." |
the text to read, exactly as it is written |
/.../ |
the regular expression recognizing the token |
1Sum : <T_NUMBER> "+" <T_NUMBER> ; // a plus sign 2Expr : <T_NUMBER> /and|or|xor/ <T_NUMBER> ; // one of three words
Quotes are the one you want for punctuation: nothing inside them is special,
so "+", "(" and "**" mean exactly what they look like. Slashes are for
when you need a choice, a character class or a quantifier.
The same token written in several rules is declared once, and such a token is always discarded - it is punctuation by definition:
1Sum : <T_NUMBER> "+" <T_NUMBER> ; 2Unary : "+" <T_NUMBER> ; // the very same token
Escaping is only ever needed for the delimiter itself - \" inside quotes,
\/ inside slashes.
Handy for one-off punctuation; for anything that appears more than twice, declare a real token so the error messages can name it.
A slash also opens a comment, so
//and/*are read as one. Write/\//for a lone slash.
Choice
Primary : Number() | Name() | Group() ;
The alternatives are tried in order, and the first match wins. Nothing else is tried, even if it would have matched more:
1Rule : "a" | "ab" ; // ✘ never reads "ab" 2Rule : "ab" | "a" ; // ✔
Grouping
Rule : <T_A> (<T_B> | <T_C>) <T_D> ;
Quantifiers
Any token, rule or group can be followed by one:
| Written | Means |
|---|---|
e? |
zero or one time |
e* |
zero or more times |
e+ |
one or more times |
e{3} |
exactly three times |
e{2,5} |
between two and five |
e{2,} |
two or more |
e{,5} |
up to five |
1Arguments : Argument() (::T_COMMA:: Argument())* ; 2Digits : <T_DIGIT>{3} ; 3Modifiers : Modifier()* ;
Predicates
A predicate looks at what comes next without reading it. Nothing is consumed and nothing lands in the tree - the only thing that happens is that the rule either goes on or gives up.
| Written | Means |
|---|---|
&e |
go on only if e matches here |
!e |
go on only if e does not match here |
The classic use is refusing a position that belongs to somebody else. A name that is not a function call:
Variable : <T_NAME> !::T_PARENTHESIS_OPEN:: ;
foo matches. foo( does not - and, importantly, the ( is still there
afterwards for whatever rule does want it.
The other direction is committing to a branch without reading it twice:
1// Only try the expensive rule when the line really starts with "fn" 2Closure : &::T_FN:: FunctionLiteral() ;
A predicate is written before the quantifier, so it looks ahead at the whole thing at once:
Rule : &<T_DIGIT>+ Number() ; // look ahead at one or more digits
Two things to keep in mind:
- a predicate contributes nothing to
$children, so adding one does not shift the positions the reducer reads; - it costs a real attempt at matching.
!Expression()will parse a whole expression and throw it away, so prefer looking ahead at a token.
This is the one thing in a rule body that describes how something is read rather than what the language contains, which is why EBNF has no equivalent.
Where Parsing Starts
By default, it starts at the first rule in the file. Say otherwise with
%pragma root:
%pragma root Expression
Worth setting explicitly once a grammar is split across files - the "first rule" then depends on include order, which is a fragile thing to depend on.
Settings
%pragma configures the compilation from the grammar itself, so a grammar
that needs a particular setting carries it instead of relying on the code that
compiles it.
| Setting | What it does |
|---|---|
root <Rule> |
Where parsing starts |
lexer.pcre.flag <M> |
Compiles the lexer's pattern with a PCRE modifier |
lexer.pcre.disable <M> |
Compiles it without one |
lexer.pass <Class> |
Registers a lexer pass, normalizing |
lexer.check <Class> |
Registers a lexer pass, checking |
lexer.optimize <Class> |
Registers a lexer pass, optimizing |
lexer.complete <Class> |
Registers a lexer pass, checking after optimizing |
lexer.disable <Class> |
Drops a lexer pass, whenever it was registered |
parser.pass <Class> |
Registers a parser pass, normalizing |
parser.check <Class> |
Registers a parser pass, checking |
parser.optimize <Class> |
Registers a parser pass, optimizing |
parser.complete <Class> |
Registers a parser pass, checking after optimizing |
parser.disable <Class> |
Drops a parser pass |
Anything else is an error.
PCRE Modifiers
The lexer compiles its tokens into one pattern, and these say which modifiers that pattern carries. A modifier is named either the way PCRE spells it or the way phplrt calls it:
1%pragma lexer.pcre.flag Caseless // ...or "i" 2%pragma lexer.pcre.disable Utf8 // ...or "u"
By default, the pattern is compiled with S, u, s and m. See
RegexModifier for what each of them means.
Compiler Passes
A pass rewrites or checks the lexer or the grammar while it is being built, and the setting is named after the moment it runs at:
1%pragma parser.check \App\Grammar\NoLeftFactoringPass 2%pragma lexer.optimize \App\Grammar\MergeKeywordsPass
The class is created with no arguments and must implement
LexerCompilerPassInterface or ParserCompilerPassInterface - a class that
does not exist, or implements the wrong one, is reported at the line it is
written on.
A built-in pass can be dropped by name, which is how a grammar opts out of an optimization it does not want:
%pragma parser.disable \Phplrt\Parser\Builder\Compiler\NestedConcatenationParserCompilerPass
Building a Grammar describes what the passes are and when each priority runs.
Including Other Files
1%include grammar/lexemes 2%include grammar/expressions.pp3
- the path is relative to the file to include is written in;
- the extension may be omitted;
- a file included from several places is read once, so a shared
lexemes.pp3can be included by everything that needs it.
Declarations land exactly where the %include is written, which matters for
tokens: an included token list appears at that point in the token order.
Building A Result
A grammar with no reducers returns the tokens it kept. To build something else, attach PHP:
1Number -> { return (int) $children->value; } 2 : <T_DIGIT> 3 ;
A block of code is the only form a reducer takes - build the node inside it:
1Number -> { return new \App\Ast\NumberNode($offset, $children->value); } 2 : <T_DIGIT> 3 ;
This has a page of its own: PHP in a Grammar.
Naming Conventions
Nothing is enforced, but the usual style makes grammars much easier to read:
1%token T_NUMBER \d++ // tokens: T_SCREAMING_CASE 2Expression : ... ; // rules: PascalCase
A Fuller Example
1%skip T_WHITESPACE \s++ 2%skip T_COMMENT //[^\n]*+ 3 4%token T_NUMBER \d++(?:\.\d++)? 5%token T_STRING "[^"]*+" 6%token T_TRUE true 7%token T_FALSE false 8%token T_NULL null 9%token T_NAME [a-zA-Z_][a-zA-Z0-9_]*+ 10 11%pragma root Config 12 13// name = value 14// name = value 15Config : Pair()* ; 16 17Pair : <T_NAME> "=" Value() ; 18 19Value 20 : <T_NUMBER> 21 | <T_STRING> 22 | <T_TRUE> 23 | <T_FALSE> 24 | <T_NULL> 25 | List() 26 ; 27 28// [a, b, c] 29List : "[" (Value() ("," Value())*)? "]" ;
Note T_TRUE before T_NAME - otherwise true is read as a name. And note
that the punctuation is never declared: "=", "[", "," and "]" each
declare their token where they are read, and none of them needs escaping
because a value is not an expression.