Lexer Builder
This package can be installed separately with
composer require phplrt/lexer-builder
A lexer is described before it can read anything: which tokens there are, what each of them looks like, and what the lexer does with them. That description is what the builder is for, and what it compiles into is the lexer itself.
A .pp3 file says the same things in fewer characters, so reach for the
builder when the token list is not known in advance - generated from a config
file, a database, a plugin system. The last section shows the two spellings
side by side.
A First Lexer
Describe the tokens, build the lexer, run it:
1use Phplrt\Lexer\Builder\LexerBuilder; 2use Phplrt\Source\StringSource; 3 4$builder = new LexerBuilder(); 5$builder->addPattern('\d++', 'T_DIGIT'); 6$builder->addValue('+', 'T_PLUS'); 7$builder->addPattern('\s++', 'T_WHITESPACE'); 8 9$lexer = $builder->build() 10 ->toLexer(); 11 12foreach ($lexer->lex(StringSource::createFromString('23 + 42')) as $token) { 13 echo $token, "\n"; 14}
1"23" (T_DIGIT) 2" " (T_WHITESPACE) 3"+" (T_PLUS) 4" " (T_WHITESPACE) 5"42" (T_DIGIT) 6end of input
Two ways to describe a token:
-
addPattern('\d++')- a regular expression; -
addValue('+')- a literal string, escaped for you.addValue('+')andaddPattern('\+')are the same thing, but the first one is harder to get wrong.
The last token is always end of input. It is how the parser knows the
source has been read to the end.
Order Matters
The lexer tries the patterns from top to bottom and takes the first one that matches - not the longest. So this does not do what it looks like:
1$builder->addValue('*', 'T_STAR'); 2$builder->addValue('**', 'T_POW'); // never matched!
** is read as two T_STAR tokens, because T_STAR was declared first.
Put the longer literal above the shorter one:
1$builder->addValue('**', 'T_POW'); // ✔ 2$builder->addValue('*', 'T_STAR');
The same applies to keywords and identifiers - declare if before
[a-z]+, or if will always be read as an identifier.
Hiding Whitespace
You almost never want whitespace in a grammar. Mark it as hidden and it is still recognized (so the offsets stay right) but it is left out of the stream entirely:
1$builder->addPattern('\s++') 2 ->hide(); 3$builder->addPattern('//[^\n]*+') 4 ->hide(); // line comments too
Note that the token above has no name. A hidden token is not referred to by anything, so naming it is optional.
hide() is shorthand for putting the token on the Hidden channel, and that
is the one channel a lexer leaves out by default - see
Channels below, and
Choosing What Is Reported for how to have the
hidden tokens reported after all.
Fragments
A piece of a pattern that keeps coming back is named once and referred to by
(?&NAME):
1$builder->addFragment('DIGIT', '[0-9]'); 2$builder->addFragment('EXP', '[eE][+-]?(?&DIGIT)++'); 3 4$builder->addPattern('(?&DIGIT)++(\.(?&DIGIT)++)?(?&EXP)?', 'T_NUMBER');
The pieces are written into the patterns while the lexer is built, so what it compiles is what they spell out:
(?:[0-9])++(\.(?:[0-9])++)?(?:[eE][+-]?(?:[0-9])++)?
A fragment recognizes nothing on its own and becomes no token. It is written
in as a group of its own, so a quantifier after (?&NAME) counts the whole
piece; it may be written of another fragment, but not of itself; and it
reaches the patterns of every nested lexer, in
whatever order it is added.
A name that no fragment is added under is left alone in case the pattern
captures a subpattern under it - which is what (?&NAME) means to PCRE - and
is reported otherwise:
1$builder->addPattern('\((?<in>[^()]|(?&in))*+\)', 'T_NESTED'); // ✔ left alone 2$builder->addPattern('(?&DIGT)++', 'T_NUMBER'); // ✘ reported
Regex Modifiers
By default, patterns are compiled with S, u, s and m. Add or remove
modifiers for the whole lexer:
1use Phplrt\Lexer\Builder\Definition\RegexModifier; 2 3$builder->enable(RegexModifier::Caseless); // /i 4$builder->disable(RegexModifier::Utf8); // no /u 5 6$builder->addValue('true', 'T_TRUE'); 7// now matches "true", "TRUE" and "True"
Channels
A channel is a label on a token, and it is declared where the token is:
1use Phplrt\Contracts\Lexer\Channel; 2 3$builder->addPattern('\s++')->setChannel(Channel::Hidden); 4$builder->addPattern('\s++')->hide(); // the same, shorter 5$builder->addPattern('\s++')->show(); // back to Default
There are four built-in channels:
| Channel | Meaning |
|---|---|
Default |
An ordinary token. This is what a grammar is written in terms of. |
Hidden |
Read, but not reported: whitespace, comments. |
Unknown |
Text the lexer did not recognize. |
EndOfInput |
The terminal token, exactly one per stream. |
Which of them a lexer actually reports is decided when it is built rather than when it is described - see Choosing What Is Reported.
Custom Channels
Hiding a token throws it away. Sometimes you want to keep it, just told apart from the code around it - documentation comments are the usual example. Give it a channel of its own:
1$builder->addPattern('\d++', 'T_DIGIT'); 2$builder->addPattern('//[^\n]*+', 'T_COMMENT')->setChannel('comments'); 3$builder->addPattern('\s++')->hide(); 4 5foreach ($lexer->lex(StringSource::createFromString("1 // hi\n2")) as $token) { 6 echo $token->name, ' on ', $token->channel->name, "\n"; 7}
1T_DIGIT on Default 2T_COMMENT on comments 3T_DIGIT on Default
T_COMMENT is in the stream like any other token, and the channel is what
tells it apart: a documentation generator reads the comments tokens and
ignores the rest.
Reusing The Result
build() gives you a LexerBuilderResult - the compiled description - and
toLexer() turns that into a runnable lexer:
1$result = $builder->build(); 2 3$result->pattern; // the single regex the whole lexer compiles down to 4$result->names; // [0 => 'T_DIGIT', 1 => 'T_PLUS', ...] 5$result->channels; // [2 => 'Hidden', ...] 6 7$lexer = $result->toLexer();
Building is not free - it validates every pattern, drops unreachable tokens and merges everything into one big regular expression. Do it once and keep the lexer around, or better still, generate the code and skip building entirely.
How The Pattern Is Built
All the token definitions compile into a single PCRE pattern, and which token
matched is recorded with
(*MARK:n):
/\G(?|(?:(?:\d++)(*MARK:0))|(?:(?:\s++)(*MARK:1))|(?:(?:[^\s]++)(*MARK:2)))/Ssum
One pass over the input, one regex, no per-token loop - this is where the
lexer's speed comes from. The last branch is the catch-all that produces
Unknown tokens.
MarkersRegexGenerator does this, and it is swappable if you ever need a
different strategy:
1use Phplrt\Lexer\Builder\Analysis\RegexConstructionLexerAnalysisPass; 2use Phplrt\Lexer\Builder\Regex\RegexGeneratorInterface; 3 4final class MyRegexGenerator implements RegexGeneratorInterface 5{ 6 public function generate(array $tokens, array $flags): string 7 { 8 // $tokens is a map of token id => TokenDefinition 9 } 10} 11 12$builder->addAnalysisPass( 13 new RegexConstructionLexerAnalysisPass(new MyRegexGenerator()), 14);
Writing It In A Grammar Instead
Everything above has a shorter spelling in a .pp3 file:
1%fragment DIGIT [0-9] 2 3%token T_DIGIT (?&DIGIT)++ 4%token T_PLUS \+ 5%skip T_WHITESPACE \s++
A token nothing refers to by name does not have to be declared at all - a rule declares it where it reads it, and the two spellings are the two methods above:
1Sum : <T_DIGIT> "+" <T_DIGIT> ; // addValue('+') 2Expr : <T_DIGIT> /and|or/ <T_DIGIT> ; // addPattern('and|or')
The modifiers and the compiler passes are settings of the grammar there, so a grammar carries the way it wants to be compiled:
1%pragma lexer.pcre.flag Caseless 2%pragma lexer.check \App\Grammar\MyValidationPass
That is usually where you want to be - see Compiling a Grammar. The builder API is for the cases where the token list is not known in advance: generated from a config file, a database, a plugin system.
What's Next?
- Lexer - reading a source with what this compiles into.
- Nested Lexers - string interpolation, PHP inside HTML and other "a different language starts here" situations.
-
Contracts -
phplrt/lexer-contracts, for code that needs a lexer without needing this one.