Skip to content

Commit 46c4f29

Browse files
ondrejmirtesclaude
andcommitted
Remap conditional expressions through constant string concat-assigns
`$var .= '<single constant string>'` used to invalidate every conditional-expression holder mentioning $var, so a format string built across `if (!empty($target))` lost its correlation with $target and a later `empty($target)` re-check could not select the matching string. Appending a fixed suffix is injective, so holders whose target or condition on $var is a single constant string are remapped through the append instead; everything else still falls back to invalidation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CGhnJQpUWRRpg6H5LuWJkA
1 parent 56008f9 commit 46c4f29

6 files changed

Lines changed: 723 additions & 405 deletions

File tree

‎src/Analyser/ExprHandler/AssignHandler.php‎

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
use PHPStan\Node\VariableAssignNode;
7575
use PHPStan\Node\VirtualNode;
7676
use PHPStan\Php\PhpVersion;
77+
use PHPStan\Reflection\InitializerExprTypeResolver;
7778
use PHPStan\Rules\Properties\PropertyReflectionFinder;
7879
use PHPStan\ShouldNotHappenException;
7980
use PHPStan\TrinaryLogic;
@@ -115,6 +116,7 @@
115116
use function is_nan;
116117
use function is_string;
117118
use function spl_object_id;
119+
use function str_contains;
118120
use const PHP_INT_MAX;
119121

