phplrt 4.0

PP3 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// Any space char should be ignored
 2%skip  T_WHITESPACE  \s++
 3
 4// What a number and a plus looks like
 5%token T_DIGIT       \d++
 6%token T_PLUS        \+
 7
 8// Where to start
 9%pragma root Sum
10
11// The sentences
12Sum 
13  : <T_DIGIT> (::T_PLUS:: <T_DIGIT>)*
14  // Equivalent to the following:
15  // 2
16  // 2 + 3
17  // 2 + 3 + 4
18  // 2 + 3 + 4 + 5
19  // 2 + 3 + 4 + 5 + ...etc
20  ;

Save it as grammar.pp3 and it is ready to use:

1$parser = new Compiler()
2    ->load(FileSource::createFromPathname(__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 */

Includes

1%include grammar/lexemes
2%include grammar/expressions.pp3
  • the path is relative to the file the %include is written in;
  • the extension may be omitted;
  • a file included from several places is read once, so a shared lexemes.pp3 can be included by everything that needs it.

Declarations land exactly where the %include is written, so whatever an included file declares takes its place in that order.

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 wherever they are referred to.

%skip declares a token that is read and then stepped over. Use it for whitespace and comments - they still get recognized, so offsets stay correct, but they never leave the lexer and do not clutter the grammar:

1%skip T_WHITESPACE  \s++
2%skip T_COMMENT     //[^\n]*+

A skipped token is never built, so referring to one from a rule is a compiler error. When a rule has to be able to ask for it, put it on a channel instead.

Order matters. The lexer takes the first pattern that matches, not the longest one, so a longer token is declared before a shorter one it starts with:

1%token T_POW   \*\*    // ✔
2%token T_STAR  \*

See Order Matters.

A declaration is one line. It is read by a lexer of its own, which starts at %token and stops at the line break:

1%token  T_QUOTE  "
2        ▲        ▲
3        name     expr

Two more things may be written on that line - the state the token belongs to and the actions it performs - and both are below.

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
31 | %token T_QUOTE  "  -> string
4  |                    ^^

Fragments

The same piece of a pattern tends to be written over and over - a digit, an identifier, an escape sequence. %fragment names it once, and (?&NAME) writes it wherever it belongs:

1%fragment DIGIT  [0-9]
2%fragment EXP    [eE][+-]?(?&DIGIT)++
3
4%token T_NUMBER  (?&DIGIT)++(\.(?&DIGIT)++)?(?&EXP)?

The compiled token is what the pieces spell out, so nothing of this reaches the lexer:

(?:[0-9])++(\.(?:[0-9])++)?(?:[eE][+-]?(?:[0-9])++)?

A piece is written in as a group of its own, so a quantifier after (?&NAME) counts the whole piece rather than its last character.

A fragment is no token. It recognizes nothing and appears in no stream - it is a piece of an expression and nothing else. A grammar declaring nothing but fragments declares no tokens at all.

Order does not matter. Unlike a token, a fragment is written in once the whole grammar has been read, so it may be declared after what refers to it and in another %included file:

1%token T_NUMBER  (?&DIGIT)++
2%fragment DIGIT  [0-9]          // ✔

A name that no fragment is declared under is reported at the token that refers to it, which is what a misspelled piece looks like:

1error[CompilationFailedException]: The /(?&DIGT)++/ (T_NUMBER) expression refers to
2the "DIGT" fragment, which has not been declared
3 --> /app/grammar.pp3:3:1
43 | %token T_NUMBER  (?&DIGT)++
5  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^

So is a piece written of itself, whether directly or through another one:

1%fragment A  (?&B)
2%fragment B  (?&A)   // ✘ written of itself

(?&NAME) is PCRE's own spelling for calling a subpattern, and an expression capturing a subpattern under that name keeps it - the call is left alone rather than being taken for a fragment:

%token T_NESTED  \((?<in>[^()]|(?&in))*+\)   // ✔ reads itself, no fragment involved

States

A lexer reads one set of tokens at a time. A prefix before the name declares the token in a state of its own, and a state reads nothing but the tokens declared in it:

1%token        T_QUOTE_OPEN   "
2%token string:T_TEXT         [^"]++
3%token string:T_QUOTE_CLOSE  "

Everything written with no prefix belongs to the initial state, which is where reading begins. Getting into string and back out is what actions do.

A fragment belongs to no state. It is a piece of an expression rather than a thing to read, so it is written into the tokens of every state, and a state of its own is an error:

1%fragment WORD  [a-z]++
2
3%token        T_NAME  (?&WORD)
4%token string:T_TEXT  (?&WORD)    // ✔ written in here as well
5
6%fragment string:WORD  [a-z]++    // ✘ a fragment takes no state

Shared Tokens

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%token *:T_NAME        \w++

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;
  • it keeps its place among the tokens around it, and inside a state of its own it is tried after the tokens that state declares itself, so a state with a catch-all pattern still wins;
  • an external lexer is left alone: what it recognizes is decided by that lexer, not by a declaration.

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

A fragment is read by nothing, so it does nothing either:

%fragment WORD  [a-z]++ -> exit()   // ✘ a fragment takes no action

channel (x)

A channel labels a token, so that a reader of the stream can tell it apart from the code around it - documentation comments are the usual reason:

%token T_DOC_COMMENT  /\*\*.*?\*/  -> channel(docblocks)

A channel of your own is reported like any other, so the token is right there in the stream for anything that wants it; which channels are left out is a setting of the lexer. %skip is shorthand for the built-in Hidden channel - the one channel left out by default - so these two lines mean the same thing:

1%skip  T_WHITESPACE  \s++
2%token T_WHITESPACE  \s++  -> channel(Hidden)

The parser steps over such a token unless a rule names it - the same ignoring %skip does, only one stage later, which is what makes it askable:

1%token T_DOC_COMMENT  /\*\*.*?\*/  -> channel(docblocks)
2
3Documented : <T_DOC_COMMENT>? <T_NAME> ;
4Bare       : <T_NAME> ;

Documented gets the comment, Bare reads the same source without noticing it. Otherwise, the reference is ordinary – quantifiers count what they read, ::T_X:: discards the value - and a comment left at the end of the file is not input the grammar has failed to read.

The built-in channels are never stepped over: Hidden never reaches the parser, Unknown is how it reports what nothing recognizes, EndOfInput is where it stops.

state (x) and exit ()

state(x) hands the reading over to the lexer of a state, and exit() gives it back, 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()

Everything the inner 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.

External Lexers

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.

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  ;

Kept Rules

The compiler is free to remove a rule, join it with the one above it, or merge it with an equal one, and a name of its own does not save it. A # before the name does:

1#UnionType
2  : Name() ("|" Name())*
3  ;

A kept rule survives the compilation as it is written, its identifier becomes a constant of the generated parser, and the analysis may be started at it:

1$parser->parse(StringSource::createFromString('Generic<T>'));
2
3$union = $parser->withInitial(TypeParser::UnionType);
4$union->parse(StringSource::createFromString('int|string')); // ok
5$union->parse(StringSource::createFromString('Generic<T>')); // error

Marking a rule costs the optimizations the compiler would have applied to it, so mark the rules you mean to start at and no others. Nothing else about the rule changes - it recognizes the same input and hands its reducer the same value it would have without the marker.

Token References

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:: ;

Rule References

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.

Sequence

Written one after another, the parts are read in the order they appear, and all of them have to match:

Pair : <T_NAME> ::T_EQUAL:: Value() ;

Choice

Primary : Number() | Name() | Group() ;

The alternatives are tried in order, and the first match wins, so a longer alternative is written before a shorter one it starts with:

Rule : "ab" | "a" ;   // ✔ - the other way round never reads "ab"

See Alternatives Are Ordered.

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 the result, so adding one does not shift the positions a rule 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.

Error Messages

By default a failure is reported by the tokens that could have been read:

Syntax error, unexpected "," (T_COMMA), T_NAME or T_STRING expected

@error(...) replaces that with your own sentence. Write it after the element it describes:

1Call
2  : <T_NAME>
3    ::T_PARENTHESIS_OPEN::
4    ArgumentList()?
5    ::T_PARENTHESIS_CLOSE:: @error("The argument list is never closed")
6  ;
1error[UnexpectedTokenException]: The argument list is never closed
21 | foo(1, 2
3  |         ^

Write it after a rule name to describe the whole rule:

1Statement @error("a statement is expected")
2  : Assignment() | Call() | Return()
3  ;

The rule the message belongs to becomes the code of the exception, counted from one. When messages are nested, the innermost one wins.

Placeholders

A message may ask about what the reading broke on, in braces, the way a logger does:

@error("unexpected {value} on line {line}, a closing brace is expected")
Placeholder Is
{token} the token the reading broke on, described in full
{name} the name of that token
{value} the text that token is read from
{offset} the offset in bytes the reading broke at
{line} the source line the reading broke on
{column} the column within that line
{expected} T_OPEN, T_CLOSE, T_COMMA (+1 more)
{expected_list} T_OPEN, T_CLOSE, T_COMMA or T_NAME

Write a brace twice to keep it: @error("use {{name}} here"). An unknown placeholder is reported while the grammar is compiled.

Where A Message Fires

A message fires once the parser has entered the element and could not finish it. Put it past the token that commits the rule, never on that token itself:

1Call : <T_NAME> @error("...") ::T_PARENTHESIS_OPEN:: ;   // ✘ never fires
2Call : <T_NAME> ::T_PARENTHESIS_OPEN:: @error("...") ;   // ✔

A message that could never fire is a compilation error, pointed at the line it is written on. So is a message on something that always matches (X?, X*), or one written inside a repetition or a predicate.

The rule the grammar starts at is the exception - its message covers every failure the rules inside it leave undescribed, including a source that has been read only in part.

Reducers

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: Results and Reducers.

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. The one nearly every grammar wants is where parsing starts:

%pragma root Expression

By default it starts at the first rule in the file - which, once a grammar is split across files, depends on include order, and that is a fragile thing to depend on.

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 Regex Modifiers 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

Parser Builder describes what the passes are and when each priority runs.

Naming

Nothing is enforced, but the usual style makes grammars much easier to read:

1%fragment DIGIT  [0-9]   // fragments: SCREAMING_CASE, no prefix
2%token T_NUMBER  \d++    // tokens:    T_SCREAMING_CASE
3Expression : ... ;       // rules:     PascalCase

A fragment without the T_ prefix reads as what it is at the place it is referred to: (?&DIGIT) is a piece of a pattern, <T_DIGIT> is a token.

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.