Normalize double and single quotes in offset keys, to remember them with the same node-key - #6196
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 |
staabm
left a comment
There was a problem hiding this comment.
add support for
function stringConcet($m, string $k): void
{
if (is_array($m[$k.".value"]) && $m[$k.".value"]) {
assertType('non-empty-array<mixed, mixed>', $m["$k.value"]);
assertType('non-empty-array<mixed, mixed>', $m[$k.".value"]);
}
}
…e 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.
|
Pushed as What changed
Interpolation is sugar for concatenation — every part is cast to string, the results are joined — and string concatenation is associative. So an offset that is an
Your snippet is in One case deliberately left alone: a one-part interpolation. Why an AST rewrite and not a printer changeThis is the objection I raised against normalizing interpolation earlier — The one visible consequenceInside an offset, an interpolation part that can't be cast to string is now reported by $m["$o text"]; // before: Part $o (object) of encapsed string cannot be cast to string.
// now: Binary operation "." between object and ' text' results in an error.Still an error, different message and identifier ( Verification
The PR title/description are still the quote-only ones — suggest |
…on to one form" This reverts commit aa5ed10.
Fixes phpstan/phpstan#15060