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 * @var string
*/ */
public $value; public $value;
/**
* @var boolean|null
*/
public $block;
} }

View File

@ -3,6 +3,7 @@ namespace GraphQL\Language;
use GraphQL\Error\SyntaxError; use GraphQL\Error\SyntaxError;
use GraphQL\Utils\Utils; use GraphQL\Utils\Utils;
use GraphQL\Utils\BlockString;
/** /**
* A Lexer is a stateful stream generator in that every time * A Lexer is a stateful stream generator in that every time
@ -201,7 +202,15 @@ class Lexer
->readNumber($line, $col, $prev); ->readNumber($line, $col, $prev);
// " // "
case 34: 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); ->readString($line, $col, $prev);
} }
@ -370,12 +379,28 @@ class Lexer
$value = ''; $value = '';
while ( while (
$code && $code !== null &&
// not LineTerminator // not LineTerminator
$code !== 10 && $code !== 13 && $code !== 10 && $code !== 13
// not Quote (")
$code !== 34
) { ) {
// 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->assertValidStringCharacterCode($code, $this->position);
$this->moveStringCursor(1, $bytes); $this->moveStringCursor(1, $bytes);
@ -421,27 +446,83 @@ class Lexer
list ($char, $code, $bytes) = $this->readChar(); list ($char, $code, $bytes) = $this->readChar();
} }
if ($code !== 34) { throw new SyntaxError(
throw new SyntaxError( $this->source,
$this->source, $this->position,
$this->position, 'Unterminated string.'
'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; throw new SyntaxError(
$this->source,
// Skip trailing quote:
$this->moveStringCursor(1, 1);
return new Token(
Token::STRING,
$start,
$this->position, $this->position,
$line, 'Unterminated string.'
$col,
$prev,
$value
); );
} }
@ -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 * Reads from body starting at startPosition until it finds a non-whitespace
* or commented character, then places cursor to the position of that character. * or commented character, then places cursor to the position of that character.
@ -537,7 +630,7 @@ class Lexer
$byteStreamPosition = $this->byteStreamPosition; $byteStreamPosition = $this->byteStreamPosition;
} }
$code = 0; $code = null;
$utf8char = ''; $utf8char = '';
$bytes = 0; $bytes = 0;
$positionOffset = 0; $positionOffset = 0;

View File

@ -655,9 +655,11 @@ class Parser
'loc' => $this->loc($token) 'loc' => $this->loc($token)
]); ]);
case Token::STRING: case Token::STRING:
case Token::BLOCK_STRING:
$this->lexer->advance(); $this->lexer->advance();
return new StringValueNode([ return new StringValueNode([
'value' => $token->value, 'value' => $token->value,
'block' => $token->kind === Token::BLOCK_STRING,
'loc' => $this->loc($token) 'loc' => $this->loc($token)
]); ]);
case Token::NAME: case Token::NAME:

View File

@ -139,6 +139,9 @@ class Printer
return $node->value; return $node->value;
}, },
NodeKind::STRING => function(StringValueNode $node) { NodeKind::STRING => function(StringValueNode $node) {
if ($node->block) {
return "\"\"\"\n" . str_replace('"""', '\\"""', $node->value) . "\n\"\"\"";
}
return json_encode($node->value); return json_encode($node->value);
}, },
NodeKind::BOOLEAN => function(BooleanValueNode $node) { NodeKind::BOOLEAN => function(BooleanValueNode $node) {

View File

@ -27,6 +27,7 @@ class Token
const INT = 'Int'; const INT = 'Int';
const FLOAT = 'Float'; const FLOAT = 'Float';
const STRING = 'String'; const STRING = 'String';
const BLOCK_STRING = 'BlockString';
const COMMENT = 'Comment'; const COMMENT = 'Comment';
/** /**
@ -57,6 +58,7 @@ class Token
$description[self::INT] = 'Int'; $description[self::INT] = 'Int';
$description[self::FLOAT] = 'Float'; $description[self::FLOAT] = 'Float';
$description[self::STRING] = 'String'; $description[self::STRING] = 'String';
$description[self::BLOCK_STRING] = 'BlockString';
$description[self::COMMENT] = 'Comment'; $description[self::COMMENT] = 'Comment';
return $description[$kind]; 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"')); ], (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 [ return [
['"', "Syntax Error GraphQL (1:2) Unterminated string.\n\n1: \"\n ^\n"], ['"', "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"], ['"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 * @it lex reports useful string errors
*/ */
public function testReportsUsefulErrors($str, $expectedMessage) public function testLexReportsUsefulStringErrors($str, $expectedMessage)
{ {
$this->setExpectedException(SyntaxError::class, $expectedMessage); $this->setExpectedException(SyntaxError::class, $expectedMessage);
$this->lexOne($str); $this->lexOne($str);

View File

@ -497,7 +497,8 @@ fragment $fragmentName on Type {
[ [
'kind' => NodeKind::STRING, 'kind' => NodeKind::STRING,
'loc' => ['start' => 5, 'end' => 10], 'loc' => ['start' => 5, 'end' => 10],
'value' => 'abc' 'value' => 'abc',
'block' => false
] ]
] ]
], $this->nodeToArray(Parser::parseValue('[123 "abc"]'))); ], $this->nodeToArray(Parser::parseValue('[123 "abc"]')));

View File

