Tokens and Channels

A token is the smallest thing a lexer produces. It is immutable, and it carries five pieces of information.

foreach ($lexer->lex(new Source('23 + 42')) as $token) {
    $token->id;      // 0       - which definition matched
    $token->name;    // T_DIGIT - its human-readable name, or null
    $token->value;   // "23"    - the exact text that matched
    $token->offset;  // 0       - where it starts, in bytes
    $token->size;    // 2       - how long it is, in bytes
    $token->channel; // Channel::Default
}

Identifiers, Not Names

Tokens are compared by identifier, never by name:

if ($token->id === MyParser::T_DIGIT) {
    // ...
}

The identifier is just the position of the token's definition in the lexer, assigned when the lexer is built. Names exist for you and for error messages; the parser does not use them at all, which is why a token is allowed to have no name:

$builder->addPattern('\s++')->hide(); // anonymous, and that is fine

System tokens use negative identifiers, so they can never collide with yours:

Token Identifier Channel
EndOfInputToken -1 EndOfInput
UnknownToken -2 Unknown

Offsets

offset and size are counted in bytes, not in characters, so the two of them address the fragment directly:

$content = $source->content;

// The exact fragment the token was read from
$text = \substr($content, $token->offset, $token->size);

// Where the next token starts
$next = $token->offset + $token->size;

For an ordinary token size is simply strlen($value). It differs only for a token that entered a nested lexer, which is as large as everything that lexer read.

Printing

Tokens are Stringable, and they print in a form meant for error messages:

echo $token;
"23" (T_DIGIT)      // a named token
"@" (unknown token) // unrecognized input
end of input        // the terminal token

Long values are cut off, and control characters are escaped, so you can put a token straight into a message without worrying about what is in it.

Channels

A channel is a label on a token. The lexer emits everything, and the channel decides who gets to see it.

There are four built-in channels:

Channel Meaning
Default An ordinary token. The parser reads these.
Hidden Recognized, but not passed to the parser: whitespace, comments.
Unknown Text the lexer did not recognize.
EndOfInput The terminal token, exactly one per stream.
use Phplrt\Contracts\Lexer\Channel;

$builder->addPattern('\s++')->setChannel(Channel::Hidden);
$builder->addPattern('\s++')->hide();  // the same, shorter
$builder->addPattern('\s++')->show();  // back to Default

Custom Channels

Hiding a token throws it away. Sometimes you want to keep it, just not in the grammar - documentation comments are the usual example. Give it a channel of its own:

$builder->addPattern('\d++', 'T_DIGIT');
$builder->addPattern('//[^\n]*+', 'T_COMMENT')->setChannel('comments');
$builder->addPattern('\s++')->hide();

foreach ($lexer->lex(new Source("1 // hi\n2")) as $token) {
    echo $token->name, ' on ', $token->channel->name, "\n";
}
T_DIGIT on Default
T_COMMENT on comments
T_DIGIT on Default

The parser skips everything that is not on Default, so T_COMMENT never reaches your grammar - but it is right there in the token stream if a documentation generator wants it.

Choosing What The Parser Sees

The parser filters the stream before it starts. By default it drops the Hidden channel and keeps the rest. Pass your own filter to change that:

use Phplrt\Contracts\Lexer\Channel;
use Phplrt\Parser\Internal\Filter\ChannelFilter;

$parser = new Parser(
    lexer: $lexer,
    grammar: $grammar,
    initial: 0,
    // Let the hidden tokens through after all
    filter: new ChannelFilter([]),
);

ChannelFilter compares channels by identity, which works for the built-in ones (they are enum cases) but not for custom ones: every lexer builds its own UserDefinedChannel instances, so the one you construct is never the one the token carries. Match on the name instead:

use Phplrt\Contracts\Lexer\Channel;
use Phplrt\Parser\Internal\Filter\FilterInterface;

final class HiddenChannelsFilter implements FilterInterface
{
    /**
     * @var list<non-empty-string>
     */
    private readonly array $excluded;

    public function __construct(string ...$channels)
    {
        $this->excluded = [Channel::Hidden->name, ...$channels];
    }

    public function apply(iterable $tokens): iterable
    {
        foreach ($tokens as $token) {
            if (!\in_array($token->channel->name, $this->excluded, true)) {
                yield $token;
            }
        }
    }
}
$parser = new Parser(
    lexer: $lexer,
    grammar: $grammar,
    initial: 0,
    filter: new HiddenChannelsFilter('comments'),
);

Captures

If a token's pattern has capturing groups, whatever they matched is on the token:

$builder->addPattern('"([^"]*)"', 'T_STRING');
$builder->addPattern('(\d++)\.(\d++)', 'T_FLOAT');

// "hi" 3.14
$string->captures; // ["hi"]
$float->captures;  // ["3", "14"]

Captures are numbered per token, starting at zero - the first group of this token is captures[0], no matter how many groups the tokens above it have.

This saves you from parsing the value twice:

// Instead of trimming the quotes off $token->value by hand
$content = $token->captures[0];

A group that matched nothing still counts, so the numbering stays stable:

$builder->addPattern('(\+|-)?(\d++)', 'T_NUMBER');

// "42"  => captures: ["", "42"]
// "-42" => captures: ["-", "42"]

The Interface

Write against the contract, not the implementation:

use Phplrt\Contracts\Lexer\TokenInterface;

function describe(TokenInterface $token): string
{
    return \sprintf('%s at %d', $token->name ?? 'anonymous', $token->offset);
}

Phplrt\Lexer\Token\Token is the standard implementation; TokenEmbedding extends it for nested lexers.