Skip to content

Normalize double and single quotes in offset keys, to remember them with the same node-key - #6196

Merged
staabm merged 8 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-n24fb23
Aug 10, 2026
Merged

Normalize double and single quotes in offset keys, to remember them with the same node-key#6196
staabm merged 8 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-n24fb23

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

…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.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need a similar fix for

"$key.value" vs $key . '.value'

?

phpstan-bot and others added 2 commits August 8, 2026 08:10
`"$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>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's my answer to the review.

Reply to staabm

I 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. InterpolatedString is a Scalar, so it's absent from php-parser's precedenceMap and printed atomically. I prototyped the naive version:

-"$a$b"      => -$a . $b     ← same key as (-$a) . $b
"$a$b" ** 2  => $a . $b ** 2 ← same key as $a . ($b ** 2)
"$a"         => $a           ← same key as the uncast variable

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 InterpolatedString through the precedence machinery and special-casing the single-part form (as '' . $a), and then that concat form also reaches every error message that quotes the expression back to the user — the same objection that made me leave name case alone.

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 explicit_string_variable, Rector's SimpleToComplexStringVariableRector). Those already work — php-parser's pEncapsList always emits the {$...} form:

$a["$b"] / $a["{$b}"] / $a["${b}"]  →  $a["{$b}"]
$a["$b[k]"] / $a["{$b['k']}"]       →  $a["{$b['k']}"]
$$v / ${$v}                         →  ${$v}

Two commits pushed:

  • 59dd58703 — pins those interpolation spellings in ExprPrinterTest (guard tests; they pass without any source change).
  • dee330f87 — records the reasoning above on pScalar_InterpolatedString, so the next person asking finds the answer.

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.

Comment thread src/Node/Printer/Printer.php Outdated

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

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 $a['k'] — it's a problem anywhere a literal is part of an expression key. Measured on /tmp/t1.php:

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 41a1c4c3e the second dump is mixed; after it, both agree. Scoping to ArrayDimFetch::$dim would leave that case — and every other literal-in-a-key position — still broken, which is the same bug the issue reports.

The cost concern is real in two spots, and I fixed both rather than narrowing the scope. Committed as 580dcfb41:

  • pScalar_String() was calling addcslashes() to build a fully escaped copy of every string literal purely to compare it against the original. Replaced with preg_match('/[\x00-\x1f]/', ...) — same condition, no allocation, and it short-circuits on the first control character.
  • pExpr_ConstFetch() was calling Name::getParts(), which explode()s the name, then getFirst(), which scans it again. Replaced with toLowerString() plus three string comparisons: a qualified name lowercases to something containing a backslash and can never equal true/false/null, so the part-counting was never needed. Only the Name\Relative guard has to stay, since it holds just the part after namespace\.

Microbenchmark, best of 5 × 300k calls, comparing each override against Standard's implementation of the same method:

node PHPStan Standard delta
'someArrayKey' 263 ns 256 ns +7 ns
"someArrayKey" 263 ns 586 ns −323 ns
1234 102 ns 218 ns −116 ns
"x{$k}" 791 ns 947 ns −156 ns
true 272 ns 242 ns +30 ns

The overrides are cheaper than Standard for every spelling except the single-quoted string and the keyword, where they're within noise — Standard branches on the kind attribute and then does the same escaping work. And Printer::p() caches each expression's printed form on the node, so an override runs at most once per literal node per parse.

End to end, analysing src/Type/Constant src/Type/Accessory src/Rules/Comparison at level 8 with a cleared result cache, 3 runs each: 11.46 s / 11.66 s / 13.12 s without the overrides vs 11.56 s / 11.76 s / 12.52 s with them — no measurable difference.

I also added an ExprPrinterTest case pinning that $a[Foo\true] stays distinct from $a[true], since the rewritten condition is what makes that hold. ($a[namespace\true] is not distinct in the global namespace — the name resolver turns it into \true, which genuinely is the same constant.)

@staabm

staabm commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

@SanderMuller I am not sure I like this approach very much. maybe you can find a alternative fix.

@SanderMuller

Copy link
Copy Markdown
Contributor

@SanderMuller I am not sure I like this approach very much. maybe you can find a alternative fix.

Will look into it!

@SanderMuller

Copy link
Copy Markdown
Contributor

maybe you can find a alternative fix

I think there is one, and there's a concrete reason to want it.

Why the current approach bites. Printer's output does double duty: it is the expression key MutatingScope tracks types under, and the text rules quote back in error messages. Canonicalizing it for the key therefore rewrites messages. The PR updates two rule tests for exactly that (\truetrue, (null, NULL)(null, null)), but the effect reaches further — NoopRule quotes the expression and has no test covering it:

"double quoted";
0x1F;
01;
<<<EOT
heredoc body
EOT;
line 2.2.x this PR
1 Expression ""double quoted"" Expression "'double quoted'"
2 Expression "0x1f" Expression "31"
3 Expression "01" Expression "1"
4 Expression "<<<EOT…" Expression "'heredoc body'"

Telling someone who wrote 0x1F that the problem is 31 is the part I would not want to ship.

The alternative: canonicalize the key, leave the display alone. printExpr() stays faithful to the source for the 16 sites that quote expressions back to users. A new ExprPrinter::printExprKey(), backed by an ExprKeyPrinter carrying exactly the four normalizations this PR already has, is used by the 19 identity sites — ScopeOps::nodeKey(), TypeSpecifier, AssignHandler, EqualityTypeSpecifyingHelper, BinaryOpHandler and the two constant-condition bucket helpers. Printer is final (and phpstan.finalClass enforces that), so the shared virtual-node printers move to an abstract BasePrinter and both concrete printers stay final.

I built it and ran the gates. bug-15060 fails without it and passes with it; the full suite is green (21302 tests, 96881 assertions); self-analysis and phpcs are clean; and both rule tests pass with their expectations reverted to the current 2.2.x values, i.e. no message changes at all. NoopRule output is byte-identical to 2.2.x. On a doctrine/symfony codebase over 5 interleaved rounds there is no measurable CPU difference (medians 150.4 s / 144.5 s / 148.2 s for 2.2.x, this PR and the split, against a 27 s spread within a single build) and peak memory is identical.

Two costs, because they cut the other way:

  1. It is bigger, not smaller — roughly 150 lines of new logic across 12 files, against +81 in a single file here. If the size is what bothered you, this does not answer it.
  2. It needs a turbo-ext change. ScopeOps is #[ShadowedByTurboExtension], and pt_node_printed_expr() in support.cpp calls printExpr by name and reads the phpstan_cache_printer attribute — so without a matching change there (plus make bump-turbo) the native and PHP key computations would diverge. I have not implemented or tested that part; the extension is not loaded on my machine. This PR as it stands needs no C++ change at all, since the native side already calls the printer it modifies — a real point in its favour.

So there are three options, and which one is right depends on what actually bothers you:

  • the split — messages preserved, costs more code and a turbo mirror change
  • this PR as it stands — smallest, but changes messages, 0x1F31 included
  • a middle path — keep the single printer and drop only pScalar_Int(), the one normalization that makes a message unrecognisable; $a[0x1] and $a[1] would still get separate keys, but that is not the case that was reported

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 staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done — pushed as 2582373b1.

What changed

Reworked the fix the way you asked: the Printer is back to its pre-PR state, and normalization now happens at parse time in src/Parser/ArrayOffsetSpellingVisitor.php, scoped to ArrayDimFetch::$dim.

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:

  • String_KIND_SINGLE_QUOTED, except when the value holds control characters, where KIND_DOUBLE_QUOTED is used. That's ConstantStringType::export()'s canonical form, and it keeps the key newline-free so Printer::p()'s print cache still applies. (Always-single-quoted would put a real newline into the key for $a["a\nb"] — a regression against today.)
  • InterpolatedStringKIND_DOUBLE_QUOTED, so a heredoc offset and the "..." form agree.
  • Int_KIND_DEC, so $a[1]/$a[0x1]/$a[01]/$a[0b1] agree.

This also settles the error-message objection

Both rule-test expectations the printer overrides had forced are reverted to their original values and pass: \true stays \true, (null, NULL) stays (null, NULL). NoopRule output is byte-identical to 2.2.x outside array offsets:

line before now
"double quoted"; Expression ""double quoted"" unchanged
0x1F; Expression "0x1f" unchanged
<<<EOT… Expression "<<<EOT…" unchanged
\true; Expression "\true" unchanged
$a["key"]; Expression "$a["key"]" Expression "$a['key']"

The last row is the one remaining message change, and it's inherent to canonicalizing the offset.

What the narrower scope gives up

Two cases the printer overrides covered are no longer fixed, and I want to be explicit rather than bury it:

  • $b->get('key') vs $b->get("key") — literals outside an array offset still get distinct keys.
  • $a[TRUE] vs $a[true] — normalizing a ConstFetch needs a Name replacement (a structural AST change), not an attribute tweak, so I left it out. The pExpr_ConstFetch override and its test section are gone.

Neither is the reported case; #15060 is fixed.

Verification

  • bug-15060.php: 11 assertions fail with the visitor disabled, pass with it.
  • ExprPrinterTest: 6 of 18 cases fail with the visitor disabled, pass with it. Dropped the true/false/null groups to match the new scope.
  • make tests — 21248 tests, 96784 assertions, 0 failures.
  • make phpstan (cleared result cache) — no errors. make cs — clean.
  • The issue's own snippet now reports Comparison operation ">" between int<1, max> and 0 is always true for the double-quoted read.

One note: the new class needs composer dump-autoload to land in vendor/attributes.php before #[AutowiredService] picks it up — CI does that, but it'll bite anyone testing locally from a stale autoload.

* for these nodes.
*/
#[AutowiredService]
final class ArrayOffsetSpellingVisitor extends NodeVisitorAbstract

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename this class into ArrayOffsetNormalizingVisitor

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed and pushed as d7b38a53c.

What changed

src/Parser/ArrayOffsetSpellingVisitor.phpsrc/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_array chain and the count() read: still reports Comparison 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.

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR title and description need an update to reflect the PRs current state

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Static analysis and coding standards are clean. The full suite is still running; I'll report when it finishes.

@SanderMuller

SanderMuller commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Agreed, this is the better shape, and I think the tradeoff you name is the right one to take.

I ran it. $m['key'] and $m["key"] now agree, as do $m[1] and $m[0x1]. Full suite green (21281 tests), self-analysis clean.

Two things make it better than the printer version beyond the smaller diff. Error messages survive: the only change is inside offsets, where $a["dq"] is reported as $a['dq'], the same characters in different quotes. The printer version reported source 0x1F; back as Expression "31", which was the part I did not want to ship. And it needs no turbo-ext change: ScopeOps is #[ShadowedByTurboExtension] and the native pt_node_key calls printExpr by name, so a printer-side fix has to be mirrored in support.cpp and bumped, whereas a visitor normalizes the node before analysis and the native path gets the canonical spelling for free.

I also checked the fixer, since the visitor mutates kind on a node the printer later sees. On a file with `ls`, $a["double"], $a[0x1F] and a heredoc offset, --fix rewrote only the backtick line and left every offset untouched, so format-preserving printing still reproduces the original source.

On the gap: confirmed, $b->get('key') and $b->get("key") still diverge. So do $m[1] and $m["1"], even though PHP reads both as the same offset, if anyone wants a follow-up target. Offsets are where quote rewriting actually bites, so I would take this now.

On performance, the visitor runs an instanceof per node, so I checked it too: three interleaved rounds on a doctrine/symfony codebase give 138.8s median on the base against 139.4s with the visitor, against a within-build spread of about 3s. No measurable cost.

@staabm staabm changed the title Print literals from their value, not their source spelling, when building expression keys Normalize double and single quotes in offset keys, so they are remembered with the same node-key Aug 10, 2026

@staabm staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"]);
	}
}

@staabm staabm changed the title Normalize double and single quotes in offset keys, so they are remembered with the same node-key Normalize double and single quotes in offset keys, to remember them with the same node-key Aug 10, 2026
…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.
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed as aa5ed10cd.

What changed

ArrayOffsetNormalizingVisitor now normalizes the two ways of building an offset, not just the spelling of a single literal.

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 InterpolatedString or a Concat is flattened into its list of operands, adjacent literals are merged, and the list is rebuilt as one left-associative Concat:

written expression key
$a["$k.value"] / $a[$k . ".value"] / $a["{$k}.value"] $a[$k . '.value']
$a["$k$j.x"] / $a[$k . $j . '.x'] / $a[$k . ($j . '.x')] / $a["$k" . "$j" . '.x'] $a[$k . $j . '.x']
$a['a' . 'b'] $a['ab']
$a["x$k"] / $a["x{$k}"] / heredoc x$k $a['x' . $k]

Your snippet is in bug-15060.php as stringConcat(), plus the reverse direction (interpolation establishing the narrowing, concat reading it), nesting, and constant folding.

One case deliberately left alone: a one-part interpolation. "$k" is a string cast and $k is not — they are the same offset only when $k already holds a string, so collapsing them would be a false collision. "$k" and "{$k}" still agree with each other. There's a test pinning that $m[$i] stays separate from $m["$i"].

Why an AST rewrite and not a printer change

This is the objection I raised against normalizing interpolation earlier — -"$a$b" printing as -$a . $b, "$a$b" ** 2 as $a . $b ** 2. That was about overriding pScalar_InterpolatedString() to return concat text while the printer still thought it was a Scalar. Replacing the node instead routes it through the normal precedence machinery, so $a[-("$a$b")] prints -"{$a}{$b}" and nothing collides. BacktickRule already builds the same InterpolatedStringPartString_Concat shape, so it's an established transformation here.

The one visible consequence

Inside an offset, an interpolation part that can't be cast to string is now reported by InvalidBinaryOperationRule instead of InvalidPartOfEncapsedStringRule:

$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 (encapsedStringPart.nonStringbinaryOp.invalid). No test in the suite covered that combination. It's recorded in the class docblock.

Verification

  • bug-15060.php: 10 assertions fail with the previous visitor, pass with this one.
  • New tests/PHPStan/Parser/ArrayOffsetNormalizingVisitorTest.php — 28 cases pinning the canonical key for every spelling above, through the container's parser and ExprPrinter, so it also guards that the visitor stays registered. 10 fail without the change.
  • make tests — 21258 tests, 96783 assertions, 0 failures (97 skipped).
  • make phpstan (cleared result cache) — no errors. make cs — clean.
  • --fix on PHP 8.5: rewriting a backtick next to an offset ($m["$k.value"] . \ls`) and *inside* one ($m["x" . `ls` . "$k.value"]) changed only the backtick — "$k.value", 'a' . 'b', "x{$k}y"and0x1F` all came back byte-identical. The synthesized nodes carry the token positions of the source they stand for, which is what makes that hold.
  • No turbo-ext change: this is still a parser visitor, so the native path gets the canonical AST before analysis.

The PR title/description are still the quote-only ones — suggest Normalize array offset spelling and shape so equivalent offsets share one expression key, since it now covers int spellings, interpolation and concatenation too.

@staabm
staabm merged commit be4165f into phpstan:2.2.x Aug 10, 2026
375 of 376 checks passed
@staabm
staabm deleted the create-pull-request/patch-n24fb23 branch August 10, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Array keys using single quotes are narrowed differently than the same array key using double quotes

3 participants