From 3a3656e27e41dc2d8bbcc5e9aae6df4985d752ef Mon Sep 17 00:00:00 2001 From: homersimpsons Date: Mon, 2 Dec 2024 02:11:39 +0100 Subject: [PATCH] :sparkles: Extended string normalization Correctly normalize mixed string and interpolation. Use `{$c}` over `${c}` after PHP 8.2 deprecation. --- phpunit-tests/StringTest.php | 51 ++++++++++++++++++++-------- src/NormalizeNodeVisitor.php | 64 ++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/phpunit-tests/StringTest.php b/phpunit-tests/StringTest.php index ff248a5..7f74d80 100644 --- a/phpunit-tests/StringTest.php +++ b/phpunit-tests/StringTest.php @@ -4,6 +4,9 @@ namespace App\Tests; +use Generator; +use PHPUnit\Framework\Attributes\DataProvider; + use function var_export; /** @@ -46,15 +49,16 @@ public function testDoubleQuotesWithEscapes(): void public function testDoubleQuotesEncapsulation(): void { + // `${a}` is deprecated since PHP8.2 $code = <<<'CODE' assertRepresentation( $code, <<<'EOF' - 'encapsed ' . $v0 . ' or ' . $v0 . '.'; + 'encapsed ' . $v0 . ' or ' . $v0 . ' or ' . $v0 . '.'; EOF, '{"v0":"a"}', ); @@ -130,35 +134,56 @@ public function testNowdoc(): void ); } - public function testUselessConcatenation(): void + /** @return Generator */ + public static function uselessConcatenationProvider(): iterable { - $code = <<<'CODE' + yield 'basic' => ["'testA' . 'testB' . 'testC';", "'testAtestBtestC';"]; + yield 'right' => ["'testA' . ('testB' . 'testC');", "'testAtestBtestC';"]; + yield 'left' => ["('testA' . 'testB') . 'testC';", "'testAtestBtestC';"]; + yield 'both' => ["('testA' . 'testB') . ('testC' . 'testD');", "'testAtestBtestCtestD';"]; + } + + #[DataProvider('uselessConcatenationProvider')] + public function testUselessConcatenation(string $input, string $output): void + { + $code = <<assertRepresentation( $code, - <<<'EOF' - 'testAtestBtestC'; + << */ + public static function uselessInterpolatedConcatenationProvider(): iterable { - $code = <<<'CODE' + yield 'left left' => ['"{$c}testA" . \'testB\';', '$v0 . \'testAtestB\';']; + yield 'left right' => ['"testA{$c}" . \'testB\';', '\'testA\' . $v0 . \'testB\';']; + yield 'right left' => ['\'testA\' . "{$c}testB";', '\'testA\' . $v0 . \'testB\';']; + yield 'right right' => ['\'testA\' . "testB{$c}";', '\'testAtestB\' . $v0;']; + yield 'left left and right right' => ['"{$c}testA" . "testB{$c}";', '$v0 . \'testAtestB\' . $v0;']; + } + + #[DataProvider('uselessInterpolatedConcatenationProvider')] + public function testUselessInterpolatedConcatenation(string $input, string $output): void + { + $code = <<assertRepresentation( $code, - <<<'EOF' - 'testAtestBtestC'; + << $part instanceof InterpolatedStringPart - ? new String_($part->value, ['kind' => String_::KIND_SINGLE_QUOTED]) - : $part, + static fn (Node $part) => $part instanceof InterpolatedStringPart ? new String_($part->value) : $part, $string->parts, ); @@ -169,16 +170,59 @@ private function normalizeInterpolatedString(InterpolatedString $string): Node /** * TRANSFORM: Simplify useless concat such as `'a' . 'b'` => `'ab'` */ - private function simplifyUselessConcat(Concat $concat): String_|null + private function simplifyUselessConcat(Concat $concat): String_|Concat { - if ($concat->left instanceof String_ && $concat->right instanceof String_) { - return new String_( - $concat->left->value . $concat->right->value, - ['kind' => String_::KIND_SINGLE_QUOTED], - ); + // 1. Flatten Concat-tree to an array of nodes: Concat('0', Concat('1', $a)) => ['0','1',$a] + $nodes = $this->unwrapConcat($concat); + // 2. Merge consecutive String_: ['0','1',$a,'2','3',$b,'4','5'] => ['01',$a,'23',$b,'45'] + $index = count($nodes) - 1; + while ($index > 0) { + $left = $nodes[$index - 1]; + $right = $nodes[$index]; + if ($left instanceof String_ && $right instanceof String_) { + array_splice($nodes, $index - 1, 2, [new String_($left->value . $right->value)]); + } + + $index--; } - return null; + // 3. Re-build a Concat-tree, left-based associativity to avoid extra-parentheses: + // ['01',$a,'23'] => Concat(Concat('01', $a), '23') + $node = array_shift($nodes); + assert($node !== null, 'Concat has at least 1 node'); + while ($right = array_shift($nodes)) { + $node = new Concat($node, $right); + } + + assert( + $node instanceof String_ || $node instanceof Concat, + 'Either everything was collapsed to a singled String_ or we have a top-level Concat remaining', + ); + + return $node; + } + + /** + * Unwrap a tree of Concat to a flat array of nodes + * + * @return list + */ + private function unwrapConcat(Concat $concat): array + { + $nodes = []; + if ($concat->left instanceof Concat) { + array_push($nodes, ...$this->unwrapConcat($concat->left)); + } else { + $nodes[] = $concat->left; + } + + if ($concat->right instanceof Concat) { + array_push($nodes, ...$this->unwrapConcat($concat->right)); + } else { + $nodes[] = $concat->right; + } + + return $nodes; } /**