Skip to content

Print literals from their value, not their source spelling, when building expression keys - #6196

Open
phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-n24fb23
Open

Print literals from their value, not their source spelling, when building expression keys#6196
phpstan-bot wants to merge 3 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-n24fb23

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

$searchParams['key'] and $searchParams["key"] refer to the same array offset, but PHPStan
narrowed 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 key
under which MutatingScope tracks types. It inherits nikic/php-parser's Standard printer, which
faithfully 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 a String_ from $node->value, ignoring the kind and docLabel
    attributes. 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 the kind attribute
    (PHP_INT_MIN keeps Standard's (-9223372036854775807-1) form, since it cannot be written as a
    literal).
  • pScalar_InterpolatedString() — always print the "..." form instead of honouring the heredoc
    kind.
  • pExpr_ConstFetch() — lowercase a single-part, non-relative true, false or null.

Analogous cases probed and found already correct, so no change was made and no test was kept:

  • Float literals — 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).
  • Curly-brace member access — $o->{'p'} / $o->{"p"} / $o->p, and the method-call equivalents,
    are already normalized by the existing pObjectProperty() override.
  • Variable variables with a constant name (${'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 already
reported by a dedicated rule (class.nameCase, function.nameCase, method.nameCase,
staticMethod.nameCase), and normalizing case in the printer would degrade error messages that
quote the expression back to the user. true/false/null are the one case-insensitive spelling
with 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 (\truetrue) and
tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php ((null, NULL)
(null, null)).

Root cause

MutatingScope keys its type table by the pretty-printed form of an expression
(ScopeOps::nodeKey()ExprPrinter::printExpr()). Two spellings of the same expression must
print identically or narrowing established under one is simply not found under the other.

nikic/php-parser's Standard printer is built for round-tripping source, so it reproduces the
literal as written: pScalar_String() and pScalar_InterpolatedString() branch on the kind
attribute (single-quoted / double-quoted / heredoc / nowdoc), pScalar_Int() branches on the
numeric base, and pExpr_ConstFetch() prints the Name verbatim. Every literal kind that carries
such 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 $a itself with a HasOffsetValueType, and reading $a["k"] then resolves
through that offset regardless of the expression key. It only surfaced where no such array type
exists — a mixed variable, 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 that
    the single- and double-quoted reads agree after isset(), after truthiness, after is_array()
    and after is_array() && truthy. Extended with the analogous spellings: "\x74est" /
    nowdoc / heredoc for strings, 0x1 / 01 / 0b1 for ints (with $m[10] pinned to mixed so a
    key 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 that
    equivalent spellings print to the same expression key, that genuinely different expressions
    ($a[1] vs $a['1'], 'a\nb' vs "a\nb", FOO vs foo) still print differently, and that a
    heredoc key contains no newline. 9 of its 17 cases fail without the fix.

Fixes phpstan/phpstan#15060

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

return parent::pObjectProperty($node);
}

/**

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 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?

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.

I'll stop polling and wait for the monitor.

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

2 participants