diff --git a/src/Language/AST/StringValueNode.php b/src/Language/AST/StringValueNode.php index d729b5a..457e4b7 100644 --- a/src/Language/AST/StringValueNode.php +++ b/src/Language/AST/StringValueNode.php @@ -9,4 +9,9 @@ class StringValueNode extends Node implements ValueNode * @var string */ public $value; + + /** + * @var boolean|null + */ + public $block; } diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 6c1bc82..c00e20a 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -3,6 +3,7 @@ namespace GraphQL\Language; use GraphQL\Error\SyntaxError; use GraphQL\Utils\Utils; +use GraphQL\Utils\BlockString; /** * A Lexer is a stateful stream generator in that every time @@ -201,7 +202,15 @@ class Lexer ->readNumber($line, $col, $prev); // " case 34: - return $this->moveStringCursor(-1, -1 * $bytes) + list(,$nextCode) = $this->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + if ($nextCode === 34 && $nextNextCode === 34) { + return $this->moveStringCursor(-2, (-1 * $bytes) - 1) + ->readBlockString($line, $col, $prev); + } + + return $this->moveStringCursor(-2, (-1 * $bytes) - 1) ->readString($line, $col, $prev); } @@ -370,12 +379,28 @@ class Lexer $value = ''; while ( - $code && + $code !== null && // not LineTerminator - $code !== 10 && $code !== 13 && - // not Quote (") - $code !== 34 + $code !== 10 && $code !== 13 ) { + // Closing Quote (") + if ($code === 34) { + $value .= $chunk; + + // Skip quote + $this->moveStringCursor(1, 1); + + return new Token( + Token::STRING, + $start, + $this->position, + $line, + $col, + $prev, + $value + ); + } + $this->assertValidStringCharacterCode($code, $this->position); $this->moveStringCursor(1, $bytes); @@ -421,27 +446,83 @@ class Lexer list ($char, $code, $bytes) = $this->readChar(); } - if ($code !== 34) { - throw new SyntaxError( - $this->source, - $this->position, - 'Unterminated string.' - ); + throw new SyntaxError( + $this->source, + $this->position, + 'Unterminated string.' + ); + } + + /** + * Reads a block string token from the source file. + * + * """("?"?(\\"""|\\(?!=""")|[^"\\]))*""" + */ + private function readBlockString($line, $col, Token $prev) + { + $start = $this->position; + + // Skip leading quotes and read first string char: + list ($char, $code, $bytes) = $this->moveStringCursor(3, 3)->readChar(); + + $chunk = ''; + $value = ''; + + while ($code !== null) { + // Closing Triple-Quote (""") + if ($code === 34) { + // Move 2 quotes + list(,$nextCode) = $this->moveStringCursor(1, 1)->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + if ($nextCode === 34 && $nextNextCode === 34) { + $value .= $chunk; + + $this->moveStringCursor(1, 1); + + return new Token( + Token::BLOCK_STRING, + $start, + $this->position, + $line, + $col, + $prev, + BlockString::value($value) + ); + } else { + // move cursor back to before the first quote + $this->moveStringCursor(-2, -2); + } + } + + $this->assertValidBlockStringCharacterCode($code, $this->position); + $this->moveStringCursor(1, $bytes); + + list(,$nextCode) = $this->readChar(); + list(,$nextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + list(,$nextNextNextCode) = $this->moveStringCursor(1, 1)->readChar(); + + // Escape Triple-Quote (\""") + if ($code === 92 && + $nextCode === 34 && + $nextNextCode === 34 && + $nextNextNextCode === 34 + ) { + $this->moveStringCursor(1, 1); + $value .= $chunk . '"""'; + $chunk = ''; + } else { + $this->moveStringCursor(-2, -2); + $chunk .= $char; + } + + list ($char, $code, $bytes) = $this->readChar(); } - $value .= $chunk; - - // Skip trailing quote: - $this->moveStringCursor(1, 1); - - return new Token( - Token::STRING, - $start, + throw new SyntaxError( + $this->source, $this->position, - $line, - $col, - $prev, - $value + 'Unterminated string.' ); } @@ -457,6 +538,18 @@ class Lexer } } + private function assertValidBlockStringCharacterCode($code, $position) + { + // SourceCharacter + if ($code < 0x0020 && $code !== 0x0009 && $code !== 0x000A && $code !== 0x000D) { + throw new SyntaxError( + $this->source, + $position, + 'Invalid character within String: ' . Utils::printCharCode($code) + ); + } + } + /** * Reads from body starting at startPosition until it finds a non-whitespace * or commented character, then places cursor to the position of that character. @@ -537,7 +630,7 @@ class Lexer $byteStreamPosition = $this->byteStreamPosition; } - $code = 0; + $code = null; $utf8char = ''; $bytes = 0; $positionOffset = 0; diff --git a/src/Language/Parser.php b/src/Language/Parser.php index b15b048..2a8b532 100644 --- a/src/Language/Parser.php +++ b/src/Language/Parser.php @@ -655,9 +655,11 @@ class Parser 'loc' => $this->loc($token) ]); case Token::STRING: + case Token::BLOCK_STRING: $this->lexer->advance(); return new StringValueNode([ 'value' => $token->value, + 'block' => $token->kind === Token::BLOCK_STRING, 'loc' => $this->loc($token) ]); case Token::NAME: diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 7e7336b..25d2a2c 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -139,6 +139,9 @@ class Printer return $node->value; }, NodeKind::STRING => function(StringValueNode $node) { + if ($node->block) { + return "\"\"\"\n" . str_replace('"""', '\\"""', $node->value) . "\n\"\"\""; + } return json_encode($node->value); }, NodeKind::BOOLEAN => function(BooleanValueNode $node) { diff --git a/src/Language/Token.php b/src/Language/Token.php index f908a5d..f98d686 100644 --- a/src/Language/Token.php +++ b/src/Language/Token.php @@ -27,6 +27,7 @@ class Token const INT = 'Int'; const FLOAT = 'Float'; const STRING = 'String'; + const BLOCK_STRING = 'BlockString'; const COMMENT = 'Comment'; /** @@ -57,6 +58,7 @@ class Token $description[self::INT] = 'Int'; $description[self::FLOAT] = 'Float'; $description[self::STRING] = 'String'; + $description[self::BLOCK_STRING] = 'BlockString'; $description[self::COMMENT] = 'Comment'; return $description[$kind]; diff --git a/src/Utils/BlockString.php b/src/Utils/BlockString.php new file mode 100644 index 0000000..eac943d --- /dev/null +++ b/src/Utils/BlockString.php @@ -0,0 +1,61 @@ + 0 && trim($lines[0], " \t") === '') { + array_shift($lines); + } + while (count($lines) > 0 && trim($lines[count($lines) - 1], " \t") === '') { + array_pop($lines); + } + + // Return a string of the lines joined with U+000A. + return implode("\n", $lines); + } + + private static function leadingWhitespace($str) { + $i = 0; + while ($i < mb_strlen($str) && ($str[$i] === ' ' || $str[$i] === '\t')) { + $i++; + } + + return $i; + } +} \ No newline at end of file diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index e818fa2..e73cf62 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -223,7 +223,101 @@ class LexerTest extends \PHPUnit_Framework_TestCase ], (array) $this->lexOne('"\u1234\u5678\u90AB\uCDEF"')); } - public function reportsUsefulErrors() { + /** + * @it lexes block strings + */ + public function testLexesBlockString() + { + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 12, + 'value' => 'simple' + ], (array) $this->lexOne('"""simple"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 19, + 'value' => ' white space ' + ], (array) $this->lexOne('""" white space """')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 22, + 'value' => 'contains " quote' + ], (array) $this->lexOne('"""contains " quote"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 31, + 'value' => 'contains """ triplequote' + ], (array) $this->lexOne('"""contains \\""" triplequote"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 16, + 'value' => "multi\nline" + ], (array) $this->lexOne("\"\"\"multi\nline\"\"\"")); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 28, + 'value' => "multi\nline\nnormalized" + ], (array) $this->lexOne("\"\"\"multi\rline\r\nnormalized\"\"\"")); + + $this->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"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 19, + 'value' => 'slashes \\\\ \\/' + ], (array) $this->lexOne('"""slashes \\\\ \\/"""')); + + $this->assertArraySubset([ + 'kind' => Token::BLOCK_STRING, + 'start' => 0, + 'end' => 68, + 'value' => "spans\n multiple\n lines" + ], (array) $this->lexOne("\"\"\" + + spans + multiple + lines + + \"\"\"")); + } + + public function reportsUsefulBlockStringErrors() { + return [ + ['"""', "Syntax Error GraphQL (1:4) Unterminated string.\n\n1: \"\"\"\n ^\n"], + ['"""no end quote', "Syntax Error GraphQL (1:16) Unterminated string.\n\n1: \"\"\"no end quote\n ^\n"], + ['"""contains unescaped ' . json_decode('"\u0007"') . ' control char"""', "Syntax Error GraphQL (1:23) Invalid character within String: \"\\u0007\""], + ['"""null-byte is not ' . json_decode('"\u0000"') . ' end of file"""', "Syntax Error GraphQL (1:21) Invalid character within String: \"\\u0000\""], + ]; + } + + /** + * @dataProvider reportsUsefulBlockStringErrors + * @it lex reports useful block string errors + */ + public function testReportsUsefulBlockStringErrors($str, $expectedMessage) + { + $this->setExpectedException(SyntaxError::class, $expectedMessage); + $this->lexOne($str); + } + + public function reportsUsefulStringErrors() { return [ ['"', "Syntax Error GraphQL (1:2) Unterminated string.\n\n1: \"\n ^\n"], ['"no end quote', "Syntax Error GraphQL (1:14) Unterminated string.\n\n1: \"no end quote\n ^\n"], @@ -243,10 +337,10 @@ class LexerTest extends \PHPUnit_Framework_TestCase } /** - * @dataProvider reportsUsefulErrors + * @dataProvider reportsUsefulStringErrors * @it lex reports useful string errors */ - public function testReportsUsefulErrors($str, $expectedMessage) + public function testLexReportsUsefulStringErrors($str, $expectedMessage) { $this->setExpectedException(SyntaxError::class, $expectedMessage); $this->lexOne($str); diff --git a/tests/Language/ParserTest.php b/tests/Language/ParserTest.php index 1a872c0..71fd7e4 100644 --- a/tests/Language/ParserTest.php +++ b/tests/Language/ParserTest.php @@ -497,7 +497,8 @@ fragment $fragmentName on Type { [ 'kind' => NodeKind::STRING, 'loc' => ['start' => 5, 'end' => 10], - 'value' => 'abc' + 'value' => 'abc', + 'block' => false ] ] ], $this->nodeToArray(Parser::parseValue('[123 "abc"]'))); diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php index 8b59910..a8d0ae2 100644 --- a/tests/Language/PrinterTest.php +++ b/tests/Language/PrinterTest.php @@ -146,7 +146,9 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } fragment frag on Friend { - foo(size: $size, bar: $b, obj: {key: "value"}) + foo(size: $size, bar: $b, obj: {key: "value", block: """ + block string uses \""" + """}) } { diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index d65c8aa..c65141e 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -615,6 +615,12 @@ class VisitorTest extends \PHPUnit_Framework_TestCase [ 'enter', 'StringValue', 'value', 'ObjectField' ], [ 'leave', 'StringValue', 'value', 'ObjectField' ], [ 'leave', 'ObjectField', 0, null ], + [ 'enter', 'ObjectField', 1, null ], + [ 'enter', 'Name', 'name', 'ObjectField' ], + [ 'leave', 'Name', 'name', 'ObjectField' ], + [ 'enter', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'StringValue', 'value', 'ObjectField' ], + [ 'leave', 'ObjectField', 1, null ], [ 'leave', 'ObjectValue', 'value', 'Argument' ], [ 'leave', 'Argument', 2, null ], [ 'leave', 'Field', 0, null ], diff --git a/tests/Language/kitchen-sink-noloc.ast b/tests/Language/kitchen-sink-noloc.ast index 3236586..d1427c1 100644 --- a/tests/Language/kitchen-sink-noloc.ast +++ b/tests/Language/kitchen-sink-noloc.ast @@ -556,7 +556,20 @@ }, "value": { "kind": "StringValue", - "value": "value" + "value": "value", + "block": false + } + }, + { + "kind": "ObjectField", + "name": { + "kind": "Name", + "value": "block" + }, + "value": { + "kind": "StringValue", + "value": "block string uses \"\"\"", + "block": true } } ] diff --git a/tests/Language/kitchen-sink.ast b/tests/Language/kitchen-sink.ast index c0128f1..606b03c 100644 --- a/tests/Language/kitchen-sink.ast +++ b/tests/Language/kitchen-sink.ast @@ -2,7 +2,7 @@ "kind": "Document", "loc": { "start": 0, - "end": 1087 + "end": 1136 }, "definitions": [ { @@ -959,7 +959,7 @@ "kind": "FragmentDefinition", "loc": { "start": 942, - "end": 1018 + "end": 1067 }, "name": { "kind": "Name", @@ -989,14 +989,14 @@ "kind": "SelectionSet", "loc": { "start": 966, - "end": 1018 + "end": 1067 }, "selections": [ { "kind": "Field", "loc": { "start": 970, - "end": 1016 + "end": 1065 }, "name": { "kind": "Name", @@ -1071,13 +1071,13 @@ "kind": "Argument", "loc": { "start": 996, - "end": 1015 + "end": 1064 }, "value": { "kind": "ObjectValue", "loc": { "start": 1001, - "end": 1015 + "end": 1064 }, "fields": [ { @@ -1100,7 +1100,32 @@ "start": 1007, "end": 1014 }, - "value": "value" + "value": "value", + "block": false + } + }, + { + "kind": "ObjectField", + "loc": { + "start": 1016, + "end": 1063 + }, + "name": { + "kind": "Name", + "loc": { + "start": 1016, + "end": 1021 + }, + "value": "block" + }, + "value": { + "kind": "StringValue", + "loc": { + "start": 1023, + "end": 1063 + }, + "value": "block string uses \"\"\"", + "block": true } } ] @@ -1123,8 +1148,8 @@ { "kind": "OperationDefinition", "loc": { - "start": 1020, - "end": 1086 + "start": 1069, + "end": 1135 }, "operation": "query", "variableDefinitions": [], @@ -1132,21 +1157,21 @@ "selectionSet": { "kind": "SelectionSet", "loc": { - "start": 1020, - "end": 1086 + "start": 1069, + "end": 1135 }, "selections": [ { "kind": "Field", "loc": { - "start": 1024, - "end": 1075 + "start": 1073, + "end": 1124 }, "name": { "kind": "Name", "loc": { - "start": 1024, - "end": 1031 + "start": 1073, + "end": 1080 }, "value": "unnamed" }, @@ -1154,22 +1179,22 @@ { "kind": "Argument", "loc": { - "start": 1032, - "end": 1044 + "start": 1081, + "end": 1093 }, "value": { "kind": "BooleanValue", "loc": { - "start": 1040, - "end": 1044 + "start": 1089, + "end": 1093 }, "value": true }, "name": { "kind": "Name", "loc": { - "start": 1032, - "end": 1038 + "start": 1081, + "end": 1087 }, "value": "truthy" } @@ -1177,22 +1202,22 @@ { "kind": "Argument", "loc": { - "start": 1046, - "end": 1059 + "start": 1095, + "end": 1108 }, "value": { "kind": "BooleanValue", "loc": { - "start": 1054, - "end": 1059 + "start": 1103, + "end": 1108 }, "value": false }, "name": { "kind": "Name", "loc": { - "start": 1046, - "end": 1052 + "start": 1095, + "end": 1101 }, "value": "falsey" } @@ -1200,21 +1225,21 @@ { "kind": "Argument", "loc": { - "start": 1061, - "end": 1074 + "start": 1110, + "end": 1123 }, "value": { "kind": "NullValue", "loc": { - "start": 1070, - "end": 1074 + "start": 1119, + "end": 1123 } }, "name": { "kind": "Name", "loc": { - "start": 1061, - "end": 1068 + "start": 1110, + "end": 1117 }, "value": "nullish" } @@ -1225,14 +1250,14 @@ { "kind": "Field", "loc": { - "start": 1079, - "end": 1084 + "start": 1128, + "end": 1133 }, "name": { "kind": "Name", "loc": { - "start": 1079, - "end": 1084 + "start": 1128, + "end": 1133 }, "value": "query" }, diff --git a/tests/Language/kitchen-sink.graphql b/tests/Language/kitchen-sink.graphql index 993de9a..53bb320 100644 --- a/tests/Language/kitchen-sink.graphql +++ b/tests/Language/kitchen-sink.graphql @@ -48,7 +48,11 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) { } fragment frag on Friend { - foo(size: $size, bar: $b, obj: {key: "value"}) + foo(size: $size, bar: $b, obj: {key: "value", block: """ + + block string uses \""" + + """}) } { diff --git a/tests/Language/schema-kitchen-sink.graphql b/tests/Language/schema-kitchen-sink.graphql index 0544266..7771a35 100644 --- a/tests/Language/schema-kitchen-sink.graphql +++ b/tests/Language/schema-kitchen-sink.graphql @@ -1,9 +1,7 @@ -# Copyright (c) 2015, Facebook, Inc. -# All rights reserved. +# Copyright (c) 2015-present, Facebook, Inc. # -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. An additional grant -# of patent rights can be found in the PATENTS file in the same directory. +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. schema { query: QueryType