Nested Lexers
Some fragments of a language are not written in that language. Inside a
string literal, + is just a plus sign. Inside a comment, nothing means
anything at all. Inside <?php ... ?>, a whole other language starts.
A single list of regular expressions cannot express that, so phplrt lets one lexer hand the reading over to another.
The Idea
A token can enter another lexer. From that point on, the other lexer reads - with its own tokens, its own rules - until one of its tokens exits. Everything it read is carried by the token that entered it.
use Phplrt\Lexer\Builder\LexerBuilder;
$builder = new LexerBuilder();
$builder->addPattern('\d++', 'T_DIGIT');
$builder->addValue('"', 'T_QUOTE_OPEN')
->enter('string'); // <- hand over
$builder->addPattern('\s++')
->hide();
// A lexer that only knows about the insides of a string
$string = $builder->addLexer('string');
$string->addPattern('\\\\.', 'T_ESCAPE');
$string->addPattern('[^"\\\\]++', 'T_TEXT');
$string->addValue('"', 'T_QUOTE_CLOSE')
->exit(); // <- hand back
Reading 1 "he\"llo" 2 gives:
T_DIGIT "1"
T_QUOTE_OPEN """ <- carries the whole string
T_TEXT "he"
T_ESCAPE "\""
T_TEXT "llo"
T_QUOTE_CLOSE """
T_DIGIT "2"
end of input
Note what did not happen: T_DIGIT did not match the llo, and the
escaped quote did not end the string. The inner lexer knows nothing about
digits, and the outer one never got a chance to look.
TokenEmbedding
A token that entered another lexer is a TokenEmbedding - a normal token
with children:
use Phplrt\Lexer\Token\TokenEmbedding;
foreach ($lexer->lex($source) as $token) {
if (!$token instanceof TokenEmbedding) {
continue;
}
foreach ($token->children as $child) {
echo $child->name, ': ', $child->value, "\n";
}
}
It is also Countable, ArrayAccess and IteratorAggregate, so all of
these work:
\count($token); // how many tokens the inner lexer read
$token[0]; // the first of them
foreach ($token as $child) { /* ... */ }
Two things about the values:
$token->value; // '"' - only the token's own text, 1 byte of it
$token->size; // 9 - but it spans everything the inner lexer read
value is the quote that opened the string, while size covers the whole
string. That is why size exists as a property instead of being computed from
the value.
In A Grammar File
The same thing in .pp3 is written with states. A token declaration says what
it does after ->, and a token declared as state:NAME belongs to that
state's lexer:
%token T_QUOTE_OPEN " -> state(string)
%token string:T_TEXT [^"]++
%token string:T_QUOTE_CLOSE " -> exit()
%skip T_WHITESPACE \s++
Str : <T_QUOTE_OPEN> ;
state(x) is "enter" and exit() hands the control back. The reading is
nested, so it can only ever go one level down and back up - there is no way
to jump straight from one state into another.
A token may do more than one thing, written with commas:
%token T_QUOTE_OPEN " -> state(string), channel(strings)
See Token Actions for the full list.
Parsing "hello" gives you a TokenEmbedding:
$result = $parser->parse(new Source('"hello"'));
echo $result->value; // '"'
echo $result->children[0]->value; // 'hello'
Bringing Your Own Lexer
The nested lexer does not have to be built by phplrt. Any
LexerInterface will do, which is how you embed a language phplrt cannot
describe with regular expressions - PHP itself, for instance:
use Phplrt\Contracts\Lexer\LexerInterface;
final class PhpTokenLexer implements LexerInterface
{
public function lex(ReadableInterface $source, int $offset = 0): iterable
{
// Read with token_get_all(), yield tokens
}
}
$builder->addPattern('<\?php', 'T_PHP_OPEN')
->enter('php');
$builder->addEmbeddedLexer('php', new PhpTokenLexer());
In a grammar file that is %lexer, which names the state and gives the
expression building the lexer:
%token T_PHP_OPEN <\?php -> state(php)
%lexer php -> { new \App\Lexer\PhpTokenLexer() }
The body is an expression rather than a block of statements - no return, no
semicolon - and whatever it evaluates to has to be a LexerInterface.
Such a lexer decides on its own where its fragment ends: it simply stops, and control returns to the lexer that called it. It does not need an "exit" token, and its own terminal token is not carried over.
It is also the one kind of state that
shared tokens skip: a %skip *:T_WHITESPACE says
nothing about a lexer that decides for itself what it reads.
This is also the escape hatch for anything a regular expression cannot do: heredocs, indentation-sensitive blocks, nested comments.
When To Reach For This
Use a nested lexer when a fragment has different lexical rules than its surroundings:
- string literals with escapes and interpolation;
- comments (especially nested ones);
- embedded languages: SQL in a string, CSS in a
<style>, PHP in HTML; - raw or verbatim blocks in a template language.
Do not use it for things the grammar can handle. A parenthesized expression is not a different language - the tokens inside it are the same tokens. That belongs in a parser rule, not a lexer state.