expectSyntaxError( Utils::chr(0x0007), 'Cannot contain the invalid character "\u0007"', $this->loc(1, 1) ); } private function expectSyntaxError($text, $message, $location) { $this->expectException(SyntaxError::class); $this->expectExceptionMessage($message); try { $this->lexOne($text); } catch (SyntaxError $error) { self::assertEquals([$location], $error->getLocations()); throw $error; } } /** * @param string $body * * @return Token */ private function lexOne($body) { $lexer = new Lexer(new Source($body)); return $lexer->advance(); } private function loc($line, $column) { return new SourceLocation($line, $column); } /** * @see it('accepts BOM header') */ public function testAcceptsBomHeader() : void { $bom = Utils::chr(0xFEFF); $expected = [ 'kind' => Token::NAME, 'start' => 2, 'end' => 5, 'value' => 'foo', ]; self::assertArraySubset($expected, (array) $this->lexOne($bom . ' foo')); } /** * @see it('records line and column') */ public function testRecordsLineAndColumn() : void { $expected = [ 'kind' => Token::NAME, 'start' => 8, 'end' => 11, 'line' => 4, 'column' => 3, 'value' => 'foo', ]; self::assertArraySubset($expected, (array) $this->lexOne("\n \r\n \r foo\n")); } /** * @see it('skips whitespace and comments') */ public function testSkipsWhitespacesAndComments() : void { $example1 = ' foo '; $expected = [ 'kind' => Token::NAME, 'start' => 6, 'end' => 9, 'value' => 'foo', ]; self::assertArraySubset($expected, (array) $this->lexOne($example1)); $example2 = ' #comment foo#comment '; $expected = [ 'kind' => Token::NAME, 'start' => 18, 'end' => 21, 'value' => 'foo', ]; self::assertArraySubset($expected, (array) $this->lexOne($example2)); $expected = [ 'kind' => Token::NAME, 'start' => 3, 'end' => 6, 'value' => 'foo', ]; $example3 = ',,,foo,,,'; self::assertArraySubset($expected, (array) $this->lexOne($example3)); } /** * @see it('errors respect whitespace') */ public function testErrorsRespectWhitespace() : void { $str = '' . "\n" . "\n" . " ?\n" . "\n"; try { $this->lexOne($str); $this->fail('Expected exception not thrown'); } catch (SyntaxError $error) { self::assertEquals( 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . "\n" . "GraphQL request (3:5)\n" . "2: \n" . "3: ?\n" . " ^\n" . "4: \n", (string) $error ); } } /** * @see it('updates line numbers in error for file context') */ public function testUpdatesLineNumbersInErrorForFileContext() : void { $str = '' . "\n" . "\n" . " ?\n" . "\n"; $source = new Source($str, 'foo.js', new SourceLocation(11, 12)); try { $lexer = new Lexer($source); $lexer->advance(); $this->fail('Expected exception not thrown'); } catch (SyntaxError $error) { self::assertEquals( 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . "\n" . "foo.js (13:6)\n" . "12: \n" . "13: ?\n" . " ^\n" . "14: \n", (string) $error ); } } public function testUpdatesColumnNumbersInErrorForFileContext() : void { $source = new Source('?', 'foo.js', new SourceLocation(1, 5)); try { $lexer = new Lexer($source); $lexer->advance(); $this->fail('Expected exception not thrown'); } catch (SyntaxError $error) { self::assertEquals( 'Syntax Error: Cannot parse the unexpected character "?".' . "\n" . "\n" . "foo.js (1:5)\n" . '1: ?' . "\n" . ' ^' . "\n", (string) $error ); } } /** * @see it('lexes strings') */ public function testLexesStrings() : void { self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 8, 'value' => 'simple', ], (array) $this->lexOne('"simple"') ); self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 15, 'value' => ' white space ', ], (array) $this->lexOne('" white space "') ); self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 10, 'value' => 'quote "', ], (array) $this->lexOne('"quote \\""') ); self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 25, 'value' => 'escaped \n\r\b\t\f', ], (array) $this->lexOne('"escaped \\\\n\\\\r\\\\b\\\\t\\\\f"') ); self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 16, 'value' => 'slashes \\ \/', ], (array) $this->lexOne('"slashes \\\\ \\\\/"') ); self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 13, 'value' => 'unicode яуц', ], (array) $this->lexOne('"unicode яуц"') ); $unicode = json_decode('"\u1234\u5678\u90AB\uCDEF"'); self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 34, 'value' => 'unicode ' . $unicode, ], (array) $this->lexOne('"unicode \u1234\u5678\u90AB\uCDEF"') ); self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => 26, 'value' => $unicode, ], (array) $this->lexOne('"\u1234\u5678\u90AB\uCDEF"') ); } /** * @see it('lexes block strings') */ public function testLexesBlockString() : void { self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 12, 'value' => 'simple', ], (array) $this->lexOne('"""simple"""') ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 19, 'value' => ' white space ', ], (array) $this->lexOne('""" white space """') ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 22, 'value' => 'contains " quote', ], (array) $this->lexOne('"""contains " quote"""') ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 31, 'value' => 'contains """ triplequote', ], (array) $this->lexOne('"""contains \\""" triplequote"""') ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 16, 'value' => "multi\nline", ], (array) $this->lexOne("\"\"\"multi\nline\"\"\"") ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 28, 'value' => "multi\nline\nnormalized", ], (array) $this->lexOne("\"\"\"multi\rline\r\nnormalized\"\"\"") ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 32, 'value' => 'unescaped \\n\\r\\b\\t\\f\\u1234', ], (array) $this->lexOne('"""unescaped \\n\\r\\b\\t\\f\\u1234"""') ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 19, 'value' => 'slashes \\\\ \\/', ], (array) $this->lexOne('"""slashes \\\\ \\/"""') ); self::assertArraySubset( [ 'kind' => Token::BLOCK_STRING, 'start' => 0, 'end' => 68, 'value' => "spans\n multiple\n lines", ], (array) $this->lexOne('""" spans multiple lines """') ); } public function reportsUsefulStringErrors() { return [ ['"', 'Unterminated string.', $this->loc(1, 2)], ['"no end quote', 'Unterminated string.', $this->loc(1, 14)], [ "'single quotes'", "Unexpected single quote character ('), did you mean to use a double quote (\")?", $this->loc( 1, 1 ), ], [ '"contains unescaped \u0007 control char"', "Invalid character within String: \"\\u0007\"", $this->loc( 1, 21 ), ], ['"null-byte is not \u0000 end of file"', 'Invalid character within String: "\\u0000"', $this->loc(1, 19)], ['"multi' . "\n" . 'line"', 'Unterminated string.', $this->loc(1, 7)], ['"multi' . "\r" . 'line"', 'Unterminated string.', $this->loc(1, 7)], ['"bad \\z esc"', 'Invalid character escape sequence: \\z', $this->loc(1, 7)], ['"bad \\x esc"', "Invalid character escape sequence: \\x", $this->loc(1, 7)], ['"bad \\u1 esc"', "Invalid character escape sequence: \\u1 es", $this->loc(1, 7)], ['"bad \\u0XX1 esc"', "Invalid character escape sequence: \\u0XX1", $this->loc(1, 7)], ['"bad \\uXXXX esc"', "Invalid character escape sequence: \\uXXXX", $this->loc(1, 7)], ['"bad \\uFXXX esc"', "Invalid character escape sequence: \\uFXXX", $this->loc(1, 7)], ['"bad \\uXXXF esc"', "Invalid character escape sequence: \\uXXXF", $this->loc(1, 7)], ]; } /** * @see it('lex reports useful string errors') * * @dataProvider reportsUsefulStringErrors */ public function testLexReportsUsefulStringErrors($str, $expectedMessage, $location) : void { $this->expectSyntaxError($str, $expectedMessage, $location); } public function reportsUsefulBlockStringErrors() { return [ ['"""', 'Unterminated string.', $this->loc(1, 4)], ['"""no end quote', 'Unterminated string.', $this->loc(1, 16)], [ '"""contains unescaped ' . json_decode('"\u0007"') . ' control char"""', "Invalid character within String: \"\\u0007\"", $this->loc( 1, 23 ), ], [ '"""null-byte is not ' . json_decode('"\u0000"') . ' end of file"""', "Invalid character within String: \"\\u0000\"", $this->loc( 1, 21 ), ], ]; } /** * @see it('lex reports useful block string errors') * * @dataProvider reportsUsefulBlockStringErrors */ public function testReportsUsefulBlockStringErrors($str, $expectedMessage, $location) : void { $this->expectSyntaxError($str, $expectedMessage, $location); } /** * @see it('lexes numbers') */ public function testLexesNumbers() : void { self::assertArraySubset( ['kind' => Token::INT, 'start' => 0, 'end' => 1, 'value' => '4'], (array) $this->lexOne('4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 5, 'value' => '4.123'], (array) $this->lexOne('4.123') ); self::assertArraySubset( ['kind' => Token::INT, 'start' => 0, 'end' => 2, 'value' => '-4'], (array) $this->lexOne('-4') ); self::assertArraySubset( ['kind' => Token::INT, 'start' => 0, 'end' => 1, 'value' => '9'], (array) $this->lexOne('9') ); self::assertArraySubset( ['kind' => Token::INT, 'start' => 0, 'end' => 1, 'value' => '0'], (array) $this->lexOne('0') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 6, 'value' => '-4.123'], (array) $this->lexOne('-4.123') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 5, 'value' => '0.123'], (array) $this->lexOne('0.123') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 5, 'value' => '123e4'], (array) $this->lexOne('123e4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 5, 'value' => '123E4'], (array) $this->lexOne('123E4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 6, 'value' => '123e-4'], (array) $this->lexOne('123e-4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 6, 'value' => '123e+4'], (array) $this->lexOne('123e+4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 8, 'value' => '-1.123e4'], (array) $this->lexOne('-1.123e4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 8, 'value' => '-1.123E4'], (array) $this->lexOne('-1.123E4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 9, 'value' => '-1.123e-4'], (array) $this->lexOne('-1.123e-4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 9, 'value' => '-1.123e+4'], (array) $this->lexOne('-1.123e+4') ); self::assertArraySubset( ['kind' => Token::FLOAT, 'start' => 0, 'end' => 11, 'value' => '-1.123e4567'], (array) $this->lexOne('-1.123e4567') ); } public function reportsUsefulNumberErrors() { return [ ['00', 'Invalid number, unexpected digit after 0: "0"', $this->loc(1, 2)], ['+1', 'Cannot parse the unexpected character "+".', $this->loc(1, 1)], ['1.', 'Invalid number, expected digit but got: ', $this->loc(1, 3)], ['1.e1', 'Invalid number, expected digit but got: "e"', $this->loc(1, 3)], ['.123', 'Cannot parse the unexpected character ".".', $this->loc(1, 1)], ['1.A', 'Invalid number, expected digit but got: "A"', $this->loc(1, 3)], ['-A', 'Invalid number, expected digit but got: "A"', $this->loc(1, 2)], ['1.0e', 'Invalid number, expected digit but got: ', $this->loc(1, 5)], ['1.0eA', 'Invalid number, expected digit but got: "A"', $this->loc(1, 5)], ]; } /** * @see it('lex reports useful number errors') * * @dataProvider reportsUsefulNumberErrors */ public function testReportsUsefulNumberErrors($str, $expectedMessage, $location) : void { $this->expectSyntaxError($str, $expectedMessage, $location); } /** * @see it('lexes punctuation') */ public function testLexesPunctuation() : void { self::assertArraySubset( ['kind' => Token::BANG, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('!') ); self::assertArraySubset( ['kind' => Token::DOLLAR, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('$') ); self::assertArraySubset( ['kind' => Token::PAREN_L, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('(') ); self::assertArraySubset( ['kind' => Token::PAREN_R, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne(')') ); self::assertArraySubset( ['kind' => Token::SPREAD, 'start' => 0, 'end' => 3, 'value' => null], (array) $this->lexOne('...') ); self::assertArraySubset( ['kind' => Token::COLON, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne(':') ); self::assertArraySubset( ['kind' => Token::EQUALS, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('=') ); self::assertArraySubset( ['kind' => Token::AT, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('@') ); self::assertArraySubset( ['kind' => Token::BRACKET_L, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('[') ); self::assertArraySubset( ['kind' => Token::BRACKET_R, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne(']') ); self::assertArraySubset( ['kind' => Token::BRACE_L, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('{') ); self::assertArraySubset( ['kind' => Token::PIPE, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('|') ); self::assertArraySubset( ['kind' => Token::BRACE_R, 'start' => 0, 'end' => 1, 'value' => null], (array) $this->lexOne('}') ); } public function reportsUsefulUnknownCharErrors() { $unicode1 = json_decode('"\u203B"'); $unicode2 = json_decode('"\u200b"'); return [ ['..', 'Cannot parse the unexpected character ".".', $this->loc(1, 1)], ['?', 'Cannot parse the unexpected character "?".', $this->loc(1, 1)], [$unicode1, "Cannot parse the unexpected character \"\\u203b\".", $this->loc(1, 1)], [$unicode2, "Cannot parse the unexpected character \"\\u200b\".", $this->loc(1, 1)], ]; } /** * @see it('lex reports useful unknown character error') * * @dataProvider reportsUsefulUnknownCharErrors */ public function testReportsUsefulUnknownCharErrors($str, $expectedMessage, $location) : void { $this->expectSyntaxError($str, $expectedMessage, $location); } /** * @see it('lex reports useful information for dashes in names') */ public function testReportsUsefulDashesInfo() : void { $q = 'a-b'; $lexer = new Lexer(new Source($q)); self::assertArraySubset( ['kind' => Token::NAME, 'start' => 0, 'end' => 1, 'value' => 'a'], (array) $lexer->advance() ); $this->expectException(SyntaxError::class); $this->expectExceptionMessage('Syntax Error: Invalid number, expected digit but got: "b"'); try { $lexer->advance(); $this->fail('Expected exception not thrown'); } catch (SyntaxError $error) { self::assertEquals([$this->loc(1, 3)], $error->getLocations()); throw $error; } } /** * @see it('produces double linked list of tokens, including comments') */ public function testDoubleLinkedList() : void { $lexer = new Lexer(new Source('{ #comment field }')); $startToken = $lexer->token; do { $endToken = $lexer->advance(); // Lexer advances over ignored comment tokens to make writing parsers // easier, but will include them in the linked list result. self::assertNotEquals('Comment', $endToken->kind); } while ($endToken->kind !== ''); self::assertEquals(null, $startToken->prev); self::assertEquals(null, $endToken->next); $tokens = []; for ($tok = $startToken; $tok; $tok = $tok->next) { if (! empty($tokens)) { // Tokens are double-linked, prev should point to last seen token. self::assertSame($tokens[count($tokens) - 1], $tok->prev); } $tokens[] = $tok; } self::assertEquals( [ '', '{', 'Comment', 'Name', '}', '', ], Utils::map( $tokens, static function ($tok) { return $tok->kind; } ) ); } }