Skip to content

[0.12][WIP] Block Strings #238

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Language/AST/StringValueNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ class StringValueNode extends Node implements ValueNode
* @var string
*/
public $value;

/**
* @var boolean|null
*/
public $block;
}
135 changes: 114 additions & 21 deletions src/Language/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

use GraphQL\Error\SyntaxError;
use GraphQL\Utils\Utils;
use GraphQL\Utils\BlockString;

/**
* A Lexer is a stateful stream generator in that every time
Expand Down Expand Up @@ -201,7 +202,15 @@ private function readToken(Token $prev)
->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);
}

Expand Down Expand Up @@ -372,10 +381,26 @@ private function readString($line, $col, Token $prev)
while (
$code &&
// 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);

Expand Down Expand Up @@ -421,27 +446,83 @@ private function readString($line, $col, Token $prev)
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.'
);
}

$value .= $chunk;
/**
* Reads a block string token from the source file.
*
* """("?"?(\\"""|\\(?!=""")|[^"\\]))*"""
*/
private function readBlockString($line, $col, Token $prev)
{
$start = $this->position;

// Skip trailing quote:
$this->moveStringCursor(1, 1);
// Skip leading quotes and read first string char:
list ($char, $code, $bytes) = $this->moveStringCursor(3, 3)->readChar();

return new Token(
Token::STRING,
$start,
$chunk = '';
$value = '';

while ($code) {
// 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, $nextBytes) = $this->readChar();
list(,$nextNextCode, $nextNextBytes) = $this->moveStringCursor(1, 1)->readChar();
list(,$nextNextNextCode, $nextNextNextBytes) = $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();
}

throw new SyntaxError(
$this->source,
$this->position,
$line,
$col,
$prev,
$value
'Unterminated string.'
);
}

Expand All @@ -457,6 +538,18 @@ private function assertValidStringCharacterCode($code, $position)
}
}

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.
Expand Down
2 changes: 2 additions & 0 deletions src/Language/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -655,9 +655,11 @@ function parseValueLiteral($isConst)
'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:
Expand Down
14 changes: 14 additions & 0 deletions src/Language/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ public function printAST($ast)
return $node->value;
},
NodeKind::STRING => function(StringValueNode $node) {
if ($node->block) {
return $this->printBlockString($node->value);
}
return json_encode($node->value);
},
NodeKind::BOOLEAN => function(BooleanValueNode $node) {
Expand Down Expand Up @@ -307,4 +310,15 @@ function($x) { return !!$x;}
)
: '';
}

/**
* Print a block string in the indented block form by adding a leading and
* trailing blank line. However, if a block string starts with whitespace and is
* a single-line, adding a leading blank line would strip that whitespace.
*/
private function printBlockString($value) {
return ($value[0] === ' ' || $value[0] === "\t") && strpos($value, "\n") === false
? '"""' . str_replace('"""', '\\"""', $value) . '"""'
: $this->indent("\"\"\"\n" . str_replace('"""', '\\"""', $value)) . "\n\"\"\"";
}
}
2 changes: 2 additions & 0 deletions src/Language/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Token
const INT = 'Int';
const FLOAT = 'Float';
const STRING = 'String';
const BLOCK_STRING = 'BlockString';
const COMMENT = 'Comment';

/**
Expand Down Expand Up @@ -57,6 +58,7 @@ public static function getKindDescription($kind)
$description[self::INT] = 'Int';
$description[self::FLOAT] = 'Float';
$description[self::STRING] = 'String';
$description[self::BLOCK_STRING] = 'BlockString';
$description[self::COMMENT] = 'Comment';

return $description[$kind];
Expand Down
61 changes: 61 additions & 0 deletions src/Utils/BlockString.php
Original file line number Diff line number Diff line change
@@ -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;
}
}
3 changes: 2 additions & 1 deletion tests/Language/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,8 @@ public function testParsesListValues()
[
'kind' => NodeKind::STRING,
'loc' => ['start' => 5, 'end' => 10],
'value' => 'abc'
'value' => 'abc',
'block' => false
]
]
], $this->nodeToArray(Parser::parseValue('[123 "abc"]')));
Expand Down
44 changes: 43 additions & 1 deletion tests/Language/PrinterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,46 @@ public function testCorrectlyPrintsOpsWithoutName()
$this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts));
}

/**
* @it correctly prints single-line block strings with leading space
*/
public function testCorrectlyPrintsSingleLineBlockStringsWithLeadingSpace()
{
$mutationAstWithArtifacts = Parser::parse(
'{ field(arg: """ space-led value""") }'
);
$expected = '{
field(arg: """ space-led value""")
}
';
$this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts));
}

/**
* @it correctly prints block strings with a first line indentation
*/
public function testCorrectlyPrintsBlockStringsWithAFirstLineIndentation()
{
$mutationAstWithArtifacts = Parser::parse(
'{
field(arg: """
first
line
indentation
""")
}'
);
$expected = '{
field(arg: """
first
line
indentation
""")
}
';
$this->assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts));
}

/**
* @it prints kitchen sink
*/
Expand Down Expand Up @@ -146,7 +186,9 @@ public function testPrintsKitchenSink()
}

fragment frag on Friend {
foo(size: $size, bar: $b, obj: {key: "value"})
foo(size: $size, bar: $b, obj: {key: "value", block: """
block string uses \"""
"""})
}

{
Expand Down
6 changes: 6 additions & 0 deletions tests/Language/VisitorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,12 @@ public function testVisitsKitchenSink()
[ '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 ],
Expand Down
Loading