@ -146,7 +146,9 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) {
} }
fragment frag on Friend { 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' ], [ 'enter', 'StringValue', 'value', 'ObjectField' ],
[ 'leave', 'StringValue', 'value', 'ObjectField' ], [ 'leave', 'StringValue', 'value', 'ObjectField' ],
[ 'leave', 'ObjectField', 0, null ], [ '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', 'ObjectValue', 'value', 'Argument' ],
[ 'leave', 'Argument', 2, null ], [ 'leave', 'Argument', 2, null ],
[ 'leave', 'Field', 0, null ], [ 'leave', 'Field', 0, null ],

View File

@ -556,7 +556,20 @@
}, },
"value": { "value": {
"kind": "StringValue", "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", "kind": "Document",
"loc": { "loc": {
"start": 0, "start": 0,
"end": 1087 "end": 1136
}, },
"definitions": [ "definitions": [
{ {
@ -959,7 +959,7 @@
"kind": "FragmentDefinition", "kind": "FragmentDefinition",
"loc": { "loc": {
"start": 942, "start": 942,
"end": 1018 "end": 1067
}, },
"name": { "name": {
"kind": "Name", "kind": "Name",
@ -989,14 +989,14 @@
"kind": "SelectionSet", "kind": "SelectionSet",
"loc": { "loc": {
"start": 966, "start": 966,
"end": 1018 "end": 1067
}, },
"selections": [ "selections": [
{ {
"kind": "Field", "kind": "Field",
"loc": { "loc": {
"start": 970, "start": 970,
"end": 1016 "end": 1065
}, },
"name": { "name": {
"kind": "Name", "kind": "Name",
@ -1071,13 +1071,13 @@
"kind": "Argument", "kind": "Argument",
"loc": { "loc": {
"start": 996, "start": 996,
"end": 1015 "end": 1064
}, },
"value": { "value": {
"kind": "ObjectValue", "kind": "ObjectValue",
"loc": { "loc": {
"start": 1001, "start": 1001,
"end": 1015 "end": 1064
}, },
"fields": [ "fields": [
{ {
@ -1100,7 +1100,32 @@
"start": 1007, "start": 1007,
"end": 1014 "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", "kind": "OperationDefinition",
"loc": { "loc": {
"start": 1020, "start": 1069,
"end": 1086 "end": 1135
}, },
"operation": "query", "operation": "query",
"variableDefinitions": [], "variableDefinitions": [],
@ -1132,21 +1157,21 @@
"selectionSet": { "selectionSet": {
"kind": "SelectionSet", "kind": "SelectionSet",
"loc": { "loc": {
"start": 1020, "start": 1069,
"end": 1086 "end": 1135
}, },
"selections": [ "selections": [
{ {
"kind": "Field", "kind": "Field",
"loc": { "loc": {
"start": 1024, "start": 1073,
"end": 1075 "end": 1124
}, },
"name": { "name": {
"kind": "Name", "kind": "Name",
"loc": { "loc": {
"start": 1024, "start": 1073,
"end": 1031 "end": 1080
}, },
"value": "unnamed" "value": "unnamed"
}, },
@ -1154,22 +1179,22 @@
{ {
"kind": "Argument", "kind": "Argument",
"loc": { "loc": {
"start": 1032, "start": 1081,
"end": 1044 "end": 1093
}, },
"value": { "value": {
"kind": "BooleanValue", "kind": "BooleanValue",
"loc": { "loc": {
"start": 1040, "start": 1089,
"end": 1044 "end": 1093
}, },
"value": true "value": true
}, },
"name": { "name": {
"kind": "Name", "kind": "Name",
"loc": { "loc": {
"start": 1032, "start": 1081,
"end": 1038 "end": 1087
}, },
"value": "truthy" "value": "truthy"
} }
@ -1177,22 +1202,22 @@
{ {
"kind": "Argument", "kind": "Argument",
"loc": { "loc": {
"start": 1046, "start": 1095,
"end": 1059 "end": 1108
}, },
"value": { "value": {
"kind": "BooleanValue", "kind": "BooleanValue",
"loc": { "loc": {
"start": 1054, "start": 1103,
"end": 1059 "end": 1108
}, },
"value": false "value": false
}, },
"name": { "name": {
"kind": "Name", "kind": "Name",
"loc": { "loc": {
"start": 1046, "start": 1095,
"end": 1052 "end": 1101
}, },
"value": "falsey" "value": "falsey"
} }
@ -1200,21 +1225,21 @@
{ {
"kind": "Argument", "kind": "Argument",
"loc": { "loc": {
"start": 1061, "start": 1110,
"end": 1074 "end": 1123
}, },
"value": { "value": {
"kind": "NullValue", "kind": "NullValue",
"loc": { "loc": {
"start": 1070, "start": 1119,
"end": 1074 "end": 1123
} }
}, },
"name": { "name": {
"kind": "Name", "kind": "Name",
"loc": { "loc": {
"start": 1061, "start": 1110,
"end": 1068 "end": 1117
}, },
"value": "nullish" "value": "nullish"
} }
@ -1225,14 +1250,14 @@
{ {
"kind": "Field", "kind": "Field",
"loc": { "loc": {
"start": 1079, "start": 1128,
"end": 1084 "end": 1133
}, },
"name": { "name": {
"kind": "Name", "kind": "Name",
"loc": { "loc": {
"start": 1079, "start": 1128,
"end": 1084 "end": 1133
}, },
"value": "query" "value": "query"
}, },

View File

@ -48,7 +48,11 @@ subscription StoryLikeSubscription($input: StoryLikeSubscribeInput) {
} }
fragment frag on Friend { 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. # Copyright (c) 2015-present, Facebook, Inc.
# All rights reserved.
# #
# This source code is licensed under the BSD-style license found in the # This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree. An additional grant # LICENSE file in the root directory of this source tree.
# of patent rights can be found in the PATENTS file in the same directory.
schema { schema {
query: QueryType query: QueryType