From 9b3eab758ef08af51054b79a6fd92b482a0c3253 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:35:25 +0300 Subject: [PATCH 1/7] Optimize Lexer::readString and Visitor::visitInParallel Lexer::readString previously decoded and validated one UTF-8 character at a time via Lexer::readChar(). Replace this with a bulk scan using strcspn() to copy runs of bytes that need no special handling (i.e. anything other than a quote, backslash, or control character) in a single native call, falling back to the original per-character logic only for quotes, line terminators, control characters, and escape sequences. Care is taken to keep $this->position (a decoded-character count, not a byte count) accurate for multi-byte UTF-8 content. TAB (0x09), a legal, unescaped SourceCharacter per the GraphQL spec, is excluded from the stop-byte set so it is scanned through like any other ordinary character instead of being individually inspected. Visitor::visitInParallel previously called extractVisitFn() to resolve the enter/leave callback for the current visitor and node kind on every single node, for every wrapped visitor - an O(nodes x visitors) number of array lookups, even though a callback's identity for a given (visitor, kind, direction) triple never changes during a traversal. Since NodeKind exposes a small, fixed set of kinds, precompute this mapping once per call (O(visitors x kinds)) instead. Additionally, replace func_get_args() plus argument unpacking in the enter/leave dispatcher closures with the five explicit, typed parameters they are always called with (matching Visitor::visit()'s single call site), avoiding func_get_args()'s per-call overhead. Add a regression test for the TAB-handling edge case described above. --- src/Language/Lexer.php | 178 ++++++++++++++++++++--------------- src/Language/Visitor.php | 42 ++++++--- tests/Language/LexerTest.php | 15 +++ 3 files changed, 147 insertions(+), 88 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index de2314319..4fbf9d25f 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -391,18 +391,50 @@ private function readString(int $line, int $col, Token $prev): Token { $start = $this->position; - // Skip leading quote and read first string char: - [$char, $code, $bytes] = $this->moveStringCursor(1, 1) - ->readChar(); + // Skip leading quote + $this->moveStringCursor(1, 1); + + // Bulk-scan runs of bytes that need no special handling via strcspn() + // (a single native call) instead of decoding/validating one UTF-8 + // character at a time. + // + // The stop-byte set below includes ASCII control characters + // (0x00-0x1F), not just backslash/quote/line-terminators, because + // assertValidStringCharacterCode() must still reject most of them. + // TAB (0x09) is excluded: it is a legal, unescaped SourceCharacter + // per the GraphQL spec, so it is scanned through like any other + // ordinary character instead of being individually inspected. + static $stopBytes; + $stopBytes ??= '"\\' . implode('', array_map('chr', array_diff(range(0x00, 0x1F), [0x09]))); - $chunk = ''; + $body = $this->source->body; + $bodyLength = strlen($body); $value = ''; - while (! in_array($code, [null, 10, 13], true)) { // not LineTerminator - if ($code === 34) { // Closing Quote (") + while (true) { + $byteStart = $this->byteStreamPosition; + $runLength = strcspn($body, $stopBytes, $byteStart); + + if ($runLength > 0) { + $chunk = substr($body, $byteStart, $runLength); $value .= $chunk; + // $this->position counts decoded characters, not bytes: count the + // non-continuation bytes (i.e. NOT matching 10xxxxxx) in the chunk to + // keep it accurate for multi-byte UTF-8 content. + $continuationBytes = preg_match_all('/[\x80-\xBF]/', $chunk); + assert(is_int($continuationBytes), 'Pattern is valid, so preg_match_all() cannot fail'); + $this->position += strlen($chunk) - $continuationBytes; + $this->byteStreamPosition = $byteStart + $runLength; + } - // Skip quote + $bytePos = $this->byteStreamPosition; + if ($bytePos >= $bodyLength) { + break; // EOF mid-string + } + + $code = ord($body[$bytePos]); + + if ($code === 34) { // Closing Quote (") $this->moveStringCursor(1, 1); return new Token( @@ -416,79 +448,77 @@ private function readString(int $line, int $col, Token $prev): Token ); } - $this->assertValidStringCharacterCode($code, $this->position); - $this->moveStringCursor(1, $bytes); - - if ($code === 92) { // \ - $value .= $chunk; - [, $code] = $this->readChar(true); - - switch ($code) { - case 34: - $value .= '"'; - break; - case 47: - $value .= '/'; - break; - case 92: - $value .= '\\'; - break; - case 98: - $value .= chr(8); // \b (backspace) - break; - case 102: - $value .= "\f"; - break; - case 110: - $value .= "\n"; - break; - case 114: - $value .= "\r"; - break; - case 116: - $value .= "\t"; - break; - case 117: - $position = $this->position; - [$hex] = $this->readChars(4); - if (preg_match('/[0-9a-fA-F]{4}/', $hex) !== 1) { - throw new SyntaxError($this->source, $position - 1, "Invalid character escape sequence: \\u{$hex}"); - } - - $code = hexdec($hex); - assert(is_int($code), 'Since only a single char is read'); + if ($code === 10 || $code === 13) { // LineTerminator + break; + } - // UTF-16 surrogate pair detection and handling. - $highOrderByte = $code >> 8; - if ($highOrderByte >= 0xD8 && $highOrderByte <= 0xDF) { - [$utf16Continuation] = $this->readChars(6); - if (preg_match('/^\\\u[0-9a-fA-F]{4}$/', $utf16Continuation) !== 1) { - throw new SyntaxError($this->source, $this->position - 5, 'Invalid UTF-16 trailing surrogate: ' . $utf16Continuation); - } + // Any other control character reaching here must be invalid + // (TAB, the only legal one, is excluded from the stop-byte set). + $this->assertValidStringCharacterCode($code, $this->position); - $surrogatePairHex = $hex . substr($utf16Continuation, 2, 4); - $value .= mb_convert_encoding(pack('H*', $surrogatePairHex), 'UTF-8', 'UTF-16'); - break; + // $code === 92 (\), i.e. an escape sequence. + $this->moveStringCursor(1, 1); + [, $code] = $this->readChar(true); + + switch ($code) { + case 34: + $value .= '"'; + break; + case 47: + $value .= '/'; + break; + case 92: + $value .= '\\'; + break; + case 98: + $value .= chr(8); // \b (backspace) + break; + case 102: + $value .= "\f"; + break; + case 110: + $value .= "\n"; + break; + case 114: + $value .= "\r"; + break; + case 116: + $value .= "\t"; + break; + case 117: + $position = $this->position; + [$hex] = $this->readChars(4); + if (preg_match('/[0-9a-fA-F]{4}/', $hex) !== 1) { + throw new SyntaxError($this->source, $position - 1, "Invalid character escape sequence: \\u{$hex}"); + } + + $code = hexdec($hex); + assert(is_int($code), 'Since only a single char is read'); + + // UTF-16 surrogate pair detection and handling. + $highOrderByte = $code >> 8; + if ($highOrderByte >= 0xD8 && $highOrderByte <= 0xDF) { + [$utf16Continuation] = $this->readChars(6); + if (preg_match('/^\\\u[0-9a-fA-F]{4}$/', $utf16Continuation) !== 1) { + throw new SyntaxError($this->source, $this->position - 5, 'Invalid UTF-16 trailing surrogate: ' . $utf16Continuation); } - $this->assertValidStringCharacterCode($code, $position - 2); - - $value .= Utils::chr($code); + $surrogatePairHex = $hex . substr($utf16Continuation, 2, 4); + $value .= mb_convert_encoding(pack('H*', $surrogatePairHex), 'UTF-8', 'UTF-16'); break; - // null means EOF, will delegate to general handling of unterminated strings - case null: - continue 2; - default: - $chr = Utils::chr($code); - throw new SyntaxError($this->source, $this->position - 1, "Invalid character escape sequence: \\{$chr}"); - } - - $chunk = ''; - } else { - $chunk .= $char; + } + + $this->assertValidStringCharacterCode($code, $position - 2); + + $value .= Utils::chr($code); + break; + // null means EOF, will delegate to general handling of unterminated strings + case null: + break; + default: + $chr = Utils::chr($code); + throw new SyntaxError($this->source, $this->position - 1, "Invalid character escape sequence: \\{$chr}"); } - - [$char, $code, $bytes] = $this->readChar(); } throw new SyntaxError($this->source, $this->position, 'Unterminated string.'); diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index d1101c68a..750a6d8c3 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -383,24 +383,42 @@ public static function visitInParallel(array $visitors): array $visitorsCount = count($visitors); $skipping = new \SplFixedArray($visitorsCount); + // extractVisitFn() otherwise re-derives the same enter/leave callable + // via array lookups on every single node visited, for every visitor - + // O(nodes x visitors) calls - even though its result for a given + // (visitor, kind, isLeaving) triple never changes during one + // traversal. NodeKind has a small, fixed set of constants, so + // precompute the callback per visitor/kind/direction once here + // (O(visitors x kinds)) instead. + static $allKinds; + $allKinds ??= array_values(array_filter((new \ReflectionClass(NodeKind::class))->getConstants(), 'is_string')); + + $enterFns = []; + $leaveFns = []; + foreach ($visitors as $i => $visitor) { + foreach ($allKinds as $kind) { + $enterFns[$i][$kind] = self::extractVisitFn($visitor, $kind, false); + $leaveFns[$i][$kind] = self::extractVisitFn($visitor, $kind, true); + } + } + return [ - 'enter' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { + // Declares node, key, parent, path, and ancestors explicitly to + // match Visitor::visit()'s `$visitFn($node, $key, $parent, + // $path, $ancestors)` call. + 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $enterFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; } - $fn = self::extractVisitFn( - $visitors[$i], - $node->kind, - false - ); + $fn = $enterFns[$i][$node->kind] ?? null; if ($fn === null) { continue; } - $result = $fn(...func_get_args()); + $result = $fn($node, $key, $parent, $path, $ancestors); if ($result === null) { continue; @@ -416,17 +434,13 @@ public static function visitInParallel(array $visitors): array return null; }, - 'leave' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { + 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $leaveFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { - $fn = self::extractVisitFn( - $visitors[$i], - $node->kind, - true - ); + $fn = $leaveFns[$i][$node->kind] ?? null; if ($fn !== null) { - $result = $fn(...func_get_args()); + $result = $fn($node, $key, $parent, $path, $ancestors); if ($result === null) { continue; diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index 25ad8f11d..e7ae5ff56 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -296,6 +296,21 @@ public function testLexesStrings(): void ); } + public function testLexesStringsWithUnescapedTabCharacter(): void + { + $source = '"before' . "\t" . 'after"'; + + self::assertArraySubset( + [ + 'kind' => Token::STRING, + 'start' => 0, + 'end' => strlen($source), + 'value' => 'before' . "\t" . 'after', + ], + (array) $this->lexOne($source) + ); + } + /** @see it('lexes block strings') */ public function testLexesBlockString(): void { From 5605b5b12029054b1c10e470ebf69ca8e4ac47c3 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:09:13 +0300 Subject: [PATCH 2/7] Fix visitInParallel() dropping visitors for custom Node kinds The enter/leave callback caches were previously prefilled only for NodeKind constants, silently skipping generic and kind-specific visitors for Nodes with custom (non-NodeKind) kinds. Switch to lazy, per-direction caches that resolve and store a callback (or `false` as a "no callback" sentinel) on first use, keyed by any kind string. Also fixes a pre-existing undefined-array-key warning in visit() when traversing a Node with a custom kind. Add regression tests covering generic and kind-specific visitors for a custom Node kind. --- src/Language/Visitor.php | 43 +++++++++++--------- tests/Language/VisitorTest.php | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 18 deletions(-) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 750a6d8c3..3b31fc38d 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -319,7 +319,9 @@ public static function visit(object $root, array $visitor, ?array $keyMap = null ]; $inList = $node instanceof NodeList; - $keys = ($inList ? $node : $visitorKeys[$node->kind]) ?? []; + // Nodes with a kind not present in $visitorKeys (e.g. a custom + // Node subclass) are treated as leaves, i.e. no children. + $keys = $inList ? $node : ($visitorKeys[$node->kind] ?? []); $index = -1; $edits = []; if ($parent !== null) { @@ -387,34 +389,34 @@ public static function visitInParallel(array $visitors): array // via array lookups on every single node visited, for every visitor - // O(nodes x visitors) calls - even though its result for a given // (visitor, kind, isLeaving) triple never changes during one - // traversal. NodeKind has a small, fixed set of constants, so - // precompute the callback per visitor/kind/direction once here - // (O(visitors x kinds)) instead. - static $allKinds; - $allKinds ??= array_values(array_filter((new \ReflectionClass(NodeKind::class))->getConstants(), 'is_string')); - + // traversal. Cache it per visitor/kind on first encounter instead, + // using `false` as a "no callback" sentinel so a plain `??` lookup + // can tell "not cached yet" (missing key -> null) apart from + // "cached, nothing to call" (`false`). A Node's kind isn't required + // to be a NodeKind constant (custom Node subclasses are supported), + // so this works for arbitrary kinds, not just the built-in ones. + // Enter and leave are cached independently in their own dispatcher. $enterFns = []; $leaveFns = []; - foreach ($visitors as $i => $visitor) { - foreach ($allKinds as $kind) { - $enterFns[$i][$kind] = self::extractVisitFn($visitor, $kind, false); - $leaveFns[$i][$kind] = self::extractVisitFn($visitor, $kind, true); - } - } return [ // Declares node, key, parent, path, and ancestors explicitly to // match Visitor::visit()'s `$visitFn($node, $key, $parent, // $path, $ancestors)` call. - 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $enterFns) { + 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; } - $fn = $enterFns[$i][$node->kind] ?? null; + $kind = $node->kind; + $fn = $enterFns[$i][$kind] ?? null; if ($fn === null) { + $fn = $enterFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, false) ?? false; + } + + if ($fn === false) { continue; } @@ -434,12 +436,17 @@ public static function visitInParallel(array $visitors): array return null; }, - 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($skipping, $visitorsCount, $leaveFns) { + 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { - $fn = $leaveFns[$i][$node->kind] ?? null; + $kind = $node->kind; + $fn = $leaveFns[$i][$kind] ?? null; + + if ($fn === null) { + $fn = $leaveFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, true) ?? false; + } - if ($fn !== null) { + if ($fn !== false) { $result = $fn($node, $key, $parent, $path, $ancestors); if ($result === null) { diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 12acd43e0..3919b1868 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1905,4 +1905,77 @@ public function testThrowsExceptionWhenAddingIntegerToNodeList(): void ] ); } + + /** + * visitInParallel() must still invoke generic enter/leave visitors for a + * Node whose kind is not one of the built-in NodeKind constants (e.g. a + * custom Node subclass defined by a consumer). See + * https://github.com/webonyx/graphql-php/pull/1948 for context: an + * optimization that precomputes enter/leave callbacks per NodeKind + * constant must not silently drop callbacks for unknown kinds. + */ + public function testVisitInParallelCallsGenericVisitorsForCustomKind(): void + { + $customNode = new class([]) extends Node { + public string $kind = 'CustomKind'; + }; + + $visited = []; + + Visitor::visit( + $customNode, + Visitor::visitInParallel([ + [ + 'enter' => static function (Node $node) use (&$visited): void { + $visited[] = ['enter', $node->kind]; + }, + 'leave' => static function (Node $node) use (&$visited): void { + $visited[] = ['leave', $node->kind]; + }, + ], + ]) + ); + + self::assertSame( + [ + ['enter', 'CustomKind'], + ['leave', 'CustomKind'], + ], + $visited + ); + } + + /** Same as above, but for a visitor keyed specifically by the custom kind. */ + public function testVisitInParallelCallsKindSpecificVisitorForCustomKind(): void + { + $customNode = new class([]) extends Node { + public string $kind = 'CustomKind'; + }; + + $visited = []; + + Visitor::visit( + $customNode, + Visitor::visitInParallel([ + [ + 'CustomKind' => [ + 'enter' => static function (Node $node) use (&$visited): void { + $visited[] = ['enter', $node->kind]; + }, + 'leave' => static function (Node $node) use (&$visited): void { + $visited[] = ['leave', $node->kind]; + }, + ], + ], + ]) + ); + + self::assertSame( + [ + ['enter', 'CustomKind'], + ['leave', 'CustomKind'], + ], + $visited + ); + } } From 44f76e8e8d5d044fb2ee838607832ba13250f45c Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:17:16 +0300 Subject: [PATCH 3/7] Address review feedback on Lexer/Visitor optimization - Use a HEREDOC for the tab-character test source, deriving the expected value from it via substr() so input and expectation can't drift apart. - Type-hint $path/$ancestors as array in visitInParallel()'s enter/leave closures, and document $key/$parent via @phpstan-param since PHP 7.4 doesn't support union types. - Add @var docblocks describing the shape of the enter/leave callback caches. - Replace the lazily-built $stopBytes runtime computation in Lexer::readString() with a precomputed STRING_STOP_BYTES constant. --- src/Language/Lexer.php | 21 +++++++++++++-------- src/Language/Visitor.php | 25 ++++++++++++++++++++----- tests/Language/LexerTest.php | 6 ++++-- tests/Language/VisitorTest.php | 2 +- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 4fbf9d25f..91e30e25f 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -34,6 +34,15 @@ class Lexer private const TOKEN_PIPE = 124; private const TOKEN_BRACE_R = 125; + /** + * Bytes that must be individually inspected while scanning a string body: + * the closing quote ("), the escape character (\), and all C0 control + * characters (0x00-0x1F) except TAB (0x09), which is a legal, unescaped + * SourceCharacter per the GraphQL spec and is scanned through like any + * other ordinary character. + */ + private const STRING_STOP_BYTES = "\"\\\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"; + public Source $source; /** @phpstan-var ParserOptions */ @@ -398,14 +407,10 @@ private function readString(int $line, int $col, Token $prev): Token // (a single native call) instead of decoding/validating one UTF-8 // character at a time. // - // The stop-byte set below includes ASCII control characters - // (0x00-0x1F), not just backslash/quote/line-terminators, because + // The stop-byte set includes ASCII control characters (0x00-0x1F), + // not just backslash/quote/line-terminators, because // assertValidStringCharacterCode() must still reject most of them. - // TAB (0x09) is excluded: it is a legal, unescaped SourceCharacter - // per the GraphQL spec, so it is scanned through like any other - // ordinary character instead of being individually inspected. - static $stopBytes; - $stopBytes ??= '"\\' . implode('', array_map('chr', array_diff(range(0x00, 0x1F), [0x09]))); + // See self::STRING_STOP_BYTES for details on the excluded TAB (0x09). $body = $this->source->body; $bodyLength = strlen($body); @@ -413,7 +418,7 @@ private function readString(int $line, int $col, Token $prev): Token while (true) { $byteStart = $this->byteStreamPosition; - $runLength = strcspn($body, $stopBytes, $byteStart); + $runLength = strcspn($body, self::STRING_STOP_BYTES, $byteStart); if ($runLength > 0) { $chunk = substr($body, $byteStart, $runLength); diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 3b31fc38d..4732302c8 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -396,14 +396,23 @@ public static function visitInParallel(array $visitors): array // to be a NodeKind constant (custom Node subclasses are supported), // so this works for arbitrary kinds, not just the built-in ones. // Enter and leave are cached independently in their own dispatcher. + /** @var array> $enterFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no enter callback for this visitor/kind". */ $enterFns = []; + /** @var array> $leaveFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no leave callback for this visitor/kind". */ $leaveFns = []; return [ - // Declares node, key, parent, path, and ancestors explicitly to - // match Visitor::visit()'s `$visitFn($node, $key, $parent, - // $path, $ancestors)` call. - 'enter' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { + /** + * Declares node, key, parent, path, and ancestors explicitly to + * match Visitor::visit()'s `$visitFn($node, $key, $parent, + * $path, $ancestors)` call. + * + * @phpstan-param string|int|null $key + * @phpstan-param Node|NodeList|null $parent + * @phpstan-param array $path + * @phpstan-param array> $ancestors + */ + 'enter' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; @@ -436,7 +445,13 @@ public static function visitInParallel(array $visitors): array return null; }, - 'leave' => static function (Node $node, $key, $parent, $path, $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { + /** + * @phpstan-param string|int|null $key + * @phpstan-param Node|NodeList|null $parent + * @phpstan-param array $path + * @phpstan-param array> $ancestors + */ + 'leave' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { $kind = $node->kind; diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index e7ae5ff56..bd0028313 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -298,14 +298,16 @@ public function testLexesStrings(): void public function testLexesStringsWithUnescapedTabCharacter(): void { - $source = '"before' . "\t" . 'after"'; + $source = <<<'GRAPHQL' + "before after" + GRAPHQL; self::assertArraySubset( [ 'kind' => Token::STRING, 'start' => 0, 'end' => strlen($source), - 'value' => 'before' . "\t" . 'after', + 'value' => substr($source, 1, -1), // strip the surrounding quotes ], (array) $this->lexOne($source) ); diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 3919b1868..2c43802d8 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1945,7 +1945,7 @@ public function testVisitInParallelCallsGenericVisitorsForCustomKind(): void ); } - /** Same as above, but for a visitor keyed specifically by the custom kind. */ + /** Same as testVisitInParallelCallsGenericVisitorsForCustomKind, but for a visitor keyed specifically by the custom kind. */ public function testVisitInParallelCallsKindSpecificVisitorForCustomKind(): void { $customNode = new class([]) extends Node { From cf27f7eabea9c52ad8210b2918ad554c1c93cc02 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:43:32 +0300 Subject: [PATCH 4/7] Remove visit() custom-kind bugfix, split into #1952 The visit() undefined-array-key warning fix for custom Node kinds, along with its regression tests, has been split out into a separate PR (webonyx/graphql-php#1952) since it is unrelated to the Lexer/Visitor optimization work here. --- src/Language/Visitor.php | 4 +- tests/Language/VisitorTest.php | 73 ---------------------------------- 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 4732302c8..23ce8dc56 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -319,9 +319,7 @@ public static function visit(object $root, array $visitor, ?array $keyMap = null ]; $inList = $node instanceof NodeList; - // Nodes with a kind not present in $visitorKeys (e.g. a custom - // Node subclass) are treated as leaves, i.e. no children. - $keys = $inList ? $node : ($visitorKeys[$node->kind] ?? []); + $keys = ($inList ? $node : $visitorKeys[$node->kind]) ?? []; $index = -1; $edits = []; if ($parent !== null) { diff --git a/tests/Language/VisitorTest.php b/tests/Language/VisitorTest.php index 2c43802d8..12acd43e0 100644 --- a/tests/Language/VisitorTest.php +++ b/tests/Language/VisitorTest.php @@ -1905,77 +1905,4 @@ public function testThrowsExceptionWhenAddingIntegerToNodeList(): void ] ); } - - /** - * visitInParallel() must still invoke generic enter/leave visitors for a - * Node whose kind is not one of the built-in NodeKind constants (e.g. a - * custom Node subclass defined by a consumer). See - * https://github.com/webonyx/graphql-php/pull/1948 for context: an - * optimization that precomputes enter/leave callbacks per NodeKind - * constant must not silently drop callbacks for unknown kinds. - */ - public function testVisitInParallelCallsGenericVisitorsForCustomKind(): void - { - $customNode = new class([]) extends Node { - public string $kind = 'CustomKind'; - }; - - $visited = []; - - Visitor::visit( - $customNode, - Visitor::visitInParallel([ - [ - 'enter' => static function (Node $node) use (&$visited): void { - $visited[] = ['enter', $node->kind]; - }, - 'leave' => static function (Node $node) use (&$visited): void { - $visited[] = ['leave', $node->kind]; - }, - ], - ]) - ); - - self::assertSame( - [ - ['enter', 'CustomKind'], - ['leave', 'CustomKind'], - ], - $visited - ); - } - - /** Same as testVisitInParallelCallsGenericVisitorsForCustomKind, but for a visitor keyed specifically by the custom kind. */ - public function testVisitInParallelCallsKindSpecificVisitorForCustomKind(): void - { - $customNode = new class([]) extends Node { - public string $kind = 'CustomKind'; - }; - - $visited = []; - - Visitor::visit( - $customNode, - Visitor::visitInParallel([ - [ - 'CustomKind' => [ - 'enter' => static function (Node $node) use (&$visited): void { - $visited[] = ['enter', $node->kind]; - }, - 'leave' => static function (Node $node) use (&$visited): void { - $visited[] = ['leave', $node->kind]; - }, - ], - ], - ]) - ); - - self::assertSame( - [ - ['enter', 'CustomKind'], - ['leave', 'CustomKind'], - ], - $visited - ); - } } From 65089c97bf82da951f7f40c7e42df1c3c70866e7 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:24:17 +0300 Subject: [PATCH 5/7] Remove Visitor optimization, split into #1953 The Visitor::visitInParallel() enter/leave callback caching optimization has been split out into a separate PR (webonyx/graphql-php#1953), so it can be reviewed independently of the Lexer::readString optimization here. --- src/Language/Visitor.php | 66 ++++++++++------------------------------ 1 file changed, 16 insertions(+), 50 deletions(-) diff --git a/src/Language/Visitor.php b/src/Language/Visitor.php index 23ce8dc56..d1101c68a 100644 --- a/src/Language/Visitor.php +++ b/src/Language/Visitor.php @@ -383,51 +383,24 @@ public static function visitInParallel(array $visitors): array $visitorsCount = count($visitors); $skipping = new \SplFixedArray($visitorsCount); - // extractVisitFn() otherwise re-derives the same enter/leave callable - // via array lookups on every single node visited, for every visitor - - // O(nodes x visitors) calls - even though its result for a given - // (visitor, kind, isLeaving) triple never changes during one - // traversal. Cache it per visitor/kind on first encounter instead, - // using `false` as a "no callback" sentinel so a plain `??` lookup - // can tell "not cached yet" (missing key -> null) apart from - // "cached, nothing to call" (`false`). A Node's kind isn't required - // to be a NodeKind constant (custom Node subclasses are supported), - // so this works for arbitrary kinds, not just the built-in ones. - // Enter and leave are cached independently in their own dispatcher. - /** @var array> $enterFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no enter callback for this visitor/kind". */ - $enterFns = []; - /** @var array> $leaveFns Cache of extractVisitFn() results, keyed by visitor index then node kind; `false` means "no leave callback for this visitor/kind". */ - $leaveFns = []; - return [ - /** - * Declares node, key, parent, path, and ancestors explicitly to - * match Visitor::visit()'s `$visitFn($node, $key, $parent, - * $path, $ancestors)` call. - * - * @phpstan-param string|int|null $key - * @phpstan-param Node|NodeList|null $parent - * @phpstan-param array $path - * @phpstan-param array> $ancestors - */ - 'enter' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$enterFns) { + 'enter' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] !== null) { continue; } - $kind = $node->kind; - $fn = $enterFns[$i][$kind] ?? null; + $fn = self::extractVisitFn( + $visitors[$i], + $node->kind, + false + ); if ($fn === null) { - $fn = $enterFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, false) ?? false; - } - - if ($fn === false) { continue; } - $result = $fn($node, $key, $parent, $path, $ancestors); + $result = $fn(...func_get_args()); if ($result === null) { continue; @@ -443,24 +416,17 @@ public static function visitInParallel(array $visitors): array return null; }, - /** - * @phpstan-param string|int|null $key - * @phpstan-param Node|NodeList|null $parent - * @phpstan-param array $path - * @phpstan-param array> $ancestors - */ - 'leave' => static function (Node $node, $key, $parent, array $path, array $ancestors) use ($visitors, $skipping, $visitorsCount, &$leaveFns) { + 'leave' => static function (Node $node) use ($visitors, $skipping, $visitorsCount) { for ($i = 0; $i < $visitorsCount; ++$i) { if ($skipping[$i] === null) { - $kind = $node->kind; - $fn = $leaveFns[$i][$kind] ?? null; - - if ($fn === null) { - $fn = $leaveFns[$i][$kind] = self::extractVisitFn($visitors[$i], $kind, true) ?? false; - } - - if ($fn !== false) { - $result = $fn($node, $key, $parent, $path, $ancestors); + $fn = self::extractVisitFn( + $visitors[$i], + $node->kind, + true + ); + + if ($fn !== null) { + $result = $fn(...func_get_args()); if ($result === null) { continue; From 031cb5258883124bede559f773d8ab92a2436c04 Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Fri, 24 Jul 2026 06:42:40 +0300 Subject: [PATCH 6/7] Fix loose switch comparison mistaking NUL byte for EOF in string escapes PHP's switch uses loose comparison, so `case null` also matches code 0, causing a backslash followed by a raw NUL byte to be treated as EOF instead of an invalid escape sequence. Check for EOF with a strict comparison before the switch instead. --- src/Language/Lexer.php | 8 +++++--- tests/Language/LexerTest.php | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 91e30e25f..d64f71ce2 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -465,6 +465,11 @@ private function readString(int $line, int $col, Token $prev): Token $this->moveStringCursor(1, 1); [, $code] = $this->readChar(true); + // null means EOF; checked separately since switch is not a strict comparison. + if ($code === null) { + break; + } + switch ($code) { case 34: $value .= '"'; @@ -517,9 +522,6 @@ private function readString(int $line, int $col, Token $prev): Token $value .= Utils::chr($code); break; - // null means EOF, will delegate to general handling of unterminated strings - case null: - break; default: $chr = Utils::chr($code); throw new SyntaxError($this->source, $this->position - 1, "Invalid character escape sequence: \\{$chr}"); diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index bd0028313..c913928ca 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -440,6 +440,7 @@ public static function reportsUsefulStringErrors(): iterable yield ['"bad \\uD835\\uXXXX esc"', 'Invalid UTF-16 trailing surrogate: \\uXXXX', self::loc(1, 13)]; yield ['"bad \\uD835\\uFXXX esc"', 'Invalid UTF-16 trailing surrogate: \\uFXXX', self::loc(1, 13)]; yield ['"bad \\uD835\\uXXXF esc"', 'Invalid UTF-16 trailing surrogate: \\uXXXF', self::loc(1, 13)]; + yield ['"bad \\' . "\0" . ' esc"', 'Invalid character escape sequence: \\' . "\0", self::loc(1, 7)]; } /** From cc722fdc06a9392d8b8fe75604d084f8a8e9b1fa Mon Sep 17 00:00:00 2001 From: OpaqueRock <305760598+OpaqueRock@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:55:00 +0300 Subject: [PATCH 7/7] Use mb_strlen for codepoint counting, simplify tab-character test --- src/Language/Lexer.php | 9 +++------ tests/Language/LexerTest.php | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index d64f71ce2..928a49550 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -423,12 +423,9 @@ private function readString(int $line, int $col, Token $prev): Token if ($runLength > 0) { $chunk = substr($body, $byteStart, $runLength); $value .= $chunk; - // $this->position counts decoded characters, not bytes: count the - // non-continuation bytes (i.e. NOT matching 10xxxxxx) in the chunk to - // keep it accurate for multi-byte UTF-8 content. - $continuationBytes = preg_match_all('/[\x80-\xBF]/', $chunk); - assert(is_int($continuationBytes), 'Pattern is valid, so preg_match_all() cannot fail'); - $this->position += strlen($chunk) - $continuationBytes; + // $this->position counts decoded characters, not bytes: use + // mb_strlen() to get the codepoint count for multi-byte UTF-8 content. + $this->position += mb_strlen($chunk, 'UTF-8'); $this->byteStreamPosition = $byteStart + $runLength; } diff --git a/tests/Language/LexerTest.php b/tests/Language/LexerTest.php index c913928ca..fa7969978 100644 --- a/tests/Language/LexerTest.php +++ b/tests/Language/LexerTest.php @@ -306,8 +306,8 @@ public function testLexesStringsWithUnescapedTabCharacter(): void [ 'kind' => Token::STRING, 'start' => 0, - 'end' => strlen($source), - 'value' => substr($source, 1, -1), // strip the surrounding quotes + 'end' => 14, + 'value' => "before\tafter", ], (array) $this->lexOne($source) );