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 anyThrowable, 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.