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/8] 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/8] 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/8] 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 From 2582373b1310fc988bc93cae2ca0ad010ae163ef Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 9 Aug 2026 07:53:19 +0000 Subject: [PATCH 4/8] Canonicalize array offset literal spelling in a parser visitor, not in the printer `$searchParams['key']` and `$searchParams["key"]` read the same offset, but MutatingScope keys types by the pretty-printed form of an expression, and the pretty printer reproduces the source spelling of a literal. Narrowing established through one spelling was therefore invisible at the other, which is why running a formatter over the file changed the analysis result. The previous approach overrode pScalar_String(), pScalar_Int(), pScalar_InterpolatedString() and pExpr_ConstFetch() in PHPStan\Node\Printer\ Printer. That printer's output does double duty: it is the expression key, and it is the text rules quote back to the user - so canonicalizing it for the key also rewrote error messages (`Expression "0x1f"` became `Expression "31"`). Normalize the offset literal at parse time instead, as staabm suggested. ArrayOffsetSpellingVisitor rewrites the spelling attribute of a String_, Int_ or InterpolatedString sitting in ArrayDimFetch::$dim, so the printer stays faithful everywhere else and both rule-test expectations that the printer overrides had forced (`\true` -> `true`, `(null, NULL)` -> `(null, null)`) go back to their original values. Only attributes are touched, never a subnode, so rules see the same AST and format-preserving printing is unaffected. Strings become single-quoted, except when the value holds control characters, where the double-quoted escaped form is used - the canonical form of ConstantStringType::export(), and the one that keeps the expression key free of newlines so Printer::p()'s print cache applies. The narrower scope drops the coverage the printer overrides had outside array offsets: `$b->get('key')` vs `$b->get("key")`, and `$a[TRUE]` vs `$a[true]`, still get distinct expression keys. Neither is the reported case. Fixes phpstan/phpstan#15060 --- src/Node/Printer/Printer.php | 81 ------------------- src/Parser/ArrayOffsetSpellingVisitor.php | 61 ++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-15060.php | 19 ----- .../PHPStan/Node/Printer/ExprPrinterTest.php | 17 ++-- .../DuplicateKeysInLiteralArraysRuleTest.php | 2 +- .../Keywords/DeclareStrictTypesRuleTest.php | 2 +- 6 files changed, 70 insertions(+), 112 deletions(-) create mode 100644 src/Parser/ArrayOffsetSpellingVisitor.php diff --git a/src/Node/Printer/Printer.php b/src/Node/Printer/Printer.php index 545660ba3dc..61ffd313960 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -5,9 +5,6 @@ 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; @@ -34,14 +31,9 @@ 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 @@ -111,79 +103,6 @@ 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. 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 - { - 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/src/Parser/ArrayOffsetSpellingVisitor.php b/src/Parser/ArrayOffsetSpellingVisitor.php new file mode 100644 index 00000000000..729921e147e --- /dev/null +++ b/src/Parser/ArrayOffsetSpellingVisitor.php @@ -0,0 +1,61 @@ +dim === null) { + return null; + } + + $dim = $node->dim; + if ($dim instanceof String_) { + // Single quotes normally, double quotes when the value holds control + // characters - the same canonical form as ConstantStringType::export(), + // and the one that keeps the expression key free of newlines. + $dim->setAttribute( + 'kind', + preg_match('/[\x00-\x1f]/', $dim->value) === 1 + ? String_::KIND_DOUBLE_QUOTED + : String_::KIND_SINGLE_QUOTED, + ); + } elseif ($dim instanceof InterpolatedString) { + $dim->setAttribute('kind', String_::KIND_DOUBLE_QUOTED); + } elseif ($dim instanceof Int_) { + $dim->setAttribute('kind', Int_::KIND_DEC); + } + + return null; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15060.php b/tests/PHPStan/Analyser/nsrt/bug-15060.php index c2447e10e1e..eac694598f7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15060.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15060.php @@ -61,22 +61,3 @@ function interpolatedSpellings($m, string $k): void HEREDOC]); } } - -function constFetchSpellings($m): void -{ - if (is_array($m[true]) && $m[true]) { - assertType('non-empty-array', $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 index fa8a326f759..79c8c20ca60 100644 --- a/tests/PHPStan/Node/Printer/ExprPrinterTest.php +++ b/tests/PHPStan/Node/Printer/ExprPrinterTest.php @@ -12,6 +12,12 @@ use function get_class; use function sprintf; +/** + * The expression key is what MutatingScope tracks types under, so equivalent + * spellings have to print identically. Offset literals get there through + * ArrayOffsetSpellingVisitor during parsing, the rest through Printer, so the + * property is asserted on the parsed-and-printed result. + */ class ExprPrinterTest extends PHPStanTestCase { @@ -54,15 +60,6 @@ public static function dataEquivalentSpellings(): array 'variable variable' => [ ['$$v', '${$v}'], ], - 'true' => [ - ['$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"}'], ], @@ -107,7 +104,7 @@ public static function dataDifferentSpellings(): array '$a[1]', '$a[10]', ], - 'other constant case is significant' => [ + 'constant case is significant' => [ '$a[FOO]', '$a[foo]', ], diff --git a/tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php b/tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php index 70effcf0125..75293922f7f 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 02aa02d597e..7aac5f8019e 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 63bcafe388028ed59740790f8380d510e839aae9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 9 Aug 2026 09:56:30 +0200 Subject: [PATCH 5/8] Delete tests/PHPStan/Node/Printer/ExprPrinterTest.php --- .../PHPStan/Node/Printer/ExprPrinterTest.php | 151 ------------------ 1 file changed, 151 deletions(-) delete mode 100644 tests/PHPStan/Node/Printer/ExprPrinterTest.php diff --git a/tests/PHPStan/Node/Printer/ExprPrinterTest.php b/tests/PHPStan/Node/Printer/ExprPrinterTest.php deleted file mode 100644 index 79c8c20ca60..00000000000 --- a/tests/PHPStan/Node/Printer/ExprPrinterTest.php +++ /dev/null @@ -1,151 +0,0 @@ - [ - ['$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["$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}'], - ], - '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]', - ], - '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; - } - -} From d7b38a53ceeccb10456ec1a3bb08b05fc1af6589 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 9 Aug 2026 08:04:50 +0000 Subject: [PATCH 6/8] Rename ArrayOffsetSpellingVisitor to ArrayOffsetNormalizingVisitor Co-Authored-By: Claude Opus 5 --- ...setSpellingVisitor.php => ArrayOffsetNormalizingVisitor.php} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/Parser/{ArrayOffsetSpellingVisitor.php => ArrayOffsetNormalizingVisitor.php} (96%) diff --git a/src/Parser/ArrayOffsetSpellingVisitor.php b/src/Parser/ArrayOffsetNormalizingVisitor.php similarity index 96% rename from src/Parser/ArrayOffsetSpellingVisitor.php rename to src/Parser/ArrayOffsetNormalizingVisitor.php index 729921e147e..40c525a142f 100644 --- a/src/Parser/ArrayOffsetSpellingVisitor.php +++ b/src/Parser/ArrayOffsetNormalizingVisitor.php @@ -28,7 +28,7 @@ * for these nodes. */ #[AutowiredService] -final class ArrayOffsetSpellingVisitor extends NodeVisitorAbstract +final class ArrayOffsetNormalizingVisitor extends NodeVisitorAbstract { #[Override] From aa5ed10cd4e579a617535950dd88fc88ba4ea5c4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 10 Aug 2026 07:35:03 +0000 Subject: [PATCH 7/8] Normalize an offset built by interpolation and by concatenation to one form `$m["$k.value"]` and `$m[$k . '.value']` read the same offset through two different AST shapes, so narrowing established through one was invisible at the other - the same bug as the two quote styles, one level up. Interpolation is sugar for concatenation: every part is cast to string and the results are joined, and string concatenation is associative. An offset built either way is therefore flattened into its list of operands, adjacent literals are merged, and the list is rebuilt as one left-associative concatenation. That makes `"$a$b.x"`, `$a . $b . '.x'` and `$a . ($b . '.x')` a single expression key, and `'a' . 'b'` the same key as `'ab'`. A one-part interpolation is left alone: `"$k"` is a string cast that `$k` is not, and the two are the same offset only when `$k` already holds a string. The synthesized nodes carry the token positions of the source they stand for, so error lines stay put and `--fix` still reproduces the original spelling - verified by fixing a backtick both next to and inside such an offset. Inside an offset, an interpolation part that cannot be cast to string is now reported by InvalidBinaryOperationRule rather than InvalidPartOfEncapsedString- Rule. That is the one change rules can see; the error is still reported. --- src/Parser/ArrayOffsetNormalizingVisitor.php | 211 ++++++++++++++++-- tests/PHPStan/Analyser/nsrt/bug-15060.php | 50 +++++ .../ArrayOffsetNormalizingVisitorTest.php | 73 ++++++ 3 files changed, 314 insertions(+), 20 deletions(-) create mode 100644 tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php diff --git a/src/Parser/ArrayOffsetNormalizingVisitor.php b/src/Parser/ArrayOffsetNormalizingVisitor.php index 40c525a142f..57b8076aceb 100644 --- a/src/Parser/ArrayOffsetNormalizingVisitor.php +++ b/src/Parser/ArrayOffsetNormalizingVisitor.php @@ -4,12 +4,17 @@ use Override; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; +use PhpParser\Node\Expr\BinaryOp\Concat; +use PhpParser\Node\InterpolatedStringPart; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Scalar\InterpolatedString; use PhpParser\Node\Scalar\String_; use PhpParser\NodeVisitorAbstract; use PHPStan\DependencyInjection\AutowiredService; +use function array_key_last; +use function count; use function preg_match; /** @@ -19,13 +24,22 @@ * through one spelling used to be invisible at the other, and reformatting a * file changed the analysis result. * - * Rewriting the offset literal to a canonical spelling here rather than in the - * printer keeps the printed form faithful everywhere else, so the expression - * text quoted back in error messages is unaffected outside of array offsets. + * The same is true of the two ways of building an offset out of parts: + * `$a["$k.value"]` and `$a[$k . '.value']` read the same offset through + * different AST shapes. Interpolation is sugar for concatenation - each part is + * cast to string and the results are joined - so an offset built either way is + * rewritten into a single canonical concatenation of its parts. * - * Only the spelling attributes are touched, never a subnode, so rules see the - * same AST and format-preserving printing still reproduces the original source - * for these nodes. + * Normalizing here rather than in the printer keeps the printed form faithful + * everywhere else, so the expression text quoted back in error messages is + * unaffected outside of array offsets. + * + * Rewriting an interpolation into a concatenation is the one change rules can + * see - inside an offset, a part that cannot be cast to string is reported by + * InvalidBinaryOperationRule instead of InvalidPartOfEncapsedStringRule. The + * synthesized nodes carry the token positions of the source they stand for, so + * error lines stay put and format-preserving printing keeps reproducing the + * original spelling. */ #[AutowiredService] final class ArrayOffsetNormalizingVisitor extends NodeVisitorAbstract @@ -38,24 +52,181 @@ public function enterNode(Node $node): ?Node return null; } - $dim = $node->dim; + $node->dim = $this->normalize($node->dim); + + return null; + } + + private function normalize(Expr $dim): Expr + { if ($dim instanceof String_) { - // Single quotes normally, double quotes when the value holds control - // characters - the same canonical form as ConstantStringType::export(), - // and the one that keeps the expression key free of newlines. - $dim->setAttribute( - 'kind', - preg_match('/[\x00-\x1f]/', $dim->value) === 1 - ? String_::KIND_DOUBLE_QUOTED - : String_::KIND_SINGLE_QUOTED, - ); - } elseif ($dim instanceof InterpolatedString) { - $dim->setAttribute('kind', String_::KIND_DOUBLE_QUOTED); - } elseif ($dim instanceof Int_) { + $this->canonicalizeStringKind($dim); + + return $dim; + } + + if ($dim instanceof Int_) { $dim->setAttribute('kind', Int_::KIND_DEC); + + return $dim; } - return null; + if (!$dim instanceof InterpolatedString && !$dim instanceof Concat) { + return $dim; + } + + $operands = $this->mergeAdjacentStrings($this->flatten($dim)); + if (count($operands) === 1) { + $operand = $operands[0]; + if ($operand instanceof String_) { + // The whole offset is a constant string built out of pieces, + // e.g. `'a' . 'b'` - spell it the way `$a['ab']` is spelled. + return $operand; + } + + // A one-part interpolation is a string cast, which the part alone is + // not: `"$k"` and `$k` are the same offset only when `$k` is already + // a string. Leave the cast in place and only pin its spelling. + $dim->setAttribute('kind', String_::KIND_DOUBLE_QUOTED); + + return $dim; + } + + $concat = $operands[0]; + for ($i = 1; $i < count($operands); $i++) { + $concat = new Concat($concat, $operands[$i], $this->spanAttributes($concat, $operands[$i])); + } + + return $concat; + } + + /** + * Collects the operands of a concatenation, no matter which of the two + * syntaxes - or which nesting of them - built it. String concatenation is + * associative, so the flat list describes the same value as the tree. + * + * @return list + */ + private function flatten(Expr $expr): array + { + if ($expr instanceof Concat) { + $operands = []; + foreach ([$expr->left, $expr->right] as $side) { + foreach ($this->flatten($side) as $operand) { + $operands[] = $operand; + } + } + + return $operands; + } + + if ($expr instanceof InterpolatedString) { + $operands = []; + foreach ($expr->parts as $part) { + if ($part instanceof InterpolatedStringPart) { + $operands[] = $this->createString($part->value, $part->getAttributes()); + continue; + } + + foreach ($this->flatten($part) as $operand) { + $operands[] = $operand; + } + } + + return $operands; + } + + if ($expr instanceof Int_) { + $expr->setAttribute('kind', Int_::KIND_DEC); + } elseif ($expr instanceof String_) { + $this->canonicalizeStringKind($expr); + } + + return [$expr]; + } + + /** + * @param list $operands + * @return non-empty-list + */ + private function mergeAdjacentStrings(array $operands): array + { + $merged = []; + foreach ($operands as $operand) { + $last = $merged === [] ? null : $merged[array_key_last($merged)]; + if (!$operand instanceof String_ || !$last instanceof String_) { + $merged[] = $operand; + continue; + } + + $merged[array_key_last($merged)] = $this->createString( + $last->value . $operand->value, + $this->spanAttributes($last, $operand), + ); + } + + if ($merged === []) { + // An interpolation always has at least one part, but an empty one + // would still describe the empty string. + return [$this->createString('', [])]; + } + + return $merged; + } + + /** + * @param array $attributes + */ + private function createString(string $value, array $attributes): String_ + { + $string = new String_($value, $attributes); + $this->canonicalizeStringKind($string); + + return $string; + } + + /** + * Single quotes normally, double quotes when the value holds control + * characters - the same canonical form as ConstantStringType::export(), and + * the one that keeps the expression key free of newlines. + */ + private function canonicalizeStringKind(String_ $string): void + { + $string->setAttribute( + 'kind', + preg_match('/[\x00-\x1f]/', $string->value) === 1 + ? String_::KIND_DOUBLE_QUOTED + : String_::KIND_SINGLE_QUOTED, + ); + } + + /** + * Keeps a synthesized node pointing at the source it stands for, so that + * error lines stay put and format-preserving printing reproduces the + * original spelling instead of the canonical one. + * + * @return array + */ + private function spanAttributes(Node $start, Node $end): array + { + $attributes = []; + foreach (['startLine', 'startTokenPos', 'startFilePos'] as $attribute) { + if (!$start->hasAttribute($attribute)) { + continue; + } + + $attributes[$attribute] = $start->getAttribute($attribute); + } + + foreach (['endLine', 'endTokenPos', 'endFilePos'] as $attribute) { + if (!$end->hasAttribute($attribute)) { + continue; + } + + $attributes[$attribute] = $end->getAttribute($attribute); + } + + return $attributes; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15060.php b/tests/PHPStan/Analyser/nsrt/bug-15060.php index eac694598f7..ea7e378cafb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15060.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15060.php @@ -61,3 +61,53 @@ function interpolatedSpellings($m, string $k): void HEREDOC]); } } + +function stringConcat($m, string $k): void +{ + if (is_array($m[$k . ".value"]) && $m[$k . ".value"]) { + assertType('non-empty-array', $m["$k.value"]); + assertType('non-empty-array', $m[$k . ".value"]); + assertType('non-empty-array', $m[$k . '.value']); + assertType('non-empty-array', $m["{$k}.value"]); + assertType('non-empty-array', $m[<<', $m[$k . '.value']); + } +} + +function concatOfConstantStrings($m): void +{ + if (is_array($m['a' . 'b']) && $m['a' . 'b']) { + assertType('non-empty-array', $m['ab']); + assertType('non-empty-array', $m["ab"]); + assertType('non-empty-array', $m['a' . 'b']); + } +} + +function concatNesting($m, string $a, string $b): void +{ + if (is_array($m[$a . $b . '.value']) && $m[$a . $b . '.value']) { + assertType('non-empty-array', $m[$a . ($b . '.value')]); + assertType('non-empty-array', $m["$a$b.value"]); + assertType('non-empty-array', $m["$a" . "$b" . '.value']); + } +} + +function singlePartInterpolationIsAStringCast($m, int $i): void +{ + if (is_array($m["$i"]) && $m["$i"]) { + assertType('non-empty-array', $m["$i"]); + assertType('non-empty-array', $m["{$i}"]); + // "$i" casts to string, $i does not - the offsets only coincide + // because PHP normalizes integer-like string keys. + assertType('mixed', $m[$i]); + } +} diff --git a/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php b/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php new file mode 100644 index 00000000000..d2b2e902a61 --- /dev/null +++ b/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php @@ -0,0 +1,73 @@ +parseString(sprintf('expr instanceof ArrayDimFetch) { + throw new ShouldNotHappenException(); + } + + $printer = self::getContainer()->getByType(ExprPrinter::class); + $this->assertSame($expectedKey, $printer->printExpr($stmts[0]->expr)); + } + +} From 72232b5cb2ef1aec4372b6db062a75d5eeeac734 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 10 Aug 2026 09:41:23 +0200 Subject: [PATCH 8/8] Revert "Normalize an offset built by interpolation and by concatenation to one form" This reverts commit aa5ed10cd4e579a617535950dd88fc88ba4ea5c4. --- src/Parser/ArrayOffsetNormalizingVisitor.php | 211 ++---------------- tests/PHPStan/Analyser/nsrt/bug-15060.php | 50 ----- .../ArrayOffsetNormalizingVisitorTest.php | 73 ------ 3 files changed, 20 insertions(+), 314 deletions(-) delete mode 100644 tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php diff --git a/src/Parser/ArrayOffsetNormalizingVisitor.php b/src/Parser/ArrayOffsetNormalizingVisitor.php index 57b8076aceb..40c525a142f 100644 --- a/src/Parser/ArrayOffsetNormalizingVisitor.php +++ b/src/Parser/ArrayOffsetNormalizingVisitor.php @@ -4,17 +4,12 @@ use Override; use PhpParser\Node; -use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; -use PhpParser\Node\Expr\BinaryOp\Concat; -use PhpParser\Node\InterpolatedStringPart; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Scalar\InterpolatedString; use PhpParser\Node\Scalar\String_; use PhpParser\NodeVisitorAbstract; use PHPStan\DependencyInjection\AutowiredService; -use function array_key_last; -use function count; use function preg_match; /** @@ -24,22 +19,13 @@ * through one spelling used to be invisible at the other, and reformatting a * file changed the analysis result. * - * The same is true of the two ways of building an offset out of parts: - * `$a["$k.value"]` and `$a[$k . '.value']` read the same offset through - * different AST shapes. Interpolation is sugar for concatenation - each part is - * cast to string and the results are joined - so an offset built either way is - * rewritten into a single canonical concatenation of its parts. + * Rewriting the offset literal to a canonical spelling here rather than in the + * printer keeps the printed form faithful everywhere else, so the expression + * text quoted back in error messages is unaffected outside of array offsets. * - * Normalizing here rather than in the printer keeps the printed form faithful - * everywhere else, so the expression text quoted back in error messages is - * unaffected outside of array offsets. - * - * Rewriting an interpolation into a concatenation is the one change rules can - * see - inside an offset, a part that cannot be cast to string is reported by - * InvalidBinaryOperationRule instead of InvalidPartOfEncapsedStringRule. The - * synthesized nodes carry the token positions of the source they stand for, so - * error lines stay put and format-preserving printing keeps reproducing the - * original spelling. + * Only the spelling attributes are touched, never a subnode, so rules see the + * same AST and format-preserving printing still reproduces the original source + * for these nodes. */ #[AutowiredService] final class ArrayOffsetNormalizingVisitor extends NodeVisitorAbstract @@ -52,181 +38,24 @@ public function enterNode(Node $node): ?Node return null; } - $node->dim = $this->normalize($node->dim); - - return null; - } - - private function normalize(Expr $dim): Expr - { + $dim = $node->dim; if ($dim instanceof String_) { - $this->canonicalizeStringKind($dim); - - return $dim; - } - - if ($dim instanceof Int_) { - $dim->setAttribute('kind', Int_::KIND_DEC); - - return $dim; - } - - if (!$dim instanceof InterpolatedString && !$dim instanceof Concat) { - return $dim; - } - - $operands = $this->mergeAdjacentStrings($this->flatten($dim)); - if (count($operands) === 1) { - $operand = $operands[0]; - if ($operand instanceof String_) { - // The whole offset is a constant string built out of pieces, - // e.g. `'a' . 'b'` - spell it the way `$a['ab']` is spelled. - return $operand; - } - - // A one-part interpolation is a string cast, which the part alone is - // not: `"$k"` and `$k` are the same offset only when `$k` is already - // a string. Leave the cast in place and only pin its spelling. - $dim->setAttribute('kind', String_::KIND_DOUBLE_QUOTED); - - return $dim; - } - - $concat = $operands[0]; - for ($i = 1; $i < count($operands); $i++) { - $concat = new Concat($concat, $operands[$i], $this->spanAttributes($concat, $operands[$i])); - } - - return $concat; - } - - /** - * Collects the operands of a concatenation, no matter which of the two - * syntaxes - or which nesting of them - built it. String concatenation is - * associative, so the flat list describes the same value as the tree. - * - * @return list - */ - private function flatten(Expr $expr): array - { - if ($expr instanceof Concat) { - $operands = []; - foreach ([$expr->left, $expr->right] as $side) { - foreach ($this->flatten($side) as $operand) { - $operands[] = $operand; - } - } - - return $operands; - } - - if ($expr instanceof InterpolatedString) { - $operands = []; - foreach ($expr->parts as $part) { - if ($part instanceof InterpolatedStringPart) { - $operands[] = $this->createString($part->value, $part->getAttributes()); - continue; - } - - foreach ($this->flatten($part) as $operand) { - $operands[] = $operand; - } - } - - return $operands; - } - - if ($expr instanceof Int_) { - $expr->setAttribute('kind', Int_::KIND_DEC); - } elseif ($expr instanceof String_) { - $this->canonicalizeStringKind($expr); - } - - return [$expr]; - } - - /** - * @param list $operands - * @return non-empty-list - */ - private function mergeAdjacentStrings(array $operands): array - { - $merged = []; - foreach ($operands as $operand) { - $last = $merged === [] ? null : $merged[array_key_last($merged)]; - if (!$operand instanceof String_ || !$last instanceof String_) { - $merged[] = $operand; - continue; - } - - $merged[array_key_last($merged)] = $this->createString( - $last->value . $operand->value, - $this->spanAttributes($last, $operand), + // Single quotes normally, double quotes when the value holds control + // characters - the same canonical form as ConstantStringType::export(), + // and the one that keeps the expression key free of newlines. + $dim->setAttribute( + 'kind', + preg_match('/[\x00-\x1f]/', $dim->value) === 1 + ? String_::KIND_DOUBLE_QUOTED + : String_::KIND_SINGLE_QUOTED, ); + } elseif ($dim instanceof InterpolatedString) { + $dim->setAttribute('kind', String_::KIND_DOUBLE_QUOTED); + } elseif ($dim instanceof Int_) { + $dim->setAttribute('kind', Int_::KIND_DEC); } - if ($merged === []) { - // An interpolation always has at least one part, but an empty one - // would still describe the empty string. - return [$this->createString('', [])]; - } - - return $merged; - } - - /** - * @param array $attributes - */ - private function createString(string $value, array $attributes): String_ - { - $string = new String_($value, $attributes); - $this->canonicalizeStringKind($string); - - return $string; - } - - /** - * Single quotes normally, double quotes when the value holds control - * characters - the same canonical form as ConstantStringType::export(), and - * the one that keeps the expression key free of newlines. - */ - private function canonicalizeStringKind(String_ $string): void - { - $string->setAttribute( - 'kind', - preg_match('/[\x00-\x1f]/', $string->value) === 1 - ? String_::KIND_DOUBLE_QUOTED - : String_::KIND_SINGLE_QUOTED, - ); - } - - /** - * Keeps a synthesized node pointing at the source it stands for, so that - * error lines stay put and format-preserving printing reproduces the - * original spelling instead of the canonical one. - * - * @return array - */ - private function spanAttributes(Node $start, Node $end): array - { - $attributes = []; - foreach (['startLine', 'startTokenPos', 'startFilePos'] as $attribute) { - if (!$start->hasAttribute($attribute)) { - continue; - } - - $attributes[$attribute] = $start->getAttribute($attribute); - } - - foreach (['endLine', 'endTokenPos', 'endFilePos'] as $attribute) { - if (!$end->hasAttribute($attribute)) { - continue; - } - - $attributes[$attribute] = $end->getAttribute($attribute); - } - - return $attributes; + return null; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15060.php b/tests/PHPStan/Analyser/nsrt/bug-15060.php index ea7e378cafb..eac694598f7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15060.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15060.php @@ -61,53 +61,3 @@ function interpolatedSpellings($m, string $k): void HEREDOC]); } } - -function stringConcat($m, string $k): void -{ - if (is_array($m[$k . ".value"]) && $m[$k . ".value"]) { - assertType('non-empty-array', $m["$k.value"]); - assertType('non-empty-array', $m[$k . ".value"]); - assertType('non-empty-array', $m[$k . '.value']); - assertType('non-empty-array', $m["{$k}.value"]); - assertType('non-empty-array', $m[<<', $m[$k . '.value']); - } -} - -function concatOfConstantStrings($m): void -{ - if (is_array($m['a' . 'b']) && $m['a' . 'b']) { - assertType('non-empty-array', $m['ab']); - assertType('non-empty-array', $m["ab"]); - assertType('non-empty-array', $m['a' . 'b']); - } -} - -function concatNesting($m, string $a, string $b): void -{ - if (is_array($m[$a . $b . '.value']) && $m[$a . $b . '.value']) { - assertType('non-empty-array', $m[$a . ($b . '.value')]); - assertType('non-empty-array', $m["$a$b.value"]); - assertType('non-empty-array', $m["$a" . "$b" . '.value']); - } -} - -function singlePartInterpolationIsAStringCast($m, int $i): void -{ - if (is_array($m["$i"]) && $m["$i"]) { - assertType('non-empty-array', $m["$i"]); - assertType('non-empty-array', $m["{$i}"]); - // "$i" casts to string, $i does not - the offsets only coincide - // because PHP normalizes integer-like string keys. - assertType('mixed', $m[$i]); - } -} diff --git a/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php b/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php deleted file mode 100644 index d2b2e902a61..00000000000 --- a/tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php +++ /dev/null @@ -1,73 +0,0 @@ -parseString(sprintf('expr instanceof ArrayDimFetch) { - throw new ShouldNotHappenException(); - } - - $printer = self::getContainer()->getByType(ExprPrinter::class); - $this->assertSame($expectedKey, $printer->printExpr($stmts[0]->expr)); - } - -}