diff --git a/src/Language/BlockString.php b/src/Language/BlockString.php index 6ccafae60..1b60dc639 100644 --- a/src/Language/BlockString.php +++ b/src/Language/BlockString.php @@ -12,6 +12,8 @@ use function mb_strlen; use function mb_substr; use function preg_split; +use function str_replace; +use function strpos; class BlockString { @@ -103,4 +105,46 @@ public static function getIndentation(string $value): int return $commonIndent ?? 0; } + + /** + * 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. + */ + public static function print( + string $value, + string $indentation = '', + bool $preferMultipleLines = false + ): string { + $valueLength = mb_strlen($value); + $isSingleLine = strpos($value, "\n") === false; + $hasLeadingSpace = $value[0] === ' ' || $value[0] === '\t'; + $hasTrailingQuote = $value[$valueLength - 1] === '"'; + $hasTrailingSlash = $value[$valueLength - 1] === '\\'; + $printAsMultipleLines = + ! $isSingleLine + || $hasTrailingQuote + || $hasTrailingSlash + || $preferMultipleLines; + + $result = ''; + // Format a multi-line block quote to account for leading space. + if ( + $printAsMultipleLines + && ! ($isSingleLine && $hasLeadingSpace) + ) { + $result .= "\n" . $indentation; + } + + $result .= $indentation !== '' + ? str_replace("\n", "\n" . $indentation, $value) + : $value; + if ($printAsMultipleLines) { + $result .= "\n"; + } + + return '"""' + . str_replace('"""', '\\"""', $result) + . '"""'; + } } diff --git a/src/Language/Printer.php b/src/Language/Printer.php index 61f4d5d78..3800c555d 100644 --- a/src/Language/Printer.php +++ b/src/Language/Printer.php @@ -55,7 +55,6 @@ use function count; use function implode; use function json_encode; -use function preg_replace; use function str_replace; use function strlen; use function strpos; @@ -408,7 +407,7 @@ protected function p(?Node $node, bool $isDescription = false): string case $node instanceof StringValueNode: if ($node->block) { - return $this->printBlockString($node->value, $isDescription); + return BlockString::print($node->value, $isDescription ? '' : ' '); } return json_encode($node->value); @@ -518,27 +517,4 @@ protected function join(array $parts, string $separator = ''): string { return implode($separator, array_filter($parts)); } - - /** - * 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. - */ - protected function printBlockString(string $value, bool $isDescription): string - { - $escaped = str_replace('"""', '\\"""', $value); - - $startsWithWhitespace = $value[0] === ' ' || $value[0] === "\t"; - $doesNotEndWithNewline = strpos($value, "\n") === false; - - if ($startsWithWhitespace && $doesNotEndWithNewline) { - return '"""' . preg_replace('/"$/', "\"\n", $escaped) . '"""'; - } - - $content = $isDescription - ? $escaped - : $this->indent($escaped); - - return '"""' . "\n" . $content . "\n" . '"""'; - } } diff --git a/src/Utils/SchemaPrinter.php b/src/Utils/SchemaPrinter.php index e1a0d2850..aa4051311 100644 --- a/src/Utils/SchemaPrinter.php +++ b/src/Utils/SchemaPrinter.php @@ -5,6 +5,7 @@ namespace GraphQL\Utils; use GraphQL\Error\Error; +use GraphQL\Language\BlockString; use GraphQL\Language\Printer; use GraphQL\Type\Definition\Directive; use GraphQL\Type\Definition\EnumType; @@ -34,7 +35,6 @@ use function sprintf; use function str_replace; use function strlen; -use function substr; /** * Given an instance of Schema, prints it in schema definition language. @@ -167,46 +167,17 @@ protected static function printDescription(array $options, $def, string $indenta return ''; } - $lines = static::descriptionLines($def->description, 120 - strlen($indentation)); if (isset($options['commentDescriptions'])) { - return static::printDescriptionWithComments($lines, $indentation, $firstInBlock); - } - - $description = $indentation !== '' && ! $firstInBlock - ? "\n" . $indentation . '"""' - : $indentation . '"""'; + $lines = static::descriptionLines($def->description, 120 - strlen($indentation)); - // In some circumstances, a single line can be used for the description. - if ( - count($lines) === 1 && - mb_strlen($lines[0]) < 70 && - substr($lines[0], -1) !== '"' - ) { - return $description . static::escapeQuote($lines[0]) . "\"\"\"\n"; - } - - // Format a multi-line block quote to account for leading space. - $hasLeadingSpace = isset($lines[0]) && - ( - substr($lines[0], 0, 1) === ' ' || - substr($lines[0], 0, 1) === '\t' - ); - if (! $hasLeadingSpace) { - $description .= "\n"; - } - - $lineLength = count($lines); - for ($i = 0; $i < $lineLength; $i++) { - if ($i !== 0 || ! $hasLeadingSpace) { - $description .= $indentation; - } - - $description .= static::escapeQuote($lines[$i]) . "\n"; + return static::printDescriptionWithComments($lines, $indentation, $firstInBlock); } - $description .= $indentation . "\"\"\"\n"; + $preferMultipleLines = mb_strlen($def->description) > 70; + $blockString = BlockString::print($def->description, '', $preferMultipleLines); + $prefix = $indentation !== '' && ! $firstInBlock ? "\n" . $indentation : $indentation; - return $description; + return $prefix . str_replace("\n", "\n" . $indentation, $blockString) . "\n"; } /** @@ -264,11 +235,6 @@ protected static function printDescriptionWithComments(array $lines, string $ind return $description; } - protected static function escapeQuote(string $line): string - { - return str_replace('"""', '\\"""', $line); - } - /** * @param array $options * @param array $args diff --git a/tests/Language/BlockStringTest.php b/tests/Language/BlockStringTest.php index 0db5a4eb4..1dcf81905 100644 --- a/tests/Language/BlockStringTest.php +++ b/tests/Language/BlockStringTest.php @@ -174,4 +174,85 @@ public function testDoNotTakeEmptyLinesIntoAccount(): void self::assertEquals(1, BlockString::getIndentation("a\n\n b")); self::assertEquals(2, BlockString::getIndentation("a\n \n b")); } + + // describe('printBlockString') + + /** + * @see it('do not escape characters') + */ + public function testDoNotEscapeCharacters(): void + { + $str = "\" \\ / \u{8} \f \n \r \t"; // \u{8} === \b + + self::assertEquals("\"\"\"\n" . $str . "\n\"\"\"", BlockString::print($str)); + } + + /** + * @see it('by default print block strings as single line') + */ + public function testByDefaultPrintBlockStringsAsSingleLine(): void + { + $str = 'one liner'; + + self::assertEquals('"""one liner"""', BlockString::print($str)); + self::assertEquals("\"\"\"\none liner\n\"\"\"", BlockString::print($str, '', true)); + } + + /** + * @see it('correctly prints single-line with leading space') + */ + public function testCorrectlyPrintsSingleLineWithLeadingSpace(): void + { + $str = ' space-led string'; + + self::assertEquals('""" space-led string"""', BlockString::print($str)); + self::assertEquals("\"\"\" space-led string\n\"\"\"", BlockString::print($str, '', true)); + } + + /** + * @see it('correctly prints single-line with leading space and quotation') + */ + public function testCorrectlyPrintsSingleLineWithLeadingSpaceAndQuotation(): void + { + $str = ' space-led value "quoted string"'; + + self::assertEquals("\"\"\" space-led value \"quoted string\"\n\"\"\"", BlockString::print($str)); + self::assertEquals("\"\"\" space-led value \"quoted string\"\n\"\"\"", BlockString::print($str)); + } + + /** + * @see it('correctly prints single-line with trailing backslash') + */ + public function testCorrectlyPrintsSingleLineWithTrailingBackslash(): void + { + $str = 'backslash \\'; + + self::assertEquals("\"\"\"\nbackslash \\\n\"\"\"", BlockString::print($str)); + self::assertEquals("\"\"\"\nbackslash \\\n\"\"\"", BlockString::print($str, '', true)); + } + + /** + * @see it('correctly prints string with a first line indentation') + */ + public function testCorrectlyPrintsStringWithAFirstLineIndentation(): void + { + $str = self::joinLines( + ' first ', + ' line ', + 'indentation', + ' string', + ); + + self::assertEquals( + self::joinLines( + '"""', + ' first ', + ' line ', + 'indentation', + ' string', + '"""', + ), + BlockString::print($str) + ); + } } diff --git a/tests/Language/PrinterTest.php b/tests/Language/PrinterTest.php index 65b20720e..15fd6e5fc 100644 --- a/tests/Language/PrinterTest.php +++ b/tests/Language/PrinterTest.php @@ -119,67 +119,6 @@ public function testPrintsFragmentWithVariableDirectives(): void self::assertEquals($expected, Printer::doPrint($queryAstWithVariableDirective)); } - /** - * @see it('correctly prints single-line with leading space') - */ - public function testCorrectlyPrintsSingleLineBlockStringsWithLeadingSpace(): void - { - $mutationAstWithArtifacts = Parser::parse( - '{ field(arg: """ space-led value""") }' - ); - $expected = '{ - field(arg: """ space-led value""") -} -'; - self::assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); - } - - /** - * @see it('correctly prints string with a first line indentation') - */ - public function testCorrectlyPrintsBlockStringsWithAFirstLineIndentation(): void - { - $mutationAstWithArtifacts = Parser::parse( - '{ - field(arg: """ - first - line - indentation - """) -}' - ); - $expected = '{ - field(arg: """ - first - line - indentation - """) -} -'; - self::assertEquals($expected, Printer::doPrint($mutationAstWithArtifacts)); - } - - /** - * @see it('correctly prints single-line with leading space and quotation') - */ - public function testCorrectlyPrintsSingleLineWithLeadingSpaceAndQuotation(): void - { - $mutationAstWithArtifacts = Parser::parse(' - { - field(arg: """ space-led value "quoted string" - """) - } - '); - $expected = <<description); } - /** - * @see it('Does not one-line print a description that ends with a quote') - */ - public function testDoesNotOneLinePrintADescriptionThatEndsWithAQuote(): void - { - $description = 'This field is "awesome"'; - $output = $this->printSingleFieldSchema([ - 'type' => Type::string(), - 'description' => $description, - ]); - - self::assertEquals( - ' -type Query { - """ - This field is "awesome" - """ - singleField: String -} -', - $output - ); - - /** @var ObjectType $recreatedRoot */ - $recreatedRoot = BuildSchema::build($output)->getTypeMap()['Query']; - $recreatedField = $recreatedRoot->getFields()['singleField']; - self::assertEquals($description, $recreatedField->description); - } - - /** - * @see it('Preserves leading spaces when printing a description') - */ - public function testPReservesLeadingSpacesWhenPrintingADescription(): void - { - $description = ' This field is "awesome"'; - $output = $this->printSingleFieldSchema([ - 'type' => Type::string(), - 'description' => $description, - ]); - - self::assertEquals( - ' -type Query { - """ This field is "awesome" - """ - singleField: String -} -', - $output - ); - - /** @var ObjectType $recreatedRoot */ - $recreatedRoot = BuildSchema::build($output)->getTypeMap()['Query']; - $recreatedField = $recreatedRoot->getFields()['singleField']; - self::assertEquals($description, $recreatedField->description); - } - /** * @see it('Print Introspection Schema') */ @@ -942,9 +885,7 @@ public function testPrintIntrospectionSchema(): void """Marks an element of a GraphQL schema as no longer supported.""" directive @deprecated( """ - Explains why this element was deprecated, usually also including a suggestion - for how to access supported similar data. Formatted using the Markdown syntax - (as specified by [CommonMark](https://commonmark.org/). + Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax (as specified by [CommonMark](https://commonmark.org/). """ reason: String = "No longer supported" ) on FIELD_DEFINITION | ENUM_VALUE @@ -952,10 +893,7 @@ public function testPrintIntrospectionSchema(): void """ A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. -In some cases, you need to provide options to alter GraphQL's execution behavior -in ways field arguments will not suffice, such as conditionally including or -skipping a field. Directives provide this by describing additional information -to the executor. +In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor. """ type __Directive { name: String! @@ -966,8 +904,7 @@ public function testPrintIntrospectionSchema(): void } """ -A Directive can be adjacent to many parts of the GraphQL language, a -__DirectiveLocation describes one such possible adjacencies. +A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. """ enum __DirectiveLocation { """Location adjacent to a query operation.""" @@ -1029,9 +966,7 @@ enum __DirectiveLocation { } """ -One possible value for a given Enum. Enum values are unique values, not a -placeholder for a string or numeric value. However an Enum value is returned in -a JSON response as a string. +One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string. """ type __EnumValue { name: String! @@ -1041,8 +976,7 @@ enum __DirectiveLocation { } """ -Object and Interface types are described by a list of Fields, each of which has -a name, potentially a list of arguments, and a return type. +Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type. """ type __Field { name: String! @@ -1054,9 +988,7 @@ enum __DirectiveLocation { } """ -Arguments provided to Fields or Directives and the input fields of an -InputObject are represented as Input Values which describe their type and -optionally a default value. +Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value. """ type __InputValue { name: String! @@ -1070,9 +1002,7 @@ enum __DirectiveLocation { } """ -A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all -available types and directives on the server, as well as the entry points for -query, mutation, and subscription operations. +A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. """ type __Schema { """A list of all types supported by this server.""" @@ -1096,14 +1026,9 @@ enum __DirectiveLocation { } """ -The fundamental unit of any GraphQL Schema is the type. There are many kinds of -types in GraphQL as represented by the `__TypeKind` enum. - -Depending on the kind of a type, certain fields describe information about that -type. Scalar types provide no information beyond a name and description, while -Enum types provide their values. Object and Interface types provide the fields -they describe. Abstract types, Union and Interface, provide the Object types -possible at runtime. List and NonNull types compose other types. +The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum. + +Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. """ type __Type { kind: __TypeKind!