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 6 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. |
|
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? |
staabm
left a comment
There was a problem hiding this comment.
instead of adjusting the Printer, could we normalize ArrayDimFetch with String_ keys into single quotes using a NodeVisitor ?
…n 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
|
Done — pushed as What changedReworked the fix the way you asked: the It rewrites only the spelling attribute of the offset literal — never a subnode — so rules see the same AST and format-preserving printing (the fixable path) is untouched:
This also settles the error-message objectionBoth rule-test expectations the printer overrides had forced are reverted to their original values and pass:
The last row is the one remaining message change, and it's inherent to canonicalizing the offset. What the narrower scope gives upTwo cases the printer overrides covered are no longer fixed, and I want to be explicit rather than bury it:
Neither is the reported case; #15060 is fixed. Verification
One note: the new class needs |
| * for these nodes. | ||
| */ | ||
| #[AutowiredService] | ||
| final class ArrayOffsetSpellingVisitor extends NodeVisitorAbstract |
There was a problem hiding this comment.
rename this class into ArrayOffsetNormalizingVisitor
There was a problem hiding this comment.
Renamed and pushed as d7b38a53c.
What changed
src/Parser/ArrayOffsetSpellingVisitor.php → src/Parser/ArrayOffsetNormalizingVisitor.php, class renamed to match. Git records it as a pure rename (R) — no other edits.
No registration change was needed: RichParser collects its visitors by interface (#[AutowiredExtensions(of: NodeVisitor::class)] behind the phpstan.parser.richParserNodeVisitor tag), so #[AutowiredService] on any NodeVisitor is enough and nothing referenced the old name by string. I did re-run composer dump-autoload so vendor/attributes.php picks up the new class name — worth knowing for anyone testing locally from a stale autoload, same caveat as when the class was first added.
Verification
make tests— 21230 tests, 96755 assertions, 0 failures (97 skipped).make phpstan(cleared result cache) — no errors.make cs— clean.- End-to-end on the issue's own snippet, mixing the two spellings across the
isset/truthy/is_arraychain and thecount()read: still reportsComparison operation ">" between int<1, max> and 0 is always true, confirming the visitor is live in the container under its new name rather than silently dropped.
There was a problem hiding this comment.
I like this way more. @SanderMuller wdyt?
it does not cover all possible cases, but I think it will cover the most common case (mixing single, double quotes)
staabm
left a comment
There was a problem hiding this comment.
PR title and description need an update to reflect the PRs current state
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Static analysis and coding standards are clean. The full suite is still running; I'll report when it finishes. |
|
Agreed, this is the better shape, and I think the tradeoff you name is the right one to take. I ran it. Two things make it better than the printer version beyond the smaller diff. Error messages survive: the only change is inside offsets, where I also checked the fixer, since the visitor mutates On the gap: confirmed, On performance, the visitor runs an |
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