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

View File

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

View File

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

View File

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

View File

@ -7,18 +7,11 @@ interface Parser {
* Parses PHP code into a node tree. * Parses PHP code into a node tree.
* *
* @param string $code The source code to parse * @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 * @return Node[]|null Array of statements (or null if the 'throwOnError' option is disabled and the parser was
* unable to recover from an error). * unable to recover from an error).
*/ */
public function parse($code); public function parse($code, ErrorHandler $errorHandler = null);
/**
* 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();
} }

View File

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

View File

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

View File

@ -15,12 +15,8 @@ class CodeParsingTest extends CodeTestAbstract
$lexer = new Lexer\Emulative(array('usedAttributes' => array( $lexer = new Lexer\Emulative(array('usedAttributes' => array(
'startLine', 'endLine', 'startFilePos', 'endFilePos', 'comments' 'startLine', 'endLine', 'startFilePos', 'endFilePos', 'comments'
))); )));
$parser5 = new Parser\Php5($lexer, array( $parser5 = new Parser\Php5($lexer);
'throwOnError' => false, $parser7 = new Parser\Php7($lexer);
));
$parser7 = new Parser\Php7($lexer, array(
'throwOnError' => false,
));
$output5 = $this->getParseOutput($parser5, $code); $output5 = $this->getParseOutput($parser5, $code);
$output7 = $this->getParseOutput($parser7, $code); $output7 = $this->getParseOutput($parser7, $code);
@ -38,11 +34,11 @@ class CodeParsingTest extends CodeTestAbstract
} }
private function getParseOutput(Parser $parser, $code) { private function getParseOutput(Parser $parser, $code) {
$stmts = $parser->parse($code); $errors = new ErrorHandler\Collecting;
$errors = $parser->getErrors(); $stmts = $parser->parse($code, $errors);
$output = ''; $output = '';
foreach ($errors as $error) { foreach ($errors->getErrors() as $error) {
$output .= $this->formatErrorMessage($error, $code) . "\n"; $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()'); $this->markTestSkipped('HHVM does not throw warnings from token_get_all()');
} }
$errorHandler = new ErrorHandler\Collecting();
$lexer = $this->getLexer(['usedAttributes' => [ $lexer = $this->getLexer(['usedAttributes' => [
'comments', 'startLine', 'endLine', 'startFilePos', 'endFilePos' 'comments', 'startLine', 'endLine', 'startFilePos', 'endFilePos'
]]); ]]);
$lexer->startLexing($code); $lexer->startLexing($code, $errorHandler);
$errors = $lexer->getErrors(); $errors = $errorHandler->getErrors();
$this->assertSame(count($messages), count($errors)); $this->assertSame(count($messages), count($errors));
for ($i = 0; $i < count($messages); $i++) { for ($i = 0; $i < count($messages); $i++) {

View File

@ -30,7 +30,6 @@ class MultipleTest extends ParserTest {
/** @dataProvider provideTestParse */ /** @dataProvider provideTestParse */
public function testParse($code, Multiple $parser, $expected) { public function testParse($code, Multiple $parser, $expected) {
$this->assertEquals($expected, $parser->parse($code)); $this->assertEquals($expected, $parser->parse($code));
$this->assertSame([], $parser->getErrors());
} }
public function provideTestParse() { public function provideTestParse() {
@ -92,22 +91,4 @@ class MultipleTest extends ParserTest {
$parser = new Multiple([$parserA, $parserB]); $parser = new Multiple([$parserA, $parserB]);
$parser->parse('dummy'); $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());
}
} }