From 41a1c4c3ee6081655899f1ae4731a1bf9bee5e88 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:42:43 +0000 Subject: [PATCH 1/3] Print literals from their value, not their source spelling, when building expression keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `Printer::pScalar_String()` now prints a `String_` from its value, ignoring the `kind`/`docLabel` attributes, so `'a'`, `"a"`, `"\x61"` and a heredoc or nowdoc holding `a` all produce one expression key. The canonical form mirrors `ConstantStringType::export()`: single quotes, or double quotes with escapes when the value contains control characters (which also keeps expression keys free of newlines, so `Printer::p()`'s print cache still applies to them). * `Printer::pScalar_Int()` always prints the decimal form, so `1`, `0x1`, `01` and `0b1` share one key (`PHP_INT_MIN` keeps the `(-…-1)` form it cannot be written as a literal without). * `Printer::pScalar_InterpolatedString()` always prints the `"..."` form, so a heredoc and the equivalent double-quoted interpolation share one key. * `Printer::pExpr_ConstFetch()` lowercases `true`, `false` and `null` — the only case-insensitive spellings PHPStan does not already report through a `*.nameCase` rule. * Probed and found already correct: float literals (`1.5`/`1.50`/`15e-1`) and `Float_` printing in general is value-based; curly-brace member access (`$o->{'p'}`, `$o->{'p'}()`) is already normalized by `pObjectProperty()`; variable variables with a constant name (`${'a'}`) and leading-`\` constant names already resolve. Deliberately left alone: class, function and method name case, which PHPStan already reports via `class.nameCase`, `function.nameCase`, `method.nameCase` and `staticMethod.nameCase`, so lowercasing them in the printer would only make error messages less faithful. * Updated two rule-test expectations that quoted the source spelling of a `true`/`NULL` keyword back to the user. --- src/Node/Printer/Printer.php | 68 +++++++++ tests/PHPStan/Analyser/nsrt/bug-15060.php | 82 +++++++++++ .../PHPStan/Node/Printer/ExprPrinterTest.php | 139 ++++++++++++++++++ .../DuplicateKeysInLiteralArraysRuleTest.php | 2 +- .../Keywords/DeclareStrictTypesRuleTest.php | 2 +- 5 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15060.php create mode 100644 tests/PHPStan/Node/Printer/ExprPrinterTest.php diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 61ffd313960..ad4e7ef4a48 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -5,6 +5,9 @@ use Override; use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ConstFetch; +use PhpParser\Node\Scalar\Int_; +use PhpParser\Node\Scalar\InterpolatedString; use PhpParser\Node\Scalar\String_; use PhpParser\PrettyPrinter\Standard; use PHPStan\DependencyInjection\AutowiredService; @@ -31,9 +34,14 @@ use PHPStan\Node\MethodCallableNode; use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Type\VerbosityLevel; +use function addcslashes; +use function count; +use function in_array; use function preg_match; use function sprintf; use function str_contains; +use function strtolower; +use const PHP_INT_MAX; /** * @api @@ -103,6 +111,66 @@ protected function pObjectProperty(Node $node): string return parent::pObjectProperty($node); } + /** + * Print a string literal from its value instead of its source spelling, so + * that `'a'`, `"a"`, `"\x61"` and a heredoc or nowdoc holding `a` all end up + * with the same expression key. The chosen form mirrors + * ConstantStringType::export(). + */ + #[Override] + protected function pScalar_String(String_ $node): string // phpcs:ignore + { + if (addcslashes($node->value, "\0..\37") !== $node->value) { + return '"' . $this->escapeString($node->value, '"') . '"'; + } + + return $this->pSingleQuotedString($node->value); + } + + /** + * Always print the double-quoted form so that a heredoc and the equivalent + * `"..."` interpolation share one expression key. + */ + #[Override] + protected function pScalar_InterpolatedString(InterpolatedString $node): string // phpcs:ignore + { + return '"' . $this->pEncapsList($node->parts, '"') . '"'; + } + + /** + * Always print the decimal form so that `1`, `0x1`, `01` and `0b1` share one + * expression key. + */ + #[Override] + protected function pScalar_Int(Int_ $node): string // phpcs:ignore + { + if ($node->value === -PHP_INT_MAX - 1) { + // PHP_INT_MIN cannot be represented as a literal, because the sign is + // not part of the literal. + return '(-' . PHP_INT_MAX . '-1)'; + } + + return (string) $node->value; + } + + /** + * Lowercase the `true`, `false` and `null` keywords, the only case-insensitive + * spellings the analyser does not already report through a `*.nameCase` rule. + */ + #[Override] + protected function pExpr_ConstFetch(ConstFetch $node): string // phpcs:ignore + { + $name = $node->name; + if (count($name->getParts()) === 1 && !$name->isRelative()) { + $lowercasedName = strtolower($name->getFirst()); + if (in_array($lowercasedName, ['true', 'false', 'null'], true)) { + return $lowercasedName; + } + } + + return parent::pExpr_ConstFetch($node); + } + protected function pPHPStan_Node_TypeExpr(TypeExpr $expr): string // phpcs:ignore { return sprintf('__phpstanType(%s)', $expr->getExprType()->describe(VerbosityLevel::precise())); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15060.php b/tests/PHPStan/Analyser/nsrt/bug-15060.php new file mode 100644 index 00000000000..c2447e10e1e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15060.php @@ -0,0 +1,82 @@ +', $searchParams['test']); + assertType('array', $searchParams["test"]); + } + + if (is_array($searchParams["test"]) && $searchParams["test"]) { + assertType('non-empty-array', $searchParams['test']); + assertType('non-empty-array', $searchParams["test"]); + } + } +} + +function otherStringSpellings($m): void +{ + if (is_array($m['test']) && $m['test']) { + assertType('non-empty-array', $m['test']); + assertType('non-empty-array', $m["test"]); + assertType('non-empty-array', $m["\x74est"]); + assertType('non-empty-array', $m[<<<'NOWDOC' + test + NOWDOC]); + assertType('non-empty-array', $m[<<', $m[1]); + assertType('non-empty-array', $m[0x1]); + assertType('non-empty-array', $m[01]); + assertType('non-empty-array', $m[0b1]); + assertType('mixed', $m[10]); + } +} + +function interpolatedSpellings($m, string $k): void +{ + if (is_array($m["x$k"]) && $m["x$k"]) { + assertType('non-empty-array', $m["x$k"]); + assertType('non-empty-array', $m["x{$k}"]); + assertType('non-empty-array', $m[<<', $m[true]); + assertType('non-empty-array', $m[TRUE]); + assertType('non-empty-array', $m[True]); + } + + if (is_array($m[null]) && $m[null]) { + assertType('non-empty-array', $m[null]); + assertType('non-empty-array', $m[NULL]); + } + + if (is_array($m[false]) && $m[false]) { + assertType('non-empty-array', $m[false]); + assertType('non-empty-array', $m[FALSE]); + } +} diff --git a/tests/PHPStan/Node/Printer/ExprPrinterTest.php b/tests/PHPStan/Node/Printer/ExprPrinterTest.php new file mode 100644 index 00000000000..23c5e8ca80e --- /dev/null +++ b/tests/PHPStan/Node/Printer/ExprPrinterTest.php @@ -0,0 +1,139 @@ + [ + ['$a[\'test\']', '$a["test"]', '$a["\x74est"]'], + ], + 'string heredoc' => [ + ['$a[\'test\']', "\$a[<< [ + ['$a["a\nb"]', "\$a[<< [ + ['$a[1]', '$a[0x1]', '$a[01]', '$a[0b1]'], + ], + 'int separator' => [ + ['$a[10]', '$a[1_0]'], + ], + 'float' => [ + ['$a[1.5]', '$a[1.50]', '$a[15e-1]'], + ], + 'interpolated string' => [ + ['$a["x$b"]', '$a["x{$b}"]', "\$a[<< [ + ['$a[true]', '$a[TRUE]', '$a[True]', '$a[\true]'], + ], + 'false' => [ + ['$a[false]', '$a[FALSE]', '$a[\FALSE]'], + ], + 'null' => [ + ['$a[null]', '$a[NULL]', '$a[\null]'], + ], + 'object property' => [ + ['$a->b', '$a->{\'b\'}', '$a->{"b"}'], + ], + 'method name' => [ + ['$a->b()', '$a->{\'b\'}()', '$a->{"b"}()'], + ], + ]; + } + + /** + * @param non-empty-list $codes + */ + #[DataProvider('dataEquivalentSpellings')] + public function testEquivalentSpellingsPrintTheSame(array $codes): void + { + $exprPrinter = self::getContainer()->getByType(ExprPrinter::class); + + $expected = null; + foreach ($codes as $code) { + $printed = $exprPrinter->printExpr($this->parseExpr($code)); + if ($expected === null) { + $expected = $printed; + continue; + } + + $this->assertSame($expected, $printed, sprintf('%s should print the same as %s', $code, $codes[0])); + } + } + + public static function dataDifferentSpellings(): array + { + return [ + 'numeric string vs int' => [ + '$a[1]', + '$a[\'1\']', + ], + 'single quoted backslash is literal' => [ + '$a[\'a\nb\']', + '$a["a\nb"]', + ], + 'different int' => [ + '$a[1]', + '$a[10]', + ], + 'other constant case is significant' => [ + '$a[FOO]', + '$a[foo]', + ], + ]; + } + + #[DataProvider('dataDifferentSpellings')] + public function testDifferentSpellingsPrintDifferently(string $code, string $otherCode): void + { + $exprPrinter = self::getContainer()->getByType(ExprPrinter::class); + + $this->assertNotSame( + $exprPrinter->printExpr($this->parseExpr($code)), + $exprPrinter->printExpr($this->parseExpr($otherCode)), + ); + } + + public function testPrintedFormNeverContainsNewline(): void + { + $exprPrinter = self::getContainer()->getByType(ExprPrinter::class); + + foreach (["\$a[<<assertStringNotContainsString("\n", $exprPrinter->printExpr($this->parseExpr($code)), $code); + } + } + + private function parseExpr(string $code): Expr + { + /** @var Parser $parser */ + $parser = self::getContainer()->getService('currentPhpVersionRichParser'); + + /** @var Stmt[] $stmts */ + $stmts = $parser->parseString(sprintf('expr; + } + +} diff --git a/tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php b/tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php index 75293922f7f..70effcf0125 100644 --- a/tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php @@ -26,7 +26,7 @@ public function testDuplicateKeys(): void define('PHPSTAN_DUPLICATE_KEY', 0); $this->analyse([__DIR__ . '/data/duplicate-keys.php'], [ [ - 'Array has 2 duplicate keys with value \'\' (null, NULL).', + 'Array has 2 duplicate keys with value \'\' (null, null).', 15, ], [ diff --git a/tests/PHPStan/Rules/Keywords/DeclareStrictTypesRuleTest.php b/tests/PHPStan/Rules/Keywords/DeclareStrictTypesRuleTest.php index 7aac5f8019e..02aa02d597e 100644 --- a/tests/PHPStan/Rules/Keywords/DeclareStrictTypesRuleTest.php +++ b/tests/PHPStan/Rules/Keywords/DeclareStrictTypesRuleTest.php @@ -98,7 +98,7 @@ public function testNonsenseBool(): void { $this->analyse([__DIR__ . '/data/declare-strict-nonsense-bool.php'], [ [ - 'Declare strict_types must have 0 or 1 as its value, \true given.', + 'Declare strict_types must have 0 or 1 as its value, true given.', 1, ], ]); From 59dd587031d465bba99c416c2ca8f447cc753773 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 8 Aug 2026 08:10:46 +0000 Subject: [PATCH 2/3] Cover the interpolation syntaxes a formatter rewrites in ExprPrinterTest `"$b"`, `"{$b}"` and `"${b}"` are the same expression written three ways, and php-cs-fixer's explicit_string_variable and Rector's SimpleToComplexStringVariableRector rewrite between them - the same class of formatter-driven churn that motivated this branch. php-parser's pEncapsList already emits the `{$...}` form for all three, and likewise normalizes the unquoted offset in `"$b[k]"` and the `$$v` / `${$v}` spellings, so no printer change is needed; these cases pin that down. Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Node/Printer/ExprPrinterTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/PHPStan/Node/Printer/ExprPrinterTest.php b/tests/PHPStan/Node/Printer/ExprPrinterTest.php index 23c5e8ca80e..fa8a326f759 100644 --- a/tests/PHPStan/Node/Printer/ExprPrinterTest.php +++ b/tests/PHPStan/Node/Printer/ExprPrinterTest.php @@ -39,6 +39,21 @@ public static function dataEquivalentSpellings(): array 'interpolated string' => [ ['$a["x$b"]', '$a["x{$b}"]', "\$a[<< [ + ['$a["$b"]', '$a["{$b}"]', '$a["${b}"]'], + ], + 'interpolated property syntax' => [ + ['$a["$o->p"]', '$a["{$o->p}"]'], + ], + 'interpolated offset syntax' => [ + ['$a["$b[k]"]', '$a["{$b[\'k\']}"]'], + ], + 'variable variable' => [ + ['$$v', '${$v}'], + ], 'true' => [ ['$a[true]', '$a[TRUE]', '$a[True]', '$a[\true]'], ], From dee330f87e01074fe67f70432ef46498e668b06e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 8 Aug 2026 08:10:52 +0000 Subject: [PATCH 3/3] Record why interpolation is not normalized into concatenation `"$b.value"` and `$b . '.value'` compute the same string but keep separate expression keys, so narrowing established through one is not visible at the other. Normalizing the printer across the two is not a safe extension of printing literals from their value: an InterpolatedString is a Scalar and therefore atomic in the precedence map, so emitting it as a Concat makes `-"$a$b"` print `-$a . $b` - the key of `(-$a) . $b` - and `"$a"` print `$a`, dropping the string cast. A false key collision is worse than the missing narrowing, and the rewritten form would surface in every error message quoting the expression. Co-Authored-By: Claude Opus 5 --- src/Node/Printer/Printer.php | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index ad4e7ef4a48..545660ba3dc 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -129,7 +129,20 @@ protected function pScalar_String(String_ $node): string // phpcs:ignore /** * Always print the double-quoted form so that a heredoc and the equivalent - * `"..."` interpolation share one expression key. + * `"..."` interpolation share one expression key. The parts themselves are + * already spelling-independent: `"$b"`, `"{$b}"` and `"${b}"` all print as + * `"{$b}"`. + * + * Normalizing goes no further than the spelling: `"$b.value"` deliberately + * keeps a different key than `$b . '.value'`, even though the two compute + * the same string. Printing an InterpolatedString as a Concat would need + * the node to take part in precedence handling - unlike a Scalar, which is + * atomic - and without that `-"$a$b"` prints as `-$a . $b` and collides + * with `(-$a) . $b`, and `"$a"` prints as `$a` and collides with the + * uncast variable. A false collision hands one expression a type + * established for a different one, which is worse than the narrowing this + * would recover; and the concat form would then also reach every error + * message that quotes the expression back to the user. */ #[Override] protected function pScalar_InterpolatedString(InterpolatedString $node): string // phpcs:ignore