fix: use PHP 8 concatenation precedence in the pretty printer

The precedence table gave "." the same precedence as "+" and "-", which is
the PHP 7 ordering. PHP 8.0 lowered "." below the arithmetic and shift
operators, so printing ('abc' . 1) + 2 produced 'abc' . 1 + 2, which PHP 8
reparses as 'abc' . (1 + 2). The same applied to "-", "<<" and ">>".

The printer already targets a specific PHP version and already varies
parenthesization by version, so select the concatenation precedence the same
way: for a target of PHP 8.0 or newer, place "." between the shift operators
and the pipe operator, matching the PHP8 block of grammar/php.y. Targets below
8.0 keep the PHP 7 table, which is correct for them and produces unchanged
output.

Fixes #1041
This commit is contained in:
dualfroz committed 2026-09-03 14:09:59 +02:00
1 parent 09376a3b68
commit 00e7c77eee
4 files changed
+86 -4

No files matched your search

+4
View File
@@ -67,6 +67,10 @@ The behaviors controlled by `phpVersion` (defaults to PHP 7.4) are:
for this has been removed with the introduction of flexible heredoc/nowdoc strings. for this has been removed with the introduction of flexible heredoc/nowdoc strings.
* For PHP >= 7.3, heredoc/nowdoc strings are indented just like regular code. This was allowed with * For PHP >= 7.3, heredoc/nowdoc strings are indented just like regular code. This was allowed with
the introduction of flexible heredoc/nowdoc strings. the introduction of flexible heredoc/nowdoc strings.
* For PHP >= 8.0, the concatenation operator is treated as binding less tightly than the arithmetic
and shift operators, matching the precedence change in PHP 8.0. For older versions `.` keeps the
same precedence as `+` and `-`. This determines where parentheses are required, so the printed
code only reparses into the same AST under the targeted version.
The default pretty printer does not provide functionality for fine-grained customization of code The default pretty printer does not provide functionality for fine-grained customization of code
formatting. formatting.
+8
View File
@@ -172,4 +172,12 @@ class PhpVersion {
public function supportsNewDereferenceWithoutParentheses(): bool { public function supportsNewDereferenceWithoutParentheses(): bool {
return $this->id >= 80400; return $this->id >= 80400;
} }
/**
* Whether the concatenation operator has lower precedence than the arithmetic and shift
* operators. Prior to PHP 8.0, "." had the same precedence as "+" and "-".
*/
public function hasLowerConcatPrecedence(): bool {
return $this->id >= 80000;
}
} }
+19 -4
View File
@@ -33,6 +33,13 @@ abstract class PrettyPrinterAbstract implements PrettyPrinter {
protected const MAX_PRECEDENCE = 1000; protected const MAX_PRECEDENCE = 1000;
/**
* @var array{int, int, int} Precedence of the concatenation operator on PHP >= 8.0, where it
* binds less tightly than the arithmetic and shift operators, but
* more tightly than the pipe operator.
*/
protected const PHP8_CONCAT_PRECEDENCE = [62, 63, 62];
/** @var array<class-string, array{int, int, int}> */ /** @var array<class-string, array{int, int, int}> */
protected array $precedenceMap = [ protected array $precedenceMap = [
// [precedence, precedenceLHS, precedenceRHS] // [precedence, precedenceLHS, precedenceRHS]
@@ -59,7 +66,8 @@ abstract class PrettyPrinterAbstract implements PrettyPrinter {
BinaryOp\Mod::class => [ 40, 41, 40], BinaryOp\Mod::class => [ 40, 41, 40],
BinaryOp\Plus::class => [ 50, 51, 50], BinaryOp\Plus::class => [ 50, 51, 50],
BinaryOp\Minus::class => [ 50, 51, 50], BinaryOp\Minus::class => [ 50, 51, 50],
// FIXME: This precedence is incorrect for PHP 8. // This is the PHP 7 precedence. For PHP >= 8.0 the constructor replaces it with
// PHP8_CONCAT_PRECEDENCE.
BinaryOp\Concat::class => [ 50, 51, 50], BinaryOp\Concat::class => [ 50, 51, 50],
BinaryOp\ShiftLeft::class => [ 60, 61, 60], BinaryOp\ShiftLeft::class => [ 60, 61, 60],
BinaryOp\ShiftRight::class => [ 60, 61, 60], BinaryOp\ShiftRight::class => [ 60, 61, 60],
@@ -179,9 +187,13 @@ abstract class PrettyPrinterAbstract implements PrettyPrinter {
* * PhpVersion $phpVersion: The PHP version to target (default to PHP 7.4). This option * * PhpVersion $phpVersion: The PHP version to target (default to PHP 7.4). This option
* controls compatibility of the generated code with older PHP * controls compatibility of the generated code with older PHP
* versions in cases where a simple stylistic choice exists (e.g. * versions in cases where a simple stylistic choice exists (e.g.
* array() vs []). It is safe to pretty-print an AST for a newer * array() vs []). It also selects the operator precedence table,
* PHP version while specifying an older target (but the result will * which changed for the concatenation operator in PHP 8.0, so the
* of course not be compatible with the older version in that case). * printed code is only guaranteed to parse back into the same AST
* under the targeted version. It is safe to pretty-print an AST for
* a newer PHP version while specifying an older target (but the
* result will of course not be compatible with the older version in
* that case).
* * string $newline: The newline style to use. Should be "\n" (default) or "\r\n". * * string $newline: The newline style to use. Should be "\n" (default) or "\r\n".
* * string $indent: The indentation to use. Should either be all spaces or a single * * string $indent: The indentation to use. Should either be all spaces or a single
* tab. Defaults to four spaces (" "). * tab. Defaults to four spaces (" ").
@@ -195,6 +207,9 @@ abstract class PrettyPrinterAbstract implements PrettyPrinter {
*/ */
public function __construct(array $options = []) { public function __construct(array $options = []) {
$this->phpVersion = $options['phpVersion'] ?? PhpVersion::fromComponents(7, 4); $this->phpVersion = $options['phpVersion'] ?? PhpVersion::fromComponents(7, 4);
if ($this->phpVersion->hasLowerConcatPrecedence()) {
$this->precedenceMap[BinaryOp\Concat::class] = self::PHP8_CONCAT_PRECEDENCE;
}
$this->newline = $options['newline'] ?? "\n"; $this->newline = $options['newline'] ?? "\n";
if ($this->newline !== "\n" && $this->newline != "\r\n") { if ($this->newline !== "\n" && $this->newline != "\r\n") {
@@ -0,0 +1,55 @@
Precedence of the concatenation operator
-----
<?php
echo ('abc' . 1) + 2;
echo ('abc' . 1) - 2;
echo 2 + ('abc' . 1);
echo 'abc' . (1 + 2);
echo (1 + 2) . 'abc';
echo ('a' . 'b') << 1;
echo 'a' . (1 << 2);
echo 'a' . 'b' . 'c';
echo 'a' . ('b' . 'c');
echo ('a' . 'b') == 'c';
echo 'a' . ('b' == 'c');
-----
!!version=8.0
echo ('abc' . 1) + 2;
echo ('abc' . 1) - 2;
echo 2 + ('abc' . 1);
echo 'abc' . 1 + 2;
echo 1 + 2 . 'abc';
echo ('a' . 'b') << 1;
echo 'a' . 1 << 2;
echo 'a' . 'b' . 'c';
echo 'a' . ('b' . 'c');
echo 'a' . 'b' == 'c';
echo 'a' . ('b' == 'c');
-----
<?php
echo ('abc' . 1) + 2;
echo ('abc' . 1) - 2;
echo 2 + ('abc' . 1);
echo 'abc' . (1 + 2);
echo (1 + 2) . 'abc';
echo ('a' . 'b') << 1;
echo 'a' . (1 << 2);
echo 'a' . 'b' . 'c';
echo 'a' . ('b' . 'c');
echo ('a' . 'b') == 'c';
echo 'a' . ('b' == 'c');
-----
!!version=7.4
echo 'abc' . 1 + 2;
echo 'abc' . 1 - 2;
echo 2 + ('abc' . 1);
echo 'abc' . (1 + 2);
echo 1 + 2 . 'abc';
echo 'a' . 'b' << 1;
echo 'a' . (1 << 2);
echo 'a' . 'b' . 'c';
echo 'a' . ('b' . 'c');
echo 'a' . 'b' == 'c';
echo 'a' . ('b' == 'c');