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
31 | 1 + 2
42 | 3 * (4 + )
5  | ^
63 |

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

The package has three entry points. ErrorPrinter is the one you use, and it is what this page is about. The other two are the halves it is built from: Analyzer works out where an error happened, and SnippetReader reads the source code lines around that place. Reach for them when you need the data rather than the picture.

Catching Errors

1use Phplrt\Parser\Exception\UnexpectedTokenException;
2use Phplrt\Source\VirtualSource;
3
4try {
5    $parser->parse(VirtualSource::createFromString('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

Naming Sources

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

$parser->parse(StringSource::createFromString($input));
1error[UnexpectedTokenException]: Syntax error, unexpected "3" (T_NUMBER), T_PLUS expected
21 | 3
3  | ^

Wrap the input in a FileSource or a VirtualSource and the error can say where:

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

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

Catching By Stage

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}

Your Own Errors

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::print() takes an exception and works out everything else from it. An exception implementing the lexer or parser contracts already knows the source and the token it failed on, so nothing else is needed:

1use Phplrt\Exception\ErrorPrinter;
2
3echo new ErrorPrinter()->print($e);

An ordinary exception knows only the PHP file and line it was thrown from, so that is what gets printed. To point it at your own source instead, say where:

 1use Phplrt\Exception\ErrorPrinter;
 2use Phplrt\Source\VirtualSource;
 3
 4$source = VirtualSource::createFromString('config.txt', <<<'TXT'
 5    name = "phplrt"
 6    version = four
 7    debug = true
 8    TXT);
 9
10echo new ErrorPrinter()
11    ->print(new \DomainException('Expected a number'))
12    ->withSource($source)
13    ->withInterval(offset: 26, length: 4);
1error[DomainException]: Expected a number
2 --> config.txt:2:11
31 | name = "phplrt"
42 | version = four
5  |           ^^^^
63 | debug = true

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

Overrides

print() returns a PrintableError, a builder in which every with*() method returns a new object. The source code is read only at the moment the result is turned into a string, so building it costs nothing:

1use Phplrt\Exception\Analysis\FailureLevel;
2
3$printer->print($e)
4    ->withMessage('...')                // the message above the snippet
5    ->withClass('MyError')              // the name in brackets after the level
6    ->withLevel(FailureLevel::Warning)  // the severity
7    ->withSource($source)               // the source the snippet is read from
8    ->withInterval(26, 4)               // the underlined fragment, in bytes
9    ->withoutInterval();                // no fragment at all, just the position

Every one of them replaces a value inside the analysis of the error, which is the only thing a PrintableError holds besides the renderer:

1$printed = $printer->print($e);
2
3$printed->error->class;   // the class of the exception
4$printed->error->message; // the message of the exception
5$printed->error->level;   // FailureLevel::Error, or the severity of an ErrorException
6$printed->error->source;
7$printed->error->interval;

An empty message hides the whole header, and an empty class prints the severity alone:

echo $printer->print($e)->withClass('');
1error: Expected a number
2 --> config.txt:2:11
31 | name = "phplrt"
42 | version = four
5  |           ^^^^
63 | debug = true

Severity

FailureLevel::Error, FailureLevel::Warning and FailureLevel::Debug are available. An error that carries a severity of its own - a PHP ErrorException - is printed with it, so E_USER_WARNING becomes FailureLevel::Warning on its own:

1use Phplrt\Exception\Analysis\FailureLevel;
2
3FailureLevel::fromException($e);           // the severity of an ErrorException, or the default one
4FailureLevel::fromSeverity(\E_DEPRECATED); // FailureLevel::Debug

Chain And Trace

Every error that has led to the one being printed is printed as well, the innermost one first, and the stack trace of the error closes the report:

1error[ParseError]: syntax error, unexpected token "}"
2 --> config.txt:2:11
3...
4error[DomainException]: The configuration cannot be read
5 --> config.txt:2:11
6...
7#0 /app/src/Config.php(42): App\Config->read()
8#1 {main}

An error whose source cannot be read any more is left out of the report instead of taking the rest of it down.

Colors

Output to a terminal is colored, and output to anything else is not. The decision is made by RustStyleRenderer::createDefault(), which respects NO_COLOR and FORCE_COLOR.

Override it by choosing the renderer yourself:

1use Phplrt\Exception\ErrorPrinter;
2use Phplrt\Exception\Printer\Renderer\AnsiRustStyleRenderer;
3use Phplrt\Exception\Printer\Renderer\RawRustStyleRenderer;
4
5$printer = new ErrorPrinter(new RawRustStyleRenderer());  // never colored
6$printer = new ErrorPrinter(new AnsiRustStyleRenderer()); // always colored

Both are RustStyleRenderer: the same layout, printed either as plain text or with the escape sequences. A line is printed as long as it is - nothing is wrapped or cut - so the output is as wide as the widest line of the source.

Your Own Renderer

RendererInterface takes everything that is known about the error and returns a string, so a one-line-per-error format for a CI log is a small class. The source code lines are read by a SnippetReader of its own:

 1use Phplrt\Contracts\Source\FileInterface;
 2use Phplrt\Exception\Analysis\FailureResult;
 3use Phplrt\Exception\Printer\Renderer\RendererInterface;
 4use Phplrt\Exception\Snippet\CapturedSourceLine;
 5use Phplrt\Exception\SnippetReader;
 6
 7final class CompactRenderer implements RendererInterface
 8{
 9    public function __construct(
10        private readonly SnippetReader $reader = new SnippetReader(),
11    ) {}
12
13    public function render(FailureResult $error, \Throwable $e): string
14    {
15        foreach ($this->reader->read($error) as $line) {
16            // Only the lines containing the error itself are captured
17            if ($line instanceof CapturedSourceLine) {
18                return \sprintf(
19                    '%s:%d:%d: %s',
20                    $error->source instanceof FileInterface ? $error->source->pathname : '-',
21                    $line->number,
22                    $line->captured->offset + 1,
23                    $error->message,
24                );
25            }
26        }
27
28        return $error->message;
29    }
30}
1echo new ErrorPrinter(new CompactRenderer())
2    ->print($e)
3    ->withSource($source)
4    ->withInterval(26, 4);
5// config.txt:2:11: Expected a number

The same renderer can be chosen for a single error rather than for the whole printer:

echo $printer->print($e)->withRenderer(new CompactRenderer());

Self-Printing 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)
19            ->withSource($this->source)
20            ->withInterval($this->offset, $this->length);
21    }
22}

The message and the class are taken from the exception itself, so nothing is repeated.

An exception that implements Phplrt\Contracts\Lexer\Exception\RuntimeExceptionInterface or its parser counterpart needs none of this - print($this) finds the source and the fragment through the contract:

1public function __toString(): string
2{
3    return (string) new ErrorPrinter()->print($this);
4}

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

What's Next?

ErrorPrinter is the two halves below glued together and rendered. Use them directly when the picture is not what you are after:

  • Analysing an Error - Analyzer, the source, the position and the fragment behind any Throwable, for diagnostics you report somewhere other than a terminal.
  • Reading a Snippet - SnippetReader, the source code lines around the fragment and the exact bytes captured on each of them.