From 5247de40f40c505c1d3e8ab412c9be17eebfc6c0 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:11:17 +0000 Subject: [PATCH 1/3] Subtract a union from plain mixed at once Removing a union from mixed peeled its members one at a time, and every step rebuilt the growing subtracted union. Plain mixed minus a union is mixed with that union subtracted, so subtract it in one step. The comparison types (getSmallerType() and friends) build exactly that, and since floats narrow into ranges their subtracted unions carry NAN and a float range as well. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-f541-75cc-9cd3-8f0206f0ec3f --- src/Type/TypeCombinator.php | 11 +++++++++++ turbo-ext/src/TypeCombinator.cpp | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index f42af28f3f2..cdbdefba2ad 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -128,6 +128,17 @@ private static function canRemoveUnionAtOnce(Type $fromType): bool public static function doRemove(Type $fromType, Type $typeToRemove): Type { if ($typeToRemove instanceof UnionType) { + // Plain mixed minus a union is mixed with that union subtracted - + // peeling the members would rebuild the subtracted union once per + // member and end with the same one. + if ( + get_class($fromType) === MixedType::class + && $fromType->getSubtractedType() === null + && get_class($typeToRemove) === UnionType::class + ) { + return $fromType->subtract($typeToRemove); + } + // Removing the members one at a time is what keeps the result precise: // removing one can narrow the type so that removing the next achieves // more. A sealed hierarchy is the exception - there the subtraction is diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 0f5f69d4ce0..0fe45058a14 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -632,6 +632,15 @@ class TypeCombinator static zv::Val doRemove(zval *fromType, zval *typeToRemove) { if (isInstance(typeToRemove, pt_ce_union_type)) { + // Plain mixed minus a union is mixed with that union subtracted - + // peeling the members would rebuild the subtracted union once per + // member and end with the same one. + if (Z_OBJCE_P(fromType) == pt_ce_mixed_type && Z_OBJCE_P(typeToRemove) == pt_ce_union_type) { + zv::Val subtractedType = call(fromType, PT_LC("getsubtractedtype")); + PT_FAIL_IF_UNDEF(subtractedType); + if (isNull(subtractedType)) return call(fromType, PT_LC("subtract"), 1, typeToRemove); + } + int atOnce = canRemoveUnionAtOnce(fromType); PT_FAIL_IF_NEG(atOnce); if (atOnce) { From b3adc67b686f736601b9a3aa264cee4082a0b31c Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:11:17 +0000 Subject: [PATCH 2/3] Build the equivalent comparison forms once per comparison node Resolving a comparison and specifying its types each built the equivalent and complementary forms again and printed every one of them to look it up in the scope. Keep the forms with their printed keys in the comparison node's attributes and read the scope's holders by key. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-f541-75cc-9cd3-8f0206f0ec3f --- src/Analyser/ExprHandler/BinaryOpHandler.php | 155 ++++++++----- turbo-ext/src/BinaryOpHandler.cpp | 227 ++++++++++++++----- turbo-ext/src/generated/BinaryOpHandler.h | 24 +- 3 files changed, 287 insertions(+), 119 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 4aa6fac7a8d..0db158ed120 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -68,6 +68,8 @@ final class BinaryOpHandler implements ExprHandler { + private const COMPARISON_FORMS_ATTRIBUTE = 'phpstan_comparison_forms'; + public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, @@ -737,12 +739,13 @@ private function resolveRememberedComparisonType(MutatingScope $scope, BinaryOp return null; } - foreach ($forms as [$form, $isComplement]) { - if (!$scope->hasExpressionType($form)->yes()) { + foreach ($forms as [, $isComplement, $formKey]) { + $holder = $scope->expressionTypes[$formKey] ?? null; + if ($holder === null || !$holder->getCertainty()->yes()) { continue; } - $formType = $scope->getTrackedExpressionType($form)->toBoolean(); + $formType = $holder->getType()->toBoolean(); if ($formType->isTrue()->yes()) { return new ConstantBooleanType(!$isComplement); } @@ -776,9 +779,12 @@ private function specifyRememberedComparisonForms(SpecifiedTypes $specifiedTypes $exprKey = $this->exprPrinter->printExpr($expr); $sureTypes = []; - foreach ($forms as [$form, $isComplement]) { - $formKey = $this->exprPrinter->printExpr($form); - if ($formKey === $exprKey || !$scope->hasExpressionType($form)->yes()) { + foreach ($forms as [$form, $isComplement, $formKey]) { + if ($formKey === $exprKey) { + continue; + } + $holder = $scope->expressionTypes[$formKey] ?? null; + if ($holder === null || !$holder->getCertainty()->yes()) { continue; } @@ -797,78 +803,117 @@ private function specifyRememberedComparisonForms(SpecifiedTypes $specifiedTypes * paired with whether it is the complement (always the opposite value). * * @param callable(Expr): Type $getType - * @return list|null + * @return list|null */ private function getEquivalentComparisonForms(BinaryOp $expr, callable $getType): ?array + { + if ( + !$expr instanceof BinaryOp\Identical + && !$expr instanceof BinaryOp\NotIdentical + && !$expr instanceof BinaryOp\Equal + && !$expr instanceof BinaryOp\NotEqual + && !$expr instanceof BinaryOp\Smaller + && !$expr instanceof BinaryOp\SmallerOrEqual + && !$expr instanceof BinaryOp\Greater + && !$expr instanceof BinaryOp\GreaterOrEqual + ) { + return null; + } + + $left = $expr->left; + $right = $expr->right; + + // The forms are built and printed once per comparison node and kept + // in its attributes: scopes are keyed by the printed expression, and + // fresh form nodes would be printed again on every lookup. Attributes + // travel to new nodes, which can be of another class or have other + // operands. + $cached = $expr->getAttribute(self::COMPARISON_FORMS_ATTRIBUTE); + if ($cached === null || $cached[0] !== get_class($expr) || $cached[1] !== $left || $cached[2] !== $right) { + $cached = [get_class($expr), $left, $right, ...$this->createComparisonForms($expr)]; + $expr->setAttribute(self::COMPARISON_FORMS_ATTRIBUTE, $cached); + } + + $forms = $cached[3]; + $complementForms = $cached[4]; + + // `<` and `>=` only complement each other for comparable operands + // (NAN and uncomparable arrays/objects are neither smaller nor + // larger-or-equal) + if ($complementForms === [] || !$getType($left)->isInteger()->yes() || !$getType($right)->isInteger()->yes()) { + return $forms; + } + + return array_merge($forms, $complementForms); + } + + /** + * The equivalent forms, and the complement forms that hold only for + * comparable operands, each with its node key (a comparison's node key + * is its printed form). + * + * @return array{list, list} + */ + private function createComparisonForms(BinaryOp $expr): array { $left = $expr->left; $right = $expr->right; + $complementForms = []; if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { $isNegated = $expr instanceof BinaryOp\NotIdentical; - - return [ + $forms = [ [new BinaryOp\Identical($left, $right), $isNegated], [new BinaryOp\Identical($right, $left), $isNegated], [new BinaryOp\NotIdentical($left, $right), !$isNegated], [new BinaryOp\NotIdentical($right, $left), !$isNegated], ]; - } - - if ($expr instanceof BinaryOp\Equal || $expr instanceof BinaryOp\NotEqual) { + } elseif ($expr instanceof BinaryOp\Equal || $expr instanceof BinaryOp\NotEqual) { $isNegated = $expr instanceof BinaryOp\NotEqual; - - return [ + $forms = [ [new BinaryOp\Equal($left, $right), $isNegated], [new BinaryOp\Equal($right, $left), $isNegated], [new BinaryOp\NotEqual($left, $right), !$isNegated], [new BinaryOp\NotEqual($right, $left), !$isNegated], ]; - } - - if ( - !$expr instanceof BinaryOp\Smaller - && !$expr instanceof BinaryOp\SmallerOrEqual - && !$expr instanceof BinaryOp\Greater - && !$expr instanceof BinaryOp\GreaterOrEqual - ) { - return null; - } - - // normalized to `$smaller < $larger` / `$smaller <= $larger` - if ($expr instanceof BinaryOp\Smaller || $expr instanceof BinaryOp\SmallerOrEqual) { - $smaller = $left; - $larger = $right; } else { - $smaller = $right; - $larger = $left; - } - $orEqual = $expr instanceof BinaryOp\SmallerOrEqual || $expr instanceof BinaryOp\GreaterOrEqual; - $forms = $orEqual - ? [ - [new BinaryOp\SmallerOrEqual($smaller, $larger), false], - [new BinaryOp\GreaterOrEqual($larger, $smaller), false], - ] - : [ - [new BinaryOp\Smaller($smaller, $larger), false], - [new BinaryOp\Greater($larger, $smaller), false], - ]; - - // `<` and `>=` only complement each other for comparable operands - // (NAN and uncomparable arrays/objects are neither smaller nor - // larger-or-equal) - if (!$getType($left)->isInteger()->yes() || !$getType($right)->isInteger()->yes()) { - return $forms; + // normalized to `$smaller < $larger` / `$smaller <= $larger` + if ($expr instanceof BinaryOp\Smaller || $expr instanceof BinaryOp\SmallerOrEqual) { + $smaller = $left; + $larger = $right; + } else { + $smaller = $right; + $larger = $left; + } + + if ($expr instanceof BinaryOp\SmallerOrEqual || $expr instanceof BinaryOp\GreaterOrEqual) { + $forms = [ + [new BinaryOp\SmallerOrEqual($smaller, $larger), false], + [new BinaryOp\GreaterOrEqual($larger, $smaller), false], + ]; + $complementForms = [ + [new BinaryOp\Smaller($larger, $smaller), true], + [new BinaryOp\Greater($smaller, $larger), true], + ]; + } else { + $forms = [ + [new BinaryOp\Smaller($smaller, $larger), false], + [new BinaryOp\Greater($larger, $smaller), false], + ]; + $complementForms = [ + [new BinaryOp\SmallerOrEqual($larger, $smaller), true], + [new BinaryOp\GreaterOrEqual($smaller, $larger), true], + ]; + } } - if ($orEqual) { - $forms[] = [new BinaryOp\Smaller($larger, $smaller), true]; - $forms[] = [new BinaryOp\Greater($smaller, $larger), true]; - } else { - $forms[] = [new BinaryOp\SmallerOrEqual($larger, $smaller), true]; - $forms[] = [new BinaryOp\GreaterOrEqual($smaller, $larger), true]; + $keyed = [[], []]; + foreach ([$forms, $complementForms] as $i => $list) { + foreach ($list as [$form, $isComplement]) { + $keyed[$i][] = [$form, $isComplement, $this->exprPrinter->printExpr($form)]; + } } - return $forms; + return $keyed; } /** diff --git a/turbo-ext/src/BinaryOpHandler.cpp b/turbo-ext/src/BinaryOpHandler.cpp index be34bec1c95..35751ac48b2 100644 --- a/turbo-ext/src/BinaryOpHandler.cpp +++ b/turbo-ext/src/BinaryOpHandler.cpp @@ -38,6 +38,7 @@ #include "support.h" #include "generated/BinaryOpHandler.h" #include "generated/TypeResult.h" +#include "generated/MutatingScope.h" namespace slots = ptdecl::BinaryOpHandler::slot; namespace sigs = ptdecl::BinaryOpHandler::sig; @@ -342,6 +343,7 @@ int isScalarOrNegatedScalar(zval *node) /* the twin's literals, permanent interned strings (module startup) */ zend_string *pt_boh_division_by_zero_error = nullptr; zend_string *pt_boh_countable = nullptr; +zend_string *pt_boh_comparison_forms = nullptr; /* $result->type of a TypeResult (the slot of the native class, the property * otherwise); UNDEF = pending exception */ @@ -655,58 +657,140 @@ class BinaryOpHandler return typeResultType(typeResult.raw()); } - /* one entry of getEquivalentComparisonForms(): the form node and whether - * it is the complement */ + /* one entry of getEquivalentComparisonForms(): the form node, whether it + * is the complement and its node key - borrowed from the cache array the + * comparison node keeps in its attributes (the caller holds it) */ struct ComparisonForm { - zv::Val node; + zval *node = NULL; bool isComplement = false; + zend_string *key = NULL; }; static constexpr int COMPARISON_FORMS_LIMIT = 6; - /* Mirrors getEquivalentComparisonForms(); count = -1 for a non-comparison - * ($forms === null). false = pending exception */ - template - static bool equivalentComparisonForms(int kind, zval *left, zval *right, GetType &&getType, ComparisonForm (&forms)[COMPARISON_FORMS_LIMIT], int &count) + /* Mirrors the COMPARISON_FORMS_ATTRIBUTE cache of + * getEquivalentComparisonForms(): [class, left, right, forms, + * complementForms]. Built and stored on a miss; UNDEF = pending + * exception */ + zv::Val comparisonFormsCache(zval *expr, int kind, zval *left, zval *right) const { - count = 0; - auto add = [&](int classIdx, zval *formLeft, zval *formRight, bool isComplement) -> bool { + zval *cached = pt_node_attribute(Z_OBJ_P(expr), pt_boh_comparison_forms); + if (cached != NULL) { + ZVAL_DEREF(cached); + if (Z_TYPE_P(cached) == IS_ARRAY) { + zval *cachedClass = zend_hash_index_find(Z_ARRVAL_P(cached), 0); + zval *cachedLeft = zend_hash_index_find(Z_ARRVAL_P(cached), 1); + zval *cachedRight = zend_hash_index_find(Z_ARRVAL_P(cached), 2); + if ( + cachedClass != NULL && Z_TYPE_P(cachedClass) == IS_STRING && zend_string_equals(Z_STR_P(cachedClass), Z_OBJCE_P(expr)->name) + && cachedLeft != NULL && Z_TYPE_P(cachedLeft) == IS_OBJECT && Z_OBJ_P(cachedLeft) == Z_OBJ_P(left) + && cachedRight != NULL && Z_TYPE_P(cachedRight) == IS_OBJECT && Z_OBJ_P(cachedRight) == Z_OBJ_P(right) + ) { + return zv::Val::copyOf(zv::Ref(cached)); + } + } + } + + zv::Val created = createComparisonForms(expr, kind, left, right); + if (UNEXPECTED(created.isUndef())) return zv::Val(); + zv::Arr cache = zv::Arr::create(5); + cache.push(zv::Val::string(Z_OBJCE_P(expr)->name)); + cache.push(zv::Ref(left)); + cache.push(zv::Ref(right)); + cache.push(zv::Ref(zend_hash_index_find(Z_ARRVAL_P(created.raw()), 0))); + cache.push(zv::Ref(zend_hash_index_find(Z_ARRVAL_P(created.raw()), 1))); + zv::Val cacheHold = zv::Val(std::move(cache)); + pt_node_set_attribute(Z_OBJ_P(expr), pt_boh_comparison_forms, cacheHold.raw()); + return cacheHold; + } + + /* Mirrors createComparisonForms(): [forms, complementForms], each entry + * [node, isComplement, nodeKey]; UNDEF = pending exception */ + zv::Val createComparisonForms(zval *expr, int kind, zval *left, zval *right) const + { + zval *exprPrinter = OBJ_PROP_NUM(self, slots::exprPrinter); + zv::Arr forms = zv::Arr::create(4); + zv::Arr complementForms = zv::Arr::create(2); + auto add = [&](zv::Arr &list, int classIdx, zval *formLeft, zval *formRight, bool isComplement) -> bool { zv::Args argv{formLeft, formRight}; zv::Val node = pt_type_new(classIdx, 2, argv); if (UNEXPECTED(node.isUndef())) return false; - forms[count].node = std::move(node); - forms[count].isComplement = isComplement; - count++; + // a comparison's node key is its printed form + zend_string *key = pt_expr_printer_print(exprPrinter, Z_OBJ_P(node.raw())); + if (UNEXPECTED(key == NULL)) return false; + zv::Arr entry = zv::Arr::create(3); + entry.push(std::move(node)); + entry.push(zv::Val::boolean(isComplement)); + entry.push(zv::Val::adoptString(key)); + list.push(zv::Val(std::move(entry))); return true; }; + bool built; if (kind == KIND_IDENTICAL || kind == KIND_NOT_IDENTICAL || kind == KIND_EQUAL || kind == KIND_NOT_EQUAL) { bool identity = kind == KIND_IDENTICAL || kind == KIND_NOT_IDENTICAL; bool isNegated = kind == KIND_NOT_IDENTICAL || kind == KIND_NOT_EQUAL; int sameClass = identity ? PT_CLASS_IDENTICAL_EXPR : PT_CLASS_EQUAL_EXPR; int negatedClass = identity ? PT_CLASS_BINARY_OP_NOT_IDENTICAL : PT_CLASS_NOT_EQUAL_EXPR; - return add(sameClass, left, right, isNegated) - && add(sameClass, right, left, isNegated) - && add(negatedClass, left, right, !isNegated) - && add(negatedClass, right, left, !isNegated); + built = add(forms, sameClass, left, right, isNegated) + && add(forms, sameClass, right, left, isNegated) + && add(forms, negatedClass, left, right, !isNegated) + && add(forms, negatedClass, right, left, !isNegated); + } else { + // normalized to `$smaller < $larger` / `$smaller <= $larger` + bool smallerFirst = kind == KIND_SMALLER || kind == KIND_SMALLER_OR_EQUAL; + zval *smaller = smallerFirst ? left : right; + zval *larger = smallerFirst ? right : left; + if (kind == KIND_SMALLER_OR_EQUAL || kind == KIND_GREATER_OR_EQUAL) { + built = add(forms, PT_CLASS_SMALLER_OR_EQUAL_EXPR, smaller, larger, false) + && add(forms, PT_CLASS_GREATER_OR_EQUAL_EXPR, larger, smaller, false) + && add(complementForms, PT_CLASS_SMALLER_EXPR, larger, smaller, true) + && add(complementForms, PT_CLASS_GREATER_EXPR, smaller, larger, true); + } else { + built = add(forms, PT_CLASS_SMALLER_EXPR, smaller, larger, false) + && add(forms, PT_CLASS_GREATER_EXPR, larger, smaller, false) + && add(complementForms, PT_CLASS_SMALLER_OR_EQUAL_EXPR, larger, smaller, true) + && add(complementForms, PT_CLASS_GREATER_OR_EQUAL_EXPR, smaller, larger, true); + } } + if (UNEXPECTED(!built)) return zv::Val(); + + zv::Arr result = zv::Arr::create(2); + result.push(zv::Val(std::move(forms))); + result.push(zv::Val(std::move(complementForms))); + return zv::Val(std::move(result)); + } - if (kind != KIND_SMALLER && kind != KIND_SMALLER_OR_EQUAL && kind != KIND_GREATER && kind != KIND_GREATER_OR_EQUAL) { + /* Mirrors getEquivalentComparisonForms(); count = -1 for a non-comparison + * ($forms === null). The forms borrow from hold. false = pending + * exception */ + template + bool equivalentComparisonForms(zval *expr, int kind, zval *left, zval *right, GetType &&getType, zv::Val &hold, ComparisonForm (&forms)[COMPARISON_FORMS_LIMIT], int &count) const + { + count = 0; + if ( + kind != KIND_IDENTICAL && kind != KIND_NOT_IDENTICAL && kind != KIND_EQUAL && kind != KIND_NOT_EQUAL + && kind != KIND_SMALLER && kind != KIND_SMALLER_OR_EQUAL && kind != KIND_GREATER && kind != KIND_GREATER_OR_EQUAL + ) { count = -1; return true; } - // normalized to `$smaller < $larger` / `$smaller <= $larger` - bool smallerFirst = kind == KIND_SMALLER || kind == KIND_SMALLER_OR_EQUAL; - zval *smaller = smallerFirst ? left : right; - zval *larger = smallerFirst ? right : left; - bool orEqual = kind == KIND_SMALLER_OR_EQUAL || kind == KIND_GREATER_OR_EQUAL; - if (orEqual) { - if (!add(PT_CLASS_SMALLER_OR_EQUAL_EXPR, smaller, larger, false) || !add(PT_CLASS_GREATER_OR_EQUAL_EXPR, larger, smaller, false)) return false; - } else { - if (!add(PT_CLASS_SMALLER_EXPR, smaller, larger, false) || !add(PT_CLASS_GREATER_EXPR, larger, smaller, false)) return false; - } + hold = comparisonFormsCache(expr, kind, left, right); + if (UNEXPECTED(hold.isUndef())) return false; + auto read = [&](zval *list) { + for (zv::ArrayEntry entry : zv::ArrRef(list)) { + zval *form = entry.value().deref().raw(); + forms[count].node = zend_hash_index_find(Z_ARRVAL_P(form), 0); + forms[count].isComplement = Z_TYPE_P(zend_hash_index_find(Z_ARRVAL_P(form), 1)) == IS_TRUE; + forms[count].key = Z_STR_P(zend_hash_index_find(Z_ARRVAL_P(form), 2)); + count++; + } + }; + read(zend_hash_index_find(Z_ARRVAL_P(hold.raw()), 3)); + zval *complementForms = zend_hash_index_find(Z_ARRVAL_P(hold.raw()), 4); + if (zend_hash_num_elements(Z_ARRVAL_P(complementForms)) == 0) return true; // `<` and `>=` only complement each other for comparable operands zv::Val leftType = getType(left); @@ -720,30 +804,50 @@ class BinaryOpHandler if (UNEXPECTED(rightIsInteger < 0)) return false; if (!rightIsInteger) return true; - if (orEqual) { - return add(PT_CLASS_SMALLER_EXPR, larger, smaller, true) && add(PT_CLASS_GREATER_EXPR, smaller, larger, true); - } + read(complementForms); + return true; + } - return add(PT_CLASS_SMALLER_OR_EQUAL_EXPR, larger, smaller, true) && add(PT_CLASS_GREATER_OR_EQUAL_EXPR, smaller, larger, true); + /* $scope->expressionTypes[$formKey] ?? null when its certainty is yes; + * NULL otherwise, and false = pending exception */ + static bool certainHolder(zval *scope, zend_string *formKey, zend_object *&holder) + { + holder = NULL; + zval *table; + zval rv; + if (EXPECTED(instanceof_function(Z_OBJCE_P(scope), pt_ce_mutating_scope))) { + table = OBJ_PROP_NUM(Z_OBJ_P(scope), ptdecl::MutatingScope::slot::expressionTypes); + } else { + table = zend_read_property(Z_OBJCE_P(scope), Z_OBJ_P(scope), PT_LC("expressionTypes"), 0, &rv); + if (UNEXPECTED(EG(exception) != NULL)) return false; + } + ZVAL_DEREF(table); + if (UNEXPECTED(Z_TYPE_P(table) != IS_ARRAY)) return true; + zval *found = zend_symtable_find(Z_ARRVAL_P(table), formKey); + if (found == NULL) return true; + ZVAL_DEREF(found); + if (UNEXPECTED(!pt_check_holder(found))) return false; + if (pt_holder_certainty_value(Z_OBJ_P(found)) == PT_TRI_YES) holder = Z_OBJ_P(found); + return true; } /* Mirrors resolveRememberedComparisonType(); found = false for null */ template - zv::Val resolveRememberedComparisonType(zval *scope, int kind, zval *left, zval *right, GetType &&getType, bool &found) const + zv::Val resolveRememberedComparisonType(zval *scope, zval *expr, int kind, zval *left, zval *right, GetType &&getType, bool &found) const { found = false; + zv::Val formsHold; ComparisonForm forms[COMPARISON_FORMS_LIMIT]; int count; - if (UNEXPECTED(!equivalentComparisonForms(kind, left, right, getType, forms, count))) return zv::Val(); + if (UNEXPECTED(!equivalentComparisonForms(expr, kind, left, right, getType, formsHold, forms, count))) return zv::Val(); for (int i = 0; i < count; i++) { - zend_long has = pt_mutating_scope_has_expression_type(Z_OBJ_P(scope), forms[i].node.raw()); - if (UNEXPECTED(has < 0)) return zv::Val(); - if (has != PT_TRI_YES) continue; + zend_object *holder; + if (UNEXPECTED(!certainHolder(scope, forms[i].key, holder))) return zv::Val(); + if (holder == NULL) continue; - zv::Val formType = pt_mutating_scope_get_tracked_expression_type(Z_OBJ_P(scope), Z_OBJ_P(forms[i].node.raw())); - if (UNEXPECTED(formType.isUndef())) return zv::Val(); + zval *formType = OBJ_PROP_NUM(holder, PT_ETH_PROP_TYPE); ptoh::BooleanOf formBoolean; - if (UNEXPECTED(!formBoolean.init(formType.raw()))) return zv::Val(); + if (UNEXPECTED(!formBoolean.init(formType))) return zv::Val(); int isTrue = formBoolean.isTrue(); if (UNEXPECTED(isTrue < 0)) return zv::Val(); if (isTrue) { @@ -780,9 +884,10 @@ class BinaryOpHandler if (UNEXPECTED(!pt_mutating_scope_native_types_promoted(Z_OBJ_P(scope), scopeNativeTypesPromoted))) return zv::Val(); return pt_expression_result_get_type_on_scope(e == left ? leftResult : rightResult, scope, scopeNativeTypesPromoted); }; + zv::Val formsHold; ComparisonForm forms[COMPARISON_FORMS_LIMIT]; int count; - if (UNEXPECTED(!equivalentComparisonForms(kind, left, right, getType, forms, count))) return zv::Val(); + if (UNEXPECTED(!equivalentComparisonForms(expr, kind, left, right, getType, formsHold, forms, count))) return zv::Val(); if (count < 0) return specifiedTypes; zval *exprPrinter = OBJ_PROP_NUM(self, slots::exprPrinter); @@ -792,16 +897,14 @@ class BinaryOpHandler zv::Arr sureTypes = zv::Arr::create(COMPARISON_FORMS_LIMIT); bool hasSureTypes = false; for (int i = 0; i < count; i++) { - zend_string *formKey = pt_expr_printer_print(exprPrinter, Z_OBJ_P(forms[i].node.raw())); - if (UNEXPECTED(formKey == NULL)) return zv::Val(); - zv::Str formKeyHold = zv::Str::adopt(formKey); + zend_string *formKey = forms[i].key; if (zend_string_equals(formKey, exprKey)) continue; - zend_long has = pt_mutating_scope_has_expression_type(Z_OBJ_P(scope), forms[i].node.raw()); - if (UNEXPECTED(has < 0)) return zv::Val(); - if (has != PT_TRI_YES) continue; + zend_object *holder; + if (UNEXPECTED(!certainHolder(scope, formKey, holder))) return zv::Val(); + if (holder == NULL) continue; zv::Arr entry = zv::Arr::create(2); - entry.push(zv::Ref(forms[i].node.raw())); + entry.push(zv::Ref(forms[i].node)); entry.push(ptoh::constantBoolean(contextTrue != forms[i].isComplement)); sureTypes.set(formKey, zv::Val(std::move(entry))); hasSureTypes = true; @@ -1063,7 +1166,7 @@ class BinaryOpHandler if (UNEXPECTED(right == NULL)) return zv::Val(); { bool found; - zv::Val rememberedType = resolveRememberedComparisonType(scope, kind, left, right, getType, found); + zv::Val rememberedType = resolveRememberedComparisonType(scope, expr, kind, left, right, getType, found); if (UNEXPECTED(EG(exception) != NULL)) return zv::Val(); if (found) return rememberedType; } @@ -1893,10 +1996,12 @@ PT_MINIT_REGISTRATION(pt_register_binary_op_handler) { pt_boh_division_by_zero_error = zend_string_init_interned(PT_LC("DivisionByZeroError"), 1); pt_boh_countable = zend_string_init_interned(PT_LC("Countable"), 1); + pt_boh_comparison_forms = zend_string_init_interned(PT_LC("phpstan_comparison_forms"), 1); reg::Class cls("PHPStan\\Analyser\\ExprHandler\\BinaryOpHandler"); ptdecl::BinaryOpHandler::declareClass(cls); ptdecl::BinaryOpHandler::declareProperties(cls); + cls.privateClassConstantString("COMPARISON_FORMS_ATTRIBUTE", "phpstan_comparison_forms"); /* the real parameter class names: the DI container autowires the * service by reflecting the constructor */ @@ -1954,7 +2059,7 @@ PT_MINIT_REGISTRATION(pt_register_binary_op_handler) if (UNEXPECTED(right == NULL)) RETURN_THROWS(); auto getType = [&](zval *e) { return pt_type_call_callable(getTypeCallable, 1, e); }; bool found; - zv::Val type = BinaryOpHandler(Z_OBJ_P(ZEND_THIS)).resolveRememberedComparisonType(scope, kind, left, right, getType, found); + zv::Val type = BinaryOpHandler(Z_OBJ_P(ZEND_THIS)).resolveRememberedComparisonType(scope, expr, kind, left, right, getType, found); if (UNEXPECTED(EG(exception) != NULL)) RETURN_THROWS(); if (!found) RETURN_NULL(); PT_RETURN_VAL(std::move(type)); @@ -1979,20 +2084,34 @@ PT_MINIT_REGISTRATION(pt_register_binary_op_handler) zval *right = ptoh::binaryOpRight(expr); if (UNEXPECTED(right == NULL)) RETURN_THROWS(); auto getType = [&](zval *e) { return pt_type_call_callable(getTypeCallable, 1, e); }; + zv::Val formsHold; BinaryOpHandler::ComparisonForm forms[BinaryOpHandler::COMPARISON_FORMS_LIMIT]; int count; - if (UNEXPECTED(!BinaryOpHandler::equivalentComparisonForms(kind, left, right, getType, forms, count))) RETURN_THROWS(); + if (UNEXPECTED(!BinaryOpHandler(Z_OBJ_P(ZEND_THIS)).equivalentComparisonForms(expr, kind, left, right, getType, formsHold, forms, count))) RETURN_THROWS(); if (count < 0) RETURN_NULL(); zv::Arr list = zv::Arr::create((uint32_t) count); for (int i = 0; i < count; i++) { - zv::Arr pair = zv::Arr::create(2); - pair.push(std::move(forms[i].node)); - pair.push(zv::Val::boolean(forms[i].isComplement)); - list.push(zv::Val(std::move(pair))); + zv::Arr entry = zv::Arr::create(3); + entry.push(zv::Ref(forms[i].node)); + entry.push(zv::Val::boolean(forms[i].isComplement)); + entry.push(zv::Val::string(forms[i].key)); + list.push(zv::Val(std::move(entry))); } PT_RETURN_VAL(zv::Val(std::move(list))); }); + cls.method(sigs::createComparisonForms, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *expr; + if (!zp::parse(execute_data, expr)) RETURN_THROWS(); + int kind = kindOf(expr); + if (UNEXPECTED(kind < 0)) RETURN_THROWS(); + zval *left = ptoh::binaryOpLeft(expr); + if (UNEXPECTED(left == NULL)) RETURN_THROWS(); + zval *right = ptoh::binaryOpRight(expr); + if (UNEXPECTED(right == NULL)) RETURN_THROWS(); + PT_RETURN_VAL(BinaryOpHandler(Z_OBJ_P(ZEND_THIS)).createComparisonForms(expr, kind, left, right)); + }); + cls.method(sigs::resolveEqualType, [](INTERNAL_FUNCTION_PARAMETERS) { zval *scope, *expr, *leftResult, *rightResult; if (!zp::parse(execute_data, scope, expr, leftResult, rightResult)) RETURN_THROWS(); diff --git a/turbo-ext/src/generated/BinaryOpHandler.h b/turbo-ext/src/generated/BinaryOpHandler.h index fc6feb1a7dc..14eafa17c7a 100644 --- a/turbo-ext/src/generated/BinaryOpHandler.h +++ b/turbo-ext/src/generated/BinaryOpHandler.h @@ -91,11 +91,12 @@ inline constexpr char strings[] = "PHPStan\\Analyser\\TypeSpecifierContext\0" /* 1092 */ "specifyRememberedComparisonForms\0" /* 1130 */ "getEquivalentComparisonForms\0" /* 1163 */ - "PhpParser\\Node\\Expr\\BinaryOp\\Equal\0" /* 1192 */ - "resolveEqualType\0" /* 1227 */ - "rootExpr\0" /* 1244 */ - "type\0" /* 1253 */ - "createRangeTypes"; /* 1258 */ + "createComparisonForms\0" /* 1192 */ + "PhpParser\\Node\\Expr\\BinaryOp\\Equal\0" /* 1214 */ + "resolveEqualType\0" /* 1249 */ + "rootExpr\0" /* 1266 */ + "type\0" /* 1275 */ + "createRangeTypes"; /* 1280 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 28), /* __construct $initializerExprTypeResolver */ reg::packed(75, 0, 100), /* __construct $richerScopeGetTypeHelper */ @@ -130,14 +131,16 @@ inline constexpr reg::PackedArg args[] = { reg::packed(634, 0, 935), /* getEquivalentComparisonForms $expr */ reg::packed(964, MAY_BE_CALLABLE), /* getEquivalentComparisonForms $getType */ reg::packed(659, MAY_BE_NULL | MAY_BE_ARRAY), /* getEquivalentComparisonForms return */ + reg::packed(634, 0, 935), /* createComparisonForms $expr */ + reg::packed(659, MAY_BE_ARRAY), /* createComparisonForms return */ reg::packed(747, 0, 753), /* resolveEqualType $scope */ - reg::packed(634, 0, 1192), /* resolveEqualType $expr */ + reg::packed(634, 0, 1214), /* resolveEqualType $expr */ reg::packed(1069, 0, 889), /* resolveEqualType $leftResult */ reg::packed(1080, 0, 889), /* resolveEqualType $rightResult */ reg::packed(659, 0, 972), /* resolveEqualType return */ - reg::packed(1244, MAY_BE_NULL, 639), /* createRangeTypes $rootExpr */ + reg::packed(1266, MAY_BE_NULL, 639), /* createRangeTypes $rootExpr */ reg::packed(634, 0, 639), /* createRangeTypes $expr */ - reg::packed(1253, 0, 972), /* createRangeTypes $type */ + reg::packed(1275, 0, 972), /* createRangeTypes $type */ reg::packed(846, 0, 1092), /* createRangeTypes $context */ reg::packed(659, 0, 1037), /* createRangeTypes return */ }; @@ -152,8 +155,9 @@ inline constexpr sigtab::Sig processExpr = { { 923 /* processExpr */, 7, 11, 7, inline constexpr sigtab::Sig resolveRememberedComparisonType = { { 990 /* resolveRememberedComparisonType */, 3, 19, 3, 22, ZEND_ACC_PRIVATE } }; inline constexpr sigtab::Sig specifyRememberedComparisonForms = { { 1130 /* specifyRememberedComparisonForms */, 6, 23, 6, 29, ZEND_ACC_PRIVATE } }; inline constexpr sigtab::Sig getEquivalentComparisonForms = { { 1163 /* getEquivalentComparisonForms */, 2, 30, 2, 32, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig resolveEqualType = { { 1227 /* resolveEqualType */, 4, 33, 4, 37, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig createRangeTypes = { { 1258 /* createRangeTypes */, 4, 38, 4, 42, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig createComparisonForms = { { 1192 /* createComparisonForms */, 1, 33, 1, 34, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig resolveEqualType = { { 1249 /* resolveEqualType */, 4, 35, 4, 39, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig createRangeTypes = { { 1280 /* createRangeTypes */, 4, 40, 4, 44, ZEND_ACC_PRIVATE } }; } // namespace sig } // namespace ptdecl::BinaryOpHandler From 2c7e7f772dc136d5198786275f56fc2a1d903ab9 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Fri, 6 Nov 2026 15:55:24 +0000 Subject: [PATCH 3/3] Keep the comparison forms with their complements in the cache Merging the cached equivalent and complement forms on every lookup built a new array each time, and reading them from the node attribute left array_merge() without a list type. Store the forms followed by their complements next to the plain forms and return one of the two. Amp-Thread-ID: https://ampcode.com/threads/T-01a1b134-f541-75cc-9cd3-8f0206f0ec3f --- src/Analyser/ExprHandler/BinaryOpHandler.php | 36 ++++++++------- turbo-ext/src/BinaryOpHandler.cpp | 46 +++++++++++++------- 2 files changed, 51 insertions(+), 31 deletions(-) diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index 0db158ed120..f6034c385f7 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -834,25 +834,23 @@ private function getEquivalentComparisonForms(BinaryOp $expr, callable $getType) $expr->setAttribute(self::COMPARISON_FORMS_ATTRIBUTE, $cached); } - $forms = $cached[3]; - $complementForms = $cached[4]; - // `<` and `>=` only complement each other for comparable operands // (NAN and uncomparable arrays/objects are neither smaller nor // larger-or-equal) - if ($complementForms === [] || !$getType($left)->isInteger()->yes() || !$getType($right)->isInteger()->yes()) { - return $forms; + if ($cached[4] === null || !$getType($left)->isInteger()->yes() || !$getType($right)->isInteger()->yes()) { + return $cached[3]; } - return array_merge($forms, $complementForms); + return $cached[4]; } /** - * The equivalent forms, and the complement forms that hold only for - * comparable operands, each with its node key (a comparison's node key - * is its printed form). + * The equivalent forms, and for `<`, `<=`, `>` and `>=` also the + * equivalent forms followed by the complement forms that hold only for + * comparable operands - each form with its node key (a comparison's node + * key is its printed form). * - * @return array{list, list} + * @return array{list, list|null} */ private function createComparisonForms(BinaryOp $expr): array { @@ -906,14 +904,20 @@ private function createComparisonForms(BinaryOp $expr): array } } - $keyed = [[], []]; - foreach ([$forms, $complementForms] as $i => $list) { - foreach ($list as [$form, $isComplement]) { - $keyed[$i][] = [$form, $isComplement, $this->exprPrinter->printExpr($form)]; - } + $keyedForms = []; + foreach ($forms as [$form, $isComplement]) { + $keyedForms[] = [$form, $isComplement, $this->exprPrinter->printExpr($form)]; + } + if ($complementForms === []) { + return [$keyedForms, null]; + } + + $keyedWithComplements = $keyedForms; + foreach ($complementForms as [$form, $isComplement]) { + $keyedWithComplements[] = [$form, $isComplement, $this->exprPrinter->printExpr($form)]; } - return $keyed; + return [$keyedForms, $keyedWithComplements]; } /** diff --git a/turbo-ext/src/BinaryOpHandler.cpp b/turbo-ext/src/BinaryOpHandler.cpp index 35751ac48b2..7bfaba7d0fe 100644 --- a/turbo-ext/src/BinaryOpHandler.cpp +++ b/turbo-ext/src/BinaryOpHandler.cpp @@ -705,8 +705,9 @@ class BinaryOpHandler return cacheHold; } - /* Mirrors createComparisonForms(): [forms, complementForms], each entry - * [node, isComplement, nodeKey]; UNDEF = pending exception */ + /* Mirrors createComparisonForms(): [forms, forms with the complement + * forms appended or null], each entry [node, isComplement, nodeKey]; + * UNDEF = pending exception */ zv::Val createComparisonForms(zval *expr, int kind, zval *left, zval *right) const { zval *exprPrinter = OBJ_PROP_NUM(self, slots::exprPrinter); @@ -756,9 +757,17 @@ class BinaryOpHandler } if (UNEXPECTED(!built)) return zv::Val(); + zv::Val formsHold = zv::Val(std::move(forms)); zv::Arr result = zv::Arr::create(2); - result.push(zv::Val(std::move(forms))); - result.push(zv::Val(std::move(complementForms))); + result.push(zv::Ref(formsHold.raw())); + if (zend_hash_num_elements(Z_ARRVAL_P(complementForms.raw())) == 0) { + result.push(zv::Val::null()); + } else { + zv::Arr withComplements = zv::Arr::create(6); + for (zv::ArrayEntry entry : zv::ArrRef(formsHold.raw())) withComplements.push(entry.value()); + for (zv::ArrayEntry entry : zv::ArrRef(complementForms.raw())) withComplements.push(entry.value()); + result.push(zv::Val(std::move(withComplements))); + } return zv::Val(std::move(result)); } @@ -788,23 +797,30 @@ class BinaryOpHandler count++; } }; - read(zend_hash_index_find(Z_ARRVAL_P(hold.raw()), 3)); - zval *complementForms = zend_hash_index_find(Z_ARRVAL_P(hold.raw()), 4); - if (zend_hash_num_elements(Z_ARRVAL_P(complementForms)) == 0) return true; + zval *plainForms = zend_hash_index_find(Z_ARRVAL_P(hold.raw()), 3); + zval *withComplements = zend_hash_index_find(Z_ARRVAL_P(hold.raw()), 4); + if (Z_TYPE_P(withComplements) == IS_NULL) { + read(plainForms); + return true; + } // `<` and `>=` only complement each other for comparable operands zv::Val leftType = getType(left); if (UNEXPECTED(leftType.isUndef())) return false; int leftIsInteger = trinaryYes(leftType.raw(), PT_OP_IS_INTEGER); if (UNEXPECTED(leftIsInteger < 0)) return false; - if (!leftIsInteger) return true; - zv::Val rightType = getType(right); - if (UNEXPECTED(rightType.isUndef())) return false; - int rightIsInteger = trinaryYes(rightType.raw(), PT_OP_IS_INTEGER); - if (UNEXPECTED(rightIsInteger < 0)) return false; - if (!rightIsInteger) return true; - - read(complementForms); + if (leftIsInteger) { + zv::Val rightType = getType(right); + if (UNEXPECTED(rightType.isUndef())) return false; + int rightIsInteger = trinaryYes(rightType.raw(), PT_OP_IS_INTEGER); + if (UNEXPECTED(rightIsInteger < 0)) return false; + if (rightIsInteger) { + read(withComplements); + return true; + } + } + + read(plainForms); return true; }