120122
/**
@@ -166,6 +168,7 @@ public function __construct(
166168
private PropertyHookThrowPointsResolver $propertyHookThrowPointsResolver,
167169
private StatementsHandler $statementsHandler,
168170
private ArrayUnpackingHelper $arrayUnpackingHelper,
171+
private InitializerExprTypeResolver $initializerExprTypeResolver,
169172
)
170173
{
171174
}
@@ -1404,7 +1407,23 @@ public function applyWrite(
14041407
}
14051408

14061409
$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage);
1407-
1410+
$remappedConditionalExpressions = [];
1411+
if (
1412+
// only the concat-assign's own write remaps - an enclosing plain
1413+
// assignment of the same variable (`$s = $s .= 'x'`) sees the
1414+
// already-remapped holders and would remap them a second time
1415+
$isAssignOp
1416+
&& $assignedExpr instanceof AssignOp\Concat
1417+
&& $assignedExpr->var instanceof Variable
1418+
&& $assignedExpr->var->name === $var->name
1419+
&& $scope->getConditionalExpressions() !== []
1420+
) {
1421+
$remappedConditionalExpressions = $this->remapConditionalExpressionsThroughConcatAssign(
1422+
$scope->getConditionalExpressions(),
1423+
'$' . $var->name,
1424+
$valueResult->getType(),
1425+
);
1426+
}
14081427
$nativeType = $this->readAssignedValueType($nodeScopeResolver, $storedAssignedExprResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain());
14091428
$scope = $scope->assignVariable(
14101429
$var->name,
@@ -1413,6 +1432,9 @@ public function applyWrite(
14131432
TrinaryLogic::createYes(),
14141433
[],
14151434
);
1435+
foreach ($remappedConditionalExpressions as $exprString => $holders) {
1436+
$scope = $scope->addConditionalExpressions((string) $exprString, $holders); // @phpstan-ignore cast.useless
1437+
}
14161438
foreach ($conditionalExpressions as $exprString => $holders) {
14171439
$scope = $scope->addConditionalExpressions((string) $exprString, $holders);
14181440
}
@@ -2171,6 +2193,100 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(NodeSco
21712193
return $conditionalExpressions;
21722194
}
21732195

2196+
/**
2197+
* `$var .= <single constant string>` keeps the conditional-expression
2198+
* holders about $var alive by remapping them through the append instead of
2199+
* losing them to the write's invalidation. A consequence type about $var is
2200+
* concatenated with the appended constant; a condition on $var is remapped
2201+
* only when it is itself a single constant string - appending a fixed
2202+
* suffix is injective, so the remapped condition selects exactly the states
2203+
* the original condition did. Holders mentioning $var inside a composite
2204+
* expression, with a non-Yes certainty about $var, or with a non-constant
2205+
* condition on $var are left to the regular invalidation. This keeps e.g. a
2206+
* format string built across `if (!empty($target))` correlated with
2207+
* $target while further pieces are appended.
2208+
*
2209+
* @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
2210+
* @return array<string, ConditionalExpressionHolder[]>
2211+
*/
2212+
private function remapConditionalExpressionsThroughConcatAssign(
2213+
array $conditionalExpressions,
2214+
string $varExprString,
2215+
Type $appendedType,
2216+
): array
2217+
{
2218+
$appendedConstantStrings = $appendedType->getConstantStrings();
2219+
if (count($appendedConstantStrings) !== 1 || !$appendedType->equals($appendedConstantStrings[0])) {
2220+
return [];
2221+
}
2222+
$appendedConstantString = $appendedConstantStrings[0];
2223+
2224+
$remapped = [];
2225+
foreach ($conditionalExpressions as $targetExprString => $holders) {
2226+
$targetExprString = (string) $targetExprString; // @phpstan-ignore cast.useless
2227+
$targetIsVar = $targetExprString === $varExprString;
2228+
if (!$targetIsVar && str_contains($targetExprString, $varExprString)) {
2229+
// a composite target containing the variable ($var[0], f($var), ...)
2230+
continue;
2231+
}
2232+
2233+
foreach ($holders as $holder) {
2234+
$holderTouchesVar = $targetIsVar;
2235+
$remappable = true;
2236+
$newConditions = [];
2237+
foreach ($holder->getConditionExpressionTypeHolders() as $conditionExprString => $conditionHolder) {
2238+
$conditionExprString = (string) $conditionExprString; // @phpstan-ignore cast.useless
2239+
if ($conditionExprString === $varExprString) {
2240+
$holderTouchesVar = true;
2241+
$conditionConstantStrings = $conditionHolder->getType()->getConstantStrings();
2242+
if (
2243+
!$conditionHolder->getCertainty()->yes()
2244+
|| count($conditionConstantStrings) !== 1
2245+
|| !$conditionHolder->getType()->equals($conditionConstantStrings[0])
2246+
) {
2247+
$remappable = false;
2248+
break;
2249+
}
2250+
$newConditions[$conditionExprString] = ExpressionTypeHolder::createYes(
2251+
$conditionHolder->getExpr(),
2252+
$conditionConstantStrings[0]->append($appendedConstantString),
2253+
);
2254+
continue;
2255+
}
2256+
2257+
if (str_contains($conditionExprString, $varExprString)) {
2258+
$remappable = false;
2259+
break;
2260+
}
2261+
2262+
$newConditions[$conditionExprString] = $conditionHolder;
2263+
}
2264+
if (!$remappable || !$holderTouchesVar) {
2265+
continue;
2266+
}
2267+
2268+
$typeHolder = $holder->getTypeHolder();
2269+
if ($targetIsVar) {
2270+
if (!$typeHolder->getCertainty()->yes()) {
2271+
// the append leaves the variable defined on every path -
2272+
// an undefined/maybe consequence cannot be carried over
2273+
continue;
2274+
}
2275+
$concatType = $this->initializerExprTypeResolver->resolveConcatType($typeHolder->getType(), $appendedType);
2276+
if ($concatType instanceof ErrorType) {
2277+
continue;
2278+
}
2279+
$typeHolder = ExpressionTypeHolder::createYes($typeHolder->getExpr(), $concatType);
2280+
}
2281+
2282+
$newHolder = new ConditionalExpressionHolder($newConditions, $typeHolder);
2283+
$remapped[$targetExprString][$newHolder->getKey()] = $newHolder;
2284+
}
2285+
}
2286+
2287+
return $remapped;
2288+
}
2289+
21742290
/**
21752291
* Records "if the assigned variable has $variableType, the target variable is
21762292
* certainly defined (with its branch-scope type)" holders for variables whose
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug9854;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
$url = 'testUrl';
8+
$linkText = 'testLinkText';
9+
10+
$htmlLinkStructure = '<a href="%s"';
11+
if (!empty($target)) {
12+
$htmlLinkStructure .= ' target="%s"';
13+
}
14+
$htmlLinkStructure .= ' class="link">%s</a>';
15+
16+
if (empty($target)) {
17+
assertType('\'<a href="%s" class="link">%s</a>\'', $htmlLinkStructure);
18+
return sprintf($htmlLinkStructure, $url, $linkText);
19+
}
20+
21+
assertType('\'<a href="%s" target="%s" class="link">%s</a>\'', $htmlLinkStructure);
22+
return sprintf($htmlLinkStructure, $url, $target, $linkText);

‎tests/PHPStan/Rules/Functions/PrintfParametersRuleTest.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,9 @@ public function testFormatParser(): void
194194
]);
195195
}
196196

197+
public function testBug9854(): void
198+
{
199+
$this->analyse([__DIR__ . '/data/bug-9854.php'], []);
200+
}
201+
197202
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php declare(strict_types = 1);
2+
3+
$url = 'testUrl';
4+
$linkText = 'testLinkText';
5+
6+
$htmlLinkStructure = '<a href="%s"';
7+
if (!empty($target)) {
8+
$htmlLinkStructure .= ' target="%s"';
9+
}
10+
$htmlLinkStructure .= ' class="link">%s</a>';
11+
12+
if (empty($target)) {
13+
return sprintf($htmlLinkStructure, $url, $linkText);
14+
}
15+
16+
return sprintf($htmlLinkStructure, $url, $target, $linkText);

‎turbo-ext/src/AssignHandler.cpp‎

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1084,7 +1084,7 @@ class AssignHandler
10841084
void construct(zval *argv)
10851085
{
10861086
zv::ObjRef object(self);
1087-
for (uint32_t i = 0; i < 20; i++) {
1087+
for (uint32_t i = 0; i < 21; i++) {
10881088
object.propAtWrite(i, zv::Val::copyOf(zv::Ref(&argv[i])));
10891089
}
10901090
}
@@ -2159,10 +2159,35 @@ class AssignHandler
21592159
AH_VALB(assignNode, newVariableAssignNode(var, assignedExpr.raw()));
21602160
AH_OKB(nsrCallNodeCallback(nsr, nodeCallback, assignNode.raw(), scopeBeforeAssignEval.raw(), storage));
21612161
}
2162+
zv::Val remappedConditionalExpressions = ahEmptyArray();
2163+
// only the concat-assign's own write remaps - an enclosing plain
2164+
// assignment of the same variable (`$s = $s .= 'x'`) sees the
2165+
// already-remapped holders and would remap them a second time
2166+
if (Z_TYPE_P(AH_TARGET(target, isAssignOp)) == IS_TRUE && ahIs(assignedExpr.raw(), PT_CLASS_ASSIGN_OP_CONCAT)) {
2167+
zend_string *concatVarName = ahStringVariableName(AH_PROP(assignedExpr.raw(), var));
2168+
if (concatVarName != NULL && zend_string_equals(concatVarName, varName)) {
2169+
AH_VALB(scopeConditionalExpressions, pt_mutating_scope_get_conditional_expressions(Z_OBJ_P(scope.raw())));
2170+
if (Z_TYPE_P(scopeConditionalExpressions.raw()) == IS_ARRAY && zend_hash_num_elements(Z_ARRVAL_P(scopeConditionalExpressions.raw())) > 0) {
2171+
AH_VALB(appendedType, pt_expression_result_get_type(valueResult));
2172+
zv::Str varExprString = zv::Str::adopt(zend_string_concat2(PT_LC("$"), ZSTR_VAL(varName), ZSTR_LEN(varName)));
2173+
AH_SETB(remappedConditionalExpressions, remapConditionalExpressionsThroughConcatAssign(scopeConditionalExpressions.raw(), varExprString.get(), appendedType.raw()));
2174+
}
2175+
}
2176+
}
21622177

21632178
AH_VALB(nativeScope, pt_mutating_scope_do_not_treat_phpdoc_types_as_certain(Z_OBJ_P(scope.raw())));
21642179
AH_VALB(nativeType, readAssignedValueType(nsr, stored, assignedExpr.raw(), nativeScope.raw()));
21652180
AH_SETB(scope, pt_mutating_scope_assign_variable(Z_OBJ_P(scope.raw()), varName, type.raw(), nativeType.raw(), ahTrinary(PT_TRI_YES)));
2181+
for (auto entry : zv::TableRef(Z_ARRVAL_P(remappedConditionalExpressions.raw()))) {
2182+
zval *holders = entry.value().raw();
2183+
zend_string *exprString = entry.stringKeyOrNull();
2184+
if (exprString != NULL) {
2185+
AH_SETB(scope, pt_mutating_scope_add_conditional_expressions(Z_OBJ_P(scope.raw()), exprString, Z_ARRVAL_P(holders)));
2186+
} else {
2187+
zv::Str key = zv::Str::adopt(zend_long_to_str((zend_long) entry.indexKey()));
2188+
AH_SETB(scope, pt_mutating_scope_add_conditional_expressions(Z_OBJ_P(scope.raw()), key.get(), Z_ARRVAL_P(holders)));
2189+
}
2190+
}
21662191
for (auto entry : zv::TableRef(Z_ARRVAL_P(conditionalExpressions.raw()))) {
21672192
zval *holders = entry.value().raw();
21682193
zend_string *exprString = entry.stringKeyOrNull();
@@ -2485,6 +2510,127 @@ class AssignHandler
24852510
return true;
24862511
}
24872512

2513+
static bool strContains(zend_string *haystack, zend_string *needle)
2514+
{
2515+
return zend_memnstr(ZSTR_VAL(haystack), ZSTR_VAL(needle), ZSTR_LEN(needle), ZSTR_VAL(haystack) + ZSTR_LEN(haystack)) != NULL;
2516+
}
2517+
2518+
/* the single constant string a type is exactly (count(getConstantStrings())
2519+
* === 1 and equals() it), UNDEF = pending exception, PHP null = none */
2520+
static zv::Val singleConstantString(zval *type)
2521+
{
2522+
AH_VAL(constantStrings, ahCall(type, PT_LC("getconstantstrings"), 0, NULL));
2523+
if (Z_TYPE_P(constantStrings.raw()) != IS_ARRAY || zend_hash_num_elements(Z_ARRVAL_P(constantStrings.raw())) != 1) return zv::Val::null();
2524+
zval *constantString = zend_hash_index_find(Z_ARRVAL_P(constantStrings.raw()), 0);
2525+
if (UNEXPECTED(constantString == NULL)) {
2526+
pt_throw_should_not_happen();
2527+
return zv::Val();
2528+
}
2529+
ZVAL_DEREF(constantString);
2530+
bool equal;
2531+
AH_OK(ahEquals(type, constantString, equal));
2532+
if (!equal) return zv::Val::null();
2533+
return zv::Val::copyOf(zv::Ref(constantString));
2534+
}
2535+
2536+
/* $holder->getConditionExpressionTypeHolders() / ->getTypeHolder() of a ConditionalExpressionHolder */
2537+
static zv::Val conditionalHolderPart(zval *holder, bool conditions)
2538+
{
2539+
if (EXPECTED(Z_TYPE_P(holder) == IS_OBJECT && Z_OBJCE_P(holder) == pt_ce_cond_expr_holder)) {
2540+
return zv::Val::copyOf(zv::ObjRef(Z_OBJ_P(holder)).propAt(conditions ? PT_CEH_PROP_CONDS : PT_CEH_PROP_TYPEHOLDER).deref());
2541+
}
2542+
return conditions ? ahCall(holder, PT_LC("getconditionexpressiontypeholders"), 0, NULL) : ahCall(holder, PT_LC("gettypeholder"), 0, NULL);
2543+
}
2544+
2545+
/* (twin remapConditionalExpressionsThroughConcatAssign) `$var .= <single
2546+
* constant string>` keeps the conditional-expression holders about $var
2547+
* alive by remapping them through the append instead of losing them to
2548+
* the write's invalidation; see the PHP twin for the rules. */
2549+
zv::Val remapConditionalExpressionsThroughConcatAssign(zval *conditionalExpressions, zend_string *varExprString, zval *appendedType)
2550+
{
2551+
AH_VAL(appendedConstantString, singleConstantString(appendedType));
2552+
if (appendedConstantString.isNull()) return ahEmptyArray();
2553+
2554+
zv::Val remapped = ahEmptyArray();
2555+
for (auto targetEntry : zv::TableRef(Z_ARRVAL_P(conditionalExpressions))) {
2556+
zv::Str targetExprString = targetEntry.stringKeyOrNull() != NULL ? zv::Str::copyOf(targetEntry.stringKey()) : zv::Str::adopt(zend_long_to_str((zend_long) targetEntry.indexKey()));
2557+
bool targetIsVar = zend_string_equals(targetExprString.get(), varExprString);
2558+
if (!targetIsVar && strContains(targetExprString.get(), varExprString)) {
2559+
// a composite target containing the variable ($var[0], f($var), ...)
2560+
continue;
2561+
}
2562+
2563+
zval *holders = targetEntry.value().deref().raw();
2564+
if (UNEXPECTED(Z_TYPE_P(holders) != IS_ARRAY)) continue;
2565+
for (auto holderEntry : zv::TableRef(Z_ARRVAL_P(holders))) {
2566+
zval *holder = holderEntry.value().deref().raw();
2567+
bool holderTouchesVar = targetIsVar;
2568+
bool remappable = true;
2569+
zv::Val newConditions = ahEmptyArray();
2570+
AH_VAL(conditions, conditionalHolderPart(holder, true));
2571+
if (UNEXPECTED(Z_TYPE_P(conditions.raw()) != IS_ARRAY)) continue;
2572+
for (auto conditionEntry : zv::TableRef(Z_ARRVAL_P(conditions.raw()))) {
2573+
zv::Str conditionExprString = conditionEntry.stringKeyOrNull() != NULL ? zv::Str::copyOf(conditionEntry.stringKey()) : zv::Str::adopt(zend_long_to_str((zend_long) conditionEntry.indexKey()));
2574+
zval *conditionHolder = conditionEntry.value().deref().raw();
2575+
if (zend_string_equals(conditionExprString.get(), varExprString)) {
2576+
holderTouchesVar = true;
2577+
if (pt_holder_certainty_value(Z_OBJ_P(conditionHolder)) != PT_TRI_YES) {
2578+
remappable = false;
2579+
break;
2580+
}
2581+
zval *conditionType = OBJ_PROP_NUM(Z_OBJ_P(conditionHolder), PT_ETH_PROP_TYPE);
2582+
ZVAL_DEREF(conditionType);
2583+
AH_VAL(conditionConstantString, singleConstantString(conditionType));
2584+
if (conditionConstantString.isNull()) {
2585+
remappable = false;
2586+
break;
2587+
}
2588+
zval appendArg;
2589+
ZVAL_COPY_VALUE(&appendArg, appendedConstantString.raw());
2590+
AH_VAL(appended, ahCall(conditionConstantString.raw(), PT_LC("append"), 1, &appendArg));
2591+
zval *conditionExpr = OBJ_PROP_NUM(Z_OBJ_P(conditionHolder), PT_ETH_PROP_EXPR);
2592+
ZVAL_DEREF(conditionExpr);
2593+
AH_VAL(newConditionHolder, newExpressionTypeHolder(conditionExpr, appended.raw(), PT_TRI_YES));
2594+
ahSetKey(newConditions, conditionExprString.get(), newConditionHolder.raw());
2595+
continue;
2596+
}
2597+
2598+
if (strContains(conditionExprString.get(), varExprString)) {
2599+
remappable = false;
2600+
break;
2601+
}
2602+
2603+
ahSetKey(newConditions, conditionExprString.get(), conditionHolder);
2604+
}
2605+
if (!remappable || !holderTouchesVar) continue;
2606+
2607+
AH_VAL(typeHolder, conditionalHolderPart(holder, false));
2608+
if (targetIsVar) {
2609+
if (pt_holder_certainty_value(Z_OBJ_P(typeHolder.raw())) != PT_TRI_YES) {
2610+
// the append leaves the variable defined on every path -
2611+
// an undefined/maybe consequence cannot be carried over
2612+
continue;
2613+
}
2614+
zval *holderType = OBJ_PROP_NUM(Z_OBJ_P(typeHolder.raw()), PT_ETH_PROP_TYPE);
2615+
ZVAL_DEREF(holderType);
2616+
zv::Args concatArgs{holderType, appendedType};
2617+
AH_VAL(concatType, ahCall(prop(slots::initializerExprTypeResolver), PT_LC("resolveconcattype"), 2, concatArgs));
2618+
if (Z_TYPE_P(concatType.raw()) == IS_OBJECT && instanceof_function(Z_OBJCE_P(concatType.raw()), pt_ce_error_type)) continue;
2619+
zval *holderExpr = OBJ_PROP_NUM(Z_OBJ_P(typeHolder.raw()), PT_ETH_PROP_EXPR);
2620+
ZVAL_DEREF(holderExpr);
2621+
AH_SET(typeHolder, newExpressionTypeHolder(holderExpr, concatType.raw(), PT_TRI_YES));
2622+
}
2623+
2624+
AH_VAL(newHolder, newConditionalExpressionHolder(newConditions.raw(), typeHolder.raw()));
2625+
zv::Str newHolderKey = zv::Str::adopt(conditionalHolderKey(newHolder.raw()));
2626+
AH_OK(!newHolderKey.isNull());
2627+
nestedSet(remapped, targetExprString.get(), newHolderKey.get(), newHolder.raw());
2628+
}
2629+
}
2630+
2631+
return remapped;
2632+
}
2633+
24882634
/* Records "if the assigned variable has $variableType, the target variable is
24892635
* certainly defined (with its branch-scope type)" holders for variables whose
24902636
* certainty is Yes in the given branch continuation scope of the RHS (the
@@ -4960,7 +5106,7 @@ PT_MINIT_REGISTRATION(pt_register_assign_handler)
49605106
cls.method(sigs::__construct, [](INTERNAL_FUNCTION_PARAMETERS) {
49615107
zval *argv;
49625108
uint32_t argc;
4963-
ZEND_PARSE_PARAMETERS_START(20, 20)
5109+
ZEND_PARSE_PARAMETERS_START(21, 21)
49645110
Z_PARAM_VARIADIC('+', argv, argc)
49655111
ZEND_PARSE_PARAMETERS_END();
49665112
for (uint32_t i = 0; i < argc; i++) {

0 commit comments

Comments
 (0)