From 42bf452c53156df1fdae6202775f162b746772fd Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 2 Oct 2026 22:42:18 +0000 Subject: [PATCH 1/5] test: update FakeMessage method signatures for google/protobuf compatibility --- tests/Unit/autoload.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/autoload.php b/tests/Unit/autoload.php index 5252100a5..3cfb7cfd5 100644 --- a/tests/Unit/autoload.php +++ b/tests/Unit/autoload.php @@ -64,7 +64,7 @@ public function __call(string $name, array $arguments) } } - public function serializeToString() + public function serializeToString($recursion_limit = \Google\Protobuf\Internal\CodedInputStream::DEFAULT_RECURSION_LIMIT) { // This serialization does not need to be compatible with real proto serialization. $result = ''; @@ -75,7 +75,7 @@ public function serializeToString() return $result; } - public function mergeFromString($s) + public function mergeFromString($s, $recursion_limit = \Google\Protobuf\Internal\CodedInputStream::DEFAULT_RECURSION_LIMIT) { $pos = 0; while ($pos < strlen($s)) { From aa27f216c6eb263828f5b804bd67868414499b7f Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 5 Oct 2026 20:09:43 +0000 Subject: [PATCH 2/5] chore: replace microsoft/tolerant-php-parser with native PhpToken --- composer.json | 5 +- .../FragmentInjectionProcessor.php | 125 ++++++++++++------ 2 files changed, 91 insertions(+), 39 deletions(-) diff --git a/composer.json b/composer.json index 86dbdc72f..c162ebafa 100644 --- a/composer.json +++ b/composer.json @@ -38,7 +38,6 @@ "google/protobuf": "^5.0", "friendsofphp/php-cs-fixer": "^3", "symfony/yaml": "^6.0", - "microsoft/tolerant-php-parser": "^0.1.2", "symplify/coding-standard": "13.0.1" }, "require-dev": { @@ -55,6 +54,10 @@ "config": { "platform": { "php": "8.2" + }, + "allow-plugins": { + "php-http/discovery": false, + "tbachert/spi": false } } } diff --git a/src/PostProcessor/FragmentInjectionProcessor.php b/src/PostProcessor/FragmentInjectionProcessor.php index fa0ca8535..d885dee07 100644 --- a/src/PostProcessor/FragmentInjectionProcessor.php +++ b/src/PostProcessor/FragmentInjectionProcessor.php @@ -19,16 +19,15 @@ namespace Google\PostProcessor; use LogicException; -use Microsoft\PhpParser\DiagnosticsProvider; -use Microsoft\PhpParser\Node\MethodDeclaration; -use Microsoft\PhpParser\Node\Statement\ClassDeclaration; -use Microsoft\PhpParser\Parser; -use Microsoft\PhpParser\PositionUtilities; use ParseError; +use PhpToken; class FragmentInjectionProcessor implements ProcessorInterface { - private ClassDeclaration $classNode; + private string $contents; + /** @var array */ + private array $methods; + private int $classEndLine; public static function run(string $inputDir): void { @@ -62,26 +61,89 @@ private static function inject(string $fragmentFile, string $classFile): void print("Fragment written to $classFile\n"); } - private static function fromCode(string $contents): ClassDeclaration + /** + * @return array{array, int} + */ + private static function parseClass(string $contents): array { - $parser = new Parser(); - $astNode = $parser->parseSourceFile($contents); - if ($errors = DiagnosticsProvider::getDiagnostics($astNode)) { - throw new ParseError('Provided contents contains a PHP syntax error'); + try { + $tokens = PhpToken::tokenize($contents, TOKEN_PARSE); + } catch (ParseError $e) { + throw new ParseError('Provided contents contains a PHP syntax error', 0, $e); } - foreach ($astNode->getDescendantNodes() as $childNode) { - if ($childNode instanceof ClassDeclaration) { - return $childNode; + $count = count($tokens); + $inClass = false; + $depth = 0; + $memberStartLine = null; + $methods = []; + $classEndLine = null; + + for ($i = 0; $i < $count; $i++) { + $token = $tokens[$i]; + if (!$inClass) { + if ($token->id === T_CLASS) { + $prev = $i - 1; + while ($prev >= 0 && $tokens[$prev]->id === T_WHITESPACE) { + $prev--; + } + if ($prev < 0 || ($tokens[$prev]->id !== T_DOUBLE_COLON && $tokens[$prev]->id !== T_NEW)) { + $inClass = true; + } + } + continue; + } + + if ($token->text === '{' || $token->id === T_CURLY_OPEN || $token->id === T_DOLLAR_OPEN_CURLY_BRACES) { + $depth++; + if ($depth === 1) { + $memberStartLine = null; + } + continue; + } + + if ($token->text === '}') { + $depth--; + if ($depth === 1) { + $memberStartLine = null; + } elseif ($depth === 0) { + $classEndLine = $token->line; + break; + } + continue; + } + + if ($depth === 1) { + if ($token->text === ';') { + $memberStartLine = null; + continue; + } + if ($memberStartLine === null && $token->id !== T_WHITESPACE) { + $memberStartLine = $token->line; + } + if ($token->id === T_FUNCTION) { + $j = $i + 1; + while ($j < $count && ($tokens[$j]->id === T_WHITESPACE || $tokens[$j]->text === '&')) { + $j++; + } + if ($j < $count && $tokens[$j]->id === T_STRING) { + $methods[$tokens[$j]->text] = $memberStartLine ?? $token->line; + } + } } } - throw new LogicException('Provided contents does not contain a PHP class'); + if (!$inClass || $classEndLine === null) { + throw new LogicException('Provided contents does not contain a PHP class'); + } + + return [$methods, $classEndLine]; } public function __construct(string $contents) { - $this->classNode = self::fromCode($contents); + [$this->methods, $this->classEndLine] = self::parseClass($contents); + $this->contents = $contents; } /** @@ -94,44 +156,31 @@ public function insert(string $newContent, ?string $insertBeforeMethod = null): ? $this->getInsertLineBeforeMethod($insertBeforeMethod) : $this->getInsertLineBeforeFirstMethod(); - $lines = explode(PHP_EOL, $this->classNode->getFileContents()); + $lines = explode(PHP_EOL, $this->contents); array_splice($lines, $insertLine, 0, $newContent); $contents = implode(PHP_EOL, $lines); - $this->classNode = self::fromCode($contents); + [$this->methods, $this->classEndLine] = self::parseClass($contents); + $this->contents = $contents; } public function getContents(): string { - return $this->classNode->getFileContents(); - } - - private function getLineNumberFromPosition(int $startPosition): int - { - return PositionUtilities::getLineCharacterPositionFromPosition( - $startPosition, - $this->classNode->getFileContents() - )->line; + return $this->contents; } private function getInsertLineBeforeFirstMethod(): int { - foreach ($this->classNode->getDescendantNodes() as $childNode) { - if ($childNode instanceof MethodDeclaration) { - return $this->getLineNumberFromPosition($childNode->getFullStartPosition()) + 1; - } + if (!empty($this->methods)) { + return reset($this->methods) - 1; } // if there are no methods in the file, insert fragment before the end of the class - return $this->getLineNumberFromPosition($this->classNode->getEndPosition()); + return $this->classEndLine - 1; } private function getInsertLineBeforeMethod(string $insertBeforeMethod): int { - foreach ($this->classNode->getDescendantNodes() as $childNode) { - if ($childNode instanceof MethodDeclaration) { - if ($childNode->getName() === $insertBeforeMethod) { - return $this->getLineNumberFromPosition($childNode->getFullStartPosition()) + 1; - } - } + if (isset($this->methods[$insertBeforeMethod])) { + return $this->methods[$insertBeforeMethod] - 1; } throw new LogicException( From 9b47952133f370c9f262e3306e026312aa4d8a24 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Mon, 5 Oct 2026 20:23:25 +0000 Subject: [PATCH 3/5] refactor: simplify FragmentInjectionProcessor token helpers --- .../FragmentInjectionProcessor.php | 148 +++++++++--------- 1 file changed, 73 insertions(+), 75 deletions(-) diff --git a/src/PostProcessor/FragmentInjectionProcessor.php b/src/PostProcessor/FragmentInjectionProcessor.php index d885dee07..20d606092 100644 --- a/src/PostProcessor/FragmentInjectionProcessor.php +++ b/src/PostProcessor/FragmentInjectionProcessor.php @@ -24,10 +24,20 @@ class FragmentInjectionProcessor implements ProcessorInterface { + private const METHOD_PREFIX_TOKENS = [ + T_PUBLIC, + T_PROTECTED, + T_PRIVATE, + T_STATIC, + T_FINAL, + T_ABSTRACT, + T_DOC_COMMENT, + T_COMMENT, + ]; + private string $contents; - /** @var array */ - private array $methods; - private int $classEndLine; + /** @var PhpToken[] */ + private array $tokens; public static function run(string $inputDir): void { @@ -62,9 +72,9 @@ private static function inject(string $fragmentFile, string $classFile): void } /** - * @return array{array, int} + * @return PhpToken[] */ - private static function parseClass(string $contents): array + private static function fromCode(string $contents): array { try { $tokens = PhpToken::tokenize($contents, TOKEN_PARSE); @@ -72,77 +82,18 @@ private static function parseClass(string $contents): array throw new ParseError('Provided contents contains a PHP syntax error', 0, $e); } - $count = count($tokens); - $inClass = false; - $depth = 0; - $memberStartLine = null; - $methods = []; - $classEndLine = null; - - for ($i = 0; $i < $count; $i++) { - $token = $tokens[$i]; - if (!$inClass) { - if ($token->id === T_CLASS) { - $prev = $i - 1; - while ($prev >= 0 && $tokens[$prev]->id === T_WHITESPACE) { - $prev--; - } - if ($prev < 0 || ($tokens[$prev]->id !== T_DOUBLE_COLON && $tokens[$prev]->id !== T_NEW)) { - $inClass = true; - } - } - continue; - } - - if ($token->text === '{' || $token->id === T_CURLY_OPEN || $token->id === T_DOLLAR_OPEN_CURLY_BRACES) { - $depth++; - if ($depth === 1) { - $memberStartLine = null; - } - continue; - } - - if ($token->text === '}') { - $depth--; - if ($depth === 1) { - $memberStartLine = null; - } elseif ($depth === 0) { - $classEndLine = $token->line; - break; - } - continue; - } - - if ($depth === 1) { - if ($token->text === ';') { - $memberStartLine = null; - continue; - } - if ($memberStartLine === null && $token->id !== T_WHITESPACE) { - $memberStartLine = $token->line; - } - if ($token->id === T_FUNCTION) { - $j = $i + 1; - while ($j < $count && ($tokens[$j]->id === T_WHITESPACE || $tokens[$j]->text === '&')) { - $j++; - } - if ($j < $count && $tokens[$j]->id === T_STRING) { - $methods[$tokens[$j]->text] = $memberStartLine ?? $token->line; - } - } + foreach ($tokens as $token) { + if ($token->is(T_CLASS)) { + return $tokens; } } - if (!$inClass || $classEndLine === null) { - throw new LogicException('Provided contents does not contain a PHP class'); - } - - return [$methods, $classEndLine]; + throw new LogicException('Provided contents does not contain a PHP class'); } public function __construct(string $contents) { - [$this->methods, $this->classEndLine] = self::parseClass($contents); + $this->tokens = self::fromCode($contents); $this->contents = $contents; } @@ -159,7 +110,7 @@ public function insert(string $newContent, ?string $insertBeforeMethod = null): $lines = explode(PHP_EOL, $this->contents); array_splice($lines, $insertLine, 0, $newContent); $contents = implode(PHP_EOL, $lines); - [$this->methods, $this->classEndLine] = self::parseClass($contents); + $this->tokens = self::fromCode($contents); $this->contents = $contents; } @@ -170,21 +121,68 @@ public function getContents(): string private function getInsertLineBeforeFirstMethod(): int { - if (!empty($this->methods)) { - return reset($this->methods) - 1; + if (null !== $methodIndex = $this->findMethodTokenIndex()) { + return $this->getMethodStartLine($methodIndex) - 1; } + // if there are no methods in the file, insert fragment before the end of the class - return $this->classEndLine - 1; + return $this->getClassEndLine() - 1; } private function getInsertLineBeforeMethod(string $insertBeforeMethod): int { - if (isset($this->methods[$insertBeforeMethod])) { - return $this->methods[$insertBeforeMethod] - 1; + if (null !== $methodIndex = $this->findMethodTokenIndex($insertBeforeMethod)) { + return $this->getMethodStartLine($methodIndex) - 1; } throw new LogicException( 'Provided contents does not contain method ' . $insertBeforeMethod ); } + + private function findMethodTokenIndex(?string $methodName = null): ?int + { + foreach ($this->tokens as $i => $token) { + if (!$token->is(T_FUNCTION)) { + continue; + } + $nameToken = $this->tokens[$i + 2] ?? null; + if ($nameToken && $nameToken->is(T_STRING)) { + if ($methodName === null || $nameToken->text === $methodName) { + return $i; + } + } + } + + return null; + } + + private function getMethodStartLine(int $functionTokenIndex): int + { + $startLine = $this->tokens[$functionTokenIndex]->line; + for ($i = $functionTokenIndex - 1; $i >= 0; $i--) { + $token = $this->tokens[$i]; + if ($token->is(T_WHITESPACE)) { + continue; + } + if ($token->is(self::METHOD_PREFIX_TOKENS)) { + $startLine = $token->line; + continue; + } + break; + } + + return $startLine; + } + + private function getClassEndLine(): int + { + for ($i = count($this->tokens) - 1; $i >= 0; $i--) { + if ($this->tokens[$i]->text === '}') { + return $this->tokens[$i]->line; + } + } + + throw new LogicException('Provided contents does not contain a PHP class'); + } } From fc3c1f5953a097a980b5f0a109ccf39b51005d93 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Tue, 6 Oct 2026 18:24:59 +0000 Subject: [PATCH 4/5] style: import CodedInputStream in tests/Unit/autoload.php --- tests/Unit/autoload.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/Unit/autoload.php b/tests/Unit/autoload.php index 3cfb7cfd5..c2cc451ca 100644 --- a/tests/Unit/autoload.php +++ b/tests/Unit/autoload.php @@ -16,6 +16,7 @@ */ declare(strict_types=1); +use Google\Protobuf\Internal\CodedInputStream; use function Opis\Closure\{serialize, unserialize}; if (!isset($GLOBALS['gapic_php_test_temp_files'])) { @@ -64,7 +65,7 @@ public function __call(string $name, array $arguments) } } - public function serializeToString($recursion_limit = \Google\Protobuf\Internal\CodedInputStream::DEFAULT_RECURSION_LIMIT) + public function serializeToString($recursion_limit = CodedInputStream::DEFAULT_RECURSION_LIMIT) { // This serialization does not need to be compatible with real proto serialization. $result = ''; @@ -75,7 +76,7 @@ public function serializeToString($recursion_limit = \Google\Protobuf\Internal\C return $result; } - public function mergeFromString($s, $recursion_limit = \Google\Protobuf\Internal\CodedInputStream::DEFAULT_RECURSION_LIMIT) + public function mergeFromString($s, $recursion_limit = CodedInputStream::DEFAULT_RECURSION_LIMIT) { $pos = 0; while ($pos < strlen($s)) { From ab5efebd44fdf7e2ba07b98fb2d9eec7d0ddb910 Mon Sep 17 00:00:00 2001 From: Brent Shaffer Date: Fri, 9 Oct 2026 20:25:01 +0000 Subject: [PATCH 5/5] feat: support PHP 8 attributes in FragmentInjectionProcessor --- src/PostProcessor/FragmentInjectionProcessor.php | 12 ++++++++++++ .../PostProcessor/FragmentInjectionProcessorTest.php | 8 +++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/PostProcessor/FragmentInjectionProcessor.php b/src/PostProcessor/FragmentInjectionProcessor.php index 20d606092..38433fe9f 100644 --- a/src/PostProcessor/FragmentInjectionProcessor.php +++ b/src/PostProcessor/FragmentInjectionProcessor.php @@ -33,6 +33,7 @@ class FragmentInjectionProcessor implements ProcessorInterface T_ABSTRACT, T_DOC_COMMENT, T_COMMENT, + T_ATTRIBUTE, ]; private string $contents; @@ -165,6 +166,17 @@ private function getMethodStartLine(int $functionTokenIndex): int if ($token->is(T_WHITESPACE)) { continue; } + if ($token->is(']')) { + $depth = 1; + while ($i > 0 && $depth > 0) { + $token = $this->tokens[--$i]; + if ($token->is(']')) { + $depth++; + } elseif ($token->is(['[', T_ATTRIBUTE])) { + $depth--; + } + } + } if ($token->is(self::METHOD_PREFIX_TOKENS)) { $startLine = $token->line; continue; diff --git a/tests/Unit/PostProcessor/FragmentInjectionProcessorTest.php b/tests/Unit/PostProcessor/FragmentInjectionProcessorTest.php index dc26aec97..39a6119d7 100644 --- a/tests/Unit/PostProcessor/FragmentInjectionProcessorTest.php +++ b/tests/Unit/PostProcessor/FragmentInjectionProcessorTest.php @@ -48,6 +48,7 @@ public function __construct(private string \$foo) /** * Empty method */ + #[\ReturnTypeWillChange] public function bar() { } @@ -66,7 +67,7 @@ class Bar * @runInSeparateProcess * @dataProvider provideAstMethodReplacer */ - public function testAstMethodReplacer(string $classContents, string $insertBeforeMethod = null) + public function testAstMethodReplacer(string $classContents, ?string $insertBeforeMethod = null) { // the class / method to insert into // if no method is defined, the first method is used ("__construct" in this case) @@ -81,6 +82,11 @@ public function testAstMethodReplacer(string $classContents, string $insertBefor // Test that the function exists as expected $b = new \Bar('bar'); $this->assertEquals('bar', $b->methodOne()); + if ($insertBeforeMethod === 'bar') { + $refMethod = new \ReflectionMethod(\Bar::class, 'bar'); + $this->assertCount(1, $refMethod->getAttributes(\ReturnTypeWillChange::class)); + $this->assertStringContainsString('Empty method', (string) $refMethod->getDocComment()); + } } public function provideAstMethodReplacer()