diff --git a/src/Error/SyntaxError.php b/src/Error/SyntaxError.php index 36bf7db..7f9bd5e 100644 --- a/src/Error/SyntaxError.php +++ b/src/Error/SyntaxError.php @@ -14,8 +14,13 @@ class SyntaxError extends Error public function __construct(Source $source, $position, $description) { $location = $source->getLocation($position); + $line = $location->line + $source->locationOffset->line - 1; + $columnOffset = self::getColumnOffset($source, $location); + $column = $location->column + $columnOffset; + $syntaxError = - "Syntax Error {$source->name} ({$location->line}:{$location->column}) $description\n\n" . + "Syntax Error {$source->name} ({$line}:{$column}) $description\n" . + "\n". self::highlightSourceAtLocation($source, $location); parent::__construct($syntaxError, null, $source, [$position]); @@ -29,22 +34,38 @@ class SyntaxError extends Error public static function highlightSourceAtLocation(Source $source, SourceLocation $location) { $line = $location->line; - $prevLineNum = (string) ($line - 1); - $lineNum = (string) $line; - $nextLineNum = (string) ($line + 1); + $lineOffset = $source->locationOffset->line - 1; + $columnOffset = self::getColumnOffset($source, $location); + + $contextLine = $line + $lineOffset; + $prevLineNum = (string) ($contextLine - 1); + $lineNum = (string) $contextLine; + $nextLineNum = (string) ($contextLine + 1); $padLen = mb_strlen($nextLineNum, 'UTF-8'); $unicodeChars = json_decode('"\u2028\u2029"'); // Quick hack to get js-compatible representation of these chars $lines = preg_split('/\r\n|[\n\r' . $unicodeChars . ']/su', $source->body); - $lpad = function($len, $str) { + $whitespace = function ($len) { + return str_repeat(' ', $len); + }; + + $lpad = function ($len, $str) { return str_pad($str, $len - mb_strlen($str, 'UTF-8') + 1, ' ', STR_PAD_LEFT); }; + $lines[0] = $whitespace($source->locationOffset->column - 1) . $lines[0]; + return ($line >= 2 ? $lpad($padLen, $prevLineNum) . ': ' . $lines[$line - 2] . "\n" : '') . ($lpad($padLen, $lineNum) . ': ' . $lines[$line - 1] . "\n") . - (str_repeat(' ', 1 + $padLen + $location->column) . "^\n") . + ($whitespace(2 + $padLen + $location->column - 1 + $columnOffset) . "^\n") . ($line < count($lines) ? $lpad($padLen, $nextLineNum) . ': ' . $lines[$line] . "\n" : ''); } + + public static function getColumnOffset(Source $source, SourceLocation $location) + { + return $location->line === 1 ? $source->locationOffset->column - 1 : 0; + } + } diff --git a/src/Language/Source.php b/src/Language/Source.php index a46d374..29fd0fe 100644 --- a/src/Language/Source.php +++ b/src/Language/Source.php @@ -3,6 +3,10 @@ namespace GraphQL\Language; use GraphQL\Utils\Utils; +/** + * Class Source + * @package GraphQL\Language + */ class Source { /** @@ -20,7 +24,26 @@ class Source */ public $name; - public function __construct($body, $name = null) + /** + * @var SourceLocation + */ + public $locationOffset; + + /** + * Source constructor. + * + * A representation of source input to GraphQL. + * `name` and `locationOffset` are optional. They are useful for clients who + * store GraphQL documents in source files; for example, if the GraphQL input + * starts at line 40 in a file named Foo.graphql, it might be useful for name to + * be "Foo.graphql" and location to be `{ line: 40, column: 0 }`. + * line and column in locationOffset are 1-indexed + * + * @param $body + * @param null $name + * @param SourceLocation|null $location + */ + public function __construct($body, $name = null, SourceLocation $location = null) { Utils::invariant( is_string($body), @@ -30,6 +53,16 @@ class Source $this->body = $body; $this->length = mb_strlen($body, 'UTF-8'); $this->name = $name ?: 'GraphQL'; + $this->locationOffset = $location ?: new SourceLocation(1, 1); + + Utils::invariant( + $this->locationOffset->line > 0, + 'line in locationOffset is 1-indexed and must be positive' + ); + Utils::invariant( + $this->locationOffset->column > 0, + 'column in locationOffset is 1-indexed and must be positive' + ); } /** diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index 3509b83..ec74ea3 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -3,6 +3,7 @@ namespace GraphQL\Tests\Language; use GraphQL\Language\Lexer; use GraphQL\Language\Source; +use GraphQL\Language\SourceLocation; use GraphQL\Language\Token; use GraphQL\Error\SyntaxError; use GraphQL\Utils\Utils; @@ -107,14 +108,14 @@ class LexerTest extends \PHPUnit_Framework_TestCase */ public function testErrorsRespectWhitespace() { - $example = " + $str = '' . + "\n" . + "\n" . + " ?\n" . + "\n"; - ? - - -"; try { - $this->lexOne($example); + $this->lexOne($str); $this->fail('Expected exception not thrown'); } catch (SyntaxError $e) { $this->assertEquals( @@ -129,6 +130,48 @@ class LexerTest extends \PHPUnit_Framework_TestCase } } + /** + * @it updates line numbers in error for file context + */ + public function testUpdatesLineNumbersInErrorForFileContext() + { + $str = '' . + "\n" . + "\n" . + " ?\n" . + "\n"; + $source = new Source($str, 'foo.js', new SourceLocation(11, 12)); + + $this->setExpectedException( + SyntaxError::class, + 'Syntax Error foo.js (13:6) ' . + 'Cannot parse the unexpected character "?".' . "\n" . + "\n" . + '12: ' . "\n" . + '13: ?' . "\n" . + ' ^' . "\n" . + '14: ' . "\n" + ); + $lexer = new Lexer($source); + $lexer->advance(); + } + + public function testUpdatesColumnNumbersInErrorForFileContext() + { + $source = new Source('?', 'foo.js', new SourceLocation(1, 5)); + + $this->setExpectedException( + SyntaxError::class, + 'Syntax Error foo.js (1:5) ' . + 'Cannot parse the unexpected character "?".' . "\n" . + "\n" . + '1: ?' . "\n" . + ' ^' . "\n" + ); + $lexer = new Lexer($source); + $lexer->advance(); + } + /** * @it lexes strings */