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:

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

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

use Phplrt\Parser\Exception\UnexpectedTokenException;
use Phplrt\Source\VirtualFile;

try {
    $parser->parse(new VirtualFile('expr.txt', $input));
} catch (UnexpectedTokenException $e) {
    echo $e->getMessage(); // Syntax error, unexpected "3" (T_NUMBER)
    echo $e;               // ...plus the snippet above
}

The exception carries everything needed to build your own message:

$e->token;         // the token it choked on
$e->token->name;   // T_NUMBER
$e->token->offset; // 6
$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));
error[UnexpectedTokenException]: Syntax error, unexpected "3" (T_NUMBER)
  |
1 | 3
  | ^

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:

use Phplrt\Contracts\Lexer\Exception\LexerExceptionInterface;
use Phplrt\Contracts\Parser\Exception\ParserExceptionInterface;
use Phplrt\Contracts\Source\Exception\SourceExceptionInterface;

try {
    $parser->parse($source);
} catch (SourceExceptionInterface $e) {
    // The source could not be read at all
} catch (LexerExceptionInterface $e) {
    // The text could not be turned into tokens
} catch (ParserExceptionInterface $e) {
    // The tokens did not match the grammar
}

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

use Phplrt\Contracts\Parser\Exception\RuntimeExceptionInterface;

catch (RuntimeExceptionInterface $e) {
    // Something in the input, not something in the grammar
}

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:

use Phplrt\Exception\ErrorPrinter;
use Phplrt\Source\VirtualFile;

$source = new VirtualFile('config.txt', <<<'TXT'
    name = "phplrt"
    version = four
    debug = true
    TXT);

echo new ErrorPrinter()
    ->print($source, offset: 26, length: 4)
    ->withMessage('Expected a number')
    ->withClass('TypeError');
error[TypeError]: Expected a number
 --> config.txt:2:11
  |
1 | name = "phplrt"
2 | version = four
  |           ^^^^
3 | debug = true

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

Severity

use Phplrt\Exception\Printer\Level;

echo new ErrorPrinter()
    ->print($source, 26, 4)
    ->withMessage('Consider writing 4 instead')
    ->withLevel(Level::Warning);
warning: Consider writing 4 instead
 --> config.txt:2:11
  |
1 | name = "phplrt"
2 | version = four
  |           ^^^^
3 | 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:

$printer->print($source, $offset, $length)
    ->withMessage('...')       // the message above the snippet
    ->withClass('MyError')     // the name in brackets after the level
    ->withLevel(Level::Warning)
    ->withPathname('other.txt') // override the file name
    ->withLinesAround(0)        // no context lines, just the one that matters
    ->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:

use Phplrt\Exception\Printer\RustStylePrinter;

$printer = new ErrorPrinter(new RustStylePrinter(
    colors: false,
));

// ...or set the width, for a narrow terminal
$printer = new ErrorPrinter(new RustStylePrinter(
    width: 80,
));

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:

use Phplrt\Exception\Printer\ErrorInfo;
use Phplrt\Exception\Printer\PrinterInterface;
use Phplrt\Exception\Snippet\CapturedSourceLine;

final class CompactPrinter implements PrinterInterface
{
    public function print(iterable $snippets, ?ErrorInfo $info = null): string
    {
        foreach ($snippets as $line) {
            // Only the lines containing the error itself are captured
            if ($line instanceof CapturedSourceLine) {
                return \sprintf(
                    '%s:%d:%d: %s',
                    $info?->pathname ?? '-',
                    $line->number,
                    $line->startColumn,
                    $info?->message ?? '',
                );
            }
        }

        return $info?->message ?? '';
    }
}
$printer = new ErrorPrinter(new CompactPrinter());

echo $printer->print($source, 26, 4)
    ->withMessage('Expected a number');
// 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().

use Phplrt\Contracts\Source\ReadableInterface;
use Phplrt\Exception\ErrorPrinter;

final class TypeException extends \RuntimeException
{
    public function __construct(
        string $message,
        public readonly ReadableInterface $source,
        public readonly int $offset,
        public readonly int $length = 0,
    ) {
        parent::__construct($message);
    }

    public function __toString(): string
    {
        return (string) new ErrorPrinter()
            ->print($this->source, $this->offset, $this->length)
            ->withMessage($this->getMessage())
            ->withClass(static::class);
    }
}

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