Print literals from their value, not their source spelling, when building expression keys - #6196
Print literals from their value, not their source spelling, when building expression keys#6196phpstan-bot wants to merge 3 commits into
Conversation
…ding expression keys
* `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.
`"$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 <noreply@anthropic.com>
`"$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 <noreply@anthropic.com>
|
Pushed. Here's my answer to the review. Reply to staabmI checked, and the gap is real but I don't think we should close it here. It reproduces, symmetrically: function f($m, string $key): void {
if (is_array($m["$key.value"]) && $m["$key.value"]) {
assertType('non-empty-array<mixed, mixed>', $m["$key.value"]);
assertType('mixed', $m[$key . '.value']); // ← narrowing not visible
}
}…and the same the other way round. But normalizing it isn't a safe extension of this PR. Everything in this PR normalizes spelling of one node — same AST node, different source attribute. Interpolation vs. concatenation is two different AST shapes, and collapsing them in the printer breaks in ways that are worse than the bug. Those are false collisions — PHPStan would hand one expression a type established for a genuinely different one. That's a worse failure mode than the missing narrowing. Fixing them means routing The formatter angle is already covered. The original issue was pint rewriting quotes. No formatter rewrites concat ↔ interpolation, but several do rewrite between interpolation syntaxes (php-cs-fixer's Two commits pushed:
Happy to open a follow-up issue for the concat case if you'd rather track it, but I'd keep it out of this PR. |
| return parent::pObjectProperty($node); | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
instead of paying the costs for this check on every scalar-string, string-interpolation, const-fetch could we scope it instead on offsets of array-dim-fetch only?
There was a problem hiding this comment.
I'll stop polling and wait for the monitor.
|
I'll report once the checks finish. Summary of what I did so far: The scoping suggestion doesn't hold up. The literal isn't only a problem in if (is_array($b->get('key')) && $b->get('key')) {
\PHPStan\dumpType($b->get('key')); // non-empty-array<mixed, mixed>
\PHPStan\dumpType($b->get("key")); // before the fix: mixed
}Before The cost concern is real in two spots, and I fixed both rather than narrowing the scope. Committed as
Microbenchmark, best of 5 × 300k calls, comparing each override against
The overrides are cheaper than End to end, analysing I also added an |
|
@SanderMuller I am not sure I like this approach very much. maybe you can find a alternative fix. |
Will look into it! |
I think there is one, and there's a concrete reason to want it. Why the current approach bites. "double quoted";
0x1F;
01;
<<<EOT
heredoc body
EOT;
Telling someone who wrote The alternative: canonicalize the key, leave the display alone. I built it and ran the gates. Two costs, because they cut the other way:
So there are three options, and which one is right depends on what actually bothers you:
Happy to push the split as a PR if you want to look at it properly, turbo side included. Which axis matters most to you? |
Summary
$searchParams['key']and$searchParams["key"]refer to the same array offset, but PHPStannarrowed them independently: narrowing established through the double-quoted spelling was invisible
at the single-quoted one and vice versa. Running a formatter that rewrites quotes therefore changed
the analysis result, which is what the reporter hit.
The cause is in
PHPStan\Node\Printer\Printer, whose printed output doubles as the expression keyunder which
MutatingScopetracks types. It inherits nikic/php-parser'sStandardprinter, whichfaithfully reproduces the source spelling of a literal — exactly the wrong property for a key that
is supposed to identify an expression. The fix makes the printer derive literals from their value.
Changes
All in
src/Node/Printer/Printer.php:pScalar_String()— print aString_from$node->value, ignoring thekindanddocLabelattributes. The canonical form mirrors
ConstantStringType::export(): single quotes normally,double quotes with escapes when the value contains control characters.
pScalar_Int()— always print the decimal form instead of honouring thekindattribute(
PHP_INT_MINkeepsStandard's(-9223372036854775807-1)form, since it cannot be written as aliteral).
pScalar_InterpolatedString()— always print the"..."form instead of honouring the heredockind.pExpr_ConstFetch()— lowercase a single-part, non-relativetrue,falseornull.Analogous cases probed and found already correct, so no change was made and no test was kept:
pScalar_Float()is already value-based, so$a[1.5],$a[1.50]and$a[15e-1]already agreed (kept as a guard case in the new printer test, since it is the same code path).
$o->{'p'}/$o->{"p"}/$o->p, and the method-call equivalents,are already normalized by the existing
pObjectProperty()override.${'a'}) and a leading-\on a constant name(
\PHP_INT_MAX) already resolve to the same thing as their plain spelling.Deliberately left alone: class, function and method name case (
c::$p,$c->GET(),STRVAL(1)) does produce a distinct expression key, but every one of those spellings is alreadyreported by a dedicated rule (
class.nameCase,function.nameCase,method.nameCase,staticMethod.nameCase), and normalizing case in the printer would degrade error messages thatquote the expression back to the user.
true/false/nullare the one case-insensitive spellingwith no such rule, which is why they are normalized here.
Two rule-test expectations were updated because their messages quote the printed expression:
tests/PHPStan/Rules/Keywords/DeclareStrictTypesRuleTest.php(\true→true) andtests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php((null, NULL)→(null, null)).Root cause
MutatingScopekeys its type table by the pretty-printed form of an expression(
ScopeOps::nodeKey()→ExprPrinter::printExpr()). Two spellings of the same expression mustprint identically or narrowing established under one is simply not found under the other.
nikic/php-parser's
Standardprinter is built for round-tripping source, so it reproduces theliteral as written:
pScalar_String()andpScalar_InterpolatedString()branch on thekindattribute (single-quoted / double-quoted / heredoc / nowdoc),
pScalar_Int()branches on thenumeric base, and
pExpr_ConstFetch()prints theNameverbatim. Every literal kind that carriessuch a spelling attribute was therefore affected by the same pattern — the key encodes syntax where
it should encode value. The fix is to print each of them from the value the node holds, which is
the same thing the pre-existing
pObjectProperty()override already does for$obj->{'n'}.Note that the bug was masked whenever the array was already typed as an array: narrowing
$a['k']also refines$aitself with aHasOffsetValueType, and reading$a["k"]then resolvesthrough that offset regardless of the expression key. It only surfaced where no such array type
exists — a
mixedvariable, as in the reported snippet.Deriving the string form from the value also keeps expression keys newline-free (a heredoc key used
to embed real newlines), so
Printer::p()'s print cache now applies to them too.Test
tests/PHPStan/Analyser/nsrt/bug-15060.php— the reporter's playground snippet, asserting thatthe single- and double-quoted reads agree after
isset(), after truthiness, afteris_array()and after
is_array() && truthy. Extended with the analogous spellings:"\x74est"/nowdoc / heredoc for strings,
0x1/01/0b1for ints (with$m[10]pinned tomixedso akey collapse would be caught), heredoc for interpolated strings, and
TRUE/True/NULL/FALSE. 14 assertions in this file fail without the fix.tests/PHPStan/Node/Printer/ExprPrinterTest.php— a new unit test asserting directly thatequivalent spellings print to the same expression key, that genuinely different expressions
(
$a[1]vs$a['1'],'a\nb'vs"a\nb",FOOvsfoo) still print differently, and that aheredoc key contains no newline. 9 of its 17 cases fail without the fix.
Fixes phpstan/phpstan#15060