Introduce ErrorHandler

Add ErrorHandler interface, as well as ErrorHandler\Throwing
and ErrorHandler\Collecting. The error handler is passed to
Parser::parse(). This supersedes the throwOnError option.

NameResolver now accepts an ErrorHandler in the ctor.
This commit is contained in:
Nikita Popov 2016-10-09 13:15:24 +02:00
parent 90834bff8e
commit f99a96e0a2
15 changed files with 204 additions and 126 deletions

View File

@ -0,0 +1,13 @@
<?php
namespace PhpParser;
interface ErrorHandler
{
/**
* Handle an error generated during lexing, parsing or some other operation.
*
* @param Error $error The error that needs to be handled
*/
public function handleError(Error $error);
}

View File

@ -0,0 +1,46 @@
<?php
namespace PhpParser\ErrorHandler;
use PhpParser\Error;
use PhpParser\ErrorHandler;
/**
* Error handler that collects all errors into an array.
*
* This allows graceful handling of errors.
*/
class Collecting implements ErrorHandler
{
/** @var Error[] Collected errors */
private $errors = [];
public function handleError(Error $error) {
$this->errors[] = $error;
}
/**
* Get collected errors.
*
* @return Error[]
*/
public function getErrors() {
return $this->errors;
}
/**
* Check whether there are any errors.
*
* @return bool
*/
public function hasErrors() {
return !empty($this->errors);
}
/**
* Reset/clear collected errors.
*/
public function clearErrors() {
$this->errors = [];
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace PhpParser\ErrorHandler;
use PhpParser\Error;
use PhpParser\ErrorHandler;
/**
* Error handler that handles all errors by throwing them.
*
* This is the default strategy used by all components.
*/
class Throwing implements ErrorHandler
{
public function handleError(Error $error) {
throw $error;
}
}

View File

@ -9,7 +9,6 @@ class Lexer
{
protected $code;
protected $tokens;
protected $errors;
protected $pos;
protected $line;
protected $filePos;
@ -54,13 +53,18 @@ class Lexer
* the getErrors() method.
*
* @param string $code The source code to lex
* @param ErrorHandler|null $errorHandler Error handler to use for lexing errors. Defaults to
* ErrorHandler\Throwing
*/
public function startLexing($code) {
public function startLexing($code, ErrorHandler $errorHandler = null) {
if (null === $errorHandler) {
$errorHandler = new ErrorHandler\Throwing();
}
$this->code = $code; // keep the code around for __halt_compiler() handling
$this->pos = -1;
$this->line = 1;
$this->filePos = 0;
$this->errors = [];
// If inline HTML occurs without preceding code, treat it as if it had a leading newline.
// This ensures proper composability, because having a newline is the "safe" assumption.
@ -70,7 +74,7 @@ class Lexer
$this->resetErrors();
$this->tokens = @token_get_all($code);
$this->handleErrors();
$this->handleErrors($errorHandler);
if (false !== $scream) {
ini_set('xdebug.scream', $scream);
@ -88,7 +92,7 @@ class Lexer
}
}
private function handleInvalidCharacterRange($start, $end, $line) {
private function handleInvalidCharacterRange($start, $end, $line, ErrorHandler $errorHandler) {
for ($i = $start; $i < $end; $i++) {
$chr = $this->code[$i];
if ($chr === 'b' || $chr === 'B') {
@ -105,12 +109,12 @@ class Lexer
);
}
$this->errors[] = new Error($errorMsg, [
$errorHandler->handleError(new Error($errorMsg, [
'startLine' => $line,
'endLine' => $line,
'startFilePos' => $i,
'endFilePos' => $i,
]);
]));
}
}
@ -132,7 +136,7 @@ class Lexer
&& false === strpos($error['message'], 'Undefined variable');
}
protected function handleErrors() {
protected function handleErrors(ErrorHandler $errorHandler) {
if (!$this->errorMayHaveOccurred()) {
return;
}
@ -151,7 +155,8 @@ class Lexer
if (substr($this->code, $filePos, $tokenLen) !== $tokenValue) {
// Something is missing, must be an invalid character
$nextFilePos = strpos($this->code, $tokenValue, $filePos);
$this->handleInvalidCharacterRange($filePos, $nextFilePos, $line);
$this->handleInvalidCharacterRange(
$filePos, $nextFilePos, $line, $errorHandler);
$filePos = $nextFilePos;
}
@ -163,19 +168,20 @@ class Lexer
if (substr($this->code, $filePos, 2) === '/*') {
// Unlike PHP, HHVM will drop unterminated comments entirely
$comment = substr($this->code, $filePos);
$this->errors[] = new Error('Unterminated comment', [
$errorHandler->handleError(new Error('Unterminated comment', [
'startLine' => $line,
'endLine' => $line + substr_count($comment, "\n"),
'startFilePos' => $filePos,
'endFilePos' => $filePos + \strlen($comment),
]);
]));
// Emulate the PHP behavior
$isDocComment = isset($comment[3]) && $comment[3] === '*';
$this->tokens[] = [$isDocComment ? T_DOC_COMMENT : T_COMMENT, $comment, $line];
} else {
// Invalid characters at the end of the input
$this->handleInvalidCharacterRange($filePos, \strlen($this->code), $line);
$this->handleInvalidCharacterRange(
$filePos, \strlen($this->code), $line, $errorHandler);
}
return;
}
@ -183,12 +189,12 @@ class Lexer
// Check for unterminated comment
$lastToken = $this->tokens[count($this->tokens) - 1];
if ($this->isUnterminatedComment($lastToken)) {
$this->errors[] = new Error('Unterminated comment', [
$errorHandler->handleError(new Error('Unterminated comment', [
'startLine' => $line - substr_count($lastToken[1], "\n"),
'endLine' => $line,
'startFilePos' => $filePos - \strlen($lastToken[1]),
'endFilePos' => $filePos,
]);
]));
}
}
@ -302,15 +308,6 @@ class Lexer
return $this->tokens;
}
/**
* Returns errors that occurred during lexing.
*
* @return Error[] Array of lexer errors
*/
public function getErrors() {
return $this->errors;
}
/**
* Handles __halt_compiler() by returning the text after it.
*

View File

@ -2,6 +2,7 @@
namespace PhpParser\Lexer;
use PhpParser\ErrorHandler;
use PhpParser\Parser\Tokens;
class Emulative extends \PhpParser\Lexer
@ -50,10 +51,10 @@ class Emulative extends \PhpParser\Lexer
$this->tokenMap[self::T_POW_EQUAL] = Tokens::T_POW_EQUAL;
}
public function startLexing($code) {
public function startLexing($code, ErrorHandler $errorHandler = null) {
$this->inObjectAccess = false;
parent::startLexing($code);
parent::startLexing($code, $errorHandler);
if ($this->requiresEmulation($code)) {
$this->emulateTokens();
}

View File

@ -4,9 +4,7 @@ namespace PhpParser;
class NodeTraverser implements NodeTraverserInterface
{
/**
* @var NodeVisitor[] Visitors
*/
/** @var NodeVisitor[] Visitors */
protected $visitors;
/**

View File

@ -2,6 +2,7 @@
namespace PhpParser\NodeVisitor;
use PhpParser\ErrorHandler;
use PhpParser\NodeVisitorAbstract;
use PhpParser\Error;
use PhpParser\Node;
@ -18,6 +19,18 @@ class NameResolver extends NodeVisitorAbstract
/** @var array Map of format [aliasType => [aliasName => originalName]] */
protected $aliases;
/** @var ErrorHandler Error handler */
protected $errorHandler;
/**
* Constructs a name resolution visitor.
*
* @param ErrorHandler|null $errorHandler Error handler
*/
public function __construct(ErrorHandler $errorHandler = null) {
$this->errorHandler = $errorHandler ?: new ErrorHandler\Throwing;
}
public function beforeTraverse(array $nodes) {
$this->resetState();
}
@ -132,13 +145,14 @@ class NameResolver extends NodeVisitorAbstract
Stmt\Use_::TYPE_CONSTANT => 'const ',
);
throw new Error(
$this->errorHandler->handleError(new Error(
sprintf(
'Cannot use %s%s as %s because the name is already in use',
$typeStringMap[$type], $name, $use->alias
),
$use->getLine()
);
$use->getAttributes()
));
return;
}
$this->aliases[$type][$aliasName] = $name;
@ -160,10 +174,10 @@ class NameResolver extends NodeVisitorAbstract
// don't resolve special class names
if (in_array(strtolower($name->toString()), array('self', 'parent', 'static'))) {
if (!$name->isUnqualified()) {
throw new Error(
$this->errorHandler->handleError(new Error(
sprintf("'\\%s' is an invalid class name", $name->toString()),
$name->getLine()
);
$name->getAttributes()
));
}
return $name;

View File

@ -7,18 +7,11 @@ interface Parser {
* Parses PHP code into a node tree.
*
* @param string $code The source code to parse
* @param ErrorHandler|null $errorHandler Error handler to use for lexer/parser errors, defaults
* to ErrorHandler\Throwing.
*
* @return Node[]|null Array of statements (or null if the 'throwOnError' option is disabled and the parser was
* unable to recover from an error).
*/
public function parse($code);
/**
* Get array of errors that occurred during the last parse.
*
* This method may only return multiple errors if the 'throwOnError' option is disabled.
*
* @return Error[]
*/
public function getErrors();
public function parse($code, ErrorHandler $errorHandler = null);
}

View File

@ -3,61 +3,52 @@
namespace PhpParser\Parser;
use PhpParser\Error;
use PhpParser\ErrorHandler;
use PhpParser\Parser;
class Multiple implements Parser {
/** @var Parser[] List of parsers to try, in order of preference */
private $parsers;
/** @var Error[] Errors collected during last parse */
private $errors;
/**
* Create a parser which will try multiple parsers in an order of preference.
*
* Parsers will be invoked in the order they're provided to the constructor. If one of the
* parsers runs without errors, it's output is returned. Otherwise the errors (and
* PhpParser\Error exception) of the first parser are used.
* parsers runs without throwing, it's output is returned. Otherwise the exception that the
* first parser generated is thrown.
*
* @param Parser[] $parsers
*/
public function __construct(array $parsers) {
$this->parsers = $parsers;
$this->errors = [];
}
public function parse($code) {
list($firstStmts, $firstErrors, $firstError) = $this->tryParse($this->parsers[0], $code);
if ($firstErrors === []) {
$this->errors = [];
public function parse($code, ErrorHandler $errorHandler = null) {
if (null === $errorHandler) {
$errorHandler = new ErrorHandler\Throwing;
}
list($firstStmts, $firstError) = $this->tryParse($this->parsers[0], $errorHandler, $code);
if ($firstError === null) {
return $firstStmts;
}
for ($i = 1, $c = count($this->parsers); $i < $c; ++$i) {
list($stmts, $errors) = $this->tryParse($this->parsers[$i], $code);
if ($errors === []) {
$this->errors = [];
list($stmts, $error) = $this->tryParse($this->parsers[$i], $errorHandler, $code);
if ($error === null) {
return $stmts;
}
}
$this->errors = $firstErrors;
if ($firstError) {
throw $firstError;
}
return $firstStmts;
throw $firstError;
}
public function getErrors() {
return $this->errors;
}
private function tryParse(Parser $parser, $code) {
private function tryParse(Parser $parser, ErrorHandler $errorHandler, $code) {
$stmts = null;
$error = null;
try {
$stmts = $parser->parse($code);
$stmts = $parser->parse($code, $errorHandler);
} catch (Error $error) {}
$errors = $parser->getErrors();
return [$stmts, $errors, $error];
return [$stmts, $error];
}
}

View File

@ -103,8 +103,8 @@ abstract class ParserAbstract implements Parser
/** @var array Start attributes of last *read* token */
protected $lookaheadStartAttributes;
/** @var bool Whether to throw on first error */
protected $throwOnError;
/** @var ErrorHandler Error handler */
protected $errorHandler;
/** @var Error[] Errors collected during last parse */
protected $errors;
/** @var int Error state, used to avoid error floods */
@ -114,42 +114,36 @@ abstract class ParserAbstract implements Parser
* Creates a parser instance.
*
* @param Lexer $lexer A lexer
* @param array $options Options array. The boolean 'throwOnError' option determines whether an exception should be
* thrown on first error, or if the parser should try to continue parsing the remaining code
* and build a partial AST.
* @param array $options Options array. Currently no options are supported.
*/
public function __construct(Lexer $lexer, array $options = array()) {
$this->lexer = $lexer;
$this->errors = array();
$this->throwOnError = isset($options['throwOnError']) ? $options['throwOnError'] : true;
}
/**
* Get array of errors that occurred during the last parse.
*
* This method may only return multiple errors if the 'throwOnError' option is disabled.
*
* @return Error[]
*/
public function getErrors() {
return $this->errors;
if (isset($options['throwOnError'])) {
throw new \LogicException(
'"throwOnError" is no longer supported, use "errorHandler" instead');
}
}
/**
* Parses PHP code into a node tree.
*
* If a non-throwing error handler is used, the parser will continue parsing after an error
* occurred and attempt to build a partial AST.
*
* @param string $code The source code to parse
* @param ErrorHandler|null $errorHandler Error handler to use for lexer/parser errors, defaults
* to ErrorHandler\Throwing.
*
* @return Node[]|null Array of statements (or null if the 'throwOnError' option is disabled and the parser was
* unable to recover from an error).
*/
public function parse($code) {
// Initialize the lexer and inherit lexing errors
$this->lexer->startLexing($code);
$this->errors = $this->lexer->getErrors();
if ($this->throwOnError && !empty($this->errors)) {
throw $this->errors[0];
}
public function parse($code, ErrorHandler $errorHandler = null) {
$this->errorHandler = $errorHandler ?: new ErrorHandler\Throwing;
// Initialize the lexer
$this->lexer->startLexing($code, $this->errorHandler);
// We start off with no lookahead-token
$symbol = self::SYMBOL_NONE;
@ -346,10 +340,7 @@ abstract class ParserAbstract implements Parser
}
protected function emitError(Error $error) {
$this->errors[] = $error;
if ($this->throwOnError) {
throw $error;
}
$this->errorHandler->handleError($error);
}
protected function getErrorMessage($symbol, $state) {

View File

@ -15,12 +15,8 @@ class CodeParsingTest extends CodeTestAbstract
$lexer = new Lexer\Emulative(array('usedAttributes' => array(
'startLine', 'endLine', 'startFilePos', 'endFilePos', 'comments'
)));
$parser5 = new Parser\Php5($lexer, array(
'throwOnError' => false,
));
$parser7 = new Parser\Php7($lexer, array(
'throwOnError' => false,
));
$parser5 = new Parser\Php5($lexer);
$parser7 = new Parser\Php7($lexer);
$output5 = $this->getParseOutput($parser5, $code);
$output7 = $this->getParseOutput($parser7, $code);
@ -38,11 +34,11 @@ class CodeParsingTest extends CodeTestAbstract
}
private function getParseOutput(Parser $parser, $code) {
$stmts = $parser->parse($code);
$errors = $parser->getErrors();
$errors = new ErrorHandler\Collecting;
$stmts = $parser->parse($code, $errors);
$output = '';
foreach ($errors as $error) {
foreach ($errors->getErrors() as $error) {
$output .= $this->formatErrorMessage($error, $code) . "\n";
}

View File

@ -0,0 +1,22 @@
<?php
namespace PhpParser\ErrorHandler;
use PhpParser\Error;
class CollectingTest extends \PHPUnit_Framework_TestCase {
public function testHandleError() {
$errorHandler = new Collecting();
$this->assertFalse($errorHandler->hasErrors());
$this->assertEmpty($errorHandler->getErrors());
$errorHandler->handleError($e1 = new Error('Test 1'));
$errorHandler->handleError($e2 = new Error('Test 2'));
$this->assertTrue($errorHandler->hasErrors());
$this->assertSame([$e1, $e2], $errorHandler->getErrors());
$errorHandler->clearErrors();
$this->assertFalse($errorHandler->hasErrors());
$this->assertEmpty($errorHandler->getErrors());
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace PhpParser\ErrorHandler;
use PhpParser\Error;
class ThrowingTest extends \PHPUnit_Framework_TestCase {
/**
* @expectedException \PhpParser\Error
* @expectedExceptionMessage Test
*/
public function testHandleError() {
$errorHandler = new Throwing();
$errorHandler->handleError(new Error('Test'));
}
}

View File

@ -19,11 +19,12 @@ class LexerTest extends \PHPUnit_Framework_TestCase
$this->markTestSkipped('HHVM does not throw warnings from token_get_all()');
}
$errorHandler = new ErrorHandler\Collecting();
$lexer = $this->getLexer(['usedAttributes' => [
'comments', 'startLine', 'endLine', 'startFilePos', 'endFilePos'
]]);
$lexer->startLexing($code);
$errors = $lexer->getErrors();
$lexer->startLexing($code, $errorHandler);
$errors = $errorHandler->getErrors();
$this->assertSame(count($messages), count($errors));
for ($i = 0; $i < count($messages); $i++) {

View File

@ -30,7 +30,6 @@ class MultipleTest extends ParserTest {
/** @dataProvider provideTestParse */
public function testParse($code, Multiple $parser, $expected) {
$this->assertEquals($expected, $parser->parse($code));
$this->assertSame([], $parser->getErrors());
}
public function provideTestParse() {
@ -92,22 +91,4 @@ class MultipleTest extends ParserTest {
$parser = new Multiple([$parserA, $parserB]);
$parser->parse('dummy');
}
public function testGetErrors() {
$errorsA = [new Error('A1'), new Error('A2')];
$parserA = $this->getMockBuilder('PhpParser\Parser')->getMock();
$parserA->expects($this->at(0))->method('parse');
$parserA->expects($this->at(1))
->method('getErrors')->will($this->returnValue($errorsA));
$errorsB = [new Error('B1'), new Error('B2')];
$parserB = $this->getMockBuilder('PhpParser\Parser')->getMock();
$parserB->expects($this->at(0))->method('parse');
$parserB->expects($this->at(1))
->method('getErrors')->will($this->returnValue($errorsB));
$parser = new Multiple([$parserA, $parserB]);
$parser->parse('dummy');
$this->assertSame($errorsA, $parser->getErrors());
}
}