phplrt 4.0

Error Reporting

This package can be installed separately with composer require phplrt/exception

An error that says "syntax error at offset 137" is technically correct and practically useless. Phplrt errors point at the source:

1error[UnexpectedTokenException]: Syntax error, unexpected "3" (T_NUMBER), T_PLUS expected
2 --> expr.txt:2:1
3  |
41 | 1 + 2
52 | 3 * (4 + )
6  | ^
73 |

This works out of the box: install phplrt/exception and any parser or lexer exception renders like this when converted to a string.

Catching Syntax Errors

1use Phplrt\Parser\Exception\UnexpectedTokenException;
2use Phplrt\Source\VirtualFile;
3
4try {
5    $parser->parse(new VirtualFile('expr.txt', $input));
6} catch (UnexpectedTokenException $e) {
7    echo $e->getMessage(); // Syntax error, unexpected "3" (T_NUMBER), T_PLUS expected
8    echo $e;               // ...plus the snippet above
9}

The exception carries everything needed to build your own message:

1$e->token;         // the token it choked on
2$e->token->name;   // T_NUMBER
3$e->token->offset; // 6
4$e->source;        // the source it was reading

Give Your Sources A Name

This is the one thing you have to do yourself. A bare Source has no name, so an error can only show the snippet:

$parser->parse(new Source($input));
1error[UnexpectedTokenException]: Syntax error, unexpected "3" (T_NUMBER), T_PLUS expected
2  |
31 | 3
4  | ^

Wrap the input in a File or a VirtualFile and the error can say where:

$parser->parse(new VirtualFile('user-input.txt', $input));
 --> user-input.txt:2:1

VirtualFile costs nothing - it is a string with a name attached - so use it even when the input never touched the disk.

Catching Everything

The contracts give you one interface per stage, which is usually the right granularity:

 1use Phplrt\Contracts\Lexer\Exception\LexerExceptionInterface;
 2use Phplrt\Contracts\Parser\Exception\ParserExceptionInterface;
 3use Phplrt\Contracts\Source\Exception\SourceExceptionInterface;
 4
 5try {
 6    $parser->parse($source);
 7} catch (SourceExceptionInterface $e) {
 8    // The source could not be read at all
 9} catch (LexerExceptionInterface $e) {
10    // The text could not be turned into tokens
11} catch (ParserExceptionInterface $e) {
12    // The tokens did not match the grammar
13}

Each has a RuntimeExceptionInterface counterpart for errors that happened while reading a particular source, as opposed to errors in the setup:

1use Phplrt\Contracts\Parser\Exception\RuntimeExceptionInterface;
2
3catch (RuntimeExceptionInterface $e) {
4    // Something in the input, not something in the grammar
5}

Errors Of Your Own

Parsing is rarely the last step. The stages after it - resolving names, type checking, evaluating - find their own problems, and those deserve the same treatment. ErrorPrinter renders any offset in any source:

 1use Phplrt\Exception\ErrorPrinter;
 2use Phplrt\Source\VirtualFile;
 3
 4$source = new VirtualFile('config.txt', <<<'TXT'
 5    name = "phplrt"
 6    version = four
 7    debug = true
 8    TXT);
 9
10echo new ErrorPrinter()
11    ->print($source, offset: 26, length: 4)
12    ->withMessage('Expected a number')
13    ->withClass('TypeError');
1error[TypeError]: Expected a number
2 --> config.txt:2:11
3  |
41 | name = "phplrt"
52 | version = four
6  |           ^^^^
73 | debug = true

This is where the $offset you kept on every AST node pays for itself.

Severity

1use Phplrt\Exception\Printer\Level;
2
3echo new ErrorPrinter()
4    ->print($source, 26, 4)
5    ->withMessage('Consider writing 4 instead')
6    ->withLevel(Level::Warning);
1warning: Consider writing 4 instead
2 --> config.txt:2:11
3  |
41 | name = "phplrt"
52 | version = four
6  |           ^^^^
73 | debug = true

Level::Error, Level::Warning and Level::Debug are available.

Adjusting The Output

Everything is a with*() method returning a new object, and the source is read only when the result is turned into a string:

1$printer->print($source, $offset, $length)
2    ->withMessage('...')       // the message above the snippet
3    ->withClass('MyError')     // the name in brackets after the level
4    ->withLevel(Level::Warning)
5    ->withPathname('other.txt') // override the file name
6    ->withLinesAround(0)        // no context lines, just the one that matters
7    ->withLength(4);            // the size of the underlined fragment

withLinesAround(0) is worth knowing about - for a list of many warnings, two context lines each is a wall of text.

Colors

Output to a terminal is colored, and output to anything else is not. Override that when you need to:

 1use Phplrt\Exception\Printer\RustStylePrinter;
 2
 3$printer = new ErrorPrinter(new RustStylePrinter(
 4    colors: false,
 5));
 6
 7// ...or set the width, for a narrow terminal
 8$printer = new ErrorPrinter(new RustStylePrinter(
 9    width: 80,
10));

A Different Format

PrinterInterface takes the captured lines and returns a string, so a one-line-per-error format for a CI log is a small class:

 1use Phplrt\Exception\Printer\ErrorInfo;
 2use Phplrt\Exception\Printer\PrinterInterface;
 3use Phplrt\Exception\Snippet\CapturedSourceLine;
 4
 5final class CompactPrinter implements PrinterInterface
 6{
 7    public function print(iterable $snippets, ?ErrorInfo $info = null): string
 8    {
 9        foreach ($snippets as $line) {
10            // Only the lines containing the error itself are captured
11            if ($line instanceof CapturedSourceLine) {
12                return \sprintf(
13                    '%s:%d:%d: %s',
14                    $info?->pathname ?? '-',
15                    $line->number,
16                    $line->startColumn,
17                    $info?->message ?? '',
18                );
19            }
20        }
21
22        return $info?->message ?? '';
23    }
24}
1$printer = new ErrorPrinter(new CompactPrinter());
2
3echo $printer->print($source, 26, 4)
4    ->withMessage('Expected a number');
5// config.txt:2:11: Expected a number

Attaching Snippets To Your Own Exceptions

The pattern the parser uses works for anything: keep the source and the offset on the exception, and render them in __toString().

 1use Phplrt\Contracts\Source\ReadableInterface;
 2use Phplrt\Exception\ErrorPrinter;
 3
 4final class TypeException extends \RuntimeException
 5{
 6    public function __construct(
 7        string $message,
 8        public readonly ReadableInterface $source,
 9        public readonly int $offset,
10        public readonly int $length = 0,
11    ) {
12        parent::__construct($message);
13    }
14
15    public function __toString(): string
16    {
17        return (string) new ErrorPrinter()
18            ->print($this->source, $this->offset, $this->length)
19            ->withMessage($this->getMessage())
20            ->withClass(static::class);
21    }
22}

Now every error your language reports looks the same as every error phplrt reports, which is exactly what you want.