RFC: Block String

This RFC adds a new form of `StringValue`, the multi-line string, similar to that found in Python and Scala.

A multi-line string starts and ends with a triple-quote:

```
"""This is a triple-quoted string
and it can contain multiple lines"""
```

Multi-line strings are useful for typing literal bodies of text where new lines should be interpretted literally. In fact, the only escape sequence used is `\"""` and `\` is otherwise allowed unescaped. This is beneficial when writing documentation within strings which may reference the back-slash often:

```
"""
In a multi-line string \n and C:\\ are unescaped.
"""
```

The primary value of multi-line strings are to write long-form input directly in query text, in tools like GraphiQL, and as a prerequisite to another pending RFC to allow docstring style documentation in the Schema Definition Language.

Ref: graphql/graphql-js#926
This commit is contained in:
Daniel Tschinder 2018-02-08 14:58:08 +01:00
parent 46816a7cda
commit 8747ff8954
14 changed files with 382 additions and 73 deletions

View File

@ -9,4 +9,9 @@ class StringValueNode extends Node implements ValueNode
* @var string
*/
public $value;
/**
* @var boolean|null
*/
public $block;
}

View File

@ -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;

View File

@ -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:

View File

@ -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) {

View File

@ -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];

61
src/Utils/BlockString.php Normal file
View File

@ -0,0 +1,61 @@
<?php
namespace GraphQL\Utils;
class BlockString {
/**
* Produces the value of a block string from its parsed raw value, similar to
* Coffeescript's block string, Python's docstring trim or Ruby's strip_heredoc.
*
* This implements the GraphQL spec's BlockStringValue() static algorithm.
*/
public static function value($rawString) {
// Expand a block string's raw value into independent lines.
$lines = preg_split("/\\r\\n|[\\n\\r]/", $rawString);
// Remove common indentation from all lines but first.
$commonIndent = null;
$linesLength = count($lines);
for ($i = 1; $i < $linesLength; $i++) {
$line = $lines[$i];
$indent = self::leadingWhitespace($line);
if (
$indent < mb_strlen($line) &&
($commonIndent === null || $indent < $commonIndent)
) {
$commonIndent = $indent;
if ($commonIndent === 0) {
break;
}
}
}
if ($commonIndent) {
for ($i = 1; $i < $linesLength; $i++) {
$line = $lines[$i];
$lines[$i] = mb_substr($line, $commonIndent);
}
}
// Remove leading and trailing blank lines.
while (count($lines) > 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;
}
}

View File

@ -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);

View File

@ -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"]')));

View File

@ -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 \"""
"""})
}
{

View File

@ -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 ],

View File

@ -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
}
}
]

View File

@ -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"
},

View File

@ -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 \"""
"""})
}
{

View File

@ -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