diff --git a/CLAUDE.md b/CLAUDE.md index 36263a6c57a..bcf4a40b887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,7 +294,7 @@ New introduced classes in these categories should be shadowed with a native C++ - **Always verify the test fails before the fix.** Stating "tests pass" is not enough — that only proves the fix doesn't break things. `git stash` the source change, run the test, see it fail for the right reason; `git stash pop`, run again, see it pass. Only then is the fix confirmed. The `regression-test` skill bakes this in for bug-issue workflows. - **Reproducers come from the OP's playground link** in the issue body, not from later comments. Copy the snippet as close to verbatim as practical. - **Integration tests**: `AnalyserIntegrationTest` runs full analysis on test files and checks error output. -- **Benchmarks**: every file in `tests/bench/data` is analysed by [Tally](https://github.com/phpstan/tally) in the Benchmarks workflow, under CodSpeed's CPU simulation, which counts instructions instead of measuring time, so the numbers are the same on every runner. `tests/bench/AnalysisBench.php` boots PHPStan and analyses `tests/bench/Warmup.php` uncounted in a `#[Before]` method, so only the analysis of the benchmark file itself is counted. The expected instructions, cycles and memory are in the comment at the top of each data file (`tests/bench/AnalysedFileHeader.php`); a new file without the comment passes and gets its numbers from CI. When a change moves them on purpose or adds a benchmark, download the workflow's `tally-results` artifact and run `vendor/bin/tally accept `. The configuration is `tally.php`. +- **Benchmarks**: every file in `tests/bench/data` is analysed by [Tally](https://github.com/phpstan/tally) in the Benchmarks workflow, under CodSpeed's CPU simulation, which counts instructions instead of measuring time, so the numbers are the same on every runner. `tests/bench/AnalysisBench.php` boots PHPStan and analyses `tests/bench/Warmup.php` uncounted in a `#[Before]` method, so only the analysis of the benchmark file itself is counted. The expected instructions, cycles and memory are in the comment at the top of each data file (`tests/bench/AnalysedFileHeader.php`); a new file without the comment passes and gets its numbers from CI. When a change moves them on purpose or adds a benchmark, download the workflow's `tally-results` artifact and run `vendor/bin/tally accept `. The configuration is `tally.php`. The other `tests/bench/*Bench.php` classes are microbenchmarks of internals such as `TypeCombinator` and `ConstantArrayType`: they boot the container with `BenchContainer` in a `#[Before]` method, build their inputs there too, and declare how the instructions grow with the input (`#[Linear]`, `#[Quadratic]`, `#[Complexity]`, …) instead of expected numbers. Warm up on different inputs than the counted call, because the type system memoizes results. ### Adding support for new PHP versions diff --git a/Makefile b/Makefile index 1c773c96aad..e935b2ad1f9 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ lint: --exclude tests/PHPStan/Analyser/data \ --exclude tests/bench/data \ --exclude tests/bench/Warmup.php \ + --exclude tests/bench/BenchEnum.php \ --exclude tests/PHPStan/Analyser/nsrt \ --exclude tests/PHPStan/Rules/Methods/data \ --exclude tests/PHPStan/Rules/Functions/data \ diff --git a/build/phpstan.neon b/build/phpstan.neon index 3347041d2d8..4246f8980a0 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -112,8 +112,13 @@ parameters: - # Tally calls the benchmark methods, their data providers and #[Before] methods identifier: shipmonk.deadMethod - path: ../tests/bench/AnalysisBench.php + path: ../tests/bench/*Bench.php reportUnmatched: false + - + # the benchmarks name the cases by string, C0 to C255 + identifier: shipmonk.deadEnumCase + path: ../tests/bench/BenchEnum.php + reportUnmatched: false # the dead code detector runs on PHP 8.2+ only - identifier: shipmonk.deadConstant paths: diff --git a/build/tally-pre-8.1.neon b/build/tally-pre-8.1.neon index e1e01e4f694..1cc733fb084 100644 --- a/build/tally-pre-8.1.neon +++ b/build/tally-pre-8.1.neon @@ -2,6 +2,4 @@ parameters: excludePaths: # Tally's sources use enums and readonly properties, which PHPStan does not parse for PHP < 8.1 - ../tally.php - - ../tests/bench/AnalysedFileExpectations.php - - ../tests/bench/AnalysedFileHeader.php - - ../tests/bench/AnalysisBench.php + - ../tests/bench/* diff --git a/conf/parsers.neon b/conf/parsers.neon index 05b1fabb788..e83b7959126 100644 --- a/conf/parsers.neon +++ b/conf/parsers.neon @@ -54,7 +54,7 @@ services: arguments: currentPhpVersionRichParser: @cachedRichParser currentPhpVersionSimpleParser: @cachedSimpleParser - php8Parser: @php8Parser + php8Parser: @cachedPhp8Parser singleReflectionFile: %singleReflectionFile% autowired: false @@ -83,6 +83,15 @@ services: cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax% autowired: false + # reflection asks for the nodes of a stub again for each symbol it declares + cachedPhp8Parser: + class: PHPStan\Parser\CachedParser + arguments: + originalParser: @php8Parser + cachedNodesByStringCountMax: %cache.nodesByStringCountMax% + cachedSourceBytesMax: %cache.nodesByStringSourceBytesMax% + autowired: false + defaultAnalysisParser: factory: @pathRoutingParser autowired: false diff --git a/src/Analyser/ConditionalExpressionHolder.php b/src/Analyser/ConditionalExpressionHolder.php index 107e5833140..73715ccf03d 100644 --- a/src/Analyser/ConditionalExpressionHolder.php +++ b/src/Analyser/ConditionalExpressionHolder.php @@ -39,6 +39,7 @@ public function getTypeHolder(): ExpressionTypeHolder return $this->typeHolder; } + /** ScopeOps::createConditionalExpressions() builds the same key from parts; keep the two in sync. */ public function getKey(): string { $parts = []; diff --git a/src/Analyser/Ignore/IgnoredErrorHelperResult.php b/src/Analyser/Ignore/IgnoredErrorHelperResult.php index b1858ac8d8a..46c2245a5f5 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelperResult.php +++ b/src/Analyser/Ignore/IgnoredErrorHelperResult.php @@ -368,16 +368,7 @@ public function process( $analysedFilesKeys = array_fill_keys($analysedFiles, true); if (!$hasInternalErrors && !$collectedDataRuleSkipped) { - // asked only about an entry that would be reported; only an entry with `paths` has a part that differs from the entry - $judge = new UnmatchedIgnoredErrorJudge( - $this->fileHelper, - $this->analyseFileExcluder, - $analysedFiles, - $this->analysedPaths, - $this->analysedPathsFromConfig, - $collectedData, - $onlyFiles, - ); + $judge = null; foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) { $reportUnmatched = is_array($unmatchedIgnoredError) ? ($unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors) @@ -385,6 +376,16 @@ public function process( if ($reportUnmatched === false) { continue; } + // asked only about an entry that would be reported; only an entry with `paths` has a part that differs from the entry + $judge ??= new UnmatchedIgnoredErrorJudge( + $this->fileHelper, + $this->analyseFileExcluder, + $analysedFiles, + $this->analysedPaths, + $this->analysedPathsFromConfig, + $collectedData, + $onlyFiles, + ); $realCount = $realCounts[$i] ?? null; if ( isset($unmatchedIgnoredError['count']) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index fddd7bae801..7548fb28976 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -778,6 +778,12 @@ public function hasVariableType(string $variableName): TrinaryLogic /** @api */ public function getVariableType(string $variableName): Type { + // a certainly defined variable, the common case, does not need the rest + $holder = $this->expressionTypes['$' . $variableName] ?? null; + if ($holder !== null && $holder->getCertainty()->yes()) { + return $holder->getType(); + } + $hasVariableType = $this->hasVariableType($variableName); if ($hasVariableType->maybe()) { @@ -977,6 +983,12 @@ public function getScopeNativeType(Expr $expr): Type public function getNodeKey(Expr $node): string { + // ScopeOps::nodeKey() has the same fast path, but this method is hot enough + // that the static call and the services fetch before it are measurable + if ($node instanceof Variable && !$node->name instanceof Expr) { + return '$' . $node->name; + } + return ScopeOps::nodeKey($node, $this->services->exprPrinter); } @@ -3562,7 +3574,7 @@ private function resolveScopeStateType(Expr $expr, bool $native): Type public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { - if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { + if ($expr instanceof Scalar || $this->isSpecifyExpressionTypeNoop($expr, $type)) { return $this; } @@ -4335,10 +4347,13 @@ public function isInFirstLevelStatement(): bool public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self { - $merged = $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope?->getTemplateArgumentConstraints()); + if ($otherScope === null || $otherScope === $this) { + return $this; + } + + $merged = $this->mergeWithVariableState($otherScope, $preserveVacuousConditionals)->addTemplateArgumentConstraints($otherScope->getTemplateArgumentConstraints()); if ( - $otherScope === null - || $merged->templateArgumentConstraints === null + $merged->templateArgumentConstraints === null || $merged->templateArgumentFrame === null || !$merged->templateArgumentFrame->isObservingClosures() ) { diff --git a/src/Analyser/NullsafeOperatorHelper.php b/src/Analyser/NullsafeOperatorHelper.php index c7d5d292820..4a49bb361b8 100644 --- a/src/Analyser/NullsafeOperatorHelper.php +++ b/src/Analyser/NullsafeOperatorHelper.php @@ -50,12 +50,14 @@ public static function getNullsafeShortcircuitedExpr(Expr $expr): Expr && !$current instanceof Expr\NullsafePropertyFetch ) { $next = self::chainedInto($current); - if ($next === null) { - // Every level of a chain is resolved in turn and each one used to - // walk down to the root again, so a chain of depth N cost O(N^2) - // walk steps — with no nullsafe operator in it at all. Marking - // the levels on the way out makes that O(N) per chain. - for ($level = $expr; $level !== null; $level = self::chainedInto($level)) { + if ($next === null || ($current === $expr && $next->getAttribute(self::SHORTCIRCUITED_ATTRIBUTE) === true)) { + // Every level of a chain is resolved in turn, innermost first, and + // each one used to walk down to the root again, so a chain of depth N + // cost O(N^2) walk steps — with no nullsafe operator in it at all. + // A marked level has none at or below it: when the level right below + // is marked, as it is when the chain is resolved innermost first, only + // this level is marked, which makes that O(N) per chain. + for ($level = $expr; $level !== null && $level !== $next; $level = self::chainedInto($level)) { $level->setAttribute(self::SHORTCIRCUITED_ATTRIBUTE, true); } diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 26480258178..80c2511bb20 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -27,6 +27,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; +use PHPStan\Type\VerbosityLevel; use function array_filter; use function array_key_exists; use function array_key_first; @@ -389,6 +390,26 @@ public static function createConditionalExpressions( ): array { $newVariableTypes = $ourExpressionTypes; + $differingExprStrings = array_keys($differingKeys); + + // A guard needs its expression certainly defined in both branches. Collect + // those keys once - the guard loop below walks only them, and without any + // neither target loop can add a holder, which skips the passes over every key. + $guardCandidates = []; + foreach ($differingExprStrings as $exprString) { + if ( + !array_key_exists($exprString, $newVariableTypes) + || !array_key_exists($exprString, $theirExpressionTypes) + || !$newVariableTypes[$exprString]->getCertainty()->yes() + || !$theirExpressionTypes[$exprString]->getCertainty()->yes() + ) { + continue; + } + $guardCandidates[] = $exprString; + } + if ($guardCandidates === []) { + return $conditionalExpressions; + } // A guard is only ever consumed paired with a target: a *different* key in // the first target loop below, any key in the second one. Deriving a guard @@ -399,7 +420,7 @@ public static function createConditionalExpressions( // set instead of repeating the checks. $targets = []; $hasUndefinedTarget = false; - foreach (array_keys($differingKeys) as $exprString) { + foreach ($differingExprStrings as $exprString) { if (!array_key_exists($exprString, $newVariableTypes)) { if ( array_key_exists($exprString, $mergedExpressionTypes) @@ -432,7 +453,7 @@ public static function createConditionalExpressions( // valid conditional *target*; the flag also lets the target loop skip // pairing these absorbed targets with constant-array guards. $guardsToExclude = []; - foreach (array_keys($differingKeys) as $exprString) { + foreach ($differingExprStrings as $exprString) { if (!array_key_exists($exprString, $theirExpressionTypes)) { continue; } @@ -457,10 +478,10 @@ public static function createConditionalExpressions( } $typeGuards = []; - foreach (array_keys($differingKeys) as $exprString) { - if (!array_key_exists($exprString, $newVariableTypes)) { - continue; - } + // with no their-branch entry the merged holder keeps our type with lowered + // certainty, so no later type assertion can tell the branches apart - the + // candidates are certain in both branches + foreach ($guardCandidates as $exprString) { $holder = $newVariableTypes[$exprString]; if ($holder->getExpr() instanceof VirtualNode) { continue; @@ -468,23 +489,11 @@ public static function createConditionalExpressions( if (!array_key_exists($exprString, $mergedExpressionTypes)) { continue; } - if (!$holder->getCertainty()->yes()) { - continue; - } if (array_key_exists($exprString, $guardsToExclude)) { continue; } - if (!array_key_exists($exprString, $theirExpressionTypes)) { - // with no their-branch entry the merged holder keeps our type with - // lowered certainty, so no later type assertion can tell the - // branches apart - continue; - } $theirHolder = $theirExpressionTypes[$exprString]; - if (!$theirHolder->getCertainty()->yes()) { - continue; - } if ($holder->equalTypes($theirHolder)) { continue; } @@ -530,6 +539,13 @@ public static function createConditionalExpressions( $guardIsSuperTypeOfTheirExprCache = []; $theirExprIsSuperTypeOfGuardCache = []; + // The holders below are keyed by ConditionalExpressionHolder::getKey(), + // which describes both the guard and the target type of each pair. Build + // the same key from the guard part described once per guard and the + // target part described once per target instead of once per pair - the + // descriptions of big constant arrays dominate the merge otherwise. + $guardKeyPrefixes = []; + foreach (array_keys($targets) as $exprString) { $holder = $newVariableTypes[$exprString]; @@ -540,6 +556,8 @@ public static function createConditionalExpressions( continue; } + $targetKeySuffix = null; + $exprIsGuardExcluded = array_key_exists($exprString, $guardsToExclude); foreach ($variableTypeGuards as $guardExprString => $guardHolder) { // A subtype-absorbed target (kept only for re-narrowing) paired with a @@ -578,12 +596,13 @@ public static function createConditionalExpressions( } } - $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $holder); - $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; + $targetKeySuffix ??= self::conditionalExpressionKeySuffix($holder); + $guardKeyPrefixes[$guardExprString] ??= self::conditionalExpressionKeyPrefix($guardExprString, $guardHolder); + $conditionalExpressions[$exprString][$guardKeyPrefixes[$guardExprString] . $targetKeySuffix] = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $holder); } } - foreach (array_keys($differingKeys) as $exprString) { + foreach ($differingExprStrings as $exprString) { if (!array_key_exists($exprString, $mergedExpressionTypes)) { continue; } @@ -595,15 +614,29 @@ public static function createConditionalExpressions( continue; } + $undefinedHolder = new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo()); + $targetKeySuffix = self::conditionalExpressionKeySuffix($undefinedHolder); foreach ($typeGuards as $guardExprString => $guardHolder) { - $conditionalExpression = new ConditionalExpressionHolder([$guardExprString => $guardHolder], new ExpressionTypeHolder($mergedExprTypeHolder->getExpr(), new ErrorType(), TrinaryLogic::createNo())); - $conditionalExpressions[$exprString][$conditionalExpression->getKey()] = $conditionalExpression; + $guardKeyPrefixes[$guardExprString] ??= self::conditionalExpressionKeyPrefix($guardExprString, $guardHolder); + $conditionalExpressions[$exprString][$guardKeyPrefixes[$guardExprString] . $targetKeySuffix] = new ConditionalExpressionHolder([$guardExprString => $guardHolder], $undefinedHolder); } } return $conditionalExpressions; } + /** The part of ConditionalExpressionHolder::getKey() a single-guard holder's guard contributes. */ + private static function conditionalExpressionKeyPrefix(string $guardExprString, ExpressionTypeHolder $guardHolder): string + { + return $guardExprString . '=' . $guardHolder->getType()->describe(VerbosityLevel::precise()); + } + + /** The part of ConditionalExpressionHolder::getKey() the target holder contributes. */ + private static function conditionalExpressionKeySuffix(ExpressionTypeHolder $typeHolder): string + { + return ' => ' . $typeHolder->getType()->describe(VerbosityLevel::precise()) . ' (' . $typeHolder->getCertainty()->describe() . ')'; + } + /** * The part of our branch type the other branch cannot produce. * diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index e4ee58b46ea..00ce15a0f05 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -177,15 +177,16 @@ public function resolveFileDependencies(?Dependencies $dependencies, array $anal $functionNames = []; foreach ($reflectionsByFile as $file => $reflections) { foreach ($reflections as $id => $reflection) { - if ($reflection instanceof FunctionReflection) { - $functionNames[$reflection->getName()] = $reflection->getName(); + // classes first, they are most of the reflections + if ($reflection instanceof ClassReflection) { + $classReflections[$id] = $reflection; continue; } - if (!$reflection instanceof ClassReflection) { + if (!$reflection instanceof FunctionReflection) { continue; } - $classReflections[$id] = $reflection; + $functionNames[$reflection->getName()] = $reflection->getName(); } $this->resolveFiles($file, $reflections, $analysedFiles, $fileDependencies, $packages); } diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 409afced88f..0c02c5d3249 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -180,6 +180,7 @@ public function resolve(TypeNode $typeNode, NameScope $nameScope): Type } } + /** The nested nodes of a type are resolved in the declaration resolve() entered, with a name scope of the same declaration. */ private function resolveType(TypeNode $typeNode, NameScope $nameScope): Type { foreach ($this->extensionRegistryProvider->getRegistry()->getExtensions() as $extension) { @@ -647,7 +648,7 @@ private function resolveThisTypeNode(NameScope $nameScope): Type private function resolveNullableTypeNode(NullableTypeNode $typeNode, NameScope $nameScope): Type { - return TypeCombinator::union($this->resolve($typeNode->type, $nameScope), new NullType()); + return TypeCombinator::union($this->resolveType($typeNode->type, $nameScope), new NullType()); } private function resolveUnionTypeNode(UnionTypeNode $typeNode, NameScope $nameScope): Type @@ -710,10 +711,10 @@ private function resolveIntersectionTypeNode(IntersectionTypeNode $typeNode, Nam private function resolveConditionalTypeNode(ConditionalTypeNode $typeNode, NameScope $nameScope): Type { return new ConditionalType( - $this->resolve($typeNode->subjectType, $nameScope), - $this->resolve($typeNode->targetType, $nameScope), - $this->resolve($typeNode->if, $nameScope), - $this->resolve($typeNode->else, $nameScope), + $this->resolveType($typeNode->subjectType, $nameScope), + $this->resolveType($typeNode->targetType, $nameScope), + $this->resolveType($typeNode->if, $nameScope), + $this->resolveType($typeNode->else, $nameScope), $typeNode->negated, ); } @@ -722,16 +723,16 @@ private function resolveConditionalTypeForParameterNode(ConditionalTypeForParame { return new ConditionalTypeForParameter( $typeNode->parameterName, - $this->resolve($typeNode->targetType, $nameScope), - $this->resolve($typeNode->if, $nameScope), - $this->resolve($typeNode->else, $nameScope), + $this->resolveType($typeNode->targetType, $nameScope), + $this->resolveType($typeNode->if, $nameScope), + $this->resolveType($typeNode->else, $nameScope), $typeNode->negated, ); } private function resolveArrayTypeNode(ArrayTypeNode $typeNode, NameScope $nameScope): Type { - $itemType = $this->resolve($typeNode->type, $nameScope); + $itemType = $this->resolveType($typeNode->type, $nameScope); return new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $itemType); } @@ -1084,10 +1085,10 @@ private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $ $templateTags[$templateType->name] = new TemplateTag( $templateType->name, $templateType->bound !== null - ? $this->resolve($templateType->bound, $nameScope) + ? $this->resolveType($templateType->bound, $nameScope) : new MixedType(), $templateType->default !== null - ? $this->resolve($templateType->default, $nameScope) + ? $this->resolveType($templateType->default, $nameScope) : null, TemplateTypeVariance::createInvariant(), ); @@ -1104,7 +1105,7 @@ private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $ $templateTypeMap = TemplateTypeMap::createEmpty(); } - $mainType = $this->resolve($typeNode->identifier, $nameScope); + $mainType = $this->resolveType($typeNode->identifier, $nameScope); $isVariadic = false; $parameters = array_values(array_map( @@ -1118,7 +1119,7 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi return new NativeParameterReflection( $parameterName, $parameterNode->isOptional || $parameterNode->isVariadic, - $this->resolve($parameterNode->type, $nameScope), + $this->resolveType($parameterNode->type, $nameScope), $parameterNode->isReference ? PassedByReference::createCreatesNewVariable() : PassedByReference::createNo(), $parameterNode->isVariadic, defaultValue: null, @@ -1131,7 +1132,7 @@ function (CallableTypeParameterNode $parameterNode) use ($nameScope, &$isVariadi if ($assertions !== null) { $returnType = new BooleanType(); } else { - $returnType = $this->resolve($typeNode->returnType, $nameScope); + $returnType = $this->resolveType($typeNode->returnType, $nameScope); } if ($mainType instanceof CallableType) { @@ -1185,10 +1186,10 @@ private function resolveCallableReturnTypeAssertions(CallableTypeNode $typeNode, return CallableAssertionsHelper::createAssertionsFromConditional( $returnTypeNode->parameterName, - $this->resolve($returnTypeNode->targetType, $nameScope), + $this->resolveType($returnTypeNode->targetType, $nameScope), $returnTypeNode->negated, - $this->resolve($returnTypeNode->if, $nameScope), - $this->resolve($returnTypeNode->else, $nameScope), + $this->resolveType($returnTypeNode->if, $nameScope), + $this->resolveType($returnTypeNode->else, $nameScope), ); } @@ -1201,7 +1202,7 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name foreach ($typeNode->items as $itemNode) { $items[] = [ $this->resolveArrayShapeOffsetType($itemNode, $nameScope), - $this->resolve($itemNode->valueType, $nameScope), + $this->resolveType($itemNode->valueType, $nameScope), $itemNode->optional, ]; } @@ -1212,11 +1213,11 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name // when the shape gets built, so that it can be printed back as `...`. $unsealedKeyType = $typeNode->unsealedType === null || $typeNode->unsealedType->keyType === null ? null - : $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope)); + : $this->transformUnsafeArrayKey($this->resolveType($typeNode->unsealedType->keyType, $nameScope)); $unsealedValueType = $typeNode->unsealedType === null ? new MixedType() - : $this->resolve($typeNode->unsealedType->valueType, $nameScope); + : $this->resolveType($typeNode->unsealedType->valueType, $nameScope); $unsealed = [$unsealedKeyType, $unsealedValueType]; } @@ -1324,7 +1325,7 @@ private function resolveObjectShapeNode(ObjectShapeNode $typeNode, NameScope $na $keyType = new ConstantStringType($itemNode->keyName->value); } - $items[] = [$keyType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional]; + $items[] = [$keyType, $this->resolveType($itemNode->valueType, $nameScope), $itemNode->optional]; } return LateResolvableObjectShapeType::create($items); @@ -1492,8 +1493,8 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc private function resolveOffsetAccessNode(OffsetAccessTypeNode $typeNode, NameScope $nameScope): Type { - $type = $this->resolve($typeNode->type, $nameScope); - $offset = $this->resolve($typeNode->offset, $nameScope); + $type = $this->resolveType($typeNode->type, $nameScope); + $offset = $this->resolveType($typeNode->offset, $nameScope); if ($type->isOffsetAccessible()->no() || $type->hasOffsetValueType($offset)->no()) { return new ErrorType(); @@ -1564,7 +1565,7 @@ public function resolveMultiple(array $typeNodes, NameScope $nameScope): array { $types = []; foreach ($typeNodes as $typeNode) { - $types[] = $this->resolve($typeNode, $nameScope); + $types[] = $this->resolveType($typeNode, $nameScope); } return $types; diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index f6434612be8..231e4b3ae5a 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -69,20 +69,26 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc // the remaining template types are then inferred from what is left over, // e.g. in partition(iterable $values, callable(T0|T1): ($value is T0 ? true : false) $predicate) // called with iterable and is_int(...), T0 becomes int and T1 string - $predicateTypeMap = TemplateTypeMap::createEmpty(); + // null when no passed callable has a type predicate, which is almost always + $predicateTypeMap = null; foreach ($parameters as $param) { - if (!isset($namedArgTypes[$param->getName()])) { + $paramName = $param->getName(); + if (!isset($namedArgTypes[$paramName])) { continue; } - $predicateTypeMap = $predicateTypeMap->union( - self::inferPredicateTemplateTypes($param->getType(), $namedArgTypes[$param->getName()]), - ); + $paramPredicateTypeMap = self::inferPredicateTemplateTypes($param->getType(), $namedArgTypes[$paramName]); + if ($paramPredicateTypeMap === null || $paramPredicateTypeMap->isEmpty()) { + continue; + } + + $predicateTypeMap = $predicateTypeMap === null ? $paramPredicateTypeMap : $predicateTypeMap->union($paramPredicateTypeMap); } foreach ($parameters as $param) { - if (isset($namedArgTypes[$param->getName()])) { - $argType = $namedArgTypes[$param->getName()]; + $paramName = $param->getName(); + if (isset($namedArgTypes[$paramName])) { + $argType = $namedArgTypes[$paramName]; } elseif ($param->getDefaultValue() !== null) { $argType = $param->getDefaultValue(); } elseif ($param->isVariadic()) { @@ -91,12 +97,14 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc continue; } - $paramType = self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); + $paramType = $predicateTypeMap === null ? $param->getType() : self::resolvePredicateTemplateTypes($param->getType(), $predicateTypeMap); $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); - $passedArgs['$' . $param->getName()] = $argType; + $passedArgs['$' . $paramName] = $argType; } - $typeMap = $typeMap->union($predicateTypeMap); + if ($predicateTypeMap !== null) { + $typeMap = $typeMap->union($predicateTypeMap); + } $returnType = $parametersAcceptor->getReturnType(); if ( @@ -104,7 +112,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc && !$returnType->isNegated() && array_key_exists($returnType->getParameterName(), $passedArgs) ) { - $paramType = self::resolvePredicateTemplateTypes($returnType->getTarget(), $predicateTypeMap); + $paramType = $predicateTypeMap === null ? $returnType->getTarget() : self::resolvePredicateTemplateTypes($returnType->getTarget(), $predicateTypeMap); $argType = $passedArgs[$returnType->getParameterName()]; $typeMap = $typeMap->union($paramType->inferTemplateTypes($argType)); } @@ -169,9 +177,12 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc return $result; } - private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): TemplateTypeMap + /** + * @return TemplateTypeMap|null null when the parameter type is no callable with a type predicate + */ + private static function inferPredicateTemplateTypes(Type $paramType, Type $argType): ?TemplateTypeMap { - $typeMap = TemplateTypeMap::createEmpty(); + $typeMap = null; foreach ($paramType instanceof UnionType ? $paramType->getTypes() : [$paramType] as $innerType) { if (!$innerType instanceof CallableParametersAcceptor) { continue; @@ -182,6 +193,7 @@ private static function inferPredicateTemplateTypes(Type $paramType, Type $argTy // asked only for parameters with asserts - it reflects the class named by // a callable-like argument + $typeMap ??= TemplateTypeMap::createEmpty(); if (!$argType->isCallable()->yes()) { return $typeMap; } @@ -196,10 +208,6 @@ private static function inferPredicateTemplateTypes(Type $paramType, Type $argTy private static function resolvePredicateTemplateTypes(Type $type, TemplateTypeMap $predicateTypeMap): Type { - if ($predicateTypeMap->isEmpty()) { - return $type; - } - return TemplateTypeHelper::resolveTemplateTypes( $type, $predicateTypeMap, diff --git a/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php index 3cd4bc862dc..c35885ba08a 100644 --- a/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php +++ b/src/Reflection/ReflectionProvider/MemoizingReflectionProvider.php @@ -82,7 +82,11 @@ public function getClass(string $className): ClassReflection { $lowerClassName = strtolower($className); $classReflection = $this->classes[$lowerClassName] ??= $this->provider->getClass($className); - $this->recordFoundClass($className, $lowerClassName); + // read after the class was found: building its reflection resolves declarations in other contexts + $context = ValueDependencyCollector::getContext(); + if (($this->knownClasses[$lowerClassName] ?? null) !== $context) { + $this->recordFoundClass($className, $lowerClassName, $context); + } return $classReflection; } @@ -91,7 +95,10 @@ public function getClassName(string $className): string { $lowerClassName = strtolower($className); $name = $this->classNames[$lowerClassName] ??= $this->provider->getClassName($className); - $this->recordFoundClass($className, $lowerClassName); + $context = ValueDependencyCollector::getContext(); + if (($this->knownClasses[$lowerClassName] ?? null) !== $context) { + $this->recordFoundClass($className, $lowerClassName, $context); + } return $name; } @@ -99,14 +106,8 @@ public function getClassName(string $className): string /** * @param lowercase-string $lowerClassName */ - private function recordFoundClass(string $className, string $lowerClassName): void + private function recordFoundClass(string $className, string $lowerClassName, int $context): void { - // read after the class was found: building its reflection resolves declarations in other contexts - $context = ValueDependencyCollector::getContext(); - if (($this->knownClasses[$lowerClassName] ?? null) === $context) { - return; - } - $this->knownClasses[$lowerClassName] = $context; $this->valueDependencyCollector->recordFoundSymbol(ValueDependencyCollector::KIND_CLASS, $className); } diff --git a/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php b/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php index 0356056da59..f0e8eb8cf5b 100644 --- a/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php +++ b/src/Rules/RestrictedUsage/RestrictedMethodCallableUsageRule.php @@ -54,7 +54,9 @@ public function processNode(Node $node, Scope $scope): array $methodName = $node->getName()->name; $methodCalledOnType = $scope->getType($node->getVar()); - $referencedClasses = TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $methodCalledOnType->isObject()->yes() ? $methodCalledOnType : TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; diff --git a/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php b/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php index a0a200285b1..9581f5cc600 100644 --- a/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php +++ b/src/Rules/RestrictedUsage/RestrictedMethodUsageRule.php @@ -54,7 +54,9 @@ public function processNode(Node $node, Scope $scope): array $methodName = $node->name->name; $methodCalledOnType = $scope->getType($node->var); - $referencedClasses = TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $methodCalledOnType->isObject()->yes() ? $methodCalledOnType : TypeCombinator::intersect($methodCalledOnType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; diff --git a/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php b/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php index dfc28e19836..b50768a5d95 100644 --- a/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php +++ b/src/Rules/RestrictedUsage/RestrictedPropertyUsageRule.php @@ -53,7 +53,9 @@ public function processNode(Node $node, Scope $scope): array $propertyName = $node->name->name; $propertyCalledOnType = $scope->getType($node->var); - $referencedClasses = TypeCombinator::intersect($propertyCalledOnType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $propertyCalledOnType->isObject()->yes() ? $propertyCalledOnType : TypeCombinator::intersect($propertyCalledOnType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; diff --git a/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php b/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php index 289e4e622ea..1e306cc5e8d 100644 --- a/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php +++ b/src/Rules/RestrictedUsage/RestrictedUsageOfDeprecatedStringCastRule.php @@ -45,7 +45,9 @@ public function processNode(Node $node, Scope $scope): array } $exprType = $scope->getType($node->expr); - $referencedClasses = TypeCombinator::intersect($exprType, new ObjectWithoutClassType())->getObjectClassNames(); + // the intersection only removes what is not an object, such as the null of a nullable type + $objectType = $exprType->isObject()->yes() ? $exprType : TypeCombinator::intersect($exprType, new ObjectWithoutClassType()); + $referencedClasses = $objectType->getObjectClassNames(); $errors = []; diff --git a/src/Type/Accessory/HasMethodType.php b/src/Type/Accessory/HasMethodType.php index ffae597200d..1e65e7c5a80 100644 --- a/src/Type/Accessory/HasMethodType.php +++ b/src/Type/Accessory/HasMethodType.php @@ -43,6 +43,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function get_class; use function sprintf; use function strtolower; @@ -105,6 +106,11 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { + // what $type->isSubTypeOf($this) answers for another HasMethodType, without its callable checks + if (get_class($type) === self::class) { + return IsSuperTypeOfResult::createFromTrinaryLogic($type->hasMethod($this->methodName)); + } + if ($type instanceof CompoundType) { return $type->isSubTypeOf($this); } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 461954fec6e..741dcab08e2 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -823,6 +823,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $results = []; $valueResults = []; + $otherKeyIndexMap = $type->getKeyIndexMap(); foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { @@ -848,13 +849,14 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult // The offset value at one of $type's explicit keys depends only on // the value type stored there, so keys sharing their value types on // both sides (keys written through a union offset) compare them once. - $otherIndex = $type->getKeyIndex($keyType); - $valuePairKey = $otherIndex !== null - ? spl_object_id($this->valueTypes[$i]) . ':' . spl_object_id($type->valueTypes[$otherIndex]) - : null; - if ($valuePairKey !== null && array_key_exists($valuePairKey, $valueResults)) { - $results[] = $valueResults[$valuePairKey]; - continue; + $otherIndex = $otherKeyIndexMap[$keyType->getValue()] ?? null; + if ($otherIndex !== null) { + $valueId = spl_object_id($this->valueTypes[$i]); + $otherValueId = spl_object_id($type->valueTypes[$otherIndex]); + if (isset($valueResults[$valueId][$otherValueId])) { + $results[] = $valueResults[$valueId][$otherValueId]; + continue; + } } $otherValueType = $type->getOffsetValueType($keyType); @@ -866,8 +868,8 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult if ($isValueSuperType->no()) { return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); } - if ($valuePairKey !== null) { - $valueResults[$valuePairKey] = $isValueSuperType; + if ($otherIndex !== null) { + $valueResults[$valueId][$otherValueId] = $isValueSuperType; } $results[] = $isValueSuperType; } @@ -1047,6 +1049,13 @@ public function equals(Type $type): bool public function isCallable(): TrinaryLogic { + // the shapes doFindTypeAndMethodNames() rejects by their keys alone, without + // describing the whole shape for the recursion guard + $keyTypesCount = count($this->keyTypes); + if ($keyTypesCount > 2 || ($keyTypesCount !== 2 && !$this->isUnsealed()->yes())) { + return TrinaryLogic::createNo(); + } + $result = RecursionGuard::run($this, function (): TrinaryLogic { $hasNonExistentMethod = false; $typeAndMethods = $this->doFindTypeAndMethodNames($hasNonExistentMethod); diff --git a/src/Type/Constant/ConstantIntegerType.php b/src/Type/Constant/ConstantIntegerType.php index b9230aa36ca..60dd03378c4 100644 --- a/src/Type/Constant/ConstantIntegerType.php +++ b/src/Type/Constant/ConstantIntegerType.php @@ -19,7 +19,6 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; use function abs; -use function sprintf; use const PHP_INT_MIN; /** @api */ @@ -76,10 +75,8 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult public function describe(VerbosityLevel $level): string { - return $level->handle( - static fn (): string => 'int', - fn (): string => sprintf('%s', $this->value), - ); + // what VerbosityLevel::handle() would pick, without creating its callbacks + return $level->isTypeOnly() ? 'int' : (string) $this->value; } public function toFloat(): Type diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index 3152bf39d5a..6f1ab225393 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -131,23 +131,26 @@ public function describe(VerbosityLevel $level): string return $this->cachedDescriptions[$levelValue]; } - return $level->handle( - static fn (): string => 'string', - function () use ($levelValue): string { - $value = $this->value; - - if (!$this->isClassString) { - try { - $value = Strings::truncate($value, self::DESCRIBE_LIMIT); - } catch (RegexpException) { - $value = substr($value, 0, self::DESCRIBE_LIMIT) . "\u{2026}"; - } + // what VerbosityLevel::handle() would pick, without creating its callbacks + if ($level->isTypeOnly()) { + return 'string'; + } + + if ($level->isValue()) { + $value = $this->value; + + if (!$this->isClassString) { + try { + $value = Strings::truncate($value, self::DESCRIBE_LIMIT); + } catch (RegexpException) { + $value = substr($value, 0, self::DESCRIBE_LIMIT) . "\u{2026}"; } + } + + return $this->cachedDescriptions[$levelValue] = self::export($value); + } - return $this->cachedDescriptions[$levelValue] = self::export($value); - }, - fn (): string => $this->cachedDescriptions[$levelValue] = self::export($this->value), - ); + return $this->cachedDescriptions[$levelValue] = self::export($this->value); } private function export(string $value): string diff --git a/src/Type/FiniteTypeSet.php b/src/Type/FiniteTypeSet.php index 2cd299ac7bf..095b6e51fc6 100644 --- a/src/Type/FiniteTypeSet.php +++ b/src/Type/FiniteTypeSet.php @@ -103,7 +103,12 @@ public static function create(array $types): ?self */ public static function key(Type $type): ?string { - if ($type instanceof TemplateType || $type instanceof UnresolvedTemplateArgumentType || $type instanceof UnionType || $type instanceof IntersectionType) { + // all four are compound types; the values this is asked about are not, and skip the + // rest, which includes a class that is rarely loaded - instanceof looks it up every time + if ( + $type instanceof CompoundType + && ($type instanceof TemplateType || $type instanceof UnresolvedTemplateArgumentType || $type instanceof UnionType || $type instanceof IntersectionType) + ) { return null; } diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index a92b77d39b5..b4ab2efa03b 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -174,6 +174,11 @@ public static function toArgument(Type $type): Type */ public static function removeFinalByKeywordOverrides(Type $type): Type { + // holds no object type, and the traversal would return it as it is + if ($type->isConstantScalarValue()->yes()) { + return $type; + } + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { if ($type instanceof ObjectType) { $type = $type->withoutFinalByKeywordOverride(); diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 2b17042ebc3..302ea307323 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -869,14 +869,12 @@ public function isIterable(): TrinaryLogic public function isIterableAtLeastOnce(): TrinaryLogic { - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - return TrinaryLogic::createYes(); - } - - return $this->isIterableAtLeastOnce ??= $this->intersectResults( - static fn (Type $type): TrinaryLogic => $type->isIterableAtLeastOnce(), - static fn (Type $type): bool => !$type->isIterable()->no(), - ); + return $this->isIterableAtLeastOnce ??= $this->isCallable()->yes() && $this->isArray()->yes() + ? TrinaryLogic::createYes() + : $this->intersectResults( + static fn (Type $type): TrinaryLogic => $type->isIterableAtLeastOnce(), + static fn (Type $type): bool => !$type->isIterable()->no(), + ); } public function getArraySize(): Type @@ -957,10 +955,9 @@ public function isArray(): TrinaryLogic public function isConstantArray(): TrinaryLogic { - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - return TrinaryLogic::createYes(); - } - return $this->isConstantArray ??= $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isConstantArray()); + return $this->isConstantArray ??= $this->isCallable()->yes() && $this->isArray()->yes() + ? TrinaryLogic::createYes() + : $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isConstantArray()); } public function isOversizedArray(): TrinaryLogic @@ -970,11 +967,9 @@ public function isOversizedArray(): TrinaryLogic public function isList(): TrinaryLogic { - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - return TrinaryLogic::createYes(); - } - - return $this->isList ??= $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isList()); + return $this->isList ??= $this->isCallable()->yes() && $this->isArray()->yes() + ? TrinaryLogic::createYes() + : $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->isList()); } public function isString(): TrinaryLogic @@ -1852,7 +1847,7 @@ public function getFiniteTypes(): array $result = []; foreach ($first as $key => $finiteType) { foreach ($others as $other) { - if (!self::hasFiniteType($other, is_string($key) ? $key : null, $finiteType)) { + if (is_string($key) ? !array_key_exists($key, $other) : !self::hasFiniteType($other, null, $finiteType)) { continue 2; } } diff --git a/src/Type/IsSuperTypeOfResult.php b/src/Type/IsSuperTypeOfResult.php index 3b626ec8e94..342e060fd6a 100644 --- a/src/Type/IsSuperTypeOfResult.php +++ b/src/Type/IsSuperTypeOfResult.php @@ -141,12 +141,12 @@ public static function createFromBoolean(bool $value): self public static function createFromTrinaryLogic(TrinaryLogic $result): self { if ($result->yes()) { - return self::createYes(); + return self::$YES ??= new self($result, []); } if ($result->no()) { - return self::createNo(); + return self::$NO ??= new self($result, []); } - return self::createMaybe(); + return self::$MAYBE ??= new self($result, []); } /** @@ -258,7 +258,11 @@ public static function extremeIdentity(self ...$operands): self } } - return self::create(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)), $lazyReasons); + if ($reasons === [] && $lazyReasons === []) { + return self::createFromTrinaryLogic(TrinaryLogic::extremeIdentity(...$results)); + } + + return new self(TrinaryLogic::extremeIdentity(...$results), array_values(array_unique($reasons)), $lazyReasons); } /** @see TrinaryLogic::maxMin() */ @@ -281,7 +285,11 @@ public static function maxMin(self ...$operands): self } } - return self::create(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)), $lazyReasons); + if ($reasons === [] && $lazyReasons === []) { + return self::createFromTrinaryLogic(TrinaryLogic::maxMin(...$results)); + } + + return new self(TrinaryLogic::maxMin(...$results), array_values(array_unique($reasons)), $lazyReasons); } /** @@ -313,16 +321,21 @@ public static function lazyMaxMin( } } - return self::create( - $hasNo ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(), - array_values(array_unique($reasons)), - $lazyReasons, - ); + $result = $hasNo ? TrinaryLogic::createNo() : TrinaryLogic::createMaybe(); + if ($reasons === [] && $lazyReasons === []) { + return self::createFromTrinaryLogic($result); + } + + return new self($result, array_values(array_unique($reasons)), $lazyReasons); } public function negate(): self { - return self::create($this->result->negate(), $this->reasons, $this->lazyReasons); + if ($this->reasons === [] && $this->lazyReasons === []) { + return self::createFromTrinaryLogic($this->result->negate()); + } + + return new self($this->result->negate(), $this->reasons, $this->lazyReasons); } public function describe(): string diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index 4b96125e2d7..4720606603d 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -150,6 +150,13 @@ class ObjectType implements TypeWithClassName, SubtractableType */ private ?ClassReflection $lazyClassReflection = null; + /** + * What getClassReflection() returns, made once: for a generic class the reflection with its + * template types erased. Kept apart from the two above, which the other methods read with + * the template types in place. + */ + private ?ClassReflection $returnedClassReflection = null; + /** @var array> */ private static array $enumCases = []; @@ -596,7 +603,10 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $description = $type->describe(VerbosityLevel::cache()); } - self::touchDescriptionCacheKey($thisDescription); + // the call is skipped while the same description is asked about again and again + if ($thisDescription !== self::$lastTouchedDescription) { + self::touchDescriptionCacheKey($thisDescription); + } if (isset(self::$superTypes[$thisDescription][$description])) { return self::$superTypes[$thisDescription][$description]; @@ -1963,7 +1973,11 @@ public function getNakedClassReflection(): ?ClassReflection public function getClassReflection(): ?ClassReflection { - $classReflection = $this->resolvedClassReflection(); + if ($this->returnedClassReflection !== null) { + return $this->returnedClassReflection; + } + + $classReflection = $this->classReflection ?? $this->lazyClassReflection; if ($classReflection === null) { $reflectionProvider = ReflectionProviderStaticAccessor::getInstance(); if (!$reflectionProvider->hasClass($this->className)) { @@ -1974,10 +1988,10 @@ public function getClassReflection(): ?ClassReflection } if ($classReflection->isGeneric()) { - return $classReflection->withTypes(array_values($classReflection->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes())); + return $this->returnedClassReflection = $classReflection->withTypes(array_values($classReflection->getTemplateTypeMap()->map(static fn (): Type => new ErrorType())->getTypes())); } - return $classReflection; + return $this->returnedClassReflection = $classReflection; } /** The reflection already known, from either source — never triggers a fetch. */ diff --git a/src/Type/Php/RangeFunctionReturnTypeExtension.php b/src/Type/Php/RangeFunctionReturnTypeExtension.php index 738005ad310..2c78439d7c2 100644 --- a/src/Type/Php/RangeFunctionReturnTypeExtension.php +++ b/src/Type/Php/RangeFunctionReturnTypeExtension.php @@ -150,6 +150,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if (!$hasUnknownCombination && !$hasSkippedCombination) { + if (count($constantReturnTypes) === 1) { + // the union of one array type normalizes it again, which costs more than computing it + return $constantReturnTypes[0]; + } + if (count($constantReturnTypes) > 0) { return TypeCombinator::union(...$constantReturnTypes); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index a6486beeb46..426964dc43f 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -63,6 +63,12 @@ final class TypeCombinator { + /** + * Up to this many members of the one union doUnion() flattens, finding out whether two of + * them were already compared by scanning it costs less than indexing it. + */ + private const ALREADY_NORMALIZED_SCAN_LIMIT = 4; + /** * Turned on by TurboExtensionEnabler when the native extension is active, so that * union(), intersect() and remove() route through the memoizing TypeCombinatorCache. @@ -475,15 +481,15 @@ public static function doUnion(Type ...$types): Type // only a type containing every value of the scalar (not T of int, 1&T) // absorbs all the constants of that scalar if ($types[$i]->isBoolean()->yes()) { - if ($types[$i]->isSuperTypeOf(new BooleanType())->yes()) { + if (!isset($hasGenericScalarTypes[ConstantBooleanType::class]) && $types[$i]->isSuperTypeOf(new BooleanType())->yes()) { $hasGenericScalarTypes[ConstantBooleanType::class] = true; } } elseif ($types[$i]->isFloat()->yes()) { - if ($types[$i]->isSuperTypeOf(new FloatType())->yes()) { + if (!isset($hasGenericScalarTypes[ConstantFloatType::class]) && $types[$i]->isSuperTypeOf(new FloatType())->yes()) { $hasGenericScalarTypes[ConstantFloatType::class] = true; } } elseif ($types[$i]->isInteger()->yes() && !$types[$i] instanceof IntegerRangeType) { - if ($types[$i]->isSuperTypeOf(new IntegerType())->yes()) { + if (!isset($hasGenericScalarTypes[ConstantIntegerType::class]) && $types[$i]->isSuperTypeOf(new IntegerType())->yes()) { $hasGenericScalarTypes[ConstantIntegerType::class] = true; } } elseif ($types[$i]->isString()->yes() && $types[$i]->isClassString()->no() && TypeUtils::getAccessoryTypes($types[$i]) === []) { @@ -526,27 +532,32 @@ public static function doUnion(Type ...$types): Type static fn (IntegerRangeType $a, IntegerRangeType $b): int => ($a->getMin() ?? PHP_INT_MIN) <=> ($b->getMin() ?? PHP_INT_MIN) ?: ($a->getMax() ?? PHP_INT_MAX) <=> ($b->getMax() ?? PHP_INT_MAX), ); - usort( - $floatRangeTypes, - static fn (FloatRangeType $a, FloatRangeType $b): int => $a->getMin() <=> $b->getMin() ?: $a->getMax() <=> $b->getMax(), - ); // array_merge() hands the first array back as it is when the second one // is empty, so the keys the bucketing above unset must be renumbered here - // sorted by min, touching float ranges are neighbours: merging them now lets a - // constant meet the merged range (float and NAN are float) - $mergedFloatRangeTypes = []; - foreach ($floatRangeTypes as $floatRangeType) { - $last = count($mergedFloatRangeTypes) - 1; - if ($last >= 0) { - $merged = $mergedFloatRangeTypes[$last]->tryUnion($floatRangeType); - if ($merged instanceof FloatRangeType) { - $mergedFloatRangeTypes[$last] = $merged; - continue; + if ($floatRangeTypes === []) { + // most unions have no float range, and sorting an empty list still creates the closure + $types = array_merge(array_values($types), $integerRangeTypes); + } else { + usort( + $floatRangeTypes, + static fn (FloatRangeType $a, FloatRangeType $b): int => $a->getMin() <=> $b->getMin() ?: $a->getMax() <=> $b->getMax(), + ); + // sorted by min, touching float ranges are neighbours: merging them now lets a + // constant meet the merged range (float and NAN are float) + $mergedFloatRangeTypes = []; + foreach ($floatRangeTypes as $floatRangeType) { + $last = count($mergedFloatRangeTypes) - 1; + if ($last >= 0) { + $merged = $mergedFloatRangeTypes[$last]->tryUnion($floatRangeType); + if ($merged instanceof FloatRangeType) { + $mergedFloatRangeTypes[$last] = $merged; + continue; + } } + $mergedFloatRangeTypes[] = $floatRangeType; } - $mergedFloatRangeTypes[] = $floatRangeType; + $types = array_merge(array_values($types), $integerRangeTypes, $mergedFloatRangeTypes); } - $types = array_merge(array_values($types), $integerRangeTypes, $mergedFloatRangeTypes); $typesCount = count($types); if (isset($scalarTypes[IntersectionType::class])) { @@ -602,9 +613,22 @@ public static function doUnion(Type ...$types): Type $types = array_values($newTypes); } + // compareTypesInUnion() leaves a pair of constant arrays as it is unless + // both are intersections, so such pairs are skipped without calling it. A + // bare ConstantArrayType is the only type whose getConstantArrays() is + // itself. Every array went to processArrayTypes(), so only its results + // can be such. + $processedArrayTypes = self::processArrayTypes($arrayTypes); + $isPlainConstantArray = null; + if (count($processedArrayTypes) > 1) { + $isPlainConstantArray = array_fill(0, count($types), false); + foreach ($processedArrayTypes as $processedArrayType) { + $isPlainConstantArray[] = $processedArrayType->getConstantArrays() === [$processedArrayType]; + } + } $types = array_merge( $types, - self::processArrayTypes($arrayTypes), + $processedArrayTypes, ); $typesCount = count($types); @@ -612,7 +636,10 @@ public static function doUnion(Type ...$types): Type // unique while this lookup refers to them $normalizedGroupsByType = []; $normalizedTwice = []; - if ($typesCount > 1) { + // a single flattened union of a few members, as in most unions, is cheaper to scan - without + // new local variables, which every call of this method initializes + $scanAlreadyNormalized = $alreadyNormalizedCounter === 1 && count($alreadyNormalized[0]) <= self::ALREADY_NORMALIZED_SCAN_LIMIT; + if ($typesCount > 1 && !$scanAlreadyNormalized) { foreach ($alreadyNormalized as $group => $normalizedTypes) { foreach ($normalizedTypes as $normalizedType) { $normalizedTypeId = spl_object_id($normalizedType); @@ -628,11 +655,32 @@ public static function doUnion(Type ...$types): Type // transform A | A to A // transform A | never to A for ($i = 0; $i < $typesCount; $i++) { - $iGroups = $normalizedGroupsByType[spl_object_id($types[$i])] ?? null; + if ($scanAlreadyNormalized) { + // null for a type not in the flattened union, which skips the scan of its pairs below + $iGroups = in_array($types[$i], $alreadyNormalized[0], true) ? [] : null; + } else { + $iGroups = $normalizedGroupsByType[spl_object_id($types[$i])] ?? null; + } for ($j = $i + 1; $j < $typesCount; $j++) { - if ($iGroups !== null && self::isAlreadyNormalized($iGroups, $normalizedGroupsByType, $normalizedTwice, $types[$i], $types[$j])) { + if ($isPlainConstantArray !== null && $isPlainConstantArray[$i] && $isPlainConstantArray[$j]) { continue; } + if ($iGroups !== null) { + if ($scanAlreadyNormalized) { + // what isAlreadyNormalized() answers: $types[$j] is another member of + // the flattened union, which holds $types[$i] + $normalizedTypes = $alreadyNormalized[0]; + if ( + $types[$i] !== $types[$j] + ? in_array($types[$j], $normalizedTypes, true) + : count(array_keys($normalizedTypes, $types[$j], true)) > 1 + ) { + continue; + } + } elseif (self::isAlreadyNormalized($iGroups, $normalizedGroupsByType, $normalizedTwice, $types[$i], $types[$j])) { + continue; + } + } $compareResult = self::compareTypesInUnion($types[$i], $types[$j]); if ($compareResult === null) { continue; @@ -641,17 +689,29 @@ public static function doUnion(Type ...$types): Type [$a, $b] = $compareResult; if ($a !== null) { $types[$i] = $a; - $iGroups = $normalizedGroupsByType[spl_object_id($a)] ?? null; + $iGroups = $scanAlreadyNormalized + ? (in_array($a, $alreadyNormalized[0], true) ? [] : null) + : ($normalizedGroupsByType[spl_object_id($a)] ?? null); + if ($isPlainConstantArray !== null) { + $isPlainConstantArray[$i] = $a->getConstantArrays() === [$a]; + array_splice($isPlainConstantArray, $j, 1); + } array_splice($types, $j--, 1); $typesCount--; continue 1; } - if ($b !== null) { - $types[$j] = $b; - array_splice($types, $i--, 1); - $typesCount--; - continue 2; + if ($b === null) { + continue; + } + + $types[$j] = $b; + if ($isPlainConstantArray !== null) { + $isPlainConstantArray[$j] = $b->getConstantArrays() === [$b]; + array_splice($isPlainConstantArray, $i, 1); } + array_splice($types, $i--, 1); + $typesCount--; + continue 2; } } @@ -1002,17 +1062,17 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array // numeric-string | non-decimal-int-string → string (preserving common accessories) // Works because decimal-int-string ⊂ numeric-string, so together they cover all strings + // A base that is the type itself was compared with the other one above. if ($a->isString()->yes() && $b->isString()->yes()) { - $decimalIntString = new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]); if ($b->isDecimalIntegerString()->no()) { $bBase = self::removeDecimalIntStringAccessory($b); - if ($bBase->isSuperTypeOf($a)->yes() && $a->isSuperTypeOf($decimalIntString)->yes()) { + if ($bBase !== $b && $bBase->isSuperTypeOf($a)->yes() && $a->isSuperTypeOf(new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]))->yes()) { return [null, $bBase]; } } if ($a->isDecimalIntegerString()->no()) { $aBase = self::removeDecimalIntStringAccessory($a); - if ($aBase->isSuperTypeOf($b)->yes() && $b->isSuperTypeOf($decimalIntString)->yes()) { + if ($aBase !== $a && $aBase->isSuperTypeOf($b)->yes() && $b->isSuperTypeOf(new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]))->yes()) { return [$aBase, null]; } } diff --git a/src/Type/UnionType.php b/src/Type/UnionType.php index e8d2d8528b3..99b46164c63 100644 --- a/src/Type/UnionType.php +++ b/src/Type/UnionType.php @@ -443,22 +443,23 @@ public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsRes */ private function finiteTypeSetContainedIn(Type $otherType, bool $yesOnly): ?TrinaryLogic { - $finiteTypeSet = $this->getFiniteTypeSet(); - if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { - return null; - } - if (!$otherType instanceof self || $otherType instanceof TemplateType) { // A single value covers a member iff they are the same value, and no member // stands for a value it is not keyed by - so one lookup answers for every member // at once. This is the shape TypeCombinator::remove() takes to ask whether // removing a value empties a union of values, and the only way a non-union other - // type reaches this helper at all. + // type reaches this helper at all. Its key comes first: without one, such as for + // string accepting a union of strings, this union's set is not built. $key = FiniteTypeSet::key($otherType); if ($key === null) { return null; } + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { + return null; + } + $containment = $finiteTypeSet->containedInKey($key); // No constant value accepts another one - not even a numeric string an int, in @@ -474,6 +475,11 @@ private function finiteTypeSetContainedIn(Type $otherType, bool $yesOnly): ?Trin return $containment; } + $finiteTypeSet = $this->getFiniteTypeSet(); + if ($finiteTypeSet === null || !$finiteTypeSet->isComplete()) { + return null; + } + $otherFiniteTypeSet = $otherType->getFiniteTypeSet(); if ($otherFiniteTypeSet === null) { return null; diff --git a/src/Type/UnionTypeHelper.php b/src/Type/UnionTypeHelper.php index bbd90c1970b..e55d2b4e9c3 100644 --- a/src/Type/UnionTypeHelper.php +++ b/src/Type/UnionTypeHelper.php @@ -32,11 +32,7 @@ public static function sortTypes(array $types): array return $types; } - // usort() compares a member with many others - an array shape, whose description - // is costly, is described once - $arrayDescriptions = []; - - usort($types, static function (Type $a, Type $b) use (&$arrayDescriptions): int { + usort($types, static function (Type $a, Type $b): int { if ($a instanceof NullType) { return 1; } elseif ($b instanceof NullType) { @@ -108,10 +104,6 @@ public static function sortTypes(array $types): array return -1; } - if ($a instanceof FloatRangeType && $b instanceof FloatRangeType) { - return $a->getMin() <=> $b->getMin(); - } - if ($a instanceof ConstantStringType && $b instanceof ConstantStringType) { return self::compareStrings($a->getValue(), $b->getValue()); } @@ -123,6 +115,10 @@ public static function sortTypes(array $types): array ); } + if ($a instanceof FloatRangeType && $b instanceof FloatRangeType) { + return $a->getMin() <=> $b->getMin(); + } + if ( ($a instanceof CallableType || $a instanceof ClosureType) && ($b instanceof CallableType || $b instanceof ClosureType) @@ -141,6 +137,12 @@ public static function sortTypes(array $types): array return 1; } + // usort() compares a member with many others - an array shape, whose description + // is costly, is described once. Each sortTypes() call creates the closure anew, + // with its own static variables, and binding them here instead of with use() + // spares the comparisons that do not reach this branch. + static $arrayDescriptions = []; + return self::compareStrings( $arrayDescriptions[spl_object_id($a)] ??= $a->describe(VerbosityLevel::value()), $arrayDescriptions[spl_object_id($b)] ??= $b->describe(VerbosityLevel::value()), diff --git a/tally.php b/tally.php index 586e8c54b82..c739f54e7d1 100644 --- a/tally.php +++ b/tally.php @@ -6,7 +6,7 @@ use Tally\Configuration; return Configuration::create() - ->withPaths([__DIR__ . '/tests/bench/AnalysisBench.php']) + ->withPaths([__DIR__ . '/tests/bench']) // bin/phpstan restarts PHP with these OPcache settings, the benchmarks run without the restart ->withIniEntries([...OpcacheSettings::getIniEntries(), 'memory_limit=-1']) // The slowest benchmark takes a few seconds natively and about 13 minutes under the simulation. diff --git a/tests/bench/AcceptsResultBench.php b/tests/bench/AcceptsResultBench.php new file mode 100644 index 00000000000..1c2d5f6db84 --- /dev/null +++ b/tests/bench/AcceptsResultBench.php @@ -0,0 +1,107 @@ + */ + private array $results = []; + + private StringType $string; + + private UnionType $union; + + public function __construct(private ScratchDirectory $scratch) + { + $this->string = new StringType(); + $this->union = new UnionType([new ConstantStringType('a'), new ConstantStringType('b')]); + } + + /** + * @return iterable + */ + public static function results(): iterable + { + foreach ([16, 256] as $results) { + yield 'results-' . $results => [$results]; + } + } + + #[Before] + public function boot(int $results): void + { + BenchContainer::create($this->scratch); + + AcceptsResult::maxMin(...self::maybes(4)); + AcceptsResult::extremeIdentity(...self::maybes(4)); + $this->string->accepts(new UnionType(self::strings('warmup', 4)), true); + + $this->results = self::maybes($results); + $this->union = new UnionType(self::strings('string', $results)); + } + + #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '16.91K', cycles: '70.34K', memory: '1.180KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '212.4K', cycles: '362.0K', memory: '24.24KiB')] + public function benchMaxMin(int $results): AcceptsResult + { + return AcceptsResult::maxMin(...$this->results); + } + + #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '18.73K', cycles: '80.59K', memory: '1.547KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '227.3K', cycles: '392.4K', memory: '32.30KiB')] + public function benchExtremeIdentity(int $results): AcceptsResult + { + return AcceptsResult::extremeIdentity(...$this->results); + } + + /** + * StringType::accepts() accepts each constant string of the union. + */ + #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '43.10K', cycles: '136.6K', memory: '1.547KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '557.0K', cycles: '736.1K', memory: '32.30KiB')] + public function benchStringAcceptsUnion(int $results): AcceptsResult + { + return $this->string->accepts($this->union, true); + } + + /** + * @return list + */ + private static function maybes(int $count): array + { + $results = []; + for ($i = 0; $i < $count; $i++) { + $results[] = new AcceptsResult(TrinaryLogic::createMaybe(), []); + } + + return $results; + } + + /** + * @return list + */ + private static function strings(string $prefix, int $count): array + { + $strings = []; + for ($i = 0; $i < $count; $i++) { + $strings[] = new ConstantStringType($prefix . $i); + } + + return $strings; + } + +} diff --git a/tests/bench/AccessoryArrayListTypeBench.php b/tests/bench/AccessoryArrayListTypeBench.php new file mode 100644 index 00000000000..3b38ee4f3da --- /dev/null +++ b/tests/bench/AccessoryArrayListTypeBench.php @@ -0,0 +1,78 @@ + is a list, asked a thousand times. + */ +final class AccessoryArrayListTypeBench +{ + + private const CALLS = 1000; + + private ?ArrayType $array = null; + + private ?AccessoryArrayListType $list = null; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + $this->array = new ArrayType(new IntegerType(), new MixedType()); + $this->list = new AccessoryArrayListType(); + $this->list->isSuperTypeOf($this->array); + $this->list->accepts($this->array, true); + } + + #[Expected(instructions: '1.805M', cycles: '1.869M', memory: '472B')] + public function benchIsSuperTypeOf(): IsSuperTypeOfResult + { + [$list, $array] = $this->types(); + $result = IsSuperTypeOfResult::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $list->isSuperTypeOf($array); + } + + return $result; + } + + #[Expected(instructions: '2.611M', cycles: '2.652M', memory: '216B')] + public function benchAccepts(): bool + { + [$list, $array] = $this->types(); + $result = false; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $list->accepts($array, true)->yes(); + } + + return $result; + } + + /** + * @return array{AccessoryArrayListType, ArrayType} + */ + private function types(): array + { + if ($this->list === null || $this->array === null) { + throw new LogicException('boot() did not run.'); + } + + return [$this->list, $this->array]; + } + +} diff --git a/tests/bench/AnalysedCode.php b/tests/bench/AnalysedCode.php new file mode 100644 index 00000000000..52671e4005a --- /dev/null +++ b/tests/bench/AnalysedCode.php @@ -0,0 +1,176 @@ + */ + private array $nodes = []; + + private function __construct(private Container $container) + { + } + + /** + * The classes and functions the code declares are known to reflection, as in an analysed file. + */ + public static function analyse(ScratchDirectory $scratch, string $code): self + { + $file = $scratch->path() . '/analysed.php'; + file_put_contents($file, $code); + + // the root of the PHPStan that is loaded, so the benchmarks also run against other versions + $containerFactory = new ContainerFactory(dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3)); + $container = $containerFactory->create($scratch->path() . '/container', [$containerFactory->getConfigDirectory() . '/config.level8.neon'], [$file]); + + $analysed = new self($container); + + /** @var Parser $parser */ + $parser = $container->getService('currentPhpVersionRichParser'); + $nodeScopeResolver = $container->getByType(NodeScopeResolver::class); + $nodeScopeResolver->setAnalysedFiles([$file]); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($file)); + $nodeScopeResolver->processNodes($parser->parseFile($file), $scope, static function (Node $node, Scope $scope) use ($analysed): void { + // 2.1.34 to 2.2.9 give rules a FiberScope, which asks the fiber of the analysis for its types + if (is_a($scope, 'PHPStan\Analyser\Fiber\FiberScope')) { + // @phpstan-ignore class.notFound, method.deprecated (the MutatingScope a FiberScope of 2.1.34 to 2.2.9 resolves its types with) + $scope = $scope->toMutatingScope(); + } + if (!$scope instanceof MutatingScope) { + throw new LogicException('The analysis gave a ' . get_class($scope) . '.'); + } + $analysed->nodes[] = [$node, $scope]; + }); + + return $analysed; + } + + public function getContainer(): Container + { + return $this->container; + } + + /** + * @template T of Node + * @param class-string $nodeClass + * @return list in the order the analysis visited them, with the scopes rules get + */ + public function nodes(string $nodeClass): array + { + $nodes = []; + foreach ($this->nodes as [$node, $scope]) { + if (!$node instanceof $nodeClass) { + continue; + } + + $nodes[] = [$node, $scope]; + } + + if ($nodes === []) { + throw new LogicException('The code has no ' . $nodeClass . '.'); + } + + return $nodes; + } + + /** + * The rule or extension the container registers, or one created the way the container would, + * with what each version's constructor asks for. + * + * @template T of object + * @param class-string $class + * @param array $parameters values of constructor parameters, instead of the configured ones + * @return T + */ + public function get(string $class, array $parameters = []): object + { + if ($parameters === []) { + foreach ($this->container->getServicesByTag('phpstan.rules.rule') as $rule) { + if ($rule instanceof $class) { + return $rule; + } + } + if ($this->container->findServiceNamesByType($class) !== []) { + return $this->container->getByType($class); + } + } + + $constructor = (new ReflectionClass($class))->getConstructor(); + $arguments = []; + foreach ($constructor === null ? [] : $constructor->getParameters() as $parameter) { + $name = $parameter->getName(); + $type = $parameter->getType(); + $extensions = $parameter->getAttributes('PHPStan\DependencyInjection\AutowiredExtensions'); + $configured = $parameter->getAttributes('PHPStan\DependencyInjection\AutowiredParameter'); + if (array_key_exists($name, $parameters)) { + $arguments[] = $parameters[$name]; + } elseif ($extensions !== []) { + $arguments[] = $this->container->getExtensionsCollection($extensions[0]->newInstance()->of); + } elseif ($configured !== []) { + $arguments[] = $this->parameter($configured[0]->newInstance()->ref ?? '%' . $name . '%'); + } elseif ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + if (is_a($this->container, $typeName)) { + $arguments[] = $this->container; + } elseif (class_exists($typeName) || interface_exists($typeName)) { + $arguments[] = $this->container->getByType($typeName); + } else { + throw new LogicException('Unknown type ' . $typeName . ' of the parameter $' . $name . ' of ' . $class . '.'); + } + } elseif ($parameter->isDefaultValueAvailable()) { + $arguments[] = $parameter->getDefaultValue(); + } elseif ($this->container->hasParameter($name)) { + $arguments[] = $this->container->getParameter($name); + } else { + throw new LogicException('Pass the parameter $' . $name . ' of ' . $class . '.'); + } + } + + return new $class(...$arguments); + } + + private function parameter(string $ref): mixed + { + if (str_starts_with($ref, '@')) { + return $this->container->getService(substr($ref, 1)); + } + + $value = $this->container->getParameters(); + foreach (explode('.', trim($ref, '%')) as $key) { + $value = $value[$key]; + } + + return $value; + } + +} diff --git a/tests/bench/ArgumentsNormalizerBench.php b/tests/bench/ArgumentsNormalizerBench.php new file mode 100644 index 00000000000..5db49228a78 --- /dev/null +++ b/tests/bench/ArgumentsNormalizerBench.php @@ -0,0 +1,83 @@ + + */ + public static function calls(): iterable + { + yield 'positional' => [false]; + yield 'named' => [true]; + } + + #[Before] + public function boot(bool $named): void + { + $container = BenchContainer::create($this->scratch); + $this->acceptor = $container->getByType(ReflectionProvider::class)->getFunction(new Name('str_replace'), null)->getVariants()[0]; + + ArgumentsNormalizer::reorderFuncArguments($this->acceptor, self::call(false)); + ArgumentsNormalizer::reorderFuncArguments($this->acceptor, self::call(true)); + + $this->call = self::call($named); + } + + /** + * A call without named arguments is returned as it is, a call with them is rebuilt in the order of the parameters. + */ + #[DataProvider(method: 'calls')] + #[ExpectedFor(dataSet: 'named', instructions: '24.70K', cycles: '121.3K', memory: '2.516KiB')] + #[ExpectedFor(dataSet: 'positional', instructions: '2.670K', cycles: '27.86K', memory: '0B')] + public function benchReorderFuncArguments(bool $named): ?FuncCall + { + if ($this->acceptor === null || $this->call === null) { + throw new LogicException('boot() did not run.'); + } + + return ArgumentsNormalizer::reorderFuncArguments($this->acceptor, $this->call); + } + + private static function call(bool $named): FuncCall + { + $search = new String_('a'); + $replace = new String_('b'); + $subject = new Variable('subject'); + if (!$named) { + return new FuncCall(new Name('str_replace'), [new Arg($search), new Arg($replace), new Arg($subject)]); + } + + return new FuncCall(new Name('str_replace'), [ + new Arg($subject, name: new Identifier('subject')), + new Arg($search, name: new Identifier('search')), + new Arg($replace, name: new Identifier('replace')), + ]); + } + +} diff --git a/tests/bench/ArrayTypeBench.php b/tests/bench/ArrayTypeBench.php new file mode 100644 index 00000000000..1d4e4e00b05 --- /dev/null +++ b/tests/bench/ArrayTypeBench.php @@ -0,0 +1,77 @@ +scratch); + + $this->array = new ArrayType(new IntegerType(), new MixedType()); + $this->array->isList(); + new ArrayType(IntegerRangeType::fromInterval(0, 100), new MixedType()); + } + + /** + * array may be a list, which takes two comparisons of its key type to find out. + */ + #[Expected(instructions: '616.4K', cycles: '654.3K', memory: '376B')] + public function benchIsList(): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->array()->isList(); + } + + return $result; + } + + /** + * The constructor turns a benevolent (int|string) key into mixed, which other key types do not + * need to be described for. + */ + #[Expected(instructions: '1.699M', cycles: '1.749M', memory: '416B')] + public function benchConstruct(): Type + { + $keyType = IntegerRangeType::fromInterval(0, 100); + $itemType = new MixedType(); + $array = $this->array(); + for ($i = 0; $i < self::CALLS; $i++) { + $array = new ArrayType($keyType, $itemType); + } + + return $array; + } + + private function array(): ArrayType + { + if ($this->array === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->array; + } + +} diff --git a/tests/bench/BenchContainer.php b/tests/bench/BenchContainer.php new file mode 100644 index 00000000000..25aac5c8ed7 --- /dev/null +++ b/tests/bench/BenchContainer.php @@ -0,0 +1,30 @@ + $additionalConfigFiles such as one with the parameters a benchmark needs + */ + public static function create(ScratchDirectory $scratch, array $additionalConfigFiles = []): Container + { + // the root of the PHPStan that is loaded, so the benchmarks also run against other versions + $containerFactory = new ContainerFactory(dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3)); + + return $containerFactory->create($scratch->path(), [$containerFactory->getConfigDirectory() . '/config.level8.neon', ...$additionalConfigFiles], []); + } + +} diff --git a/tests/bench/BenchEnum.php b/tests/bench/BenchEnum.php new file mode 100644 index 00000000000..aa70336f55d --- /dev/null +++ b/tests/bench/BenchEnum.php @@ -0,0 +1,268 @@ +path() . '/' . $name . '.php'; + $code = sprintf(" + */ + public static function cases(string $className, int $cases): array + { + $types = []; + for ($i = 0; $i < $cases; $i++) { + $types[] = new EnumCaseObjectType($className, 'C' . $i); + } + + return $types; + } + +} diff --git a/tests/bench/BenchFixtures.php b/tests/bench/BenchFixtures.php new file mode 100644 index 00000000000..d27f12f4dc2 --- /dev/null +++ b/tests/bench/BenchFixtures.php @@ -0,0 +1,23 @@ +path() . '/fixtures-' . md5($code) . '.php'; + file_put_contents($file, " $scopes + */ + private function __construct(private array $scopes) + { + } + + /** + * @param string $name the file name, each analysis needs its own because the parser caches by file contents + */ + public static function analyse(Container $container, ScratchDirectory $scratch, string $name, string $code): self + { + [$file, $nodes] = self::parse($container, $scratch, $name, $code); + $markers = []; + foreach (self::markers($nodes) as $marker => [$stmt]) { + $markers[$marker] = $stmt; + } + + $scopes = []; + $resolver = $container->getByType(NodeScopeResolver::class); + $resolver->setAnalysedFiles([$file]); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($file)); + $resolver->processNodes($nodes, $scope, static function (Node $node, Scope $scope) use ($markers, &$scopes): void { + foreach ($markers as $marker => $stmt) { + if ($node !== $stmt) { + continue; + } + // the rules get a FiberScope from 2.1.34 and a NodeCallbackScope from 2.3, + // the walk itself uses the MutatingScope behind them + if (method_exists($scope, 'toWalkScope')) { // @phpstan-ignore function.alreadyNarrowedType (not before 2.3) + $scope = $scope->toWalkScope(); + } elseif (method_exists($scope, 'toMutatingScope')) { + $scope = $scope->toMutatingScope(); // @phpstan-ignore method.deprecated (what 2.1.34 to 2.2 have) + } + if (!$scope instanceof MutatingScope) { + throw new LogicException('Not a MutatingScope.'); + } + $scopes[$marker] = $scope; + } + }); + + foreach (array_keys($markers) as $marker) { + if (!isset($scopes[$marker])) { + throw new LogicException($marker . '() was not reached.'); + } + } + + return new self($scopes); + } + + /** + * Parses without analysing, for statements no scope has seen yet. + * + * @return array{string, Node[]} + */ + public static function parse(Container $container, ScratchDirectory $scratch, string $name, string $code): array + { + $file = $scratch->path() . '/' . $name . '.php'; + // different contents for each name, the parser caches by them + file_put_contents($file, $code . "\n// " . $name . "\n"); + + /** @var Parser $parser */ + $parser = $container->getService('currentPhpVersionRichParser'); + + return [$file, $parser->parseFile($file)]; + } + + /** + * The `marker*();` statements in functions and methods, with the function and the statements after them. + * + * @param Node[] $nodes + * @return array}> + */ + public static function markers(array $nodes): array + { + $markers = []; + foreach ((new NodeFinder())->find($nodes, static fn (Node $node): bool => $node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) as $function) { + if (!$function instanceof Node\Stmt\Function_ && !$function instanceof Node\Stmt\ClassMethod) { + continue; + } + $stmts = $function->stmts ?? []; + foreach ($stmts as $i => $stmt) { + if ( + !$stmt instanceof Node\Stmt\Expression + || !$stmt->expr instanceof Node\Expr\FuncCall + || !$stmt->expr->name instanceof Node\Name + ) { + continue; + } + $markers[$stmt->expr->name->getLast()] = [$stmt, $function, array_slice($stmts, $i + 1)]; + } + } + + return $markers; + } + + public function scope(string $marker): MutatingScope + { + if (!isset($this->scopes[$marker])) { + throw new LogicException('No ' . $marker . '().'); + } + + return $this->scopes[$marker]; + } + +} diff --git a/tests/bench/BetterReflectionProviderBench.php b/tests/bench/BetterReflectionProviderBench.php new file mode 100644 index 00000000000..7ce706b505e --- /dev/null +++ b/tests/bench/BetterReflectionProviderBench.php @@ -0,0 +1,63 @@ +scratch); + $this->reflectionProvider = $container->getByType(ReflectionProvider::class); + + $file = $this->scratch->path() . '/anonymous-class.php'; + $code = "findInstanceOf((new ParserFactory())->createForHostVersion()->parse($code) ?? [], Node\Stmt\Class_::class); + $this->scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($file)); + + $this->reflectionProvider->getAnonymousClassReflection($classes[0], $this->scope); + $this->reflectionProvider->getAnonymousClassReflection($classes[0], $this->scope); + $this->class = $classes[1]; + $this->reflectionProvider->getAnonymousClassReflection($this->class, $this->scope); + } + + /** + * Every scope that enters an anonymous class asks for its reflection again. + */ + #[Expected(instructions: '10.83K', cycles: '92.06K', memory: '256B')] + public function benchGetAnonymousClassReflectionAgain(): ClassReflection + { + if ($this->reflectionProvider === null || $this->class === null || $this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->reflectionProvider->getAnonymousClassReflection($this->class, $this->scope); + } + +} diff --git a/tests/bench/BetterReflectionSourceLocatorFactoryBench.php b/tests/bench/BetterReflectionSourceLocatorFactoryBench.php new file mode 100644 index 00000000000..e28922808cd --- /dev/null +++ b/tests/bench/BetterReflectionSourceLocatorFactoryBench.php @@ -0,0 +1,40 @@ +factory = BenchContainer::create($this->scratch)->getByType(BetterReflectionSourceLocatorFactory::class); + } + + /** + * Every process creates the source locator when it boots, also one that never locates a class. + */ + #[Expected(instructions: '2.283K', cycles: '29.67K', memory: '1.328KiB')] + public function benchCreate(): SourceLocator + { + if ($this->factory === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->factory->create(); + } + +} diff --git a/tests/bench/ClassReflectionBench.php b/tests/bench/ClassReflectionBench.php new file mode 100644 index 00000000000..526afacc28c --- /dev/null +++ b/tests/bench/ClassReflectionBench.php @@ -0,0 +1,156 @@ + + */ + public static function depths(): iterable + { + foreach ([3, 6, 9] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(): void + { + $this->reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + BenchFixtures::declare($this->scratch, <<<'PHP' + namespace ClassReflectionBench; + + class Member + { + + /** @var list */ + public array $values = []; + + /** + * @param list $values + * @return list + */ + public function doubled(array $values): array + { + return $values; + } + + } + PHP); + $this->class = $this->reflectionProvider->getClass('ClassReflectionBench\Member'); + $scope = new OutOfClassScope(); + $this->class->getMethod('doubled', $scope); + $this->class->getProperty('values', $scope); // @phpstan-ignore method.deprecated (the method the cache early return is in) + $this->class->getName(); + } + + /** + * Asking again for a method answers from the cache filled by the first call. + */ + #[Expected(instructions: '2.194K', cycles: '28.37K', memory: '40B')] + public function benchGetMethodAgain(): ExtendedMethodReflection + { + return $this->class()->getMethod('doubled', new OutOfClassScope()); + } + + #[Expected(instructions: '2.515K', cycles: '32.66K', memory: '40B')] + public function benchGetPropertyAgain(): ExtendedPropertyReflection + { + // @phpstan-ignore method.deprecated (the method the cache early return is in) + return $this->class()->getProperty('values', new OutOfClassScope()); + } + + /** + * The hottest call on the class, memoized since 2.2.14. + */ + #[Expected(instructions: '1.016K', cycles: '13.78K', memory: '0B')] + public function benchGetName(): string + { + return $this->class()->getName(); + } + + /** + * A class implementing a lattice of interfaces in which each interface extends both interfaces + * of the level below, so walking it recursively visits the lower levels exponentially often. + * + * @return array + */ + #[DataProvider(method: 'depths')] + #[ExpectedFor(dataSet: 'depth-3', instructions: '2.531M', cycles: '3.906M', memory: '35.68KiB')] + #[ExpectedFor(dataSet: 'depth-6', instructions: '10.28M', cycles: '12.70M', memory: '66.72KiB')] + #[ExpectedFor(dataSet: 'depth-9', instructions: '55.29M', cycles: '61.48M', memory: '115.8KiB')] + public function benchGetInterfacesOfLattice(int $depth): array + { + $class = $this->reflectionProvider()->getClass(sprintf('ClassReflectionBench\Lattice%d\Implementation', $depth)); + + return $class->getInterfaces(); + } + + #[Before] + public function declareLattices(): void + { + foreach ([2, 3, 6, 9] as $depth) { + BenchFixtures::declare($this->scratch, self::lattice($depth)); + } + + // autoloads and warms up getInterfaces() on a lattice no case uses + $this->reflectionProvider()->getClass('ClassReflectionBench\Lattice2\Implementation')->getInterfaces(); + foreach ([3, 6, 9] as $depth) { + $this->reflectionProvider()->getClass(sprintf('ClassReflectionBench\Lattice%d\Implementation', $depth)); + } + } + + private static function lattice(int $depth): string + { + $code = sprintf("namespace ClassReflectionBench\\Lattice%d;\n\ninterface A0 {}\ninterface B0 {}\n", $depth); + for ($level = 1; $level <= $depth; $level++) { + $below = sprintf('A%d, B%d', $level - 1, $level - 1); + $code .= sprintf("interface A%d extends %s {}\ninterface B%d extends %s {}\n", $level, $below, $level, $below); + } + + return $code . sprintf("class Implementation implements %s {}\n", implode(', ', ['A' . $depth, 'B' . $depth])); + } + + private function class(): ClassReflection + { + if ($this->class === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->class; + } + + private function reflectionProvider(): ReflectionProvider + { + if ($this->reflectionProvider === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->reflectionProvider; + } + +} diff --git a/tests/bench/CollectorsRegistryBench.php b/tests/bench/CollectorsRegistryBench.php new file mode 100644 index 00000000000..ab7747f6fb3 --- /dev/null +++ b/tests/bench/CollectorsRegistryBench.php @@ -0,0 +1,45 @@ +scratch); + // the rules registry asked about the node type first + (new Registry([]))->getCollectors(MethodCall::class); + $this->registry = new Registry([]); + } + + /** + * The first time the collectors registry is asked about a node type. + * + * @return mixed[] + */ + #[Expected(instructions: '3.452K', cycles: '28.42K', memory: '376B')] + public function benchGetCollectors(): array + { + if ($this->registry === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->registry->getCollectors(MethodCall::class); + } + +} diff --git a/tests/bench/CombinationsHelperBench.php b/tests/bench/CombinationsHelperBench.php new file mode 100644 index 00000000000..3cca7526d12 --- /dev/null +++ b/tests/bench/CombinationsHelperBench.php @@ -0,0 +1,84 @@ +>}> + */ + public static function pairs(): iterable + { + foreach ([4, 6, 8, 10] as $lists) { + yield 'lists-' . $lists => [array_fill(0, $lists, [1, 2])]; + } + } + + /** + * @return iterable>}> + */ + public static function triples(): iterable + { + foreach ([3, 4, 5, 6] as $lists) { + yield 'lists-' . $lists => [array_fill(0, $lists, [1, 2, 3])]; + } + } + + /** + * @param list> $lists + */ + #[Before] + public function boot(array $lists): void + { + BenchContainer::create($this->scratch); + self::count([[1, 2], [3, 4]]); + } + + /** + * @param list> $lists + */ + #[DataProvider(method: 'pairs')] + #[Exponential(size: 'lists')] + public function benchPairs(array $lists): int + { + return self::count($lists); + } + + /** + * @param list> $lists + */ + #[DataProvider(method: 'triples')] + #[Complexity(size: 'lists', formula: '3^n')] + public function benchTriples(array $lists): int + { + return self::count($lists); + } + + /** + * @param list> $lists + */ + private static function count(array $lists): int + { + $combinations = 0; + foreach (CombinationsHelper::combinations($lists) as $combination) { + $combinations += count($combination); + } + + return $combinations; + } + +} diff --git a/tests/bench/ComposerHelperBench.php b/tests/bench/ComposerHelperBench.php new file mode 100644 index 00000000000..27b46d92894 --- /dev/null +++ b/tests/bench/ComposerHelperBench.php @@ -0,0 +1,41 @@ +scratch); + $this->root = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3); + ComposerHelper::getComposerConfig($this->root); + } + + /** + * Several services read the same composer.json while the container is built. + * + * @return array|null + */ + #[Expected(instructions: '1.174K', cycles: '15.80K', memory: '0B')] + public function benchGetComposerConfigAgain(): ?array + { + return ComposerHelper::getComposerConfig($this->root); + } + +} diff --git a/tests/bench/ConstantArrayTypeAcceptsBench.php b/tests/bench/ConstantArrayTypeAcceptsBench.php new file mode 100644 index 00000000000..9172794a1db --- /dev/null +++ b/tests/bench/ConstantArrayTypeAcceptsBench.php @@ -0,0 +1,77 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 128] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + + // the counted call does not autoload + self::shape('warmup', 2, new UnionType([new IntegerType(), new StringType(), new NullType()])) + ->accepts(self::shape('warmup', 2, new UnionType([new IntegerType(), new StringType()])), true); + + // every offset of a shape has the same value type, such as a type alias used for each of them + $this->accepting = self::shape('key', $keys, new UnionType([new IntegerType(), new StringType(), new NullType()])); + $this->accepted = self::shape('key', $keys, new UnionType([new IntegerType(), new StringType()])); + } + + #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '2.879M', cycles: '4.157M', memory: '18.22KiB')] + #[ExpectedFor(dataSet: 'keys-128', instructions: '22.85M', cycles: '30.77M', memory: '123.5KiB')] + public function benchAcceptsSharedValueType(int $keys): AcceptsResult + { + if ($this->accepting === null || $this->accepted === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->accepting->accepts($this->accepted, true); + } + + private static function shape(string $prefix, int $keys, Type $valueType): ConstantArrayType + { + $keyTypes = []; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType($prefix . $i); + } + + return new ConstantArrayType($keyTypes, array_fill(0, $keys, $valueType)); + } + +} diff --git a/tests/bench/ConstantArrayTypeBench.php b/tests/bench/ConstantArrayTypeBench.php new file mode 100644 index 00000000000..9acc3121ea7 --- /dev/null +++ b/tests/bench/ConstantArrayTypeBench.php @@ -0,0 +1,233 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 64, 256] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + + // the counted calls do not autoload, and do not hit what the warm-up memoized + $warmup = self::shape('warmup', 4, true); + $warmup->getOffsetValueType(new ConstantStringType('warmup1')); + $warmup->isSuperTypeOf(self::shape('warmup', 4, true)); + + $this->shape = self::shape('key', $keys, false); + // builds the key index + $this->shape->getOffsetValueType(new ConstantStringType('key0')); + $this->sameShape = self::shape('key', $keys, false); + $this->key = new ConstantStringType('key' . intdiv($keys, 2)); + + $this->sealed = self::sealed($this->shape, $keys); + $this->sealedWithoutLastKey = self::sealed($this->shape, $keys - 1); + + // every offset holds the same shape, which the analysis gets from a type alias + $nested = self::shape('nested', 16, false); + $keyTypes = [new ConstantStringType('object')]; + $valueTypes = [new ObjectType(stdClass::class)]; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType('key' . $i); + $valueTypes[] = $nested; + } + $this->nesting = new ConstantArrayType($keyTypes, $valueTypes); + $this->nesting->getReferencedClasses(); + $this->nesting->getReferencedTemplateTypes(TemplateTypeVariance::createCovariant()); + } + + /** + * A constant key is looked up in the key index, so the size of the array does not matter. + * The index is built on the first lookup, which boot() makes with another key. + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + public function benchGetOffsetValueType(int $keys): Type + { + if ($this->key === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->subject()->getOffsetValueType($this->key); + } + + #[DataProvider(method: 'keys')] + #[Linear(size: 'keys')] + public function benchIsSuperTypeOf(int $keys): bool + { + if ($this->sameShape === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->subject()->isSuperTypeOf($this->sameShape)->yes(); + } + + /** + * Two sealed shapes where one lacks a key of the other. Why they cannot be intersected is + * only described when it is reported. + */ + #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '49.30K', cycles: '158.1K', memory: '1.797KiB')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '164.7K', cycles: '300.6K', memory: '4.070KiB')] + #[ExpectedFor(dataSet: 'keys-256', instructions: '625.6K', cycles: '871.5K', memory: '20.32KiB')] + public function benchIsSuperTypeOfSealedWithoutKey(int $keys): bool + { + if ($this->sealed === null || $this->sealedWithoutLastKey === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->sealed->isSuperTypeOf($this->sealedWithoutLastKey)->no(); + } + + /** + * The verbosity of the reason is only chosen when an offset does not accept. + */ + #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '1.921M', cycles: '3.173M', memory: '157.0KiB')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '3.080M', cycles: '4.728M', memory: '157.0KiB')] + #[ExpectedFor(dataSet: 'keys-256', instructions: '7.715M', cycles: '10.97M', memory: '160.8KiB')] + public function benchAcceptsSameShape(int $keys): bool + { + if ($this->sameShape === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->subject()->accepts($this->sameShape, true)->yes(); + } + + /** + * Asked again of a shape whose offsets share one nested shape. + */ + /** + * @return list + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + public function benchGetReferencedClassesAgain(int $keys): array + { + return $this->nesting()->getReferencedClasses(); + } + + /** + * @return list + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + public function benchGetReferencedTemplateTypesAgain(int $keys): array + { + return $this->nesting()->getReferencedTemplateTypes(TemplateTypeVariance::createCovariant()); + } + + /** + * Fails before 2.1.34, which added the method, so the first call is a warm-up call of the + * benchmark instead of one in boot(). + */ + #[DataProvider(method: 'keys')] + #[Constant(size: 'keys')] + #[Warmup(calls: 1)] + public function benchHasTemplateOrLateResolvableTypeAgain(int $keys): bool + { + return $this->nesting()->hasTemplateOrLateResolvableType(); + } + + private function nesting(): ConstantArrayType + { + if ($this->nesting === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->nesting; + } + + /** + * The first keys of the shape, sealed. Before 2.2.0 the constructor ignores the argument. + */ + private static function sealed(ConstantArrayType $shape, int $keys): ConstantArrayType + { + // positional, so the versions without the parameter ignore it instead of failing + $arguments = [ + array_slice($shape->getKeyTypes(), 0, $keys), + array_slice($shape->getValueTypes(), 0, $keys), + [0], + [], + null, + [new NeverType(true), new NeverType(true)], + ]; + + return new ConstantArrayType(...$arguments); + } + + private function subject(): ConstantArrayType + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->shape; + } + + public static function shape(string $prefix, int $keys, bool $optional): ConstantArrayType + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $keys; $i++) { + $builder->setOffsetValueType(new ConstantStringType($prefix . $i), new IntegerType(), $optional); + } + + $arrays = $builder->getArray()->getConstantArrays(); + if (count($arrays) !== 1) { + throw new LogicException('The builder did not build one constant array.'); + } + + return $arrays[0]; + } + +} diff --git a/tests/bench/ConstantArrayTypeBuilderBench.php b/tests/bench/ConstantArrayTypeBuilderBench.php new file mode 100644 index 00000000000..4fd07f9a566 --- /dev/null +++ b/tests/bench/ConstantArrayTypeBuilderBench.php @@ -0,0 +1,132 @@ + */ + private array $keys = []; + + /** @var list */ + private array $closures = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function keys(): iterable + { + foreach ([16, 64, 256] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + /** + * @return iterable + */ + public static function closures(): iterable + { + foreach ([16, 64] as $closures) { + yield 'closures-' . $closures => [$closures]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + self::build([new ConstantStringType('warmup0'), new ConstantStringType('warmup1')]); + self::buildList(self::closureList(-4, 4))->getIterableValueType(); + $this->closures = self::closureList(0, $keys); + for ($i = 0; $i < $keys; $i++) { + $this->keys[] = new ConstantStringType('key' . $i); + } + } + + /** + * Setting a new key compares it with every key set before, so building an array key by key + * is quadratic in its size. + */ + #[DataProvider(method: 'keys')] + #[Quadratic(size: 'keys')] + public function benchSetOffsetValueType(int $keys): Type + { + if ($this->keys === []) { + throw new LogicException('boot() did not run.'); + } + + return self::build($this->keys); + } + + /** + * A list of closures that return 0, 1, 2, …, like an array of handlers, and the type of its values. + */ + #[DataProvider(method: 'closures')] + #[ExpectedFor(dataSet: 'closures-16', instructions: '13.03M', cycles: '17.98M', memory: '7.516KiB')] + #[ExpectedFor(dataSet: 'closures-64', instructions: '62.79M', cycles: '76.63M', memory: '1.507MiB')] + public function benchListOfClosures(int $closures): Type + { + if ($this->closures === []) { + throw new LogicException('boot() did not run.'); + } + + return self::buildList($this->closures)->getIterableValueType(); + } + + /** + * @param list $values + */ + private static function buildList(array $values): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($values as $value) { + $builder->setOffsetValueType(null, $value); + } + + return $builder->getArray(); + } + + /** + * @return list + */ + private static function closureList(int $from, int $count): array + { + $closures = []; + for ($i = 0; $i < $count; $i++) { + $closures[] = new ClosureType([], new ConstantIntegerType($from + $i), false); + } + + return $closures; + } + + /** + * @param list $keys + */ + private static function build(array $keys): Type + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($keys as $key) { + $builder->setOffsetValueType($key, new IntegerType()); + } + + return $builder->getArray(); + } + +} diff --git a/tests/bench/ConstantArrayTypeGetAllArraysBench.php b/tests/bench/ConstantArrayTypeGetAllArraysBench.php new file mode 100644 index 00000000000..b304e1e2b0b --- /dev/null +++ b/tests/bench/ConstantArrayTypeGetAllArraysBench.php @@ -0,0 +1,54 @@ + + */ + public static function optionalKeys(): iterable + { + foreach ([4, 6, 8, 10] as $keys) { + yield 'optional-keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + ConstantArrayTypeBench::shape('warmup', 4, true)->getAllArrays(); + $this->shape = ConstantArrayTypeBench::shape('optional', $keys, true); + } + + /** + * Each optional key doubles the arrays, and each array has up to all the keys. + */ + #[DataProvider(method: 'optionalKeys')] + #[Complexity(size: 'keys', formula: 'n * 2^n')] + public function benchGetAllArrays(int $keys): int + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return count($this->shape->getAllArrays()); + } + +} diff --git a/tests/bench/ConstantArrayTypePhp74Bench.php b/tests/bench/ConstantArrayTypePhp74Bench.php new file mode 100644 index 00000000000..9ae4fbabbaf --- /dev/null +++ b/tests/bench/ConstantArrayTypePhp74Bench.php @@ -0,0 +1,81 @@ + */ + private array $arrays = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + $config = $this->scratch->path() . '/php-7.4.neon'; + file_put_contents($config, "parameters:\n\tphpVersion: 70400\n"); + $containerFactory = new ContainerFactory(dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3)); + $containerFactory->create($this->scratch->path(), [$containerFactory->getConfigDirectory() . '/config.level8.neon', $config], []); + + // reflects the methods + foreach (self::arrays() as $array) { + $array->findTypeAndMethodNames(); + } + $this->arrays = self::arrays(); + } + + /** + * [$dateTime, 'format'] and the other methods of DateTime, called on an object. + */ + #[Expected(instructions: '169.9K', cycles: '324.2K', memory: '1.164KiB')] + public function benchFindTypeAndMethodNames(): int + { + $found = 0; + foreach ($this->arrays as $array) { + $found += count($array->findTypeAndMethodNames()); + } + + return $found; + } + + /** + * @return list + */ + private static function arrays(): array + { + $arrays = []; + foreach (self::METHODS as $method) { + $arrays[] = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new ObjectType(DateTime::class), new ConstantStringType($method)], + [2], + ); + } + + return $arrays; + } + +} diff --git a/tests/bench/ConstantStringTypeBench.php b/tests/bench/ConstantStringTypeBench.php new file mode 100644 index 00000000000..e0b3be39eaa --- /dev/null +++ b/tests/bench/ConstantStringTypeBench.php @@ -0,0 +1,79 @@ + */ + private array $keys = []; + + /** @var list */ + private array $callables = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + (new ConstantStringType('warmup'))->toArrayKey(); + (new ConstantStringType('DateTimeImmutable::format'))->isCallable(); + foreach (self::DATE_TIME_METHODS as $method) { + (new ConstantStringType('DateTime::' . $method))->isCallable(); + } + + for ($i = 0; $i < self::KEYS; $i++) { + $this->keys[] = new ConstantStringType('key' . $i); + } + foreach (self::DATE_TIME_METHODS as $method) { + $this->callables[] = new ConstantStringType('DateTime::' . $method); + } + } + + /** + * Strings that stay strings as array keys. + * + * @return list + */ + #[Expected(instructions: '302.2K', cycles: '435.1K', memory: '8.422KiB')] + public function benchToArrayKey(): array + { + $arrayKeys = []; + foreach ($this->keys as $key) { + $arrayKeys[] = $key->toArrayKey(); + } + + return $arrayKeys; + } + + /** + * 'DateTime::format' and other methods of DateTime, static or not. Since PHP 8 such a string + * may name an instance method, so the method is not looked into. + */ + #[Expected(instructions: '86.86K', cycles: '225.3K', memory: '952B')] + public function benchIsCallable(): TrinaryLogic + { + $result = TrinaryLogic::createYes(); + foreach ($this->callables as $callable) { + $result = $result->and($callable->isCallable()); + } + + return $result; + } + +} diff --git a/tests/bench/ConstantStringTypeDescribeBench.php b/tests/bench/ConstantStringTypeDescribeBench.php new file mode 100644 index 00000000000..e950d20203c --- /dev/null +++ b/tests/bench/ConstantStringTypeDescribeBench.php @@ -0,0 +1,51 @@ + */ + private array $strings = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + for ($i = 0; $i < self::STRINGS; $i++) { + $string = new ConstantStringType('The value of an enum case number ' . $i); + $string->describe(VerbosityLevel::value()); + $string->describe(VerbosityLevel::precise()); + $this->strings[] = $string; + } + } + + /** + * Strings already described, as the members of a union of an enum's values are again and again. + */ + #[Expected(instructions: '513.6K', cycles: '726.7K', memory: '0B')] + public function benchDescribeAgain(): string + { + $description = ''; + foreach ($this->strings as $string) { + $string->describe(VerbosityLevel::value()); + $description = $string->describe(VerbosityLevel::precise()); + } + + return $description; + } + +} diff --git a/tests/bench/DependencyResolverBench.php b/tests/bench/DependencyResolverBench.php new file mode 100644 index 00000000000..270fb7a4e43 --- /dev/null +++ b/tests/bench/DependencyResolverBench.php @@ -0,0 +1,95 @@ + */ + private array $analysedFiles = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function references(): iterable + { + yield 'analysed' => ['analysed']; + yield 'not-analysed' => ['not-analysed']; + // the class file itself is among the dependencies + yield 'own-file' => ['own-file']; + } + + #[Before] + public function boot(string $case): void + { + $container = BenchContainer::create($this->scratch); + $this->resolver = $container->getByType(DependencyResolver::class); + $fileHelper = $container->getByType(FileHelper::class); + + $file = $fileHelper->normalizePath(__FILE__); + if ($case === 'own-file') { + $file = $fileHelper->normalizePath((string) $container->getByType(ReflectionProvider::class)->getClass(self::REFERENCED_CLASS)->getFileName()); + } + + $this->analysedFiles = []; + if ($case !== 'not-analysed') { + $src = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 2); + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $srcFile) { + /** @var SplFileInfo $srcFile */ + $this->analysedFiles[$fileHelper->normalizePath($srcFile->getPathname())] = true; + } + } + + $this->dependencies = Dependencies::create($file, [], [self::REFERENCED_CLASS]); + + // what the analysis of the files before memoized + $this->benchResolveClassReference($case); + } + + /** + * The files and packages a reference to a class from an analysed file depends on. + */ + #[DataProvider(method: 'references')] + #[ExpectedFor(dataSet: 'analysed', instructions: '75.18K', cycles: '221.1K', memory: '4.234KiB')] + #[ExpectedFor(dataSet: 'not-analysed', instructions: '84.77K', cycles: '226.1K', memory: '4.234KiB')] + #[ExpectedFor(dataSet: 'own-file', instructions: '73.58K', cycles: '217.5K', memory: '4.234KiB')] + public function benchResolveClassReference(string $case): ResolvedDependencies + { + if ($this->resolver === null || $this->dependencies === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->resolveFileDependencies($this->dependencies, $this->analysedFiles); + } + +} diff --git a/tests/bench/DynamicReturnTypeExtensionRegistryBench.php b/tests/bench/DynamicReturnTypeExtensionRegistryBench.php new file mode 100644 index 00000000000..ac4fa10f760 --- /dev/null +++ b/tests/bench/DynamicReturnTypeExtensionRegistryBench.php @@ -0,0 +1,69 @@ +registry = BenchContainer::create($this->scratch)->getByType(DynamicReturnTypeExtensionRegistry::class); + $this->registry->getDynamicMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class); + $this->registry->getDynamicStaticMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class); + } + + #[Expected(instructions: '845.6K', cycles: '896.5K', memory: '424B')] + public function benchMethodExtensionsForClass(): int + { + $registry = $this->registry(); + $count = 0; + for ($i = 0; $i < self::CALLS; $i++) { + $count += count($registry->getDynamicMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class)); + } + + return $count; + } + + #[Expected(instructions: '845.6K', cycles: '891.2K', memory: '424B')] + public function benchStaticMethodExtensionsForClass(): int + { + $registry = $this->registry(); + $count = 0; + for ($i = 0; $i < self::CALLS; $i++) { + $count += count($registry->getDynamicStaticMethodReturnTypeExtensionsForClass(RecursiveArrayIterator::class)); + } + + return $count; + } + + private function registry(): DynamicReturnTypeExtensionRegistry + { + if ($this->registry === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->registry; + } + +} diff --git a/tests/bench/EnumTypesBench.php b/tests/bench/EnumTypesBench.php new file mode 100644 index 00000000000..f7348cc7033 --- /dev/null +++ b/tests/bench/EnumTypesBench.php @@ -0,0 +1,129 @@ + */ + private array $unionMembers = []; + + private ?UnionType $union = null; + + private ?IntersectionType $intersection = null; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + // reflects the enum and its cases; the types memoize their results, so the counted calls get new ones + $warmupCases = self::cases(4); + TypeCombinator::union(...self::unionMembers()); + (new UnionType($warmupCases))->getFiniteTypes(); + self::withoutFirstCase()->getFiniteTypes(); + + $this->unionMembers = self::unionMembers(); + $this->union = new UnionType(self::cases(self::CASES)); + $this->intersection = self::withoutFirstCase(); + } + + /** + * A member that is one enum case is set aside; whether the intersection with the enum is one + * does not need all of its cases. + */ + #[Expected(instructions: '156.3K', cycles: '516.7K', memory: '4.328KiB')] + public function benchUnionWithIntersection(): Type + { + if ($this->unionMembers === []) { + throw new LogicException('boot() did not run.'); + } + + return TypeCombinator::union(...$this->unionMembers); + } + + /** + * @return list + */ + #[Expected(instructions: '804.4K', cycles: '1.086M', memory: '40.11KiB')] + public function benchUnionTypeGetFiniteTypes(): array + { + if ($this->union === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->union->getFiniteTypes(); + } + + /** + * The cases of the enum are matched with the cases of the enum without C0. + * + * @return list + */ + #[Expected(instructions: '1.121M', cycles: '1.668M', memory: '72.42KiB')] + public function benchIntersectionTypeGetFiniteTypes(): array + { + if ($this->intersection === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->intersection->getFiniteTypes(); + } + + /** + * @return list + */ + private static function cases(int $count): array + { + $cases = []; + for ($i = 0; $i < $count; $i++) { + $cases[] = new EnumCaseObjectType(BenchEnum::class, 'C' . $i); + } + + return $cases; + } + + /** + * @return list + */ + private static function unionMembers(): array + { + return [ + new IntersectionType([new ObjectType(BenchEnum::class), new HasPropertyType('name')]), + new IntegerType(), + new StringType(), + ]; + } + + private static function withoutFirstCase(): IntersectionType + { + return new IntersectionType([ + new ObjectType(BenchEnum::class), + new ObjectType(BenchEnum::class, new EnumCaseObjectType(BenchEnum::class, 'C0')), + ]); + } + +} diff --git a/tests/bench/ExprPrinterBench.php b/tests/bench/ExprPrinterBench.php new file mode 100644 index 00000000000..887bda0c6b3 --- /dev/null +++ b/tests/bench/ExprPrinterBench.php @@ -0,0 +1,85 @@ + */ + private array $levels = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function depths(): iterable + { + foreach ([16, 64, 256] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(int $depth): void + { + $container = BenchContainer::create($this->scratch); + $this->printer = $container->getByType(ExprPrinter::class); + foreach (self::levels(4) as $level) { + $this->printer->printExpr($level); + } + + $this->levels = self::levels($depth); + } + + /** + * The analyser asks for the key of each level of $a->p1->p2…, innermost first, and printing + * a level prints the levels below it. + */ + #[DataProvider(method: 'depths')] + #[Linear(size: 'depth')] + public function benchPrintEveryLevelOfChain(int $depth): int + { + if ($this->printer === null) { + throw new LogicException('boot() did not run.'); + } + + $length = 0; + foreach ($this->levels as $level) { + $length += strlen($this->printer->printExpr($level)); + } + + return $length; + } + + /** + * @return list innermost first + */ + private static function levels(int $depth): array + { + $expr = new Variable('a'); + $levels = []; + for ($i = 1; $i <= $depth; $i++) { + $expr = new PropertyFetch($expr, 'p' . $i); + $levels[] = $expr; + } + + return $levels; + } + +} diff --git a/tests/bench/ExpressionTypeHolderBench.php b/tests/bench/ExpressionTypeHolderBench.php new file mode 100644 index 00000000000..a15327f784c --- /dev/null +++ b/tests/bench/ExpressionTypeHolderBench.php @@ -0,0 +1,90 @@ +scratch); + + $warmup = self::union('warmup'); + $holder = ExpressionTypeHolder::createYes(new Variable('a'), $warmup); + $holder->equals(ExpressionTypeHolder::createYes(new Variable('a'), $warmup)); + $holder->and(ExpressionTypeHolder::createMaybe(new Variable('a'), $warmup)); + + $type = self::union('member'); + $this->holder = ExpressionTypeHolder::createYes(new Variable('a'), $type); + $this->sameType = ExpressionTypeHolder::createMaybe(new Variable('a'), $type); + } + + /** + * The same holder (#4777). + */ + #[Expected(instructions: '1.101K', cycles: '14.52K', memory: '0B')] + public function benchEqualsSameHolder(): bool + { + $holder = $this->holder(); + + return $holder->equals($holder); + } + + /** + * Another holder of the same type object (#4800). + */ + #[Expected(instructions: '2.659K', cycles: '28.84K', memory: '0B')] + public function benchAndSameType(): ExpressionTypeHolder + { + if ($this->sameType === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->holder()->and($this->sameType); + } + + private function holder(): ExpressionTypeHolder + { + if ($this->holder === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->holder; + } + + private static function union(string $prefix): UnionType + { + $types = []; + for ($i = 0; $i < self::MEMBERS; $i++) { + $types[] = new ConstantStringType($prefix . $i); + } + + return new UnionType($types); + } + +} diff --git a/tests/bench/FileCacheStorageBench.php b/tests/bench/FileCacheStorageBench.php new file mode 100644 index 00000000000..55a093f41d0 --- /dev/null +++ b/tests/bench/FileCacheStorageBench.php @@ -0,0 +1,92 @@ +}> */ + private array $data = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function entries(): iterable + { + foreach ([100, 10000] as $entries) { + yield 'entries-' . $entries => [$entries]; + } + } + + /** + * @throws DirectoryCreatorException + */ + #[Before] + public function boot(int $entries): void + { + BenchContainer::create($this->scratch); + $this->storage = new FileCacheStorage($this->scratch->path() . '/file-cache-bench'); + + // the counted calls do not autoload + $this->storage->save('warmup', 'v1', self::data(2)); + $this->storage->load('warmup', 'v1'); + + $this->data = self::data($entries); + $this->storage->save('load-' . $entries, 'v1', $this->data); + } + + /** + * @throws DirectoryCreatorException + */ + #[DataProvider(method: 'entries')] + #[ExpectedFor(dataSet: 'entries-100', instructions: '246.7K', cycles: '601.2K', memory: '17.29KiB')] + #[ExpectedFor(dataSet: 'entries-10000', instructions: '19.12M', cycles: '32.34M', memory: '1.345MiB')] + public function benchSave(int $entries): void + { + if ($this->storage === null) { + throw new LogicException('boot() did not run.'); + } + + $this->storage->save('save-' . $entries, 'v1', $this->data); + } + + #[DataProvider(method: 'entries')] + #[ExpectedFor(dataSet: 'entries-100', instructions: '388.9K', cycles: '763.4K', memory: '130.1KiB')] + #[ExpectedFor(dataSet: 'entries-10000', instructions: '37.00M', cycles: '70.18M', memory: '11.67MiB')] + public function benchLoad(int $entries): mixed + { + if ($this->storage === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->storage->load('load-' . $entries, 'v1'); + } + + /** + * @return list}> shaped like the cached PHPDoc and reflection data + */ + private static function data(int $entries): array + { + $data = []; + for ($i = 0; $i < $entries; $i++) { + $data[] = ['App\\Model\\Entity' . $i, $i, ['int', 'string|null', 'list']]; + } + + return $data; + } + +} diff --git a/tests/bench/FileCacheStorageMissBench.php b/tests/bench/FileCacheStorageMissBench.php new file mode 100644 index 00000000000..a1c1fe74a0f --- /dev/null +++ b/tests/bench/FileCacheStorageMissBench.php @@ -0,0 +1,47 @@ +storage = new FileCacheStorage($this->scratch->path() . '/cache'); + $this->storage->save('written', 'v1', 'value'); + $this->storage->load('written', 'v1'); + $this->storage->load('warmup', 'v1'); + } + + /** + * The reflection cache in front of the PHP-internal locator is asked for every class name the + * locators before it do not find, and most were never written. + */ + #[Expected(instructions: '7.924K', cycles: '57.79K', memory: '824B')] + public function benchLoadMissing(): mixed + { + if ($this->storage === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->storage->load('missing', 'v1'); + } + +} diff --git a/tests/bench/FileExcluderBench.php b/tests/bench/FileExcluderBench.php new file mode 100644 index 00000000000..5c44f720d74 --- /dev/null +++ b/tests/bench/FileExcluderBench.php @@ -0,0 +1,72 @@ + + */ + public static function excludedFiles(): iterable + { + foreach ([16, 128, 1024] as $files) { + yield 'files-' . $files => [$files]; + } + } + + #[Before] + public function boot(int $files): void + { + BenchContainer::create($this->scratch); + $directory = $this->scratch->path() . '/file-excluder-bench'; + if (!is_dir($directory)) { + mkdir($directory); + } + + // excludes naming single files, as in excludePaths + $excludes = []; + for ($i = 0; $i < $files; $i++) { + $excludes[] = $directory . '/Excluded' . $i . '.php'; + touch($directory . '/Excluded' . $i . '.php'); + } + + $this->excluder = new FileExcluder(new FileHelper($directory), $excludes); + $this->file = $directory . '/Analysed.php'; + $this->excluder->isExcludedFromAnalysing($directory . '/Warmup.php'); + } + + /** + * Every found file is checked against the excludes. + */ + #[DataProvider(method: 'excludedFiles')] + #[Constant(size: 'files')] + public function benchIsNotExcluded(int $files): bool + { + if ($this->excluder === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->excluder->isExcludedFromAnalysing($this->file); + } + +} diff --git a/tests/bench/FileFinderBench.php b/tests/bench/FileFinderBench.php new file mode 100644 index 00000000000..d1bbe35c7ee --- /dev/null +++ b/tests/bench/FileFinderBench.php @@ -0,0 +1,73 @@ + */ + private array $paths = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + $container = BenchContainer::create($this->scratch); + $directory = $this->scratch->path() . '/file-finder-bench'; + foreach ([$directory, $directory . '/src', $directory . '/excluded'] as $dir) { + if (is_dir($dir)) { + continue; + } + + mkdir($dir); + } + + // the paths given on the command line, half of them in an excluded directory + $this->paths = []; + for ($i = 0; $i < self::FILES; $i++) { + $path = $directory . '/' . ($i % 2 === 0 ? 'src' : 'excluded') . '/File' . $i . '.php'; + touch($path); + $this->paths[] = $path; + } + + $fileHelper = new FileHelper($directory); + $excluder = new FileExcluder($fileHelper, [$directory . '/excluded']); + $this->finder = class_exists(DirectoryWalker::class) + ? new FileFinder($excluder, $fileHelper, ['php'], $container->getByType(DirectoryWalker::class)) + // @phpstan-ignore arguments.count (before 2.2.14) + : new FileFinder($excluder, $fileHelper, ['php']); + $this->finder->findFiles([$this->paths[0], $this->paths[1]]); + } + + #[Expected(instructions: '3.204M', cycles: '3.939M', memory: '46.80KiB')] + public function benchFindFiles(): FileFinderResult + { + if ($this->finder === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->finder->findFiles($this->paths); + } + +} diff --git a/tests/bench/FiniteTypeSetBench.php b/tests/bench/FiniteTypeSetBench.php new file mode 100644 index 00000000000..d2dee8b159d --- /dev/null +++ b/tests/bench/FiniteTypeSetBench.php @@ -0,0 +1,57 @@ + */ + private array $types = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + BenchContainer::create($this->scratch); + + $enum = BenchEnumFile::create($this->scratch, 'Cases' . self::TYPES, self::TYPES); + $this->types = [new NullType()]; + foreach (BenchEnumFile::cases($enum, self::TYPES) as $i => $case) { + $this->types[] = new ConstantStringType('value' . $i); + $this->types[] = new ConstantIntegerType($i); + $this->types[] = $case; + } + foreach ($this->types as $type) { + FiniteTypeSet::key($type); + } + } + + /** + * Every value a union is compared with is keyed. + */ + #[Expected(instructions: '355.5K', cycles: '465.2K', memory: '336B')] + public function benchKey(): ?string + { + $key = null; + foreach ($this->types as $type) { + $key = FiniteTypeSet::key($type); + } + + return $key; + } + +} diff --git a/tests/bench/GenericObjectTypeCheckBench.php b/tests/bench/GenericObjectTypeCheckBench.php new file mode 100644 index 00000000000..f7d53597b88 --- /dev/null +++ b/tests/bench/GenericObjectTypeCheckBench.php @@ -0,0 +1,88 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 64] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + $this->check = BenchContainer::create($this->scratch)->getByType(GenericObjectTypeCheck::class); + $this->check(self::shape(2)); + $this->type = self::shape($keys); + } + + /** + * A PHPDoc type that references no class cannot contain a generic object type. + * + * @return list + */ + #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '46.09K', cycles: '104.2K', memory: '0B')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '174.5K', cycles: '291.4K', memory: '0B')] + public function benchCheckTypeWithoutClasses(int $keys): array + { + if ($this->type === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->check($this->type); + } + + /** + * @return list + */ + private function check(Type $type): array + { + if ($this->check === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->check->check($type, 'a', 'b', 'c', 'd', 'e', 'f'); + } + + private static function shape(int $keys): ConstantArrayType + { + $keyTypes = []; + $valueTypes = []; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType('key' . $i); + $valueTypes[] = new UnionType([new IntegerType(), new StringType()]); + } + + return new ConstantArrayType($keyTypes, $valueTypes); + } + +} diff --git a/tests/bench/GenericParametersAcceptorResolverBench.php b/tests/bench/GenericParametersAcceptorResolverBench.php new file mode 100644 index 00000000000..d10bdbfa158 --- /dev/null +++ b/tests/bench/GenericParametersAcceptorResolverBench.php @@ -0,0 +1,86 @@ + */ + private array $argTypes = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function parameters(): iterable + { + foreach ([2, 16] as $parameters) { + yield 'parameters-' . $parameters => [$parameters]; + } + } + + #[Before] + public function boot(int $parameters): void + { + BenchContainer::create($this->scratch); + [$acceptor, $argTypes] = self::call(2); + GenericParametersAcceptorResolver::resolve($argTypes, $acceptor); + + [$this->acceptor, $this->argTypes] = self::call($parameters); + } + + /** + * A variant that is not an ExtendedParametersAcceptor, such as one an extension returns, is + * resolved into an extended one with all of its parameters copied. + */ + #[DataProvider(method: 'parameters')] + #[ExpectedFor(dataSet: 'parameters-2', instructions: '91.12K', cycles: '342.5K', memory: '4.328KiB')] + #[ExpectedFor(dataSet: 'parameters-16', instructions: '1.002M', cycles: '1.510M', memory: '10.73KiB')] + public function benchResolve(int $parameters): ParametersAcceptor + { + if ($this->acceptor === null) { + throw new LogicException('boot() did not run.'); + } + + return GenericParametersAcceptorResolver::resolve($this->argTypes, $this->acceptor); + } + + /** + * @return array{ParametersAcceptor, list} function f(T $p0, T $p1, ...): T called with integers + */ + private static function call(int $parameters): array + { + $template = TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('f'), 'T', null, TemplateTypeVariance::createInvariant()); + $params = []; + $argTypes = []; + for ($i = 0; $i < $parameters; $i++) { + $params[] = new NativeParameterReflection('p' . $i, false, $template, PassedByReference::createNo(), false, null); + $argTypes[] = new ConstantIntegerType($i); + } + + return [new FunctionVariant(new TemplateTypeMap(['T' => $template]), null, $params, false, $template), $argTypes]; + } + +} diff --git a/tests/bench/HashFunctionsReturnTypeExtensionBench.php b/tests/bench/HashFunctionsReturnTypeExtensionBench.php new file mode 100644 index 00000000000..6aa5921d8f0 --- /dev/null +++ b/tests/bench/HashFunctionsReturnTypeExtensionBench.php @@ -0,0 +1,28 @@ + */ + private string $class = HashFunctionsReturnTypeExtension::class; + + /** + * The container creates the extension for every analysis, also of code that calls no hash function. + * Since 2.3.0 its constructor takes no PHP version, PHP ignores the argument there. + */ + #[Expected(instructions: '1.640K', cycles: '19.02K', memory: '136B')] + public function benchCreate(): HashFunctionsReturnTypeExtension + { + // @phpstan-ignore new.noConstructor (2.2.13 to 2.2.17 take the PHP version) + return new $this->class(new PhpVersion(80400)); + } + +} diff --git a/tests/bench/IgnoredErrorHelperBench.php b/tests/bench/IgnoredErrorHelperBench.php new file mode 100644 index 00000000000..52692e1e78a --- /dev/null +++ b/tests/bench/IgnoredErrorHelperBench.php @@ -0,0 +1,100 @@ + */ + private array $errors = []; + + /** @var list */ + private array $files = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function ignoreErrors(): iterable + { + // entries with the identifier of the errors and several paths each, whose message does not match + yield 'paths-8' => [8, false]; + // entries with other identifiers than the errors + yield 'other-identifiers' => [1, true]; + } + + #[Before] + public function boot(int $paths, bool $otherIdentifiers): void + { + $entries = []; + for ($i = 0; $i < self::ENTRIES; $i++) { + $entryPaths = []; + for ($j = 0; $j < $paths; $j++) { + $entryPaths[] = '*/ignored' . $j . '/*'; + } + $entries[] = [ + 'message' => '#^Never reported ' . $i . '$#', + 'identifier' => $otherIdentifiers ? 'bench.other' . $i : 'bench.reported', + 'paths' => $entryPaths, + ]; + } + + $config = $this->scratch->path() . '/ignore-errors.neon'; + file_put_contents($config, Neon::encode([ + 'parameters' => [ + 'ignoreErrors' => $entries, + 'reportUnmatchedIgnoredErrors' => false, + ], + ], true)); + + $container = BenchContainer::create($this->scratch, [$config]); + $this->result = $container->getByType(IgnoredErrorHelper::class)->initialize(); + + for ($i = 0; $i < 8; $i++) { + $this->files[] = '/bench/src/File' . $i . '.php'; + } + for ($i = 0; $i < self::ERRORS; $i++) { + $file = $this->files[$i % 8]; + $this->errors[] = new Error('Reported ' . $i, $file, $i + 1, true, $file, null, null, null, null, 'bench.reported'); + } + + $this->result->process([$this->errors[0]], false, [$this->files[0]], false); + } + + /** + * Each error is matched against each ignoreErrors entry. + */ + #[DataProvider(method: 'ignoreErrors')] + #[ExpectedFor(dataSet: 'other-identifiers', instructions: '227.2K', cycles: '417.5K', memory: '3.438KiB')] + #[ExpectedFor(dataSet: 'paths-8', instructions: '21.83M', cycles: '29.11M', memory: '5.750KiB')] + public function benchProcess(int $paths, bool $otherIdentifiers): IgnoredErrorHelperProcessedResult + { + if ($this->result === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->result->process($this->errors, false, $this->files, false); + } + +} diff --git a/tests/bench/InitializerExprTypeResolverBench.php b/tests/bench/InitializerExprTypeResolverBench.php new file mode 100644 index 00000000000..8ea39d438e5 --- /dev/null +++ b/tests/bench/InitializerExprTypeResolverBench.php @@ -0,0 +1,111 @@ + + */ + public static function items(): iterable + { + foreach ([8, 128] as $items) { + yield 'items-' . $items => [$items]; + } + } + + #[Before] + public function boot(int $items): void + { + $this->resolver = BenchContainer::create($this->scratch)->getByType(InitializerExprTypeResolver::class); + $this->resolver->getType(self::ternary(4), InitializerExprContext::createEmpty()); + $this->resolver->getType(self::unpack(4), InitializerExprContext::createEmpty()); + + $this->ternary = self::ternary($items); + $this->unpack = self::unpack($items); + } + + /** + * A ternary with both branches, `[...] ? 1 : 2`, does not need the type of its condition. + */ + #[DataProvider(method: 'items')] + #[ExpectedFor(dataSet: 'items-8', instructions: '57.59K', cycles: '258.3K', memory: '2.516KiB')] + #[ExpectedFor(dataSet: 'items-128', instructions: '57.59K', cycles: '259.2K', memory: '2.516KiB')] + public function benchTernary(int $items): Type + { + if ($this->ternary === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver()->getType($this->ternary, InitializerExprContext::createEmpty()); + } + + /** + * `[...['k0' => 0, 'k1' => 1, ...]]`, whose string keys are kept since PHP 8.1 + */ + #[DataProvider(method: 'items')] + #[ExpectedFor(dataSet: 'items-8', instructions: '234.4K', cycles: '467.9K', memory: '10.89KiB')] + #[ExpectedFor(dataSet: 'items-128', instructions: '11.05M', cycles: '12.69M', memory: '143.9KiB')] + public function benchUnpackStringKeys(int $items): Type + { + if ($this->unpack === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver()->getType($this->unpack, InitializerExprContext::createEmpty()); + } + + private function resolver(): InitializerExprTypeResolver + { + if ($this->resolver === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver; + } + + private static function ternary(int $items): Expr + { + return new Expr\Ternary(self::array($items, false), new Int_(1), new Int_(2)); + } + + private static function unpack(int $items): Expr + { + return new Expr\Array_([new ArrayItem(self::array($items, true), unpack: true)]); + } + + private static function array(int $items, bool $stringKeys): Expr\Array_ + { + $arrayItems = []; + for ($i = 0; $i < $items; $i++) { + $arrayItems[] = new ArrayItem(new Int_($i), $stringKeys ? new String_('k' . $i) : null); + } + + return new Expr\Array_($arrayItems); + } + +} diff --git a/tests/bench/IntersectionTypeBench.php b/tests/bench/IntersectionTypeBench.php new file mode 100644 index 00000000000..1cbafe9863e --- /dev/null +++ b/tests/bench/IntersectionTypeBench.php @@ -0,0 +1,126 @@ + + */ + public static function members(): iterable + { + foreach ([16, 64] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + + $warmup = self::intersection('warmup', 4); + $warmup->isVoid(); + self::askWhatItIs($warmup); + $warmup->isSubTypeOf(new ObjectType(stdClass::class)); + $warmup->isAcceptedBy(new ObjectType(stdClass::class), true); + + $this->intersection = self::intersection('m', $members); + // what the intersection is was asked before, the counted call asks again + self::askWhatItIs($this->intersection); + } + + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchIsVoid(int $members): TrinaryLogic + { + return $this->subject()->isVoid(); + } + + /** + * The scope asks a type what it is many times. + */ + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchAskAgainWhatItIs(int $members): TrinaryLogic + { + return self::askWhatItIs($this->subject()); + } + + /** + * stdClass, the first member, is already a subtype of stdClass. + */ + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '10.59K', cycles: '94.13K', memory: '1.188KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '10.59K', cycles: '94.13K', memory: '1.188KiB')] + public function benchIsSubTypeOf(int $members): IsSuperTypeOfResult + { + return $this->subject()->isSubTypeOf(new ObjectType(stdClass::class)); + } + + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '230.8K', cycles: '467.4K', memory: '5.898KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '1.378M', cycles: '1.659M', memory: '11.98KiB')] + public function benchIsAcceptedBy(int $members): AcceptsResult + { + return $this->subject()->isAcceptedBy(new ObjectType(stdClass::class), true); + } + + private function subject(): IntersectionType + { + if ($this->intersection === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->intersection; + } + + private static function askWhatItIs(IntersectionType $type): TrinaryLogic + { + return $type->isBoolean() + ->or($type->isFloat()) + ->or($type->isInteger()) + ->or($type->isString()) + ->or($type->isArray()) + ->or($type->isList()) + ->or($type->isConstantArray()) + ->or($type->isOversizedArray()) + ->or($type->isIterableAtLeastOnce()) + ->or($type->isConstantScalarValue()); + } + + private static function intersection(string $prefix, int $members): IntersectionType + { + $types = [new ObjectType(stdClass::class)]; + for ($i = 1; $i < $members; $i++) { + $types[] = new HasMethodType($prefix . $i); + } + + return new IntersectionType($types); + } + +} diff --git a/tests/bench/IntersectionTypeMethodReflectionBench.php b/tests/bench/IntersectionTypeMethodReflectionBench.php new file mode 100644 index 00000000000..9e93539aed8 --- /dev/null +++ b/tests/bench/IntersectionTypeMethodReflectionBench.php @@ -0,0 +1,72 @@ + + */ + public static function methods(): iterable + { + foreach ([2, 8] as $methods) { + yield 'methods-' . $methods => [$methods]; + } + } + + #[Before] + public function boot(int $methods): void + { + $reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + $code = "namespace IntersectionTypeMethodReflectionBench;\n\n"; + for ($i = 0; $i < 8; $i++) { + $code .= sprintf("interface I%d\n{\n\n\t/** @return list */\n\tpublic function values(): array;\n\n}\n\n", $i); + } + BenchFixtures::declare($this->scratch, $code); + + $reflections = []; + for ($i = 0; $i < 8; $i++) { + $reflections[] = $reflectionProvider->getClass('IntersectionTypeMethodReflectionBench\I' . $i)->getNativeMethod('values'); + $reflections[$i]->getVariants(); + } + (new IntersectionTypeMethodReflection('values', [$reflections[6], $reflections[7]]))->getVariants(); + + $this->method = new IntersectionTypeMethodReflection('values', array_slice($reflections, 0, $methods)); + } + + /** + * The method of an intersection of interfaces that all declare it, such as `I0&I1`. + * + * @return list + */ + #[DataProvider(method: 'methods')] + #[ExpectedFor(dataSet: 'methods-2', instructions: '186.4K', cycles: '493.4K', memory: '5.094KiB')] + #[ExpectedFor(dataSet: 'methods-8', instructions: '1.070M', cycles: '1.642M', memory: '7.250KiB')] + public function benchGetVariants(int $methods): array + { + if ($this->method === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->method->getVariants(); + } + +} diff --git a/tests/bench/IntersectionTypeQueriesBench.php b/tests/bench/IntersectionTypeQueriesBench.php new file mode 100644 index 00000000000..5889a798c66 --- /dev/null +++ b/tests/bench/IntersectionTypeQueriesBench.php @@ -0,0 +1,93 @@ +scratch); + + // non-empty-list with offset 0 + $this->array = new IntersectionType([ + new ArrayType(new IntegerType(), new MixedType()), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + new HasOffsetType(new ConstantIntegerType(0)), + ]); + $this->string = new IntersectionType([ + new StringType(), + new AccessoryNonEmptyStringType(), + new AccessoryNumericStringType(), + new AccessoryLowercaseStringType(), + ]); + $this->array->isOffsetAccessible(); + $this->string->describe(VerbosityLevel::precise()); + } + + #[Expected(instructions: '349.9K', cycles: '365.6K', memory: '0B')] + public function benchIsOffsetAccessible(): TrinaryLogic + { + if ($this->array === null) { + throw new LogicException('boot() did not run.'); + } + + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->array->isOffsetAccessible(); + } + + return $result; + } + + #[Expected(instructions: '1.180M', cycles: '1.199M', memory: '0B')] + public function benchDescribe(): string + { + if ($this->string === null) { + throw new LogicException('boot() did not run.'); + } + + $description = ''; + for ($i = 0; $i < self::CALLS; $i++) { + $description = $this->string->describe(VerbosityLevel::precise()); + } + + return $description; + } + +} diff --git a/tests/bench/IsSuperTypeOfResultBench.php b/tests/bench/IsSuperTypeOfResultBench.php new file mode 100644 index 00000000000..d799cc987be --- /dev/null +++ b/tests/bench/IsSuperTypeOfResultBench.php @@ -0,0 +1,165 @@ + */ + private array $results = []; + + /** @var list */ + private array $strings = []; + + private StringType $string; + + private UnionType $union; + + public function __construct(private ScratchDirectory $scratch) + { + $this->string = new StringType(); + $this->union = new UnionType([new ConstantStringType('a'), new ConstantStringType('b')]); + } + + /** + * @return iterable + */ + public static function results(): iterable + { + foreach ([16, 256] as $results) { + yield 'results-' . $results => [$results]; + } + } + + #[Before] + public function boot(int $results): void + { + BenchContainer::create($this->scratch); + + IsSuperTypeOfResult::maxMin(...self::maybes(4)); + IsSuperTypeOfResult::extremeIdentity(...self::maybes(4)); + $this->string->isSuperTypeOf(new UnionType(self::strings('warmup', 4))); + self::accessoriesIsSuperTypeOf(self::accessories(), self::strings('warmup', 4)); + + $this->results = self::maybes($results); + $this->strings = self::strings('string', $results); + $this->union = new UnionType(self::strings('string', $results)); + } + + #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '17.77K', cycles: '64.88K', memory: '1.102KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '234.9K', cycles: '392.2K', memory: '24.16KiB')] + public function benchMaxMin(int $results): IsSuperTypeOfResult + { + return IsSuperTypeOfResult::maxMin(...$this->results); + } + + #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '19.58K', cycles: '75.07K', memory: '1.469KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '249.9K', cycles: '422.3K', memory: '32.22KiB')] + public function benchExtremeIdentity(int $results): IsSuperTypeOfResult + { + return IsSuperTypeOfResult::extremeIdentity(...$this->results); + } + + /** + * StringType::isSuperTypeOf() is asked about each constant string of the union. + */ + #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '41.64K', cycles: '128.5K', memory: '1.469KiB')] + #[ExpectedFor(dataSet: 'results-256', instructions: '548.1K', cycles: '720.9K', memory: '32.22KiB')] + public function benchStringIsSuperTypeOfUnion(int $results): IsSuperTypeOfResult + { + return $this->string->isSuperTypeOf($this->union); + } + + /** + * Each accessory type is asked about each constant string. + */ + #[DataProvider(method: 'results')] + #[ExpectedFor(dataSet: 'results-16', instructions: '262.0K', cycles: '350.0K', memory: '496B')] + #[ExpectedFor(dataSet: 'results-256', instructions: '4.107M', cycles: '4.341M', memory: '496B')] + public function benchAccessoriesIsSuperTypeOf(int $results): int + { + return self::accessoriesIsSuperTypeOf(self::accessories(), $this->strings); + } + + /** + * @param list $accessories + * @param list $strings + */ + private static function accessoriesIsSuperTypeOf(array $accessories, array $strings): int + { + $yes = 0; + foreach ($accessories as $accessory) { + foreach ($strings as $string) { + if (!$accessory->isSuperTypeOf($string)->yes()) { + continue; + } + + $yes++; + } + } + + return $yes; + } + + /** + * @return list + */ + private static function accessories(): array + { + return [ + new AccessoryNonEmptyStringType(), + new AccessoryNonFalsyStringType(), + new AccessoryLowercaseStringType(), + new AccessoryUppercaseStringType(), + new AccessoryNumericStringType(), + new AccessoryLiteralStringType(), + ]; + } + + /** + * @return list + */ + private static function maybes(int $count): array + { + $results = []; + for ($i = 0; $i < $count; $i++) { + $results[] = new IsSuperTypeOfResult(TrinaryLogic::createMaybe(), []); + } + + return $results; + } + + /** + * @return list + */ + private static function strings(string $prefix, int $count): array + { + $strings = []; + for ($i = 0; $i < $count; $i++) { + $strings[] = new ConstantStringType($prefix . $i); + } + + return $strings; + } + +} diff --git a/tests/bench/IsSuperTypeOfResultOperationsBench.php b/tests/bench/IsSuperTypeOfResultOperationsBench.php new file mode 100644 index 00000000000..a9965c8da3e --- /dev/null +++ b/tests/bench/IsSuperTypeOfResultOperationsBench.php @@ -0,0 +1,76 @@ +yes = new IsSuperTypeOfResult(TrinaryLogic::createYes(), []); + $this->maybe = new IsSuperTypeOfResult(TrinaryLogic::createMaybe(), []); + $this->no = new IsSuperTypeOfResult(TrinaryLogic::createNo(), []); + } + + #[Expected(instructions: '3.151M', cycles: '3.208M', memory: '576B')] + public function benchAnd(): IsSuperTypeOfResult + { + $result = $this->yes; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->yes->and($this->maybe); + } + + return $result; + } + + #[Expected(instructions: '3.151M', cycles: '3.209M', memory: '576B')] + public function benchOr(): IsSuperTypeOfResult + { + $result = $this->no; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->no->or($this->maybe); + } + + return $result; + } + + #[Expected(instructions: '5.538M', cycles: '5.600M', memory: '792B')] + public function benchMaxMin(): IsSuperTypeOfResult + { + $result = $this->no; + for ($i = 0; $i < self::CALLS; $i++) { + $result = IsSuperTypeOfResult::maxMin($this->no, $this->maybe, $this->no); + } + + return $result; + } + + #[Expected(instructions: '1.807M', cycles: '1.851M', memory: '144B')] + public function benchNegate(): IsSuperTypeOfResult + { + $result = $this->no; + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->maybe->negate(); + } + + return $result; + } + +} diff --git a/tests/bench/LazyInternalScopeFactoryBench.php b/tests/bench/LazyInternalScopeFactoryBench.php new file mode 100644 index 00000000000..23096b79e0f --- /dev/null +++ b/tests/bench/LazyInternalScopeFactoryBench.php @@ -0,0 +1,54 @@ +scratch); + $this->factory = $container->getByType(InternalScopeFactoryFactory::class)->create(null); + $this->context = ScopeContext::create(__FILE__); + $this->factory->create($this->context); + } + + #[Expected(instructions: '1.962M', cycles: '2.006M', memory: '1.000KiB')] + public function benchCreate(): MutatingScope + { + if ($this->factory === null || $this->context === null) { + throw new LogicException('boot() did not run.'); + } + + $scope = $this->factory->create($this->context); + for ($i = 1; $i < self::SCOPES; $i++) { + $scope = $this->factory->create($this->context); + } + + return $scope; + } + +} diff --git a/tests/bench/LoaderFactoryBench.php b/tests/bench/LoaderFactoryBench.php new file mode 100644 index 00000000000..e8ca5ffb0da --- /dev/null +++ b/tests/bench/LoaderFactoryBench.php @@ -0,0 +1,51 @@ +scratch); + $root = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3); + $this->loaderFactory = new LoaderFactory(new FileHelper($root), $root, $root, null, []); + $this->configFile = $root . '/conf/config.level8.neon'; + $this->loaderFactory->createLoader()->load($this->configFile); + } + + /** + * Building the container loads the same config files more than once, with all they include. + * + * @return mixed[] + */ + #[Expected(instructions: '549.6K', cycles: '1.044M', memory: '41.72KiB')] + public function benchLoadConfigAgain(): array + { + if ($this->loaderFactory === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->loaderFactory->createLoader()->load($this->configFile); + } + +} diff --git a/tests/bench/MemoizingReflectionProviderBench.php b/tests/bench/MemoizingReflectionProviderBench.php new file mode 100644 index 00000000000..5870272a3db --- /dev/null +++ b/tests/bench/MemoizingReflectionProviderBench.php @@ -0,0 +1,67 @@ +reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + BenchFixtures::declare($this->scratch, <<<'PHP' + namespace MemoizingReflectionProviderBench; + + class Warmup {} + class Member {} + PHP); + $this->reflectionProvider->hasClass('MemoizingReflectionProviderBench\Warmup'); + $this->reflectionProvider->getClass('MemoizingReflectionProviderBench\Warmup'); + $this->reflectionProvider->hasClass('MemoizingReflectionProviderBench\Member'); + $this->reflectionProvider->getClass('MemoizingReflectionProviderBench\Member'); + } + + #[Expected(instructions: '2.166K', cycles: '26.71K', memory: '64B')] + public function benchHasClassAgain(): bool + { + return $this->reflectionProvider()->hasClass('MemoizingReflectionProviderBench\Member'); + } + + /** + * The memo is case-insensitive since 2.1.34, like class names. + */ + #[Expected(instructions: '2.166K', cycles: '26.82K', memory: '64B')] + public function benchHasClassInOtherCase(): bool + { + return $this->reflectionProvider()->hasClass('memoizingReflectionProviderBench\member'); + } + + #[Expected(instructions: '2.345K', cycles: '29.09K', memory: '64B')] + public function benchGetClassAgain(): ClassReflection + { + return $this->reflectionProvider()->getClass('MemoizingReflectionProviderBench\Member'); + } + + private function reflectionProvider(): ReflectionProvider + { + if ($this->reflectionProvider === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->reflectionProvider; + } + +} diff --git a/tests/bench/MutatingScopeArrayDimFetchBench.php b/tests/bench/MutatingScopeArrayDimFetchBench.php new file mode 100644 index 00000000000..41cd18a5890 --- /dev/null +++ b/tests/bench/MutatingScopeArrayDimFetchBench.php @@ -0,0 +1,91 @@ + + */ + public static function arrays(): iterable + { + // 0 is array, otherwise a shape with that many keys + yield 'general-array' => [0]; + yield 'shape-32' => [32]; + } + + #[Before] + public function boot(int $keys): void + { + $container = BenchContainer::create($this->scratch); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($this->scratch->path() . '/file.php')); + foreach ([0, 4] as $warmupKeys) { + $warmup = $scope->assignVariable('warmup', self::arrayType($warmupKeys), self::arrayType($warmupKeys), TrinaryLogic::createYes()); + $warmup->specifyExpressionType(self::dimFetch('warmup'), new ConstantIntegerType(1), new IntegerType(), TrinaryLogic::createYes()); + } + + $this->scope = $scope->assignVariable('a', self::arrayType($keys), self::arrayType($keys), TrinaryLogic::createYes()); + } + + /** + * Specifying an offset also specifies the array: an array is offset-accessible already. + */ + #[DataProvider(method: 'arrays')] + #[ExpectedFor(dataSet: 'general-array', instructions: '132.8K', cycles: '508.2K', memory: '4.516KiB')] + #[ExpectedFor(dataSet: 'shape-32', instructions: '196.2K', cycles: '683.9K', memory: '6.398KiB')] + public function benchSpecifyArrayDimFetchType(int $keys): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope->specifyExpressionType(self::dimFetch('a'), new ConstantIntegerType(1), new IntegerType(), TrinaryLogic::createYes()); + } + + private static function dimFetch(string $variable): ArrayDimFetch + { + return new ArrayDimFetch(new Variable($variable), new String_('k0')); + } + + private static function arrayType(int $keys): Type + { + if ($keys === 0) { + return new ArrayType(new StringType(), new IntegerType()); + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + for ($i = 0; $i < $keys; $i++) { + $builder->setOffsetValueType(new ConstantStringType('k' . $i), new IntegerType()); + } + + return $builder->getArray(); + } + +} diff --git a/tests/bench/MutatingScopeBench.php b/tests/bench/MutatingScopeBench.php new file mode 100644 index 00000000000..f775fabeaba --- /dev/null +++ b/tests/bench/MutatingScopeBench.php @@ -0,0 +1,274 @@ +scratch); + $warmup = BenchScope::analyse($container, $this->scratch, 'warmup', self::code()); + $this->warmupScope = $warmup->scope('marker'); + $warmup->scope('markerConditional')->filterByTruthyValue(self::notNull()); + $counted = BenchScope::analyse($container, $this->scratch, 'counted', self::code()); + $this->scope = $counted->scope('marker'); + $this->conditionalScope = $counted->scope('markerConditional'); + + foreach ([$this->warmupScope, $this->scope] as $i => $scope) { + // the counted scope has resolved what pushInFunctionCall() is to remember + $scope->getType(self::resolvedExpr()); + if ($i !== 0) { + continue; + } + $scope->getVariableType('i'); + $scope->getType(self::coalesce()); + $scope->getKeepVoidType(new Expr\Variable('str')); + $scope->getKeepVoidType(self::funcCall('trim')); + $scope->getType(self::funcCall('ucfirst')); + $scope->pushInFunctionCall(null, null, true)->getType(self::resolvedExpr()); + $scope->specifyExpressionType(new String_('x'), new StringType(), new StringType(), TrinaryLogic::createYes()); + $scope->filterByTruthyValue(self::condition()); + $scope->assignExpression(self::propertyFetch(0), new IntegerType(), new IntegerType()); + $scope->restoreThis($scope); + } + } + + /** + * The certainty is looked up once (#4726). + */ + #[Expected(instructions: '2.247K', cycles: '26.67K', memory: '32B')] + public function benchGetVariableType(): Type + { + return $this->scope()->getVariableType('i'); + } + + /** + * Only the cases with a left side that may be set narrow the scope by isset() (#4661). + */ + #[Expected(instructions: '965.3K', cycles: '2.091M', memory: '93.89KiB')] + public function benchGetTypeOfCoalesceWithNullLeft(): Type + { + return $this->scope()->getType(self::coalesce()); + } + + /** + * @return iterable + */ + public static function keepVoidExprs(): iterable + { + // not a call, and a call whose type has no null in it: the type without void is the same (#4735) + yield 'variable' => ['variable']; + yield 'func-call' => ['func-call']; + } + + #[DataProvider(method: 'keepVoidExprs')] + #[ExpectedFor(dataSet: 'func-call', instructions: '389.4K', cycles: '1.305M', memory: '15.49KiB')] + #[ExpectedFor(dataSet: 'variable', instructions: '7.615K', cycles: '68.25K', memory: '128B')] + public function benchGetKeepVoidType(string $expr): Type + { + return $this->scope()->getKeepVoidType($expr === 'variable' ? new Expr\Variable('str') : self::funcCall('trim')); + } + + /** + * A function without a dynamic return type extension: the extensions for it are remembered (#4644). + */ + #[Expected(instructions: '377.5K', cycles: '1.270M', memory: '15.49KiB')] + public function benchGetTypeOfFuncCall(): Type + { + return $this->scope()->getType(self::funcCall('ucfirst')); + } + + /** + * Creates a scope, without getting the parser from the container each time (#4672). + */ + #[Expected(instructions: '11.82K', cycles: '76.11K', memory: '944B')] + public function benchPushInFunctionCall(): MutatingScope + { + return $this->scope()->pushInFunctionCall(null, null, true); + } + + /** + * An argument's type resolved before is not resolved again (#4648). + */ + #[Expected(instructions: '52.32K', cycles: '243.5K', memory: '3.086KiB')] + public function benchPushInFunctionCallGetType(): Type + { + return $this->scope()->pushInFunctionCall(null, null, true)->getType(self::resolvedExpr()); + } + + /** + * A scalar's type is not stored (#4663). + */ + #[Expected(instructions: '3.965K', cycles: '38.75K', memory: '176B')] + public function benchSpecifyScalarType(): MutatingScope + { + return $this->scope()->specifyExpressionType(new String_('x'), new StringType(), new StringType(), TrinaryLogic::createYes()); + } + + /** + * The keys of the narrowed expressions are not printed again (#4691). + */ + #[Expected(instructions: '2.047M', cycles: '3.874M', memory: '109.7KiB')] + public function benchFilterByTruthyValue(): MutatingScope + { + return $this->scope()->filterByTruthyValue(self::condition()); + } + + /** + * The scope remembers what the properties assigned in `if ($s !== null)` are when it holds, + * but not what they are otherwise, so they are not intersected with anything (#4690). + */ + #[Expected(instructions: '611.7K', cycles: '1.427M', memory: '26.10KiB')] + public function benchFilterByConditionOfConditionalExpressions(): MutatingScope + { + if ($this->conditionalScope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->conditionalScope->filterByTruthyValue(self::notNull()); + } + + /** + * Invalidates the method calls on $this without searching each tracked expression (#4692). + */ + #[Expected(instructions: '324.9K', cycles: '640.5K', memory: '34.63KiB')] + public function benchAssignPropertyFetch(): MutatingScope + { + return $this->scope()->assignExpression(self::propertyFetch(0), new IntegerType(), new IntegerType()); + } + + /** + * Finds the expressions on $this by their keys, not by searching them (#4697). + */ + #[Expected(instructions: '65.96K', cycles: '185.2K', memory: '16.61KiB')] + public function benchRestoreThis(): MutatingScope + { + $scope = $this->scope(); + + return $scope->restoreThis($scope); + } + + private function scope(): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope; + } + + private static function code(): string + { + $properties = []; + $assigns = []; + $conditionalAssigns = []; + for ($i = 0; $i < self::PROPERTIES; $i++) { + $properties[] = sprintf('public int $p%d;', $i); + $assigns[] = sprintf('$this->p%d = $i;', $i); + $conditionalAssigns[] = sprintf('$this->p%d = %d;', $i, $i); + } + + return sprintf(<<<'PHP' + + */ + public static function branches(): iterable + { + // many variables both branches share the same holders of, one variable differs + foreach ([16, 256] as $variables) { + yield 'shared-holders-' . $variables => ['shared-holders', $variables]; + } + // every variable has a different union in each branch: each is a guard of the others + foreach ([4, 16] as $variables) { + yield 'differing-unions-' . $variables => ['differing-unions', $variables]; + } + // the variables of one branch are subtypes of the other's, a constant array differs + yield 'absorbed-targets-16' => ['absorbed-targets', 16]; + // the same types, certainly defined in one branch and maybe defined in the other + yield 'maybe-defined-64' => ['maybe-defined', 64]; + // variables defined only in one branch + yield 'one-sided-64' => ['one-sided', 64]; + } + + #[Before] + public function boot(string $shape, int $variables): void + { + $container = BenchContainer::create($this->scratch); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)); + + // the warm-up merges other variables, so it fills no cache the benchmark reads + [$ours, $theirs] = self::createBranches($scope, $shape, 'warmup', 2); + $ours->mergeWith($theirs); + + [$this->ours, $this->theirs] = self::createBranches($scope, $shape, 'v', $variables); + } + + #[DataProvider(method: 'branches')] + #[ExpectedFor(dataSet: 'absorbed-targets-16', instructions: '3.036M', cycles: '4.672M', memory: '58.13KiB')] + #[ExpectedFor(dataSet: 'differing-unions-4', instructions: '3.576M', cycles: '4.634M', memory: '74.34KiB')] + #[ExpectedFor(dataSet: 'differing-unions-16', instructions: '16.78M', cycles: '20.46M', memory: '599.2KiB')] + #[ExpectedFor(dataSet: 'maybe-defined-64', instructions: '747.8K', cycles: '1.081M', memory: '18.58KiB')] + #[ExpectedFor(dataSet: 'one-sided-64', instructions: '1.405M', cycles: '2.004M', memory: '50.22KiB')] + #[ExpectedFor(dataSet: 'shared-holders-16', instructions: '153.0K', cycles: '426.3K', memory: '9.445KiB')] + #[ExpectedFor(dataSet: 'shared-holders-256', instructions: '1.372M', cycles: '2.123M', memory: '121.9KiB')] + public function benchMergeWith(string $shape, int $variables): MutatingScope + { + if ($this->ours === null || $this->theirs === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->ours->mergeWith($this->theirs); + } + + /** + * @return array{MutatingScope, MutatingScope} + */ + private static function createBranches(MutatingScope $scope, string $shape, string $prefix, int $variables): array + { + $yes = TrinaryLogic::createYes(); + $ours = $scope; + $theirs = $scope; + switch ($shape) { + case 'shared-holders': + for ($i = 0; $i < $variables; $i++) { + $type = self::strings($prefix . $i . '_', 8); + $scope = $scope->assignVariable($prefix . $i, $type, $type, $yes); + } + $ours = self::assign($scope, $prefix . 'x', new ConstantIntegerType(1)); + $theirs = self::assign($scope, $prefix . 'x', new ConstantIntegerType(2)); + break; + case 'differing-unions': + for ($i = 0; $i < $variables; $i++) { + $ours = self::assign($ours, $prefix . $i, self::strings($prefix . $i . 'a', 16)); + $theirs = self::assign($theirs, $prefix . $i, self::strings($prefix . $i . 'b', 16)); + } + break; + case 'absorbed-targets': + for ($i = 0; $i < $variables; $i++) { + $ours = self::assign($ours, $prefix . $i, new ConstantIntegerType($i)); + $theirs = self::assign($theirs, $prefix . $i, new IntegerType()); + } + $ours = self::assign($ours, $prefix . 'array', self::constantArray($prefix . 'a', 16)); + $theirs = self::assign($theirs, $prefix . 'array', self::constantArray($prefix . 'b', 16)); + break; + case 'maybe-defined': + for ($i = 0; $i < $variables; $i++) { + // the same type object, as when one branch assigned what the other only might have + $type = self::strings($prefix . $i . '_', 8); + $ours = self::assign($ours, $prefix . $i, $type); + $theirs = $theirs->assignVariable($prefix . $i, $type, $type, TrinaryLogic::createMaybe()); + } + break; + case 'one-sided': + for ($i = 0; $i < $variables; $i++) { + $ours = self::assign($ours, $prefix . $i, new ConstantIntegerType($i)); + } + break; + default: + throw new LogicException($shape); + } + + return [$ours, $theirs]; + } + + private static function assign(MutatingScope $scope, string $name, Type $type): MutatingScope + { + return $scope->assignVariable($name, $type, $type, TrinaryLogic::createYes()); + } + + private static function strings(string $prefix, int $count): Type + { + $types = []; + for ($i = 0; $i < $count; $i++) { + $types[] = new ConstantStringType($prefix . $i); + } + + return TypeCombinator::union(...$types); + } + + private static function constantArray(string $prefix, int $count): ConstantArrayType + { + $keys = []; + $values = []; + for ($i = 0; $i < $count; $i++) { + $keys[] = new ConstantStringType($prefix . $i); + $values[] = new ConstantIntegerType($i); + } + + return new ConstantArrayType($keys, $values); + } + +} diff --git a/tests/bench/MutatingScopeExpressionsBench.php b/tests/bench/MutatingScopeExpressionsBench.php new file mode 100644 index 00000000000..23c190b4657 --- /dev/null +++ b/tests/bench/MutatingScopeExpressionsBench.php @@ -0,0 +1,352 @@ + + */ + public static function expressions(): iterable + { + foreach ([16, 128] as $expressions) { + yield 'expressions-' . $expressions => [$expressions]; + } + } + + #[Before] + public function boot(int $expressions): void + { + $container = BenchContainer::create($this->scratch); + $this->scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)); + } + + /** + * Without openssl_error_string() or file functions remembered in the scope, there is + * nothing to forget. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchAfterOpenSslAndClearstatcacheCall(int $expressions): MutatingScope + { + $scope = $this->meter->exclude(fn (): MutatingScope => $this->scopeWithProperties($expressions)); + + return $scope->afterOpenSslCall('openssl_sign')->afterClearstatcacheCall(); + } + + /** + * Removing never from an expression changes nothing and does not need its type. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchRemoveNeverFromExpression(int $expressions): MutatingScope + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new ArrayDimFetch(new Variable('v' . $i), new String_('key')); + } + + return [$this->scopeWithArrays($expressions), $exprs]; + }); + + $never = new NeverType(); + foreach ($exprs as $expr) { + $scope = $scope->removeTypeFromExpression($expr, $never); + } + + return $scope; + } + + /** + * Narrowing $array['key'] also narrows $array to the types that can be accessed by an offset. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchSpecifyArrayDimFetch(int $expressions): MutatingScope + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new ArrayDimFetch(new Variable('v' . $i), $i % 2 === 0 ? new Int_($i) : new String_('k' . $i)); + } + + return [$this->scopeWithArrays($expressions), $exprs]; + }); + + $int = new IntegerType(); + foreach ($exprs as $expr) { + $scope = $scope->specifyExpressionType($expr, $int, $int, TrinaryLogic::createYes()); + } + + return $scope; + } + + /** + * A method called on a union of objects that all have it. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetMethodReflectionOnUnion(int $expressions): int + { + $scope = $this->scope(); + $union = $this->meter->exclude(static function () use ($scope): Type { + $types = []; + foreach (self::COUNTABLE_CLASSES as $class) { + $types[] = new ObjectType($class); + } + $union = TypeCombinator::union(...$types); + // fills the reflection caches + $scope->getMethodReflection($union, 'count'); + $scope->getMethodReflection($union, 'valid'); + + return $union; + }); + + $found = 0; + for ($i = 0; $i < $expressions; $i++) { + $found += $scope->getMethodReflection($union, 'count') !== null ? 1 : 0; + $found += $scope->getMethodReflection($union, 'valid') !== null ? 1 : 0; + } + + return $found; + } + + /** + * Assigning $x forgets the remembered expressions that contain it, after searching each + * one whose key contains "$x" for it. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchInvalidateExpression(int $expressions): MutatingScope + { + $scope = $this->meter->exclude(function () use ($expressions): MutatingScope { + $scope = $this->scope(); + $int = new IntegerType(); + for ($i = 0; $i < $expressions; $i++) { + // $o->m0($x, $y)->p0, every other one with $xx, which only passes the substring prefilter + $expr = new PropertyFetch( + new MethodCall(new Variable('o'), 'm' . $i, [new Arg(new Variable($i % 2 === 0 ? 'x' : 'xx')), new Arg(new Variable('y'))]), + 'p' . $i, + ); + $scope = $scope->specifyExpressionType($expr, $int, $int, TrinaryLogic::createYes()); + } + + return $scope; + }); + + return $scope->invalidateExpression(new Variable('x')); + } + + /** + * Variables are by far the most common expressions that get a key. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetNodeKeyOfVariables(int $expressions): int + { + [$getNodeKey, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new Variable('variable' . $i); + } + + // private before 2.2 + $getNodeKey = (new ReflectionMethod(MutatingScope::class, 'getNodeKey'))->getClosure($this->scope()); + $getNodeKey(new Variable('warmup')); + + return [$getNodeKey, $exprs]; + }); + + /** @var Closure(Expr): string $getNodeKey */ + $length = 0; + foreach ($exprs as $expr) { + $length += strlen($getNodeKey($expr)); + } + + return $length; + } + + /** + * $object[$dim] on an ArrayAccess object is the type of $object->offsetGet($dim). + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetTypeOfArrayAccessDimFetch(int $expressions): int + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $type = new GenericObjectType(ArrayObject::class, [new StringType(), new IntegerType()]); + $scope = $this->scope() + ->assignVariable('object', $type, $type, TrinaryLogic::createYes()) + ->assignVariable('key', new StringType(), new StringType(), TrinaryLogic::createYes()); + $scope->getType(new ArrayDimFetch(new Variable('object'), new Concat(new Variable('key'), new String_('warmup')))); + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new ArrayDimFetch(new Variable('object'), new Concat(new Variable('key'), new String_('k' . $i))); + } + + return [$scope, $exprs]; + }); + + $integers = 0; + foreach ($exprs as $expr) { + $integers += $scope->getType($expr)->isInteger()->yes() ? 1 : 0; + } + + return $integers; + } + + /** + * The type of `new ArrayObject($array)` infers the template types of the class from the + * arguments of the constructor. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetTypeOfGenericNew(int $expressions): int + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $scope = $this->scopeWithArrays($expressions + 1); + $scope->getType(new New_(new FullyQualified(ArrayObject::class), [new Arg(new Variable('v' . $expressions))])); + $scope->getType(new New_(new FullyQualified(ArrayIterator::class), [new Arg(new Variable('v' . $expressions))])); + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new New_(new FullyQualified($i % 2 === 0 ? ArrayObject::class : ArrayIterator::class), [new Arg(new Variable('v' . $i))]); + } + + return [$scope, $exprs]; + }); + + $classes = 0; + foreach ($exprs as $expr) { + $classes += count($scope->getType($expr)->getObjectClassNames()); + } + + return $classes; + } + + /** + * Each expression is resolved by the handler of its class. + */ + #[DataProvider(method: 'expressions')] + #[Linear(size: 'expressions')] + public function benchGetTypeOfFreshExpressions(int $expressions): int + { + [$scope, $exprs] = $this->meter->exclude(function () use ($expressions): array { + $scope = $this->scope()->assignVariable('i', new IntegerType(), new IntegerType(), TrinaryLogic::createYes()); + $scope->getType(new Plus(new Variable('i'), new Int_(-1))); + $exprs = []; + for ($i = 0; $i < $expressions; $i++) { + $exprs[] = new Plus(new Variable('i'), new Int_($i)); + } + + return [$scope, $exprs]; + }); + + $integers = 0; + foreach ($exprs as $expr) { + $integers += $scope->getType($expr)->isInteger()->yes() ? 1 : 0; + } + + return $integers; + } + + private function scope(): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope; + } + + /** + * $v0 to $vN, lists of strings + */ + private function scopeWithArrays(int $variables): MutatingScope + { + $scope = $this->scope(); + $type = new ArrayType(new IntegerType(), new StringType()); + for ($i = 0; $i < $variables; $i++) { + $scope = $scope->assignVariable('v' . $i, $type, $type, TrinaryLogic::createYes()); + } + + return $scope; + } + + /** + * $o->p0 to $o->pN remembered as 1 + */ + private function scopeWithProperties(int $properties): MutatingScope + { + $scope = $this->scope(); + $type = new ConstantIntegerType(1); + for ($i = 0; $i < $properties; $i++) { + $scope = $scope->specifyExpressionType(new PropertyFetch(new Variable('o'), 'p' . $i), $type, $type, TrinaryLogic::createYes()); + } + + return $scope; + } + +} diff --git a/tests/bench/MutatingScopeImpureCallsBench.php b/tests/bench/MutatingScopeImpureCallsBench.php new file mode 100644 index 00000000000..bba9670d348 --- /dev/null +++ b/tests/bench/MutatingScopeImpureCallsBench.php @@ -0,0 +1,57 @@ +value(); self::create(); helper(); ', 16); + $analysed = AnalysedCode::analyse( + $this->scratch, + 'nodes(Return_::class); + $scope = $returns[count($returns) - 2][1]; + $otherScope = $returns[count($returns) - 1][1]; + $this->scope = $scope; + $this->otherScope = $otherScope; + $scope->mergeWith($scope); + } + + /** + * Merging two branches after calls to user functions and methods that are possibly impure. + */ + #[Expected(instructions: '75.45K', cycles: '307.0K', memory: '3.523KiB')] + public function benchMergeWithAfterPossiblyImpureCalls(): MutatingScope + { + if ($this->scope === null || $this->otherScope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope->mergeWith($this->otherScope); + } + +} diff --git a/tests/bench/MutatingScopeMergeBench.php b/tests/bench/MutatingScopeMergeBench.php new file mode 100644 index 00000000000..18ddc89ed8d --- /dev/null +++ b/tests/bench/MutatingScopeMergeBench.php @@ -0,0 +1,116 @@ + + */ + public static function merges(): iterable + { + // a scope merged with itself (#4640) + yield 'same-scope' => ['same-scope']; + // variables only one side defines become maybe-defined, unless they are super-globals (#4662) + yield 'variables-on-one-side' => ['variables-on-one-side']; + // other expressions only one side tracks are forgotten (#4719, #4720) + yield 'property-fetches-on-one-side' => ['property-fetches-on-one-side']; + } + + #[Before] + public function boot(string $merge): void + { + $container = BenchContainer::create($this->scratch); + $scopes = BenchScope::analyse($container, $this->scratch, 'merge', self::code()); + $markers = [ + 'same-scope' => ['markerVariables', 'markerVariables'], + 'variables-on-one-side' => ['markerVariables', 'markerVariablesBefore'], + 'property-fetches-on-one-side' => ['markerProperties', 'markerPropertiesBefore'], + ]; + if (!isset($markers[$merge])) { + throw new LogicException('Unknown merge ' . $merge . '.'); + } + $this->ours = $scopes->scope($markers[$merge][0]); + $this->theirs = $scopes->scope($markers[$merge][1]); + + // the analysis merges the same expressions again and again, the first merge may remember what it learned about them + $this->ours->mergeWith($this->theirs); + } + + #[DataProvider(method: 'merges')] + #[ExpectedFor(dataSet: 'property-fetches-on-one-side', instructions: '1.749M', cycles: '2.291M', memory: '82.25KiB')] + #[ExpectedFor(dataSet: 'same-scope', instructions: '1.083K', cycles: '11.64K', memory: '0B')] + #[ExpectedFor(dataSet: 'variables-on-one-side', instructions: '890.9K', cycles: '1.271M', memory: '53.26KiB')] + public function benchMergeWith(string $merge): MutatingScope + { + if ($this->ours === null || $this->theirs === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->ours->mergeWith($this->theirs); + } + + private static function code(): string + { + $properties = []; + $propertyAssigns = []; + $variableAssigns = []; + for ($i = 0; $i < self::ASSIGNS; $i++) { + $properties[] = sprintf('public int $p%d;', $i); + $propertyAssigns[] = sprintf('$this->p%d = $i;', $i); + $variableAssigns[] = sprintf('$v%d = $i;', $i); + } + + return sprintf(<<<'PHP' + container = BenchContainer::create($this->scratch); + $this->container->getParameter('tmpDir'); + } + + #[Expected(instructions: '1.051K', cycles: '12.71K', memory: '0B')] + public function benchGetParameter(): mixed + { + if ($this->container === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->container->getParameter('tmpDir'); + } + + #[Expected(instructions: '1.529K', cycles: '16.16K', memory: '0B')] + public function benchHasParameter(): bool + { + if ($this->container === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->container->hasParameter('tmpDir'); + } + +} diff --git a/tests/bench/NodeScopeResolverBench.php b/tests/bench/NodeScopeResolverBench.php new file mode 100644 index 00000000000..52c4101711b --- /dev/null +++ b/tests/bench/NodeScopeResolverBench.php @@ -0,0 +1,152 @@ + */ + private array $stmts = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function statements(): iterable + { + // the assigned types have no falsey part, so there is nothing to remember for if ($v) (#4705, #4716) + yield 'assign-non-falsy' => [<<<'PHP' + $v0 = 'a'; $v1 = 'b'; $v2 = 'c'; $v3 = 'd'; $v4 = 'e'; $v5 = 'f'; $v6 = 'g'; $v7 = 'h'; + $v8 = [1]; $v9 = [2]; $v10 = [3]; $v11 = [4]; $v12 = [5]; $v13 = [6]; $v14 = [7]; $v15 = [8]; + PHP]; + // arguments of a user function, none by reference or callable (#4708, #4709, #4710, #4711) + yield 'func-call-args' => [<<<'PHP' + take($i, $j, $s, $a, $i, $j, $s, $a); + take($j, $i, $s, $a, $j, $i, $s, $a); + take($i, $i, $s, $a, $j, $j, $s, $a); + take($j, $j, $s, $a, $i, $i, $s, $a); + PHP]; + // a loop in a loop is not at the top level until the outer one converged (#4706) + yield 'nested-while' => [<<<'PHP' + while ($i > 0) { + while ($j > 0) { + $j--; + } + do { + $j++; + } while ($j < 10); + $i--; + } + PHP]; + // the storage is duplicated only at the top level (#4728) + yield 'nested-foreach' => [<<<'PHP' + foreach ($a as $k => $v) { + foreach ($a as $k2 => $v2) { + $i = $k2; + } + } + PHP]; + // without conditions there is nothing to process in a duplicated storage (#4729) + yield 'for-without-condition' => [<<<'PHP' + for (;;) { + for (;;) { + $j = 1; + break; + } + $i = 1; + break; + } + PHP]; + // with a default arm the match cannot throw UnhandledMatchError (#4739) + yield 'match-with-default' => [<<<'PHP' + $r0 = match ($i) { 1 => 'a', 2 => 'b', 3 => 'c', default => 'd' }; + $r1 = match ($j) { 1 => 'a', 2 => 'b', 3 => 'c', default => 'd' }; + $r2 = match ($s) { 'a' => 1, 'b' => 2, 'c' => 3, default => 4 }; + $r3 = match (true) { $i > 1 => 1, $j > 1 => 2, default => 3 }; + PHP]; + } + + #[Before] + public function boot(string $statements): void + { + $container = BenchContainer::create($this->scratch); + $this->resolver = $container->getByType(NodeScopeResolver::class); + $code = self::code($statements); + $this->scope = BenchScope::analyse($container, $this->scratch, 'analysed', $code)->scope('marker'); + + [, $warmupNodes] = BenchScope::parse($container, $this->scratch, 'warmup', $code); + [, $warmupFunction, $warmupStmts] = BenchScope::markers($warmupNodes)['marker']; + $this->resolver->processStmtNodes($warmupFunction, $warmupStmts, $this->scope, static function (): void { + }, StatementContext::createTopLevel()); + + [, $nodes] = BenchScope::parse($container, $this->scratch, 'counted', $code); + [, $this->function, $this->stmts] = BenchScope::markers($nodes)['marker']; + } + + #[DataProvider(method: 'statements')] + #[ExpectedFor(dataSet: 'assign-non-falsy', instructions: '9.434M', cycles: '13.90M', memory: '274.6KiB')] + #[ExpectedFor(dataSet: 'for-without-condition', instructions: '3.168M', cycles: '5.155M', memory: '64.02KiB')] + #[ExpectedFor(dataSet: 'func-call-args', instructions: '3.748M', cycles: '6.165M', memory: '146.4KiB')] + #[ExpectedFor(dataSet: 'match-with-default', instructions: '111.9M', cycles: '148.9M', memory: '425.7KiB')] + #[ExpectedFor(dataSet: 'nested-foreach', instructions: '16.72M', cycles: '24.15M', memory: '180.2KiB')] + #[ExpectedFor(dataSet: 'nested-while', instructions: '38.49M', cycles: '53.24M', memory: '382.7KiB')] + public function benchProcessStmtNodes(string $statements): StatementResult + { + if ($this->resolver === null || $this->scope === null || $this->function === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->processStmtNodes($this->function, $this->stmts, $this->scope, static function (Node $node, Scope $scope): void { + }, StatementContext::createTopLevel()); + } + + private static function code(string $statements): string + { + return sprintf(<<<'PHP' + $a + */ + function doFoo(int $i, int $j, string $s, array $a): void + { + marker(); + %s + } + PHP, $statements); + } + +} diff --git a/tests/bench/NodeScopeResolverWalkBench.php b/tests/bench/NodeScopeResolverWalkBench.php new file mode 100644 index 00000000000..cbf6ce1384c --- /dev/null +++ b/tests/bench/NodeScopeResolverWalkBench.php @@ -0,0 +1,154 @@ + + */ + public static function statements(): iterable + { + // $r = 'a0' === $t || 'a1' === $t || ...; + foreach ([16, 32] as $arms) { + yield 'or-chain-' . $arms => [sprintf('$r = %s;', self::orChain('a', $arms))]; + } + + // writes into nested arrays that are not constant arrays + $assigns = []; + for ($i = 0; $i < 16; $i++) { + $assigns[] = sprintf('$nested[\'k%d\'][\'x\'][%d] = $i;', $i, $i); + } + yield 'nested-dim-assign' => [implode("\n", $assigns)]; + + // loops converge after a few passes over the body + yield 'foreach' => ['foreach ($list as $k => $v) { if ($v > $max) { $max = $v; $maxKey = $k; } elseif ($v < 0) { $negative[] = $k; } }']; + yield 'while' => ['while ($i < 100) { if ($i % 3 === 0) { $s .= \'a\'; } elseif ($i % 3 === 1) { $max = $i; } $i++; }']; + } + + #[Before] + public function boot(string $code): void + { + $this->container = BenchContainer::create($this->scratch); + $int = new IntegerType(); + $string = new StringType(); + $scope = $this->container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)); + $variables = [ + 't' => $string, + 'i' => $int, + 's' => $string, + 'max' => $int, + 'list' => new ArrayType($int, $int), + 'negative' => new ArrayType($int, $int), + 'nested' => new ArrayType($string, new ArrayType($string, new ArrayType($int, $int))), + ]; + foreach ($variables as $name => $type) { + $scope = $scope->assignVariable($name, $type, $type, TrinaryLogic::createYes()); + } + $this->scope = $scope; + + // the warm-up walks every kind of statement once, with other constants + foreach (self::statements() as [$warmupCode]) { + $this->process($this->parse(str_replace(['\'a', '\'k'], ['\'w', '\'w'], $warmupCode))); + } + } + + #[DataProvider(method: 'statements')] + #[ExpectedFor(dataSet: 'foreach', instructions: '16.95M', cycles: '25.08M', memory: '315.2KiB')] + #[ExpectedFor(dataSet: 'nested-dim-assign', instructions: '277.4M', cycles: '330.1M', memory: '801.3KiB')] + #[ExpectedFor(dataSet: 'or-chain-16', instructions: '22.42M', cycles: '31.58M', memory: '614.1KiB')] + #[ExpectedFor(dataSet: 'or-chain-32', instructions: '50.17M', cycles: '67.50M', memory: '1.219MiB')] + #[ExpectedFor(dataSet: 'while', instructions: '10.43M', cycles: '16.14M', memory: '236.3KiB')] + public function benchProcessStmtNodes(string $code): Type + { + $stmts = $this->meter->exclude(fn (): array => $this->parse($code)); + + return $this->process($stmts); + } + + /** + * @param Stmt[] $stmts + */ + private function process(array $stmts): Type + { + if ($this->container === null || $this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + $scope = $this->scope; + $result = $this->container->getByType(NodeScopeResolver::class)->processStmtNodes( + new Stmt\Namespace_(null, $stmts), + $stmts, + $scope, + static function (Node $node, Scope $scope): void { + }, + StatementContext::createTopLevel(), + ); + + return $result->getScope()->getType(new Node\Expr\Variable('max')); + } + + /** + * @return Stmt[] + */ + private function parse(string $code): array + { + if ($this->container === null) { + throw new LogicException('boot() did not run.'); + } + + /** @var Parser $parser */ + $parser = $this->container->getService('currentPhpVersionRichParser'); + + return $parser->parseString(' */ + private array $levels = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function chains(): iterable + { + yield 'innermost-nullsafe-64' => [64, true]; + yield 'without-nullsafe-64' => [64, false]; + } + + /** + * @return iterable + */ + public static function chainsWithoutNullsafe(): iterable + { + yield 'without-nullsafe-8' => [8, false]; + } + + /** + * @return iterable + */ + public static function levelsWithoutNullsafe(): iterable + { + foreach ([16, 64] as $depth) { + yield 'levels-' . $depth => [$depth, false]; + } + } + + #[Before] + public function boot(int $depth, bool $nullsafe): void + { + $container = BenchContainer::create($this->scratch); + $scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create($this->scratch->path() . '/file.php')); + $scope = $scope->assignVariable('w', new MixedType(true), new MixedType(true), TrinaryLogic::createYes()); + $this->scope = $scope->assignVariable('a', new MixedType(true), new MixedType(true), TrinaryLogic::createYes()); + + NullsafeOperatorHelper::getNullsafeShortcircuitedExpr(self::chain('w', 4, true)); + NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($this->scope, self::chain('w', 4, false)); + + $this->chain = self::chain('a', $depth, $nullsafe); + $this->levels = []; + for ($level = $this->chain; $level instanceof PropertyFetch || $level instanceof NullsafePropertyFetch; $level = $level->var) { + $this->levels[] = $level; + } + $this->levels = array_reverse($this->levels); + } + + /** + * The analyser asks about each level of a chain, innermost first. + */ + #[DataProvider(method: 'levelsWithoutNullsafe')] + #[ExpectedFor(dataSet: 'levels-16', instructions: '74.18K', cycles: '132.2K', memory: '6.242KiB')] + #[ExpectedFor(dataSet: 'levels-64', instructions: '283.4K', cycles: '379.1K', memory: '23.87KiB')] + public function benchGetNullsafeShortcircuitedExprOfEveryLevel(int $depth, bool $nullsafe): int + { + $unchanged = 0; + foreach ($this->levels as $level) { + if (NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($level) !== $level) { + continue; + } + + $unchanged++; + } + + return $unchanged; + } + + /** + * Every link above a nullsafe one is rebuilt, a chain without one is returned as it is. + */ + #[DataProvider(method: 'chains')] + #[ExpectedFor(dataSet: 'innermost-nullsafe-64', instructions: '261.6K', cycles: '383.1K', memory: '9.609KiB')] + #[ExpectedFor(dataSet: 'without-nullsafe-64', instructions: '173.3K', cycles: '258.8K', memory: '23.87KiB')] + public function benchGetNullsafeShortcircuitedExpr(int $depth, bool $nullsafe): Expr + { + if ($this->chain === null) { + throw new LogicException('boot() did not run.'); + } + + return NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($this->chain); + } + + /** + * A chain without a nullsafe link needs no type from the scope. + */ + #[DataProvider(method: 'chainsWithoutNullsafe')] + #[ExpectedFor(dataSet: 'without-nullsafe-8', instructions: '27.17K', cycles: '75.85K', memory: '3.305KiB')] + public function benchGetNullsafeShortcircuitedExprRespectingScope(int $depth, bool $nullsafe): Expr + { + if ($this->scope === null || $this->chain === null) { + throw new LogicException('boot() did not run.'); + } + + return NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($this->scope, $this->chain); + } + + /** + * @return Expr $variable->p0->p1…, with ?-> as the innermost link when $nullsafe + */ + private static function chain(string $variable, int $depth, bool $nullsafe): Expr + { + $expr = new Variable($variable); + for ($i = 0; $i < $depth; $i++) { + $expr = $nullsafe && $i === 0 ? new NullsafePropertyFetch($expr, 'p' . $i) : new PropertyFetch($expr, 'p' . $i); + } + + return $expr; + } + +} diff --git a/tests/bench/ObjectTypeBench.php b/tests/bench/ObjectTypeBench.php new file mode 100644 index 00000000000..ba3891124be --- /dev/null +++ b/tests/bench/ObjectTypeBench.php @@ -0,0 +1,56 @@ + + */ + public static function classes(): iterable + { + yield 'class' => [DateTime::class]; + yield 'generic-class' => [ArrayObject::class]; + } + + #[Before] + public function boot(string $className): void + { + BenchContainer::create($this->scratch); + $this->object = new ObjectType($className); + $this->object->getClassReflection(); + } + + /** + * The type was asked for its class before. + */ + #[DataProvider(method: 'classes')] + #[ExpectedFor(dataSet: 'class', instructions: '741', cycles: '9.321K', memory: '0B')] + #[ExpectedFor(dataSet: 'generic-class', instructions: '741', cycles: '9.321K', memory: '0B')] + public function benchGetClassReflection(string $className): ?ClassReflection + { + if ($this->object === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->object->getClassReflection(); + } + +} diff --git a/tests/bench/ObjectTypeIsSuperTypeOfBench.php b/tests/bench/ObjectTypeIsSuperTypeOfBench.php new file mode 100644 index 00000000000..e081bed7410 --- /dev/null +++ b/tests/bench/ObjectTypeIsSuperTypeOfBench.php @@ -0,0 +1,77 @@ +scratch); + + $this->dateTime = new ObjectType(DateTime::class); + $this->exception = new ObjectType(Exception::class); + $this->union = new UnionType([new ObjectType(Exception::class), new ObjectType(stdClass::class)]); + $this->dateTime->isSuperTypeOf($this->exception); + $this->dateTime->isSuperTypeOf($this->union); + } + + #[Expected(instructions: '252.2K', cycles: '311.5K', memory: '368B')] + public function benchIsSuperTypeOfUnrelatedClass(): IsSuperTypeOfResult + { + if ($this->dateTime === null || $this->exception === null) { + throw new LogicException('boot() did not run.'); + } + + $result = IsSuperTypeOfResult::createYes(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->dateTime->isSuperTypeOf($this->exception); + } + + return $result; + } + + #[Expected(instructions: '850.0K', cycles: '1.075M', memory: '1.578KiB')] + public function benchIsSuperTypeOfUnionOfUnrelatedClasses(): IsSuperTypeOfResult + { + if ($this->dateTime === null || $this->union === null) { + throw new LogicException('boot() did not run.'); + } + + $result = IsSuperTypeOfResult::createYes(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = $this->dateTime->isSuperTypeOf($this->union); + } + + return $result; + } + +} diff --git a/tests/bench/OperatorTypeSpecifyingExtensionBench.php b/tests/bench/OperatorTypeSpecifyingExtensionBench.php new file mode 100644 index 00000000000..26e8b6f1303 --- /dev/null +++ b/tests/bench/OperatorTypeSpecifyingExtensionBench.php @@ -0,0 +1,99 @@ + */ + private array $operands = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable, string}> + */ + public static function extensions(): iterable + { + foreach (['gmp' => GmpOperatorTypeSpecifyingExtension::class, 'bcmath' => BcMathNumberOperatorTypeSpecifyingExtension::class] as $name => $extension) { + // most operations in the code have no operand of the extension's class + yield $name . '-integers' => [$extension, 'integers']; + yield $name . '-objects' => [$extension, 'objects']; + } + } + + /** + * @param class-string $extension + */ + #[Before] + public function boot(string $extension, string $operands): void + { + BenchContainer::create($this->scratch); + $this->extension = $extension === BcMathNumberOperatorTypeSpecifyingExtension::class + ? new BcMathNumberOperatorTypeSpecifyingExtension(new PhpVersion(80400)) + : new $extension(); + $class = $extension === BcMathNumberOperatorTypeSpecifyingExtension::class ? 'BcMath\Number' : 'GMP'; + // reflecting the class from its stub happens once per analysis + $this->extension->isOperatorSupported('+', new IntegerType(), new StringType()); + $this->extension->isOperatorSupported('+', new ObjectType($class), new IntegerType()); + $this->extension->specifyType('+', new ObjectType($class), new IntegerType()); + + for ($i = 0; $i < self::OPERATIONS; $i++) { + $this->operands[] = $operands === 'integers' + ? [new ConstantIntegerType($i), new IntegerType()] + : [new ObjectType($class), new ConstantIntegerType($i)]; + } + } + + /** + * @param class-string $extension + */ + #[DataProvider(method: 'extensions')] + #[ExpectedFor(dataSet: 'bcmath-integers', instructions: '309.3K', cycles: '380.8K', memory: '0B')] + #[ExpectedFor(dataSet: 'bcmath-objects', instructions: '651.3K', cycles: '786.5K', memory: '216B')] + #[ExpectedFor(dataSet: 'gmp-integers', instructions: '294.4K', cycles: '365.1K', memory: '0B')] + #[ExpectedFor(dataSet: 'gmp-objects', instructions: '593.5K', cycles: '718.6K', memory: '216B')] + public function benchSpecify(string $extension, string $operands): int + { + if ($this->extension === null) { + throw new LogicException('boot() did not run.'); + } + + $specified = 0; + foreach ($this->operands as [$left, $right]) { + if (!$this->extension->isOperatorSupported('+', $left, $right)) { + continue; + } + + $this->extension->specifyType('+', $left, $right); + $specified++; + } + + return $specified; + } + +} diff --git a/tests/bench/PackageDependencyResolverBench.php b/tests/bench/PackageDependencyResolverBench.php new file mode 100644 index 00000000000..bd0e8f4daca --- /dev/null +++ b/tests/bench/PackageDependencyResolverBench.php @@ -0,0 +1,64 @@ + + */ + public static function files(): iterable + { + yield 'package-file' => ['vendor/nikic/php-parser/lib/PhpParser/Node/Expr/Variable.php']; + // matches none of the installed packages + yield 'project-file' => ['src/Analyser/MutatingScope.php']; + } + + #[Before] + public function boot(string $file): void + { + BenchContainer::create($this->scratch); + $root = dirname((string) (new ReflectionClass(ContainerFactory::class))->getFileName(), 3); + $fileHelper = new FileHelper($root); + // the installed packages of PHPStan's own vendor directory + $this->resolver = new PackageDependencyResolver([$root], $fileHelper); + $this->file = $fileHelper->normalizePath($root . '/' . $file); + $this->resolver->resolvePackage($this->file); + } + + /** + * Every analysed file that depends on a file resolves it again. + */ + #[DataProvider(method: 'files')] + #[ExpectedFor(dataSet: 'package-file', instructions: '1.082K', cycles: '11.64K', memory: '0B')] + #[ExpectedFor(dataSet: 'project-file', instructions: '1.082K', cycles: '11.64K', memory: '0B')] + public function benchResolvePackageAgain(string $file): ?string + { + if ($this->resolver === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->resolvePackage($this->file); + } + +} diff --git a/tests/bench/ParametersAcceptorSelectorBench.php b/tests/bench/ParametersAcceptorSelectorBench.php new file mode 100644 index 00000000000..caee9ce134e --- /dev/null +++ b/tests/bench/ParametersAcceptorSelectorBench.php @@ -0,0 +1,97 @@ + + */ + public static function returnTypes(): iterable + { + yield 'template-first-16' => [16, true]; + yield 'template-first-256' => [256, true]; + yield 'no-template-256' => [256, false]; + } + + #[Before] + public function boot(int $items, bool $templateFirst): void + { + BenchContainer::create($this->scratch); + // private before 2.1.34, selectFromArgs() asks it for every call with a single variant + $this->hasTemplateOrLateResolvableType = (new ReflectionMethod(ParametersAcceptorSelector::class, 'hasAcceptorTemplateOrLateResolvableType'))->getClosure(); + ($this->hasTemplateOrLateResolvableType)(self::acceptor(4, true)); + ($this->hasTemplateOrLateResolvableType)(self::acceptor(4, false)); + + $this->acceptor = self::acceptor($items, $templateFirst); + } + + /** + * Whether a variant needs its template types resolved, for a function returning an array shape + * of nested shapes. The search can stop at a template type in the first item. + */ + #[DataProvider(method: 'returnTypes')] + #[ExpectedFor(dataSet: 'no-template-256', instructions: '543.0K', cycles: '1.047M', memory: '0B')] + #[ExpectedFor(dataSet: 'template-first-16', instructions: '1.876K', cycles: '19.70K', memory: '0B')] + #[ExpectedFor(dataSet: 'template-first-256', instructions: '1.876K', cycles: '19.70K', memory: '0B')] + public function benchHasAcceptorTemplateOrLateResolvableType(int $items, bool $templateFirst): bool + { + if ($this->hasTemplateOrLateResolvableType === null || $this->acceptor === null) { + throw new LogicException('boot() did not run.'); + } + + return ($this->hasTemplateOrLateResolvableType)($this->acceptor); + } + + private static function acceptor(int $items, bool $templateFirst): ParametersAcceptor + { + $keys = []; + $values = []; + for ($i = 0; $i < $items; $i++) { + $keys[] = new ConstantStringType('item' . $i); + $values[] = $i === 0 && $templateFirst + ? TemplateTypeFactory::create(TemplateTypeScope::createWithFunction('f'), 'T', null, TemplateTypeVariance::createInvariant()) + : self::shape($i); + } + + return new FunctionVariant(TemplateTypeMap::createEmpty(), null, [], false, new ConstantArrayType($keys, $values)); + } + + private static function shape(int $i): Type + { + return new ConstantArrayType( + [new ConstantStringType('id'), new ConstantStringType('name'), new ConstantStringType('position')], + [new ConstantIntegerType($i), new ConstantStringType('name' . $i), new ConstantIntegerType($i * 2)], + ); + } + +} diff --git a/tests/bench/ParentDirectoryRelativePathHelperBench.php b/tests/bench/ParentDirectoryRelativePathHelperBench.php new file mode 100644 index 00000000000..2d800c99b8b --- /dev/null +++ b/tests/bench/ParentDirectoryRelativePathHelperBench.php @@ -0,0 +1,57 @@ + + */ + public static function depths(): iterable + { + foreach ([4, 16, 64] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(int $depth): void + { + BenchContainer::create($this->scratch); + // the working directory and the file share $depth directories + $directory = '/' . implode('/', range(1, $depth)); + $this->helper = new ParentDirectoryRelativePathHelper($directory . '/project'); + $this->file = $directory . '/src/Foo.php'; + $this->helper->getRelativePath('/warmup/src/Foo.php'); + } + + #[DataProvider(method: 'depths')] + #[Linear(size: 'depth')] + public function benchGetRelativePath(int $depth): string + { + if ($this->helper === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->helper->getRelativePath($this->file); + } + +} diff --git a/tests/bench/PatcherBench.php b/tests/bench/PatcherBench.php new file mode 100644 index 00000000000..6141d99a30d --- /dev/null +++ b/tests/bench/PatcherBench.php @@ -0,0 +1,77 @@ +}>, array{int, int, list}): ?int)|null */ + private ?Closure $findInsertionIndex = null; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable}>}> + */ + public static function acceptedRuns(): iterable + { + foreach ([16, 64, 256, 1024] as $runs) { + yield 'runs-' . $runs => [self::runs($runs)]; + } + } + + /** + * @param list}> $runs + */ + #[Before] + public function boot(array $runs): void + { + BenchContainer::create($this->scratch); + // private, Patcher::apply() calls it for each change + $this->findInsertionIndex = (new ReflectionMethod(Patcher::class, 'findInsertionIndex'))->getClosure(); + ($this->findInsertionIndex)(self::runs(4), [15, 1, ['warmup']]); + } + + /** + * Applying a fix finds where its lines go among the changes accepted so far by binary search. + * + * @param list}> $runs + */ + #[DataProvider(method: 'acceptedRuns')] + #[Logarithmic(size: 'runs')] + public function benchFindInsertionIndex(array $runs): ?int + { + if ($this->findInsertionIndex === null) { + throw new LogicException('boot() did not run.'); + } + + // between two runs in the middle + return ($this->findInsertionIndex)($runs, [count($runs) * 5 + 3, 1, ['new']]); + } + + /** + * @return list}> one changed line every 10 lines + */ + private static function runs(int $runs): array + { + $accepted = []; + for ($i = 0; $i < $runs; $i++) { + $accepted[] = [$i * 10, 1, ['changed']]; + } + + return $accepted; + } + +} diff --git a/tests/bench/PathRoutingParserBench.php b/tests/bench/PathRoutingParserBench.php new file mode 100644 index 00000000000..c90f8809b44 --- /dev/null +++ b/tests/bench/PathRoutingParserBench.php @@ -0,0 +1,64 @@ + + */ + public static function stubs(): iterable + { + $stubs = dirname(__DIR__, 2) . '/vendor/phpstan/php-8-stubs/stubs'; + yield 'function' => [$stubs . '/Zend/strlen.php']; + yield 'class' => [$stubs . '/ext/pdo/PDO.php']; + } + + #[Before] + public function boot(string $file): void + { + $parser = BenchContainer::create($this->scratch)->getService('pathRoutingParser'); + if (!$parser instanceof Parser) { + throw new LogicException('pathRoutingParser is not a parser.'); + } + + // another stub loads the parser's classes, then the benchmark's stub is parsed the first time + $parser->parseFile(dirname(__DIR__, 2) . '/vendor/phpstan/php-8-stubs/stubs/Zend/ArrayAccess.php'); + $parser->parseFile($file); + $this->parser = $parser; + } + + /** + * Reflection asks for the nodes of a PHP 8 stub again for each symbol it declares. + * + * @return Node[] + */ + #[DataProvider(method: 'stubs')] + #[ExpectedFor(dataSet: 'class', instructions: '6.901K', cycles: '63.71K', memory: '0B')] + #[ExpectedFor(dataSet: 'function', instructions: '6.901K', cycles: '63.72K', memory: '0B')] + public function benchParsePhp8StubAgain(string $file): array + { + if ($this->parser === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->parser->parseFile($file); + } + +} diff --git a/tests/bench/Php8SignatureMapProviderBench.php b/tests/bench/Php8SignatureMapProviderBench.php new file mode 100644 index 00000000000..032f72b36f9 --- /dev/null +++ b/tests/bench/Php8SignatureMapProviderBench.php @@ -0,0 +1,71 @@ +, list}> + */ + public static function members(): iterable + { + $constants = ['ATTR_AUTOCOMMIT', 'ATTR_CASE', 'ATTR_CLIENT_VERSION', 'ATTR_CONNECTION_STATUS', 'ATTR_CURSOR', 'ATTR_CURSOR_NAME', 'ATTR_DEFAULT_FETCH_MODE', 'ATTR_DRIVER_NAME']; + $methods = ['beginTransaction', 'commit', 'errorCode', 'errorInfo', 'exec', 'getAttribute', 'inTransaction', 'lastInsertId']; + yield 'constants-1' => [['ATTR_CASE'], []]; + yield 'constants-8' => [$constants, []]; + yield 'constants-8-methods-8' => [$constants, $methods]; + } + + #[Before] + public function boot(): void + { + $this->provider = BenchContainer::create($this->scratch)->getByType(Php8SignatureMapProvider::class); + $this->provider->hasClassConstantMetadata('ReflectionMethod', 'IS_PUBLIC'); + $this->provider->hasMethodSignature('ReflectionMethod', 'invoke'); + // reads PDO's stub, which every later question about its members is answered from + $this->provider->hasClassConstantMetadata('PDO', 'ATTR_ERRMODE'); + } + + /** + * Questions about further members of a class whose stub was read already, here PDO's. Each + * member looked for on its own used to walk the stub's AST again. + * + * @param list $constants + * @param list $methods + */ + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'constants-1', instructions: '4.015K', cycles: '36.03K', memory: '136B')] + #[ExpectedFor(dataSet: 'constants-8', instructions: '28.44K', cycles: '65.63K', memory: '144B')] + #[ExpectedFor(dataSet: 'constants-8-methods-8', instructions: '55.67K', cycles: '101.5K', memory: '144B')] + public function benchFindMembers(array $constants, array $methods): int + { + if ($this->provider === null) { + throw new LogicException('boot() did not run.'); + } + + $found = 0; + foreach ($constants as $constant) { + $found += (int) $this->provider->hasClassConstantMetadata('PDO', $constant); + } + foreach ($methods as $method) { + $found += (int) $this->provider->hasMethodSignature('PDO', $method); + } + + return $found; + } + +} diff --git a/tests/bench/PhpDocInheritanceResolverBench.php b/tests/bench/PhpDocInheritanceResolverBench.php new file mode 100644 index 00000000000..43a097b30c7 --- /dev/null +++ b/tests/bench/PhpDocInheritanceResolverBench.php @@ -0,0 +1,94 @@ + + */ + public static function depths(): iterable + { + foreach ([4, 16] as $depth) { + yield 'depth-' . $depth => [$depth]; + } + } + + #[Before] + public function boot(int $depth): void + { + $reflectionProvider = BenchContainer::create($this->scratch)->getByType(ReflectionProvider::class); + foreach ([2, $depth] as $chain) { + BenchFixtures::declare($this->scratch, self::chain($chain)); + } + + $warmup = $reflectionProvider->getClass('PhpDocInheritanceResolverBench\Chain2\Level2'); + $warmup->getAncestors(); + $warmup->getNativeMethod('values')->getVariants()[0]->getReturnType(); + + // the class reflections of the chain, without their methods + $this->class = $reflectionProvider->getClass(sprintf('PhpDocInheritanceResolverBench\Chain%d\Level%d', $depth, $depth)); + $this->class->getAncestors(); + } + + /** + * A method overridden on every level of a class hierarchy, documented only at its root, inherits + * the PHPDoc of the root. + */ + #[DataProvider(method: 'depths')] + #[ExpectedFor(dataSet: 'depth-4', instructions: '3.208M', cycles: '5.530M', memory: '54.20KiB')] + #[ExpectedFor(dataSet: 'depth-16', instructions: '9.423M', cycles: '13.84M', memory: '161.0KiB')] + public function benchInheritedReturnType(int $depth): Type + { + if ($this->class === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->class->getNativeMethod('values')->getVariants()[0]->getReturnType(); + } + + private static function chain(int $depth): string + { + $code = sprintf(<<<'PHP' + namespace PhpDocInheritanceResolverBench\Chain%d; + + class Level0 + { + + /** + * @param list $values + * @return list + */ + public function values(array $values): array + { + return $values; + } + + } + + PHP, $depth); + for ($level = 1; $level <= $depth; $level++) { + $code .= sprintf("class Level%d extends Level%d\n{\n\n\tpublic function values(array \$values): array\n\t{\n\t\treturn \$values;\n\t}\n\n}\n\n", $level, $level - 1); + } + + return $code; + } + +} diff --git a/tests/bench/PhpFileCleanerBench.php b/tests/bench/PhpFileCleanerBench.php new file mode 100644 index 00000000000..9585cd965df --- /dev/null +++ b/tests/bench/PhpFileCleanerBench.php @@ -0,0 +1,67 @@ + + */ + public static function files(): iterable + { + yield 'code' => [self::file(static fn (int $i): string => sprintf("\tpublic function method%d(int \$value): int\n\t{\n\t\treturn \$value + %d;\n\t}\n\n", $i, $i))]; + yield 'docblocks' => [self::file(static fn (int $i): string => sprintf("\t/**\n\t * Returns the value increased by %d.\n%s\t */\n\tpublic function method%d(int \$value): int\n\t{\n\t\treturn \$value + %d;\n\t}\n\n", $i, str_repeat("\t * Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n", 4), $i, $i))]; + yield 'line-comments' => [self::file(static fn (int $i): string => sprintf("\tpublic function method%d(int \$value): int\n\t{\n%s\t\treturn \$value + %d;\n\t}\n\n", $i, str_repeat("\t\t// lorem ipsum dolor sit amet, consectetur adipiscing elit\n", 4), $i))]; + yield 'strings' => [self::file(static fn (int $i): string => sprintf("\tpublic function method%d(): string\n\t{\n\t\treturn 'lorem ipsum dolor sit amet' . \"consectetur adipiscing elit %d\";\n\t}\n\n", $i, $i))]; + } + + #[Before] + public function boot(): void + { + $this->cleaner = new PhpFileCleaner(); + $this->cleaner->clean("cleaner === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->cleaner->clean($contents, 2); + } + + /** + * @param callable(int): string $method + */ + private static function file(callable $method): string + { + $code = " + */ + public static function statements(): iterable + { + foreach ([16, 256] as $statements) { + yield 'statements-' . $statements => [$statements]; + } + } + + #[Before] + public function boot(int $statements): void + { + BenchContainer::create($this->scratch); + self::reflect(4)->isGenerator(); + + $this->function = self::reflect($statements); + $this->function->isGenerator(); + } + + /** + * Whether a function is a generator, asked again for the same function, which scans the body for + * a yield unless the answer is memoized on its node. + */ + #[DataProvider(method: 'statements')] + #[ExpectedFor(dataSet: 'statements-16', instructions: '1.268K', cycles: '14.69K', memory: '0B')] + #[ExpectedFor(dataSet: 'statements-256', instructions: '1.268K', cycles: '14.69K', memory: '0B')] + public function benchIsGeneratorAgain(int $statements): bool + { + if ($this->function === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->function->isGenerator(); + } + + /** + * A reflection of a function that is not a generator, with only the node isGenerator() reads, + * because the constructor differs between versions. + */ + private static function reflect(int $statements): PhpFunctionFromParserNodeReflection + { + $code = "createForHostVersion()->parse($code)[0] ?? null; + if (!$function instanceof Node\Stmt\Function_) { + throw new LogicException('Not parsed.'); + } + + $reflection = (new ReflectionClass(PhpFunctionFromParserNodeReflection::class))->newInstanceWithoutConstructor(); + (new ReflectionProperty(PhpFunctionFromParserNodeReflection::class, 'functionLike'))->setValue($reflection, $function); + + return $reflection; + } + +} diff --git a/tests/bench/PhpMethodReflectionBench.php b/tests/bench/PhpMethodReflectionBench.php new file mode 100644 index 00000000000..49c3d5b2faa --- /dev/null +++ b/tests/bench/PhpMethodReflectionBench.php @@ -0,0 +1,51 @@ +scratch)->getByType(ReflectionProvider::class); + BenchFixtures::declare($this->scratch, <<<'PHP' + namespace PhpMethodReflectionBench; + + class Member + { + + public function doubleValues(): void + { + } + + } + PHP); + $this->method = $reflectionProvider->getClass('PhpMethodReflectionBench\Member')->getNativeMethod('doubleValues'); + $this->method->getName(); + } + + #[Expected(instructions: '700', cycles: '8.840K', memory: '0B')] + public function benchGetNameAgain(): string + { + if ($this->method === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->method->getName(); + } + +} diff --git a/tests/bench/RegexGroupParserBench.php b/tests/bench/RegexGroupParserBench.php new file mode 100644 index 00000000000..cc7285150af --- /dev/null +++ b/tests/bench/RegexGroupParserBench.php @@ -0,0 +1,51 @@ + + */ + public static function patterns(): iterable + { + yield 'date' => ['/^(?\d{4})-(?\d{2})-(?\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/']; + // a pattern with a modifier the parser does not support is not checked again either + yield 'unsupported-modifier' => ['/^(?\w+)\s+(\d+)$/J']; + } + + #[Before] + public function boot(string $pattern): void + { + $this->parser = BenchContainer::create($this->scratch)->getByType(RegexGroupParser::class); + // preg_match() and the other functions of the analysed code ask for the same pattern again + $this->parser->parseGroups($pattern); + } + + #[DataProvider(method: 'patterns')] + #[ExpectedFor(dataSet: 'date', instructions: '1.426M', cycles: '2.041M', memory: '9.656KiB')] + #[ExpectedFor(dataSet: 'unsupported-modifier', instructions: '1.286K', cycles: '15.92K', memory: '0B')] + public function benchParseGroupsAgain(string $pattern): bool + { + if ($this->parser === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->parser->parseGroups($pattern) !== null; + } + +} diff --git a/tests/bench/ReturnTypeExtensionBench.php b/tests/bench/ReturnTypeExtensionBench.php new file mode 100644 index 00000000000..6b7f9c6f4c3 --- /dev/null +++ b/tests/bench/ReturnTypeExtensionBench.php @@ -0,0 +1,119 @@ + */ + private array $calls = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable, string}> + */ + public static function extensions(): iterable + { + // the extensions read the arguments of the call once + yield 'array-chunk' => [ArrayChunkFunctionReturnTypeExtension::class, self::functionBody('array $a', 'array_chunk($a, 2, true);')]; + yield 'array-column' => [ArrayColumnFunctionReturnTypeExtension::class, self::functionBody('array $a', 'array_column($a, \'id\', \'key\');')]; + + // a subject that is not mixed does not need the return type of the selected variant + yield 'str-replace-string-subject' => [ReplaceFunctionsDynamicReturnTypeExtension::class, self::functionBody('string $s', 'str_replace(\'a\', \'b\', $s);')]; + yield 'preg-replace-string-subject' => [ReplaceFunctionsDynamicReturnTypeExtension::class, self::functionBody('string $s', 'preg_replace(\'/a/\', \'b\', $s);')]; + + // a format that is not a constant string is not a union of the types of its constant strings + yield 'date-non-constant-format' => [DateFunctionReturnTypeExtension::class, self::functionBody('string $format', 'date($format);')]; + + // the length of a range longer than the array count limit is computed instead of creating the array + yield 'range-100000' => [RangeFunctionReturnTypeExtension::class, self::functionBody('', 'range(0, 100000);')]; + } + + /** + * @param class-string $extension + */ + #[Before] + public function boot(string $extension, string $code): void + { + $analysed = AnalysedCode::analyse($this->scratch, $code); + $this->extension = $analysed->get($extension); + $reflectionProvider = $analysed->getContainer()->getByType(ReflectionProvider::class); + foreach ($analysed->nodes(FuncCall::class) as [$call, $scope]) { + if (!$call->name instanceof Name) { + continue; + } + + $function = $reflectionProvider->getFunction($call->name, $scope); + if (!$this->extension->isFunctionSupported($function)) { + continue; + } + + $this->calls[] = [$function, $call, $scope]; + } + + if ($this->calls === []) { + throw new LogicException(sprintf('The code calls no function %s supports.', $extension)); + } + } + + /** + * @param class-string $extension + * @return list + */ + #[DataProvider(method: 'extensions')] + #[ExpectedFor(dataSet: 'array-chunk', instructions: '622.4K', cycles: '1.076M', memory: '23.70KiB')] + #[ExpectedFor(dataSet: 'array-column', instructions: '2.033M', cycles: '3.088M', memory: '10.32KiB')] + #[ExpectedFor(dataSet: 'date-non-constant-format', instructions: '72.79K', cycles: '163.2K', memory: '1016B')] + #[ExpectedFor(dataSet: 'preg-replace-string-subject', instructions: '1.029M', cycles: '1.676M', memory: '10.78KiB')] + #[ExpectedFor(dataSet: 'range-100000', instructions: '526.1K', cycles: '878.5K', memory: '16.39KiB')] + #[ExpectedFor(dataSet: 'str-replace-string-subject', instructions: '530.5K', cycles: '874.6K', memory: '2.594KiB')] + public function benchGetTypeFromFunctionCall(string $extension, string $code): array + { + if ($this->extension === null) { + throw new LogicException('boot() did not run.'); + } + + $types = []; + foreach ($this->calls as [$function, $call, $scope]) { + $types[] = $this->extension->getTypeFromFunctionCall($function, $call, $scope); + } + + return $types; + } + + private static function functionBody(string $parameters, string $statement): string + { + return sprintf('|null */ + private ?Rule $rule = null; + + /** @var list */ + private array $nodes = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable the rule, the code, and the PHP version + */ + public static function rules(): iterable + { + // the rule returns early for constructors before it asks for the PHP version + yield 'final-private-method-constructors' => [FinalPrivateMethodRule::class, self::classes(static fn (int $i): string => sprintf('class Foo%d { public function __construct() {} }', $i)), null]; + + // a method name that is not an identifier ends the check before the PHP version is asked whether it supports #[NoDiscard] + yield 'call-to-method-statement-with-no-discard-dynamic-name' => [CallToMethodStatementWithNoDiscardRule::class, self::functionBody('\DateTime $date, string $name', '$date->$name();'), 80500]; + + yield 'call-to-static-method-statement-with-no-discard-dynamic-name' => [CallToStaticMethodStatementWithNoDiscardRule::class, self::functionBody('string $name', '\\DateTime::$name();'), 80500]; + + // a call without arguments cannot dump a type, so the rules do not resolve the function name + yield 'dump-type-no-arguments' => [DumpTypeRule::class, self::functionBody('', 'time();'), null]; + yield 'dump-native-type-no-arguments' => [DumpNativeTypeRule::class, self::functionBody('', 'time();'), null]; + yield 'dump-phpdoc-type-no-arguments' => [DumpPhpDocTypeRule::class, self::functionBody('', 'time();'), null]; + // the type assertions have two arguments + yield 'file-assert-one-argument' => [FileAssertRule::class, self::functionBody('string $s', 'strlen($s);'), null]; + + // in a final class, static:: is the class itself, so the rule does not look up the method + yield 'call-private-method-through-static-final-class' => [CallPrivateMethodThroughStaticRule::class, ' [RestrictedMethodUsageRule::class, self::functionBody('\DateTime $date', '$date->format(\'Y\');'), null]; + yield 'restricted-function-usage' => [RestrictedFunctionUsageRule::class, self::functionBody('string $s', 'strlen($s);'), null]; + yield 'instantiation-restricted-constructor' => [InstantiationRule::class, ' [OldPhpParser4ClassRule::class, self::functionBody('', '$a = PHP_EOL;'), null]; + + yield 'node-connecting-visitor-attributes-no-arguments' => [NodeConnectingVisitorAttributesRule::class, self::functionBody('\\SplObjectStorage $s', '$s->getAttribute();'), null]; + + // the classes and functions of the analysed code are grouped by name once, not searched for each declaration + yield 'duplicate-class-declaration' => [DuplicateClassDeclarationRule::class, self::classes(static fn (int $i): string => sprintf('class Foo%d {}', $i)), null]; + yield 'duplicate-function-declaration' => [DuplicateFunctionDeclarationRule::class, ' sprintf('function foo%d(): void {}', $i), range(1, 64))), null]; + + // the return type of the type-checking function is not resolved again + yield 'impossible-check-type-function-call' => [ImpossibleCheckTypeFunctionCallRule::class, self::functionBody('int|string $v', 'if (is_int($v)) {}'), null]; + + // only the values under keys both arrays have are compared as strings + yield 'parameter-castable-to-string-diff-assoc' => [ParameterCastableToStringRule::class, self::functionBody('', 'array_diff_assoc([' . self::keys('a', 'new \stdClass()') . '], [' . self::keys('b', '1') . ']);'), null]; + + // a parameter or return type without late-resolvable types cannot contain a conditional type + yield 'function-conditional-return-type-without-conditional-types' => [FunctionConditionalReturnTypeRule::class, ' [CallToFunctionParametersRule::class, 'scratch, $code); + $instance = $analysed->get($rule, $phpVersion === null ? [] : ['phpVersion' => new PhpVersion($phpVersion)]); + if (!$instance instanceof Rule) { + throw new LogicException($rule . ' is not a rule.'); + } + $this->rule = $instance; + $this->nodes = $analysed->nodes($instance->getNodeType()); + } + + /** + * @param class-string $rule + */ + #[DataProvider(method: 'rules')] + #[ExpectedFor(dataSet: 'call-private-method-through-static-final-class', instructions: '59.05K', cycles: '111.0K', memory: '0B')] + #[ExpectedFor(dataSet: 'call-to-function-parameters-resolvable-types', instructions: '117.0M', cycles: '141.7M', memory: '13.45KiB')] + #[ExpectedFor(dataSet: 'call-to-method-statement-with-no-discard-dynamic-name', instructions: '20.65K', cycles: '50.36K', memory: '0B')] + #[ExpectedFor(dataSet: 'call-to-static-method-statement-with-no-discard-dynamic-name', instructions: '20.80K', cycles: '50.52K', memory: '0B')] + #[ExpectedFor(dataSet: 'dump-native-type-no-arguments', instructions: '21.29K', cycles: '48.81K', memory: '0B')] + #[ExpectedFor(dataSet: 'dump-phpdoc-type-no-arguments', instructions: '21.29K', cycles: '49.24K', memory: '0B')] + #[ExpectedFor(dataSet: 'dump-type-no-arguments', instructions: '21.29K', cycles: '48.80K', memory: '0B')] + #[ExpectedFor(dataSet: 'duplicate-class-declaration', instructions: '116.2K', cycles: '225.8K', memory: '0B')] + #[ExpectedFor(dataSet: 'duplicate-function-declaration', instructions: '163.0K', cycles: '296.3K', memory: '0B')] + #[ExpectedFor(dataSet: 'file-assert-one-argument', instructions: '21.25K', cycles: '49.98K', memory: '0B')] + #[ExpectedFor(dataSet: 'final-private-method-constructors', instructions: '138.0K', cycles: '236.5K', memory: '0B')] + #[ExpectedFor(dataSet: 'function-conditional-return-type-without-conditional-types', instructions: '143.7K', cycles: '258.3K', memory: '376B')] + #[ExpectedFor(dataSet: 'impossible-check-type-function-call', instructions: '6.246M', cycles: '8.939M', memory: '5.758KiB')] + #[ExpectedFor(dataSet: 'instantiation-restricted-constructor', instructions: '3.525M', cycles: '5.588M', memory: '6.531KiB')] + #[ExpectedFor(dataSet: 'node-connecting-visitor-attributes-no-arguments', instructions: '38.75K', cycles: '77.96K', memory: '40B')] + #[ExpectedFor(dataSet: 'old-php-parser4-class-outside-class', instructions: '20.21K', cycles: '44.86K', memory: '0B')] + #[ExpectedFor(dataSet: 'parameter-castable-to-string-diff-assoc', instructions: '6.028M', cycles: '7.718M', memory: '3.172KiB')] + #[ExpectedFor(dataSet: 'restricted-function-usage', instructions: '282.2K', cycles: '443.2K', memory: '848B')] + #[ExpectedFor(dataSet: 'restricted-method-usage', instructions: '376.2K', cycles: '607.7K', memory: '256B')] + public function benchProcessNode(string $rule, string $code, ?int $phpVersion): int + { + if ($this->rule === null) { + throw new LogicException('boot() did not run.'); + } + + $errors = 0; + foreach ($this->nodes as [$node, $scope]) { + $errors += count($this->rule->processNode($node, $scope)); + } + + return $errors; + } + + /** + * @param callable(int): string $class + */ + private static function classes(callable $class): string + { + return ' sprintf("'%s%d' => %s", $prefix, $i, $value), range(1, 16))); + } + + /** + * Functions whose four parameters and return type are array shapes of 16 keys. + */ + private static function shapeFunctions(): string + { + $shape = 'array{' . implode(', ', array_map(static fn (int $i): string => sprintf('k%d: int|string', $i), range(1, 16))) . '}'; + $functions = []; + for ($i = 0; $i < 16; $i++) { + $functions[] = sprintf( + "/**\n * @param %1\$s \$a\n * @param %1\$s \$b\n * @param %1\$s \$c\n * @param %1\$s \$d\n * @return %1\$s\n */\nfunction shape%2\$d(array \$a, array \$b, array \$c, array \$d): array { return \$a; }", + $shape, + $i, + ); + } + + return implode(' ', $functions); + } + +} diff --git a/tests/bench/TemplateTypeBench.php b/tests/bench/TemplateTypeBench.php new file mode 100644 index 00000000000..76b8a4c241b --- /dev/null +++ b/tests/bench/TemplateTypeBench.php @@ -0,0 +1,133 @@ + */ + private array $withDefaults = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function members(): iterable + { + foreach ([16, 256] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + $this->template = TemplateTypeFactory::create( + TemplateTypeScope::createWithFunction('foo'), + 'T', + new IntegerType(), + TemplateTypeVariance::createInvariant(), + ); + $this->template->inferTemplateTypes(self::strings('warmup', 4)); + $this->strings = self::strings('string', $members); + + self::describeAll(self::withDefaults(4)); + $this->withDefaults = self::withDefaults($members); + } + + /** + * T0 of int = 0, T1 of int = 1, …: the description of a default is guarded against recursion. + */ + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '150.0K', cycles: '261.1K', memory: '2.820KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '2.385M', cycles: '2.976M', memory: '10.51KiB')] + public function benchDescribeWithDefault(int $members): string + { + return self::describeAll($this->withDefaults); + } + + /** + * @param list $templates + */ + private static function describeAll(array $templates): string + { + $description = ''; + foreach ($templates as $template) { + $description .= $template->describe(VerbosityLevel::typeOnly()); + } + + return $description; + } + + /** + * @return list + */ + private static function withDefaults(int $members): array + { + $templates = []; + for ($i = 0; $i < $members; $i++) { + $templates[] = TemplateTypeFactory::create( + TemplateTypeScope::createWithFunction('foo'), + 'T' . $i, + new IntegerType(), + TemplateTypeVariance::createInvariant(), + default: new ConstantIntegerType($i), + ); + } + + return $templates; + } + + /** + * None of the strings is an int, so T is not inferred from any of them. + */ + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '72.31K', cycles: '193.8K', memory: '1.469KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '930.9K', cycles: '1.141M', memory: '32.22KiB')] + public function benchInferTemplateTypesFromUnion(int $members): TemplateTypeMap + { + if ($this->template === null || $this->strings === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->template->inferTemplateTypes($this->strings); + } + + private static function strings(string $prefix, int $members): UnionType + { + $strings = []; + for ($i = 0; $i < $members; $i++) { + $strings[] = new ConstantStringType($prefix . $i); + } + + return new UnionType($strings); + } + +} diff --git a/tests/bench/TemplateTypeHelperBench.php b/tests/bench/TemplateTypeHelperBench.php new file mode 100644 index 00000000000..8c2a0aa684b --- /dev/null +++ b/tests/bench/TemplateTypeHelperBench.php @@ -0,0 +1,83 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 256] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + BenchContainer::create($this->scratch); + self::resolve(self::shape('warmup', 4)); + $this->shape = self::shape('key', $keys); + } + + /** + * An array shape without template types, such as the return type of most functions. + */ + #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '8.998K', cycles: '46.85K', memory: '0B')] + #[ExpectedFor(dataSet: 'keys-256', instructions: '95.16K', cycles: '205.0K', memory: '0B')] + public function benchResolveTemplateTypes(int $keys): Type + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return self::resolve($this->shape); + } + + private static function resolve(Type $type): Type + { + return TemplateTypeHelper::resolveTemplateTypes( + $type, + TemplateTypeMap::createEmpty(), + TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + ); + } + + private static function shape(string $prefix, int $keys): Type + { + $keyTypes = []; + $valueTypes = []; + for ($i = 0; $i < $keys; $i++) { + $keyTypes[] = new ConstantStringType($prefix . $i); + $valueTypes[] = new IntegerType(); + } + + return new ConstantArrayType($keyTypes, $valueTypes); + } + +} diff --git a/tests/bench/TrinaryLogicBench.php b/tests/bench/TrinaryLogicBench.php new file mode 100644 index 00000000000..72a45b6f6c4 --- /dev/null +++ b/tests/bench/TrinaryLogicBench.php @@ -0,0 +1,149 @@ + */ + private array $operands = []; + + /** @var list */ + private array $objects = []; + + /** + * One operand is the common case, which and() and or() take without an array. + * + * @return iterable + */ + public static function operands(): iterable + { + yield 'operands-1' => [1]; + yield 'operands-4' => [4]; + } + + /** + * @return iterable + */ + public static function fourOperands(): iterable + { + yield 'operands-4' => [4]; + } + + /** + * @return iterable + */ + public static function objects(): iterable + { + yield 'objects-8' => [8]; + } + + #[Before] + public function boot(int $count): void + { + $values = [TrinaryLogic::createMaybe(), TrinaryLogic::createYes(), TrinaryLogic::createMaybe(), TrinaryLogic::createNo()]; + $this->operands = []; + $this->objects = []; + for ($i = 0; $i < $count; $i++) { + $this->operands[] = $values[$i % 4]; + $this->objects[] = $i; + } + } + + #[DataProvider(method: 'operands')] + #[ExpectedFor(dataSet: 'operands-1', instructions: '1.111M', cycles: '1.135M', memory: '0B')] + #[ExpectedFor(dataSet: 'operands-4', instructions: '2.356M', cycles: '2.387M', memory: '216B')] + public function benchAnd(int $operands): TrinaryLogic + { + $result = TrinaryLogic::createYes(); + $operandList = $this->operands; + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createYes()->and(...$operandList); + } + + return $result; + } + + #[DataProvider(method: 'operands')] + #[ExpectedFor(dataSet: 'operands-1', instructions: '1.111M', cycles: '1.136M', memory: '0B')] + #[ExpectedFor(dataSet: 'operands-4', instructions: '2.356M', cycles: '2.388M', memory: '216B')] + public function benchOr(int $operands): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + $operandList = $this->operands; + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createNo()->or(...$operandList); + } + + return $result; + } + + #[DataProvider(method: 'fourOperands')] + #[ExpectedFor(dataSet: 'operands-4', instructions: '2.739M', cycles: '2.771M', memory: '216B')] + public function benchMaxMin(int $operands): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + $operandList = $this->operands; + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::maxMin(...$operandList); + } + + return $result; + } + + /** + * The lazy methods stop at the first no or yes, so every callback answers maybe. + */ + #[DataProvider(method: 'objects')] + #[ExpectedFor(dataSet: 'objects-8', instructions: '11.62M', cycles: '11.92M', memory: '832B')] + public function benchLazyAnd(int $objects): TrinaryLogic + { + $result = TrinaryLogic::createYes(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createYes()->lazyAnd($this->objects, static fn (int $object): TrinaryLogic => TrinaryLogic::createMaybe()); + } + + return $result; + } + + /** + * The lazy methods stop at the first no or yes, so every callback answers maybe. + */ + #[DataProvider(method: 'objects')] + #[ExpectedFor(dataSet: 'objects-8', instructions: '11.62M', cycles: '11.98M', memory: '832B')] + public function benchLazyOr(int $objects): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::createNo()->lazyOr($this->objects, static fn (int $object): TrinaryLogic => TrinaryLogic::createMaybe()); + } + + return $result; + } + + /** + * The lazy methods stop at the first no or yes, so every callback answers maybe. + */ + #[DataProvider(method: 'objects')] + #[ExpectedFor(dataSet: 'objects-8', instructions: '8.283M', cycles: '8.323M', memory: '400B')] + public function benchLazyMaxMin(int $objects): TrinaryLogic + { + $result = TrinaryLogic::createNo(); + for ($i = 0; $i < self::CALLS; $i++) { + $result = TrinaryLogic::lazyMaxMin($this->objects, static fn (int $object): TrinaryLogic => TrinaryLogic::createMaybe()); + } + + return $result; + } + +} diff --git a/tests/bench/TypeCombinatorBench.php b/tests/bench/TypeCombinatorBench.php new file mode 100644 index 00000000000..5af4f0d84f6 --- /dev/null +++ b/tests/bench/TypeCombinatorBench.php @@ -0,0 +1,338 @@ + */ + private array $strings = []; + + /** @var list */ + private array $integers = []; + + /** @var list */ + private array $arrays = []; + + /** @var list */ + private array $unions = []; + + /** @var list */ + private array $taggedArrays = []; + + /** @var list */ + private array $benevolentUnions = []; + + /** @var list */ + private array $generalScalars = []; + + /** @var list */ + private array $numericStringAndStrings = []; + + /** @var list */ + private array $arraysWithSameKeys = []; + + /** @var list */ + private array $arraysWithOwnKeys = []; + + /** @var list */ + private array $intersections = []; + + /** @var list */ + private array $stringsAndNevers = []; + + private ?Type $enumOrNull = null; + + private ?UnionType $enumCases = null; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function types(): iterable + { + foreach ([16, 64, 256] as $types) { + yield 'types-' . $types => [$types]; + } + } + + /** + * @return iterable + */ + public static function smallest(): iterable + { + yield 'types-16' => [16]; + } + + #[Before] + public function boot(int $types): void + { + BenchContainer::create($this->scratch); + + // TypeCombinator memoizes its results, so the warm-up uses other types than the benchmarks + $this->createTypes('warmup', 4); + TypeCombinator::union(...$this->strings); + TypeCombinator::union(...$this->integers); + TypeCombinator::union(...$this->arrays); + TypeCombinator::intersect(...$this->unions); + TypeCombinator::remove(...$this->unions); + TypeCombinator::union(...$this->taggedArrays); + TypeCombinator::intersect(...$this->benevolentUnions); + TypeCombinator::union(...$this->generalScalars); + TypeCombinator::union(...$this->numericStringAndStrings); + TypeCombinator::union(...$this->arraysWithSameKeys); + TypeCombinator::union(...$this->arraysWithOwnKeys); + TypeCombinator::intersect(...$this->intersections); + TypeCombinator::union(...$this->stringsAndNevers); + TypeCombinator::intersect(new StringType(), new AccessoryNonEmptyStringType(), new AccessoryNumericStringType(), new AccessoryLowercaseStringType()); + $warmupEnum = BenchEnumFile::create($this->scratch, 'Warmup4', 4); + TypeCombinator::remove(new UnionType([new ObjectType($warmupEnum), new NullType()]), new UnionType(BenchEnumFile::cases($warmupEnum, 4))); + + $this->createTypes('type', $types); + $enum = BenchEnumFile::create($this->scratch, 'Cases' . $types, $types); + $this->enumOrNull = new UnionType([new ObjectType($enum), new NullType()]); + $this->enumCases = new UnionType(BenchEnumFile::cases($enum, $types)); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchUnionConstantStrings(int $types): Type + { + return TypeCombinator::union(...$this->strings); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchUnionConstantIntegers(int $types): Type + { + return TypeCombinator::union(...$this->integers); + } + + /** + * Each array is compared with the arrays already in the union, to merge the ones that differ + * only in their values. + */ + #[DataProvider(method: 'types')] + #[Polynomial(size: 'types', degree: 2)] + public function benchUnionConstantArrays(int $types): Type + { + return TypeCombinator::union(...$this->arrays); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchIntersectUnions(int $types): Type + { + return TypeCombinator::intersect(...$this->unions); + } + + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchRemoveFromUnion(int $types): Type + { + return TypeCombinator::remove(...$this->unions); + } + + /** + * Arrays of the same keys that differ in two constant values cannot be keys-supersets of each other. + */ + #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '1.491M', cycles: '2.056M', memory: '36.67KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '6.140M', cycles: '7.464M', memory: '135.3KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '32.93M', cycles: '39.25M', memory: '566.8KiB')] + public function benchUnionTaggedConstantArrays(int $types): Type + { + return TypeCombinator::union(...$this->taggedArrays); + } + + #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '168.4K', cycles: '382.9K', memory: '8.875KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '601.1K', cycles: '919.0K', memory: '26.18KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '2.307M', cycles: '3.020M', memory: '107.7KiB')] + public function benchIntersectBenevolentUnion(int $types): Type + { + return TypeCombinator::intersect(...$this->benevolentUnions); + } + + /** + * Each member is a general bool, float, int or string. + */ + #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '436.3K', cycles: '627.1K', memory: '3.430KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '473.0K', cycles: '682.2K', memory: '8.453KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '1.723M', cycles: '2.065M', memory: '38.72KiB')] + public function benchUnionGeneralScalars(int $types): Type + { + return TypeCombinator::union(...$this->generalScalars); + } + + /** + * numeric-string is compared with each constant string. + */ + #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '706.8K', cycles: '1.176M', memory: '9.969KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '2.643M', cycles: '3.650M', memory: '37.66KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '10.38M', cycles: '13.58M', memory: '146.4KiB')] + public function benchUnionNumericStringAndConstantStrings(int $types): Type + { + return TypeCombinator::union(...$this->numericStringAndStrings); + } + + /** + * The constant arrays have the same eight keys, which the general array gets once. + */ + #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '2.856M', cycles: '3.610M', memory: '64.25KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '10.91M', cycles: '12.94M', memory: '211.5KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '43.08M', cycles: '50.19M', memory: '813.5KiB')] + public function benchUnionGeneralArrayAndConstantArrays(int $types): Type + { + return TypeCombinator::union(...$this->arraysWithSameKeys); + } + + /** + * Each constant array has eight keys of its own. More than 256 values in all are generalized + * to an oversized array. + */ + #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '3.245M', cycles: '4.253M', memory: '214.9KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '100.9M', cycles: '127.5M', memory: '902.8KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '315.7M', cycles: '400.5M', memory: '3.128MiB')] + public function benchUnionConstantArraysWithOwnKeys(int $types): Type + { + return TypeCombinator::union(...$this->arraysWithOwnKeys); + } + + /** + * Intersections of stdClass with one method each are flattened into one. + */ + #[DataProvider(method: 'types')] + #[ExpectedFor(dataSet: 'types-16', instructions: '1.992M', cycles: '2.574M', memory: '6.297KiB')] + #[ExpectedFor(dataSet: 'types-64', instructions: '22.29M', cycles: '23.87M', memory: '35.36KiB')] + #[ExpectedFor(dataSet: 'types-256', instructions: '315.1M', cycles: '322.1M', memory: '115.6KiB')] + public function benchIntersectIntersections(int $types): Type + { + return TypeCombinator::intersect(...$this->intersections); + } + + /** + * The implicit never types an empty branch contributes are dropped from the union. + */ + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchUnionWithNevers(int $types): Type + { + return TypeCombinator::union(...$this->stringsAndNevers); + } + + /** + * Accessory types only, which need no sorting, a hundred times. + */ + #[DataProvider(method: 'smallest')] + #[ExpectedFor(dataSet: 'types-16', instructions: '5.855M', cycles: '6.808M', memory: '2.297KiB')] + public function benchIntersectAccessories(int $types): Type + { + $type = new StringType(); + for ($i = 0; $i < 100; $i++) { + $type = TypeCombinator::intersect(new StringType(), new AccessoryNonEmptyStringType(), new AccessoryNumericStringType(), new AccessoryLowercaseStringType()); + } + + return $type; + } + + /** + * Enum|null minus all the cases of the enum, as a match over all of them leaves it. + */ + #[DataProvider(method: 'types')] + #[Linear(size: 'types')] + public function benchRemoveEnumCases(int $types): Type + { + if ($this->enumOrNull === null || $this->enumCases === null) { + throw new LogicException('boot() did not run.'); + } + + return TypeCombinator::remove($this->enumOrNull, $this->enumCases); + } + + private function createTypes(string $prefix, int $types): void + { + $this->strings = []; + $this->integers = []; + $this->arrays = []; + $this->stringsAndNevers = []; + $others = []; + for ($i = 0; $i < $types; $i++) { + $this->stringsAndNevers[] = new ConstantStringType($prefix . $i); + $this->stringsAndNevers[] = new NeverType(); + $this->strings[] = new ConstantStringType($prefix . $i); + $this->integers[] = new ConstantIntegerType($i * 3); + $this->arrays[] = new ConstantArrayType([new ConstantStringType($prefix . $i)], [new ConstantIntegerType($i)]); + // half of them in both unions + $others[] = new ConstantStringType($prefix . ($i * 2)); + } + $this->unions = [new UnionType($this->strings), new UnionType($others)]; + $this->benevolentUnions = [new BenevolentUnionType($this->strings), new UnionType($others)]; + $this->taggedArrays = []; + for ($i = 0; $i < $types; $i++) { + $this->taggedArrays[] = new ConstantArrayType( + [new ConstantStringType('kind'), new ConstantStringType('id')], + [new ConstantStringType($prefix . $i), new ConstantIntegerType($i)], + ); + } + + $this->generalScalars = []; + $this->numericStringAndStrings = [new IntersectionType([new StringType(), new AccessoryNumericStringType()])]; + $this->arraysWithSameKeys = [new ArrayType(new IntegerType(), new StringType())]; + $this->arraysWithOwnKeys = []; + $this->intersections = []; + $generalScalars = [BooleanType::class, FloatType::class, IntegerType::class, StringType::class]; + for ($i = 0; $i < $types; $i++) { + $this->generalScalars[] = new $generalScalars[$i % 4](); + $this->numericStringAndStrings[] = new ConstantStringType($prefix . $i); + $sameKeys = []; + $ownKeys = []; + $values = []; + for ($j = 0; $j < 8; $j++) { + $sameKeys[] = new ConstantStringType($prefix . 'key' . $j); + $ownKeys[] = new ConstantStringType($prefix . $i . 'key' . $j); + $values[] = new ConstantIntegerType($i * 8 + $j); + } + $this->arraysWithSameKeys[] = new ConstantArrayType($sameKeys, $values); + $this->arraysWithOwnKeys[] = new ConstantArrayType($ownKeys, $values); + $this->intersections[] = new IntersectionType([new ObjectType(stdClass::class), new HasMethodType($prefix . $i)]); + } + } + +} diff --git a/tests/bench/TypeNodeResolverBench.php b/tests/bench/TypeNodeResolverBench.php new file mode 100644 index 00000000000..78c6ce49584 --- /dev/null +++ b/tests/bench/TypeNodeResolverBench.php @@ -0,0 +1,79 @@ + + */ + public static function items(): iterable + { + foreach ([4, 32] as $items) { + yield 'items-' . $items => [$items]; + } + } + + #[Before] + public function boot(int $items): void + { + $container = BenchContainer::create($this->scratch); + $this->resolver = $container->getByType(TypeNodeResolver::class); + $lexer = $container->getByType(Lexer::class); + $typeParser = $container->getByType(TypeParser::class); + + $parse = static fn (string $type): TypeNode => $typeParser->parse(new TokenIterator($lexer->tokenize($type))); + $this->resolver->resolve($parse(self::shape(3, 'float')), new NameScope(null, [])); + + $this->typeNode = $parse(self::shape($items, 'int')); + } + + /** + * A non-empty-list shape gets both the list and the non-empty accessory. + */ + #[DataProvider(method: 'items')] + #[ExpectedFor(dataSet: 'items-4', instructions: '80.52K', cycles: '323.3K', memory: '5.430KiB')] + #[ExpectedFor(dataSet: 'items-32', instructions: '545.4K', cycles: '854.6K', memory: '15.37KiB')] + public function benchResolveNonEmptyListShape(int $items): Type + { + if ($this->resolver === null || $this->typeNode === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->resolver->resolve($this->typeNode, new NameScope(null, [])); + } + + private static function shape(int $items, string $valueType): string + { + $values = []; + for ($i = 0; $i < $items; $i++) { + $values[] = $valueType; + } + + return 'non-empty-list{' . implode(', ', $values) . '}'; + } + +} diff --git a/tests/bench/TypeSpecifierBench.php b/tests/bench/TypeSpecifierBench.php new file mode 100644 index 00000000000..0ecfa6cd8dd --- /dev/null +++ b/tests/bench/TypeSpecifierBench.php @@ -0,0 +1,236 @@ + + */ + public static function chains(): iterable + { + foreach (['falsey', 'truthy'] as $context) { + foreach ([16, 64] as $arms) { + yield $context . '-' . $arms => [$context, $arms]; + } + } + } + + /** + * @return iterable + */ + public static function contexts(): iterable + { + yield 'truthy' => ['truthy']; + yield 'true' => ['true']; + } + + #[Before] + public function boot(): void + { + $container = BenchContainer::create($this->scratch); + $this->typeSpecifier = $container->getByType(TypeSpecifier::class); + $object = new ObjectType(SplObjectStorage::class); + $this->scope = $container->getByType(ScopeFactory::class)->create(ScopeContext::create(__FILE__)) + ->assignVariable('t', new StringType(), new StringType(), TrinaryLogic::createYes()) + ->assignVariable('i', new IntegerType(), new IntegerType(), TrinaryLogic::createYes()) + ->assignVariable('o', $object, $object, TrinaryLogic::createYes()); + + // the warm-up uses other expressions, the scope remembers the types it resolved + $this->specify(self::orChain('warmup', 4), TypeSpecifierContext::createFalsey()); + $this->specify(self::orChain('warmup', 4), TypeSpecifierContext::createTruthy()); + } + + /** + * 'a' === $t || 'b' === $t || ..., which PHP parses to a chain as deep as it is long. + */ + #[DataProvider(method: 'chains')] + #[ExpectedFor(dataSet: 'falsey-16', instructions: '11.38M', cycles: '16.93M', memory: '476.1KiB')] + #[ExpectedFor(dataSet: 'falsey-64', instructions: '50.77M', cycles: '71.38M', memory: '1.886MiB')] + #[ExpectedFor(dataSet: 'truthy-16', instructions: '16.18M', cycles: '23.04M', memory: '497.0KiB')] + #[ExpectedFor(dataSet: 'truthy-64', instructions: '94.60M', cycles: '122.5M', memory: '2.028MiB')] + public function benchSpecifyBooleanOrChain(string $context, int $arms): SpecifiedTypes + { + $chain = $this->meter->exclude(static fn (): Expr => self::orChain('arm', $arms)); + + return $this->specify($chain, $context === 'falsey' ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createTruthy()); + } + + /** + * $i ?: $t + */ + #[Expected(instructions: '186.7M', cycles: '239.2M', memory: '2.017MiB')] + public function benchSpecifyShortTernary(): int + { + $exprs = $this->meter->exclude(static function (): array { + $exprs = []; + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new Ternary(new Variable('i'), null, new Identical(new Variable('t'), new String_('t' . $i))); + } + + return $exprs; + }); + + return $this->specifyAll($exprs, TypeSpecifierContext::createTruthy()); + } + + /** + * An int remembered by match() compared with a string is always false. + */ + #[Expected(instructions: '38.39M', cycles: '52.40M', memory: '284.4KiB')] + public function benchSpecifyAlwaysFalseIdentical(): int + { + $exprs = $this->meter->exclude(static function (): array { + $exprs = []; + $int = new IntegerType(); + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new Identical(new AlwaysRememberedExpr(new Variable('i'), $int, $int), new String_('s' . $i)); + } + + return $exprs; + }); + + return $this->specifyAll($exprs, TypeSpecifierContext::createFalse()); + } + + /** + * abs($i) > 0 is not one of the calls a comparison narrows, like count() or strlen(). + */ + #[Expected(instructions: '132.1M', cycles: '172.5M', memory: '140.2KiB')] + public function benchSpecifyComparisonWithCall(): int + { + $exprs = $this->meter->exclude(function (): array { + // loads the reflection of abs() + $this->specify(new Greater(new FuncCall(new Name('abs'), [new Arg(new Variable('i'))]), new Int_(-1)), TypeSpecifierContext::createTruthy()); + $exprs = []; + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new Greater(new FuncCall(new Name('abs'), [new Arg(new Variable('i'))]), new Int_($i)); + } + + return $exprs; + }); + + return $this->specifyAll($exprs, TypeSpecifierContext::createTruthy()); + } + + /** + * Creating the narrowing of an expression: only the true and false contexts need its key. + */ + #[DataProvider(method: 'contexts')] + #[ExpectedFor(dataSet: 'true', instructions: '4.120M', cycles: '5.675M', memory: '79.96KiB')] + #[ExpectedFor(dataSet: 'truthy', instructions: '4.120M', cycles: '5.681M', memory: '79.96KiB')] + public function benchCreate(string $context): int + { + [$exprs, $type] = $this->meter->exclude(function (): array { + // loads the reflection of the method + $this->typeSpecifier()->create(new MethodCall(new Variable('o'), 'contains', [new Arg(new Variable('warmup'))]), new StringType(), TypeSpecifierContext::createTrue(), $this->scope()); + $exprs = []; + for ($i = 0; $i < self::EXPRESSIONS; $i++) { + $exprs[] = new MethodCall(new Variable('o'), 'contains', [new Arg(new Variable('o' . $i))]); + } + + return [$exprs, new StringType()]; + }); + + $typeContext = $context === 'true' ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTruthy(); + $typeSpecifier = $this->typeSpecifier(); + $scope = $this->scope(); + $sure = 0; + foreach ($exprs as $expr) { + $sure += count($typeSpecifier->create($expr, $type, $typeContext, $scope)->getSureTypes()); + } + + return $sure; + } + + /** + * @param list $exprs + */ + private function specifyAll(array $exprs, TypeSpecifierContext $context): int + { + $sure = 0; + foreach ($exprs as $expr) { + $sure += count($this->specify($expr, $context)->getSureNotTypes()); + } + + return $sure; + } + + private function specify(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + { + return $this->typeSpecifier()->specifyTypesInCondition($this->scope(), $expr, $context); + } + + private static function orChain(string $prefix, int $arms): Expr + { + $chain = new Identical(new String_($prefix . '0'), new Variable('t')); + for ($i = 1; $i < $arms; $i++) { + $chain = new BooleanOr($chain, new Identical(new String_($prefix . $i), new Variable('t'))); + } + + return $chain; + } + + private function scope(): MutatingScope + { + if ($this->scope === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->scope; + } + + private function typeSpecifier(): TypeSpecifier + { + if ($this->typeSpecifier === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->typeSpecifier; + } + +} diff --git a/tests/bench/TypeUtilsBench.php b/tests/bench/TypeUtilsBench.php new file mode 100644 index 00000000000..214ca83a03f --- /dev/null +++ b/tests/bench/TypeUtilsBench.php @@ -0,0 +1,71 @@ + + */ + public static function members(): iterable + { + foreach ([16, 128] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + TypeUtils::resolveLateResolvableTypes(self::union('warmup', 4)); + + $this->type = self::union('member', $members); + } + + /** + * The traverser visits every member of a union that holds a conditional type to resolve it. + */ + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '320.6K', cycles: '570.7K', memory: '9.563KiB')] + #[ExpectedFor(dataSet: 'members-128', instructions: '2.804M', cycles: '3.271M', memory: '66.31KiB')] + public function benchResolveLateResolvableTypes(int $members): Type + { + if ($this->type === null) { + throw new LogicException('boot() did not run.'); + } + + return TypeUtils::resolveLateResolvableTypes($this->type); + } + + private static function union(string $prefix, int $members): Type + { + $types = [new ConditionalType(new IntegerType(), new IntegerType(), new StringType(), new IntegerType(), false)]; + for ($i = 1; $i < $members; $i++) { + $types[] = new ConstantStringType($prefix . $i); + } + + return new UnionType($types); + } + +} diff --git a/tests/bench/UnionTypeBench.php b/tests/bench/UnionTypeBench.php new file mode 100644 index 00000000000..4eda0acad8d --- /dev/null +++ b/tests/bench/UnionTypeBench.php @@ -0,0 +1,222 @@ + + */ + public static function members(): iterable + { + foreach ([16, 64, 256] as $members) { + yield 'members-' . $members => [$members]; + } + } + + #[Before] + public function boot(int $members): void + { + BenchContainer::create($this->scratch); + + [$union, $other] = self::unions('warmup', 4); + $union->describe(VerbosityLevel::precise()); + $union->isSuperTypeOf($other); + self::intersections('warmup', 4)->describe(VerbosityLevel::precise()); + self::arrays(4)->getIterableValueType(); + + [$this->union, $this->other] = self::unions('member', $members); + $this->benevolent = new BenevolentUnionType($this->union->getTypes()); + + // the queries the union memoizes + $this->union->getFiniteTypes(); + $this->union->isNull(); + $this->union->isCallable(); + $this->intersections = self::intersections('member', $members); + $this->arrays = self::arrays($members); + } + + /** + * Each member is an intersection, described in parentheses. + */ + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '1.728M', cycles: '2.738M', memory: '16.65KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '7.620M', cycles: '10.73M', memory: '44.40KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '39.92M', cycles: '63.68M', memory: '198.8KiB')] + public function benchDescribeIntersections(int $members): string + { + if ($this->intersections === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->intersections->describe(VerbosityLevel::precise()); + } + + /** + * The value types of the member arrays are unioned. + */ + #[DataProvider(method: 'members')] + #[ExpectedFor(dataSet: 'members-16', instructions: '151.6K', cycles: '310.4K', memory: '4.680KiB')] + #[ExpectedFor(dataSet: 'members-64', instructions: '573.3K', cycles: '796.1K', memory: '12.18KiB')] + #[ExpectedFor(dataSet: 'members-256', instructions: '2.259M', cycles: '2.771M', memory: '62.18KiB')] + public function benchGetIterableValueType(int $members): Type + { + if ($this->arrays === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->arrays->getIterableValueType(); + } + + /** + * The members are described in a sorted order. + */ + #[DataProvider(method: 'members')] + #[Linearithmic(size: 'members')] + public function benchDescribe(int $members): string + { + return $this->union()->describe(VerbosityLevel::precise()); + } + + #[DataProvider(method: 'members')] + #[Linear(size: 'members')] + public function benchIsSuperTypeOf(int $members): bool + { + if ($this->other === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->union()->isSuperTypeOf($this->other)->yes(); + } + + /** + * Every member stays the same, so the union does too. + */ + #[DataProvider(method: 'members')] + #[Linear(size: 'members')] + public function benchUnchangedToString(int $members): Type + { + return $this->union()->toString(); + } + + #[DataProvider(method: 'members')] + #[Linear(size: 'members')] + public function benchBenevolentUnchangedToString(int $members): Type + { + if ($this->benevolent === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->benevolent->toString(); + } + + /** + * Asked again, as boot() asked it already. + * + * @return list + */ + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchGetFiniteTypesAgain(int $members): array + { + return $this->union()->getFiniteTypes(); + } + + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchIsNullAgain(int $members): TrinaryLogic + { + return $this->union()->isNull(); + } + + #[DataProvider(method: 'members')] + #[Constant(size: 'members')] + public function benchIsCallableAgain(int $members): TrinaryLogic + { + return $this->union()->isCallable(); + } + + private function union(): UnionType + { + if ($this->union === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->union; + } + + private static function intersections(string $prefix, int $members): UnionType + { + $types = []; + for ($i = 0; $i < $members; $i++) { + $types[] = new IntersectionType([new ObjectType(stdClass::class), new HasMethodType($prefix . $i)]); + } + + return new UnionType($types); + } + + /** + * Lists with one constant integer each. + */ + private static function arrays(int $members): UnionType + { + $types = []; + for ($i = 0; $i < $members; $i++) { + $types[] = new ConstantArrayType([new ConstantIntegerType(0)], [new ConstantIntegerType($i)], [1], [], TrinaryLogic::createYes()); + } + + return new UnionType($types); + } + + /** + * @return array{UnionType, UnionType} the other has half of the members of the first + */ + private static function unions(string $prefix, int $members): array + { + $types = []; + $others = []; + for ($i = 0; $i < $members; $i++) { + $types[] = new ConstantStringType($prefix . $i); + $others[] = new ConstantStringType($prefix . ($i * 2)); + } + + return [new UnionType($types), new UnionType($others)]; + } + +} diff --git a/tests/bench/UnionTypeHelperBench.php b/tests/bench/UnionTypeHelperBench.php new file mode 100644 index 00000000000..a1ad7f97238 --- /dev/null +++ b/tests/bench/UnionTypeHelperBench.php @@ -0,0 +1,57 @@ + */ + private array $cases = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + /** + * @return iterable + */ + public static function cases(): iterable + { + foreach ([16, 64, 256] as $cases) { + yield 'cases-' . $cases => [$cases]; + } + } + + #[Before] + public function boot(int $cases): void + { + BenchContainer::create($this->scratch); + + $warmup = BenchEnumFile::create($this->scratch, 'Warmup4', 4); + UnionTypeHelper::sortTypes(array_reverse(BenchEnumFile::cases($warmup, 4))); + + $enum = BenchEnumFile::create($this->scratch, 'Cases' . $cases, $cases); + $this->cases = array_reverse(BenchEnumFile::cases($enum, $cases)); + } + + /** + * A match over a large enum builds a union of its remaining cases for every arm. + * + * @return list + */ + #[DataProvider(method: 'cases')] + #[Linearithmic(size: 'cases')] + public function benchSortEnumCases(int $cases): array + { + return UnionTypeHelper::sortTypes($this->cases); + } + +} diff --git a/tests/bench/UnresolvableTypeHelperBench.php b/tests/bench/UnresolvableTypeHelperBench.php new file mode 100644 index 00000000000..c9ef98f6352 --- /dev/null +++ b/tests/bench/UnresolvableTypeHelperBench.php @@ -0,0 +1,100 @@ + + */ + public static function keys(): iterable + { + foreach ([16, 64] as $keys) { + yield 'keys-' . $keys => [$keys]; + } + } + + #[Before] + public function boot(int $keys): void + { + $helper = BenchContainer::create($this->scratch)->getByType(UnresolvableTypeHelper::class); + $class = new ReflectionClass($helper); + $method = $class->hasMethod('getUnresolvableType') ? 'getUnresolvableType' : 'containsUnresolvableType'; + $this->find = $class->getMethod($method)->getClosure($helper); + $this->find(self::shape(2)); + $this->type = self::shape($keys); + } + + /** + * An array shape whose first value is unresolvable and whose other values are array shapes: + * once the first value is found, the rest of the type is not traversed. + */ + #[DataProvider(method: 'keys')] + #[ExpectedFor(dataSet: 'keys-16', instructions: '74.44K', cycles: '182.6K', memory: '6.461KiB')] + #[ExpectedFor(dataSet: 'keys-64', instructions: '260.1K', cycles: '413.9K', memory: '25.21KiB')] + public function benchUnresolvableFirst(int $keys): bool + { + if ($this->type === null) { + throw new LogicException('boot() did not run.'); + } + + return $this->find($this->type); + } + + private function find(Type $type): bool + { + if ($this->find === null) { + throw new LogicException('boot() did not run.'); + } + + $result = ($this->find)($type); + + return $result !== null && $result !== false; + } + + private static function shape(int $keys): ConstantArrayType + { + $innerKeys = []; + $innerValues = []; + for ($i = 0; $i < 16; $i++) { + $innerKeys[] = new ConstantStringType('inner' . $i); + $innerValues[] = new UnionType([new IntegerType(), new StringType()]); + } + + $outerKeys = []; + $outerValues = []; + for ($i = 0; $i < $keys; $i++) { + $outerKeys[] = new ConstantStringType('key' . $i); + $outerValues[] = $i === 0 ? new ErrorType() : new ConstantArrayType($innerKeys, $innerValues); + } + + return new ConstantArrayType($outerKeys, $outerValues); + } + +} diff --git a/tests/bench/VarTagTypeRuleHelperBench.php b/tests/bench/VarTagTypeRuleHelperBench.php new file mode 100644 index 00000000000..99a4965b45b --- /dev/null +++ b/tests/bench/VarTagTypeRuleHelperBench.php @@ -0,0 +1,69 @@ + */ + private array $assigned = []; + + public function __construct(private ScratchDirectory $scratch) + { + } + + #[Before] + public function boot(): void + { + $analysed = AnalysedCode::analyse( + $this->scratch, + ' $b */ function test($b): void { ' . str_repeat('/** @var array $a */ $a = $b; ', 16) . '}', + ); + $this->helper = $analysed->getContainer()->getByType(VarTagTypeRuleHelper::class); + $this->varTagType = new ArrayType(new StringType(), new UnionType([new IntegerType(), new StringType()])); + foreach ($analysed->nodes(Expr\Assign::class) as [$assign, $scope]) { + $this->assigned[] = [$assign->expr, $scope]; + } + } + + /** + * Without reportWrongPhpDocTypeInVarTag, only the native type of the expression is checked + * against a @var tag that is not a PHPStan type. + */ + #[Expected(instructions: '353.4K', cycles: '603.9K', memory: '984B')] + public function benchCheckExprTypeWithoutPhpDocCheck(): int + { + if ($this->helper === null || $this->varTagType === null) { + throw new LogicException('boot() did not run.'); + } + + $errors = 0; + foreach ($this->assigned as [$expr, $scope]) { + $errors += count($this->helper->checkExprType($scope, $expr, $this->varTagType)); + } + + return $errors; + } + +} diff --git a/tests/bench/VerbosityLevelBench.php b/tests/bench/VerbosityLevelBench.php new file mode 100644 index 00000000000..9a53801225e --- /dev/null +++ b/tests/bench/VerbosityLevelBench.php @@ -0,0 +1,82 @@ + + */ + public static function values(): iterable + { + foreach ([16, 256] as $values) { + yield 'values-' . $values => [$values]; + } + } + + #[Before] + public function boot(int $values): void + { + BenchContainer::create($this->scratch); + VerbosityLevel::getRecommendedLevelByType(self::shape(4)); + $this->shape = self::shape($values); + } + + /** + * The first value of the array shape, a lowercase-string, already needs the precise level, + * so the arrays in the other values are not looked into. + */ + #[DataProvider(method: 'values')] + #[ExpectedFor(dataSet: 'values-16', instructions: '89.62K', cycles: '224.3K', memory: '2.508KiB')] + #[ExpectedFor(dataSet: 'values-256', instructions: '749.4K', cycles: '937.2K', memory: '9.930KiB')] + public function benchGetRecommendedLevelByType(int $values): VerbosityLevel + { + if ($this->shape === null) { + throw new LogicException('boot() did not run.'); + } + + return VerbosityLevel::getRecommendedLevelByType($this->shape); + } + + /** + * array{lowercase-string, array{0, 1, 2, 3}, array{4, 5, 6, 7}, …} + */ + private static function shape(int $values): Type + { + $keyTypes = [new ConstantIntegerType(0)]; + $valueTypes = [new IntersectionType([new StringType(), new AccessoryLowercaseStringType()])]; + for ($i = 1; $i < $values; $i++) { + $keyTypes[] = new ConstantIntegerType($i); + $innerKeyTypes = []; + $innerValueTypes = []; + for ($j = 0; $j < 4; $j++) { + $innerKeyTypes[] = new ConstantIntegerType($j); + $innerValueTypes[] = new ConstantIntegerType($i * 4 + $j); + } + $valueTypes[] = new ConstantArrayType($innerKeyTypes, $innerValueTypes, [4]); + } + + return new ConstantArrayType($keyTypes, $valueTypes, [$values]); + } + +} diff --git a/tests/bench/VerbosityLevelFactoriesBench.php b/tests/bench/VerbosityLevelFactoriesBench.php new file mode 100644 index 00000000000..b6a86c6d4f3 --- /dev/null +++ b/tests/bench/VerbosityLevelFactoriesBench.php @@ -0,0 +1,40 @@ += 8.1 /* - * instructions: 4.344B - * cycles: 5.682B - * memory: 94.20MiB + * instructions: 4.318B + * cycles: 5.652B + * memory: 58.76MiB */ namespace Bug10772; diff --git a/tests/bench/data/bug-10979.php b/tests/bench/data/bug-10979.php index 4d3383b2d03..b5dcbd6a75f 100644 --- a/tests/bench/data/bug-10979.php +++ b/tests/bench/data/bug-10979.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 4.081B - * cycles: 5.558B - * memory: 42.37MiB + * instructions: 3.967B + * cycles: 5.391B + * memory: 6.272MiB */ declare(strict_types=1); diff --git a/tests/bench/data/bug-11263.php b/tests/bench/data/bug-11263.php index bb35537ddb5..1c75f64b4f2 100644 --- a/tests/bench/data/bug-11263.php +++ b/tests/bench/data/bug-11263.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 1.927B - * cycles: 2.275B - * memory: 42.12MiB + * instructions: 1.916B + * cycles: 2.259B + * memory: 6.367MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/bug-11283.php b/tests/bench/data/bug-11283.php index 8756488c8a1..76b2e1b3995 100644 --- a/tests/bench/data/bug-11283.php +++ b/tests/bench/data/bug-11283.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 1.208B - * cycles: 1.591B - * memory: 40.16MiB + * instructions: 1.166B + * cycles: 1.541B + * memory: 4.506MiB */ declare(strict_types=1); diff --git a/tests/bench/data/bug-11297.php b/tests/bench/data/bug-11297.php index af51ef84c3c..e8c74d84309 100644 --- a/tests/bench/data/bug-11297.php +++ b/tests/bench/data/bug-11297.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 9.284B - * cycles: 10.27B - * memory: 65.86MiB + * instructions: 9.100B + * cycles: 10.00B + * memory: 30.08MiB */ namespace Bug11297; diff --git a/tests/bench/data/bug-11913.php b/tests/bench/data/bug-11913.php index 1fc3dc7190a..2be2df56771 100644 --- a/tests/bench/data/bug-11913.php +++ b/tests/bench/data/bug-11913.php @@ -1,9 +1,9 @@ = 8.3 /* - * instructions: 1.811B - * cycles: 2.245B - * memory: 58.08MiB + * instructions: 1.790B + * cycles: 2.223B + * memory: 22.20MiB */ namespace Bug12159; diff --git a/tests/bench/data/bug-12671.php b/tests/bench/data/bug-12671.php index 4139e86d1bc..432f10dbe56 100644 --- a/tests/bench/data/bug-12671.php +++ b/tests/bench/data/bug-12671.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 350.9M - * cycles: 436.8M - * memory: 39.21MiB + * instructions: 114.0M + * cycles: 157.4M + * memory: 1.387MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/bug-13310.php b/tests/bench/data/bug-13310.php index 5a498f9d605..6d3447dc17c 100644 --- a/tests/bench/data/bug-13310.php +++ b/tests/bench/data/bug-13310.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 12.87B - * cycles: 17.65B - * memory: 175.9MiB + * instructions: 12.62B + * cycles: 17.45B + * memory: 143.8MiB */ namespace Bug13352; diff --git a/tests/bench/data/bug-13685.php b/tests/bench/data/bug-13685.php index fc34cacab0b..96541e9f685 100644 --- a/tests/bench/data/bug-13685.php +++ b/tests/bench/data/bug-13685.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 338.9M - * cycles: 432.9M - * memory: 37.44MiB + * instructions: 290.6M + * cycles: 369.4M + * memory: 1.648MiB */ namespace BenchBug14319; diff --git a/tests/bench/data/bug-14452.php b/tests/bench/data/bug-14452.php index 023b21535a2..ea88e21b7dd 100644 --- a/tests/bench/data/bug-14452.php +++ b/tests/bench/data/bug-14452.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 1.622B - * cycles: 1.942B - * memory: 52.35MiB + * instructions: 1.590B + * cycles: 1.905B + * memory: 17.01MiB */ namespace Bug14869Enum; diff --git a/tests/bench/data/bug-14869.php b/tests/bench/data/bug-14869.php index a4038ce02f7..cf862094d1d 100644 --- a/tests/bench/data/bug-14869.php +++ b/tests/bench/data/bug-14869.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 27.17M - * cycles: 39.59M - * memory: 35.87MiB + * instructions: 27.14M + * cycles: 39.31M + * memory: 90.86KiB */ namespace Bug4308; diff --git a/tests/bench/data/bug-5081.php b/tests/bench/data/bug-5081.php index 14030da9602..a03fd9871fa 100644 --- a/tests/bench/data/bug-5081.php +++ b/tests/bench/data/bug-5081.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 53.47M - * cycles: 75.86M - * memory: 36.05MiB + * instructions: 51.80M + * cycles: 73.74M + * memory: 310.2KiB */ declare(strict_types=1); diff --git a/tests/bench/data/enum-case-union-removal.php b/tests/bench/data/enum-case-union-removal.php index 2aabd21928f..0c5a048e5b4 100644 --- a/tests/bench/data/enum-case-union-removal.php +++ b/tests/bench/data/enum-case-union-removal.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 1.923B - * cycles: 2.330B - * memory: 42.18MiB + * instructions: 1.884B + * cycles: 2.287B + * memory: 6.379MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/enum-value-keyed-map.php b/tests/bench/data/enum-value-keyed-map.php index 49319efc071..e552ec2f876 100644 --- a/tests/bench/data/enum-value-keyed-map.php +++ b/tests/bench/data/enum-value-keyed-map.php @@ -1,9 +1,9 @@ = 8.1 /* - * instructions: 697.9M - * cycles: 877.6M - * memory: 53.48MiB + * instructions: 668.5M + * cycles: 834.2M + * memory: 17.69MiB */ namespace BenchEnumValueKeyedMap; diff --git a/tests/bench/data/finite-types-optional-keys-blowup.php b/tests/bench/data/finite-types-optional-keys-blowup.php index 5c672402da7..114af40fd98 100644 --- a/tests/bench/data/finite-types-optional-keys-blowup.php +++ b/tests/bench/data/finite-types-optional-keys-blowup.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 3.496B - * cycles: 4.608B - * memory: 66.92MiB + * instructions: 3.487B + * cycles: 4.594B + * memory: 31.14MiB */ declare(strict_types = 1); diff --git a/tests/bench/data/implode-optional-keys-blowup.php b/tests/bench/data/implode-optional-keys-blowup.php index 8d95ae08d10..2c14f5d62f1 100644 --- a/tests/bench/data/implode-optional-keys-blowup.php +++ b/tests/bench/data/implode-optional-keys-blowup.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 3.264B - * cycles: 4.504B - * memory: 104.9MiB + * instructions: 3.192B + * cycles: 4.415B + * memory: 69.14MiB */ namespace ImpureCallColumnsBench; diff --git a/tests/bench/data/in-array-intersect-blowup.php b/tests/bench/data/in-array-intersect-blowup.php index ed69d503454..d0ee8765012 100644 --- a/tests/bench/data/in-array-intersect-blowup.php +++ b/tests/bench/data/in-array-intersect-blowup.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 149.4M - * cycles: 182.0M - * memory: 36.69MiB + * instructions: 148.1M + * cycles: 180.5M + * memory: 966.1KiB */ declare(strict_types = 1); diff --git a/tests/bench/data/literal-key-union-offset-write.php b/tests/bench/data/literal-key-union-offset-write.php index bc233e4b9da..771d9e37ac9 100644 --- a/tests/bench/data/literal-key-union-offset-write.php +++ b/tests/bench/data/literal-key-union-offset-write.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 819.9M - * cycles: 1.051B - * memory: 39.43MiB + * instructions: 807.8M + * cycles: 1.041B + * memory: 3.733MiB */ namespace UnionFastPath; diff --git a/tests/bench/data/unrolled-foreach-list-guards.php b/tests/bench/data/unrolled-foreach-list-guards.php index 91f637da575..04171f35e36 100644 --- a/tests/bench/data/unrolled-foreach-list-guards.php +++ b/tests/bench/data/unrolled-foreach-list-guards.php @@ -1,9 +1,9 @@ = 8.0 /* - * instructions: 2.781B - * cycles: 3.536B - * memory: 45.12MiB + * instructions: 2.076B + * cycles: 2.668B + * memory: 8.117MiB */ namespace BenchComplexUnion; diff --git a/turbo-ext/src/ConstantArrayType.cpp b/turbo-ext/src/ConstantArrayType.cpp index ac2dae0f237..82811c6ac44 100644 --- a/turbo-ext/src/ConstantArrayType.cpp +++ b/turbo-ext/src/ConstantArrayType.cpp @@ -2196,6 +2196,18 @@ class ConstantArrayType * -1 = pending exception */ [[nodiscard]] zend_long isCallable() const { + /* the shapes doFindTypeAndMethodNames() rejects by their keys alone, + * without describing the whole shape for the recursion guard */ + zval *k = keyTypes(); + if (UNEXPECTED(k == NULL)) return -1; + zend_long keyTypesCount = arrayCount(k); + if (keyTypesCount > 2) return PT_TRI_NO; + if (keyTypesCount != 2) { + zend_long unsealedness = thisIsUnsealed(); + if (UNEXPECTED(unsealedness < 0)) return -1; + if (unsealedness != PT_TRI_YES) return PT_TRI_NO; + } + zv::Val callback = pt_type_native_callback(isCallableCallback, thisZv(), NULL); if (UNEXPECTED(callback.isUndef())) return -1; zv::Val result = pt_type_recursion_guard_run(thisZv(), callback.raw()); diff --git a/turbo-ext/src/EnumCaseObjectType.cpp b/turbo-ext/src/EnumCaseObjectType.cpp index fa18fa0f6b2..79cdc13f14d 100644 --- a/turbo-ext/src/EnumCaseObjectType.cpp +++ b/turbo-ext/src/EnumCaseObjectType.cpp @@ -19,9 +19,8 @@ namespace sigs = ptdecl::EnumCaseObjectType::sig; zend_class_entry *pt_ce_enum_case_object_type = nullptr; -/* OBJ_PROP_NUM slots: the parent's ten first (ObjectType.cpp) */ -#define PT_OT_PROP_COUNT 10 -#define PT_ECOT_PROP_ENUM_CASE_NAME (PT_OT_PROP_COUNT + 0) +/* OBJ_PROP_NUM slots: the parent's first (ObjectType.cpp) */ +#define PT_ECOT_PROP_ENUM_CASE_NAME ptdecl::EnumCaseObjectType::slot::enumCaseName /* the handlers the $this-dispatch fast paths identify */ static void ZEND_FASTCALL ecotGetEnumCaseName(INTERNAL_FUNCTION_PARAMETERS); diff --git a/turbo-ext/src/GenericObjectType.cpp b/turbo-ext/src/GenericObjectType.cpp index 4cc4b2830df..97b108ecc76 100644 --- a/turbo-ext/src/GenericObjectType.cpp +++ b/turbo-ext/src/GenericObjectType.cpp @@ -41,12 +41,11 @@ zend_class_entry *pt_ce_generic_object_type = nullptr; return false; } -/* OBJ_PROP_NUM slots: the parent's ten first (ObjectType.cpp), then the +/* OBJ_PROP_NUM slots: the parent's first (ObjectType.cpp), then the * twin's promoted properties in parameter order */ -#define PT_OT_PROP_COUNT 10 -#define PT_GOT_PROP_TYPES (PT_OT_PROP_COUNT + 0) -#define PT_GOT_PROP_CLASS_REFLECTION (PT_OT_PROP_COUNT + 1) -#define PT_GOT_PROP_VARIANCES (PT_OT_PROP_COUNT + 2) +#define PT_GOT_PROP_TYPES ptdecl::GenericObjectType::slot::types +#define PT_GOT_PROP_CLASS_REFLECTION ptdecl::GenericObjectType::slot::classReflection +#define PT_GOT_PROP_VARIANCES ptdecl::GenericObjectType::slot::variances /* the handlers the $this-dispatch fast paths identify */ static void ZEND_FASTCALL gotGetClassReflection(INTERNAL_FUNCTION_PARAMETERS); diff --git a/turbo-ext/src/HasMethodType.cpp b/turbo-ext/src/HasMethodType.cpp index ce04732364e..068abb3edee 100644 --- a/turbo-ext/src/HasMethodType.cpp +++ b/turbo-ext/src/HasMethodType.cpp @@ -81,6 +81,14 @@ class HasMethodType * as a fresh result; UNDEF = pending exception */ zv::Val isSuperTypeOf(zval *type) const { + /* what $type->isSubTypeOf($this) answers for another HasMethodType, + * without its callable checks */ + if (Z_TYPE_P(type) == IS_OBJECT && Z_OBJCE_P(type) == pt_ce_has_method_type) { + zend_long has = otherHasMethod(type); + if (UNEXPECTED(has < 0)) return zv::Val(); + return pt_type_is_super_type_of_result(has); + } + bool compound; if (UNEXPECTED(!pt_type_instanceof(type, PT_CLASS_COMPOUND_TYPE, compound))) return zv::Val(); if (compound) { diff --git a/turbo-ext/src/NullsafeOperatorHelper.cpp b/turbo-ext/src/NullsafeOperatorHelper.cpp index 6f0ce7b56f7..ef8a71eb7f7 100644 --- a/turbo-ext/src/NullsafeOperatorHelper.cpp +++ b/turbo-ext/src/NullsafeOperatorHelper.cpp @@ -195,11 +195,14 @@ class NullsafeOperatorHelper zv::Val rvHold = zv::Val::adopt(rv); if (UNEXPECTED(failed)) return zv::Val(); if (next != NULL && UNEXPECTED(!requireExprOrNull(next))) return zv::Val(); - if (next == NULL || Z_TYPE_P(next) == IS_NULL) { - /* mark the levels on the way out */ + bool nextMarked = false; + if (current == expr && next != NULL && Z_TYPE_P(next) == IS_OBJECT && UNEXPECTED(!isMarked(Z_OBJ_P(next), nextMarked))) return zv::Val(); + if (next == NULL || Z_TYPE_P(next) == IS_NULL || nextMarked) { + /* mark the levels on the way out, down to a level marked already */ + zend_object *stop = nextMarked ? Z_OBJ_P(next) : NULL; zend_object *level = expr; zv::Val levelHold; - while (level != NULL) { + while (level != NULL && level != stop) { if (UNEXPECTED(!mark(level))) return zv::Val(); zval levelRv; ZVAL_UNDEF(&levelRv); diff --git a/turbo-ext/src/ObjectType.cpp b/turbo-ext/src/ObjectType.cpp index 10f66302d3a..f14ed63b59b 100644 --- a/turbo-ext/src/ObjectType.cpp +++ b/turbo-ext/src/ObjectType.cpp @@ -2526,6 +2526,10 @@ class ObjectType * pending exception */ zv::Val getClassReflection() const { + /* $this->returnedClassReflection, once made */ + zval *returned = OBJ_PROP_NUM(self, slots::returnedClassReflection); + if (Z_TYPE_P(returned) == IS_OBJECT) return zv::Val::copyOf(zv::Ref(returned)); + zv::Val classReflection; zval *resolved = resolvedClassReflection(); if (UNEXPECTED(resolved == NULL)) return zv::Val(); @@ -2560,9 +2564,13 @@ class ObjectType for (zv::ArrayEntry entry : zv::ArrRef(types.raw())) { values.push(entry.value()); } - return pt_type_call(reflection, PT_LC("withtypes"), 1, values.raw()); + zv::Val withTypes = pt_type_call(reflection, PT_LC("withtypes"), 1, values.raw()); + if (UNEXPECTED(withTypes.isUndef())) return zv::Val(); + zv::ObjRef(self).propAtWrite(slots::returnedClassReflection, zv::Val::copyOf(zv::Ref(withTypes.raw()))); + return withTypes; } + zv::ObjRef(self).propAtWrite(slots::returnedClassReflection, zv::Val::copyOf(zv::Ref(classReflection.raw()))); return classReflection; } @@ -3653,6 +3661,7 @@ PT_MINIT_REGISTRATION(pt_register_object_type) cls.privateTypedPropertyDefaultNull("cachedDescription", MAY_BE_STRING); cls.privateTypedPropertyDefaultNull("cachedPreciseName", MAY_BE_STRING); cls.privateTypedClassPropertyDefaultNull("lazyClassReflection", "PHPStan\\Reflection\\ClassReflection"); + cls.privateTypedClassPropertyDefaultNull("returnedClassReflection", "PHPStan\\Reflection\\ClassReflection"); cls.privateStaticTypedArrayPropertyDefaultEmpty("enumCases"); cls.privateStaticTypedClassPropertyDefaultNull("descriptionCacheOrder", "PHPStan\\Internal\\LruCache"); cls.privateStaticTypedPropertyDefaultNull("lastTouchedDescription", MAY_BE_STRING); diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index c19c2b3f766..a084402e9e6 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -477,6 +477,33 @@ class ScopeOps return zv::Val(); } + /* Both target loops below pair targets only with guards, and a guard + * needs its expression certainly defined in both branches - without + * such a key nothing below can add a holder, so skip the passes over + * every key. The twin also collects these keys for its guard loop to + * avoid repeating the PHP-level checks; the guard loop here repeats + * them on every differing key, which selects the same keys in the + * same order. */ + bool hasGuardCandidate = false; + for (auto diffEntry : differingKeys) { + zend_string *key = diffEntry.stringKeyOrNull(); + zend_ulong idx = diffEntry.indexKey(); + + zval *ourSlot = pt_ht_find(ours.table(), key, idx); + if (ourSlot == NULL) continue; + zval *theirSlot = pt_ht_find(theirs.table(), key, idx); + if (theirSlot == NULL) continue; + zv::Ref holder = zv::Ref(ourSlot).deref(); + if (UNEXPECTED(!pt_check_holder(holder.raw()))) return zv::Val(); + if (pt_holder_certainty_value(holder.asObject()) != PT_TRI_YES) continue; + zv::Ref theirHolder = zv::Ref(theirSlot).deref(); + if (UNEXPECTED(!pt_check_holder(theirHolder.raw()))) return zv::Val(); + if (pt_holder_certainty_value(theirHolder.asObject()) != PT_TRI_YES) continue; + hasGuardCandidate = true; + break; + } + if (!hasGuardCandidate) return zv::Arr::copyOfTable(conditional.table()); + /* A guard is only ever consumed paired with a target: a *different* key * in the first target loop below, any key in the second one. Deriving a * guard costs a TypeCombinator::remove() of the two branch types - the @@ -639,6 +666,11 @@ class ScopeOps zv::ScratchTable guardIsSuperTypeOfTheirExprCache(8); zv::ScratchTable theirExprIsSuperTypeOfGuardCache(8); zv::ScratchTable guardIsConstantArrayCache(8); + /* Holder keys describe both the guard and the target type: describe + * each guard once and each target once instead of once per pair - + * the descriptions of big constant arrays dominate the merge + * otherwise. Mirrors the twin. */ + zv::Arr guardKeyPrefixes = zv::Arr::create(8); zv::Arr result; /* stays UNDEF until the first append duplicates the input */ /* main loop: pair non-merged expressions with guards - the settled @@ -654,6 +686,7 @@ class ScopeOps bool hasSelfGuard = pt_ht_exists(typeGuards.table(), key, idx); if (typeGuards.size() - (hasSelfGuard ? 1 : 0) == 0) continue; bool exprIsGuardExcluded = pt_ht_exists(guardsToExclude.table(), key, idx); + zv::Str targetSuffix; for (auto guardEntry : zv::TableRef(typeGuards.table())) { zend_string *guardKey = guardEntry.stringKeyOrNull(); @@ -736,7 +769,13 @@ class ScopeOps } } - if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardKey, guardIdx, guardHolder, holder))) return zv::Val(); + if (targetSuffix.isNull()) { + targetSuffix = zv::Str::adopt(pt_ceh_key_target_part(holder.raw())); + if (UNEXPECTED(targetSuffix.isNull())) return zv::Val(); + } + zend_string *guardPrefix = guardKeyPrefix(guardKeyPrefixes, guardKey, guardIdx, guardHolder); + if (UNEXPECTED(guardPrefix == NULL)) return zv::Val(); + if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardKey, guardIdx, guardHolder, holder, guardPrefix, targetSuffix.get()))) return zv::Val(); } } @@ -754,10 +793,15 @@ class ScopeOps if (UNEXPECTED(!canBeUndefined(holderExpr(mergedHolder), &undefinable))) return zv::Val(); if (!undefinable) continue; + /* one undefined holder shared by every guard's pair, like the twin's */ + zv::Val noHolder = createNoErrorHolder(zv::ObjRef(mergedHolder.asObject()).propAt(PT_ETH_PROP_EXPR).raw()); + if (UNEXPECTED(noHolder.isUndef())) return zv::Val(); + zv::Str targetSuffix = zv::Str::adopt(pt_ceh_key_target_part(noHolder.raw())); + if (UNEXPECTED(targetSuffix.isNull())) return zv::Val(); for (auto guardEntry : zv::TableRef(typeGuards.table())) { - zv::Val noHolder = createNoErrorHolder(zv::ObjRef(mergedHolder.asObject()).propAt(PT_ETH_PROP_EXPR).raw()); - if (UNEXPECTED(noHolder.isUndef())) return zv::Val(); - if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardEntry.stringKeyOrNull(), guardEntry.indexKey(), guardEntry.value(), noHolder.ref()))) { + zend_string *guardPrefix = guardKeyPrefix(guardKeyPrefixes, guardEntry.stringKeyOrNull(), guardEntry.indexKey(), guardEntry.value()); + if (UNEXPECTED(guardPrefix == NULL)) return zv::Val(); + if (UNEXPECTED(!appendConditional(result, conditional, key, idx, guardEntry.stringKeyOrNull(), guardEntry.indexKey(), guardEntry.value(), noHolder.ref(), guardPrefix, targetSuffix.get()))) { return zv::Val(); } } @@ -1515,13 +1559,27 @@ class ScopeOps * under $result[$exprKey][getKey()]. Duplicates the input array into * $result lazily on the first append. */ - static bool appendConditional(zv::Arr &result, zv::TableRef input, zend_string *skey, zend_ulong idx, zend_string *guardKey, zend_ulong guardIdx, zv::Ref guardHolder, zv::Ref typeHolder) + /* the cached "=" key part of a guard (borrowed from prefixes); NULL = pending exception */ + static zend_string *guardKeyPrefix(zv::Arr &prefixes, zend_string *guardKey, zend_ulong guardIdx, zv::Ref guardHolder) + { + zval *cached = pt_ht_find(prefixes.table(), guardKey, guardIdx); + if (cached != NULL) return Z_STR_P(cached); + zend_string *prefix = pt_ceh_key_condition_part(guardKey, guardIdx, guardHolder.raw()); + if (UNEXPECTED(prefix == NULL)) return NULL; + zval prefixZv; + ZVAL_STR(&prefixZv, prefix); + pt_ht_update(prefixes.table(), guardKey, guardIdx, &prefixZv); + return prefix; + } + + /* cehKey: the holder's ConditionalExpressionHolder::getKey(), the guard's + * key part followed by the target's, each described once by the caller */ + static bool appendConditional(zv::Arr &result, zv::TableRef input, zend_string *skey, zend_ulong idx, zend_string *guardKey, zend_ulong guardIdx, zv::Ref guardHolder, zv::Ref typeHolder, zend_string *guardPrefix, zend_string *targetSuffix) { zv::Arr conditions = zv::Arr::create(1); tableAddNewCopy(conditions.table(), guardKey, guardIdx, guardHolder); - zv::Str cehKey = zv::Str::adopt(pt_ceh_key_build(conditions.table(), typeHolder.raw())); - if (UNEXPECTED(cehKey.isNull())) return false; + zv::Str cehKey = zv::Str::adopt(zend_string_concat2(ZSTR_VAL(guardPrefix), ZSTR_LEN(guardPrefix), ZSTR_VAL(targetSuffix), ZSTR_LEN(targetSuffix))); zval cehRaw; object_init_ex(&cehRaw, pt_ce_cond_expr_holder); diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 146b2ed61a3..a63def8d15a 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -1716,11 +1716,9 @@ class TypeCombinator zend_long bString = callTrinary(b, PT_LC("isstring")); if (UNEXPECTED(bString < 0)) return -1; if (bString == PT_TRI_YES) { - zv::Val decimalIntString = decimalIntStringType(); - if (UNEXPECTED(decimalIntString.isUndef())) return -1; - int result = compareStringsInUnion(a, b, decimalIntString.raw(), merged); + int result = compareStringsInUnion(a, b, merged); if (result != 0) return result < 0 ? -1 : 2; - result = compareStringsInUnion(b, a, decimalIntString.raw(), merged); + result = compareStringsInUnion(b, a, merged); if (result != 0) return result < 0 ? -1 : 1; } } @@ -1802,17 +1800,21 @@ class TypeCombinator * absorbing $type: $other without its decimal-int-string accessory * covers $type, and $type covers decimal-int-string; 1 with $merged * the base of $other, 0 for no answer; -1 = pending exception */ - static int compareStringsInUnion(zval *type, zval *other, zval *decimalIntString, zv::Val &merged) + static int compareStringsInUnion(zval *type, zval *other, zv::Val &merged) { zend_long isDecimal = callTrinary(other, PT_LC("isdecimalintegerstring")); if (UNEXPECTED(isDecimal < 0)) return -1; if (isDecimal != PT_TRI_NO) return 0; zv::Val base = removeDecimalIntStringAccessory(other); if (UNEXPECTED(base.isUndef())) return -1; + /* a base that is $other itself was compared with $type before */ + if (Z_TYPE_P(base.raw()) == IS_OBJECT && Z_TYPE_P(other) == IS_OBJECT && Z_OBJ_P(base.raw()) == Z_OBJ_P(other)) return 0; int baseCovers = isSuperTypeYes(base.raw(), type); if (UNEXPECTED(baseCovers < 0)) return -1; if (!baseCovers) return 0; - int coversDecimal = isSuperTypeYes(type, decimalIntString); + zv::Val decimalIntString = decimalIntStringType(); + if (UNEXPECTED(decimalIntString.isUndef())) return -1; + int coversDecimal = isSuperTypeYes(type, decimalIntString.raw()); if (UNEXPECTED(coversDecimal < 0)) return -1; if (!coversDecimal) return 0; merged = std::move(base); @@ -4981,6 +4983,8 @@ PT_MINIT_REGISTRATION(pt_register_type_combinator) reg::Class cls("PHPStan\\Type\\TypeCombinator"); ptdecl::TypeCombinator::declareClass(cls); + /* the twin's private const, which only its scan of a small flattened union reads */ + cls.privateClassConstantLong("ALREADY_NORMALIZED_SCAN_LIMIT", 4); cls.privateStaticTypedPropertyDefaultNull("cacheEnabled", MAY_BE_BOOL); cls.method(sigs::clearCache, [](INTERNAL_FUNCTION_PARAMETERS) { diff --git a/turbo-ext/src/UnionType.cpp b/turbo-ext/src/UnionType.cpp index 20de343a376..1e6c36967e6 100644 --- a/turbo-ext/src/UnionType.cpp +++ b/turbo-ext/src/UnionType.cpp @@ -2366,24 +2366,31 @@ class UnionType * pending exception */ zend_long finiteTypeSetContainedIn(zval *otherType, bool yesOnly) const { - zv::Val finiteTypeSet = thisGetFiniteTypeSet(); - if (UNEXPECTED(finiteTypeSet.isUndef())) return -1; - if (zv::Ref(finiteTypeSet.raw()).isNull()) return CONTAINMENT_UNSETTLED; - zend_object *set = Z_OBJ_P(finiteTypeSet.raw()); - int complete = pt_type_call_is_true(set, PT_LC("iscomplete"), 0, NULL); - if (UNEXPECTED(complete < 0)) return -1; - if (complete == 0) return CONTAINMENT_UNSETTLED; - bool otherIsUnion = zv::Ref(otherType).instanceOf(pt_ce_union_type); if (otherIsUnion) { bool isTemplate; if (UNEXPECTED(!isInstance(otherType, PT_CLASS_TEMPLATE_TYPE, isTemplate))) return -1; otherIsUnion = !isTemplate; } + + /* the key of a single other value comes first: without one this + * union's set is not built */ + zv::Val key; if (!otherIsUnion) { - zv::Val key = finiteKey(otherType); + key = finiteKey(otherType); if (UNEXPECTED(key.isUndef())) return -1; if (!zv::Ref(key.raw()).isString()) return CONTAINMENT_UNSETTLED; + } + + zv::Val finiteTypeSet = thisGetFiniteTypeSet(); + if (UNEXPECTED(finiteTypeSet.isUndef())) return -1; + if (zv::Ref(finiteTypeSet.raw()).isNull()) return CONTAINMENT_UNSETTLED; + zend_object *set = Z_OBJ_P(finiteTypeSet.raw()); + int complete = pt_type_call_is_true(set, PT_LC("iscomplete"), 0, NULL); + if (UNEXPECTED(complete < 0)) return -1; + if (complete == 0) return CONTAINMENT_UNSETTLED; + + if (!otherIsUnion) { zend_long containment = pt_type_call_trinary(set, PT_LC("containedinkey"), 1, key.raw()); if (UNEXPECTED(containment < 0)) return -1; if (containment != PT_TRI_YES && yesOnly) return CONTAINMENT_UNSETTLED; diff --git a/turbo-ext/src/generated/EnumCaseObjectType.h b/turbo-ext/src/generated/EnumCaseObjectType.h index 162e0d6d217..d007857fa52 100644 --- a/turbo-ext/src/generated/EnumCaseObjectType.h +++ b/turbo-ext/src/generated/EnumCaseObjectType.h @@ -10,7 +10,7 @@ namespace ptdecl::EnumCaseObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t enumCaseName = 10; +inline constexpr uint32_t enumCaseName = 11; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/GenericObjectType.h b/turbo-ext/src/generated/GenericObjectType.h index cbf6a698e6b..f5061196f3a 100644 --- a/turbo-ext/src/generated/GenericObjectType.h +++ b/turbo-ext/src/generated/GenericObjectType.h @@ -10,9 +10,9 @@ namespace ptdecl::GenericObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t types = 10; -inline constexpr uint32_t classReflection = 11; -inline constexpr uint32_t variances = 12; +inline constexpr uint32_t types = 11; +inline constexpr uint32_t classReflection = 12; +inline constexpr uint32_t variances = 13; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/ObjectType.h b/turbo-ext/src/generated/ObjectType.h index 7e0a8c6e061..790d15202e1 100644 --- a/turbo-ext/src/generated/ObjectType.h +++ b/turbo-ext/src/generated/ObjectType.h @@ -17,9 +17,10 @@ inline constexpr uint32_t currentAncestors = 3; inline constexpr uint32_t cachedDescription = 4; inline constexpr uint32_t cachedPreciseName = 5; inline constexpr uint32_t lazyClassReflection = 6; -inline constexpr uint32_t methodCache = 7; -inline constexpr uint32_t className = 8; -inline constexpr uint32_t classReflection = 9; +inline constexpr uint32_t returnedClassReflection = 7; +inline constexpr uint32_t methodCache = 8; +inline constexpr uint32_t className = 9; +inline constexpr uint32_t classReflection = 10; } // namespace slot inline void declareClass(reg::Class &cls) @@ -43,6 +44,7 @@ inline void declareProperties(reg::Class &cls) cls.property("cachedDescription", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL | MAY_BE_STRING); cls.property("cachedPreciseName", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL | MAY_BE_STRING); cls.property("lazyClassReflection", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\Reflection\\ClassReflection"); + cls.property("returnedClassReflection", ZEND_ACC_PRIVATE, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\Reflection\\ClassReflection"); cls.property("enumCases", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedEmptyArray, MAY_BE_ARRAY); cls.property("descriptionCacheOrder", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedNull, MAY_BE_NULL, "PHPStan\\Internal\\LruCache"); cls.property("lastTouchedDescription", ZEND_ACC_PRIVATE | ZEND_ACC_STATIC, reg::PropertyKind::TypedNull, MAY_BE_NULL | MAY_BE_STRING); diff --git a/turbo-ext/src/generated/TemplateGenericObjectType.h b/turbo-ext/src/generated/TemplateGenericObjectType.h index b0c1d3ce5da..b374faf6998 100644 --- a/turbo-ext/src/generated/TemplateGenericObjectType.h +++ b/turbo-ext/src/generated/TemplateGenericObjectType.h @@ -10,12 +10,12 @@ namespace ptdecl::TemplateGenericObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t name = 13; -inline constexpr uint32_t scope = 14; -inline constexpr uint32_t strategy = 15; -inline constexpr uint32_t variance = 16; -inline constexpr uint32_t bound = 17; -inline constexpr uint32_t default_ = 18; +inline constexpr uint32_t name = 14; +inline constexpr uint32_t scope = 15; +inline constexpr uint32_t strategy = 16; +inline constexpr uint32_t variance = 17; +inline constexpr uint32_t bound = 18; +inline constexpr uint32_t default_ = 19; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/generated/TemplateObjectType.h b/turbo-ext/src/generated/TemplateObjectType.h index fb1117c6dde..47debde35d4 100644 --- a/turbo-ext/src/generated/TemplateObjectType.h +++ b/turbo-ext/src/generated/TemplateObjectType.h @@ -10,12 +10,12 @@ namespace ptdecl::TemplateObjectType { /* the OBJ_PROP_NUM slots of the instance properties the class declares (the inherited ones come first) */ namespace slot { -inline constexpr uint32_t name = 10; -inline constexpr uint32_t scope = 11; -inline constexpr uint32_t strategy = 12; -inline constexpr uint32_t variance = 13; -inline constexpr uint32_t bound = 14; -inline constexpr uint32_t default_ = 15; +inline constexpr uint32_t name = 11; +inline constexpr uint32_t scope = 12; +inline constexpr uint32_t strategy = 13; +inline constexpr uint32_t variance = 14; +inline constexpr uint32_t bound = 15; +inline constexpr uint32_t default_ = 16; } // namespace slot inline void declareClass(reg::Class &cls) diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index 9818e91b6ee..a9915e4aa8f 100644 --- a/turbo-ext/src/support.cpp +++ b/turbo-ext/src/support.cpp @@ -1403,6 +1403,52 @@ bool pt_holder_equal_types(zval *a, zval *b, bool *out) return !EG(exception); } +/* "=": one condition of a ConditionalExpressionHolder::getKey() */ +static bool pt_ceh_key_append_condition(smart_str *str, zend_string *key_str, zend_ulong kidx, zval *holder) +{ + zval described; + ZVAL_DEREF(holder); + if (key_str != NULL) { + smart_str_append(str, key_str); + } else { + smart_str_append_long(str, (zend_long) kidx); + } + smart_str_appendc(str, '='); + if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(holder), PT_ETH_PROP_TYPE), &described))) return false; + if (EXPECTED(Z_TYPE(described) == IS_STRING)) { + smart_str_append(str, Z_STR(described)); + } + zval_ptr_dtor(&described); + return true; +} + +/* " => ()": the type holder's part of a ConditionalExpressionHolder::getKey() */ +static bool pt_ceh_key_append_target(smart_str *str, zval *type_holder) +{ + smart_str_appendl(str, " => ", 4); + { + zval described; + if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(type_holder), PT_ETH_PROP_TYPE), &described))) return false; + if (EXPECTED(Z_TYPE(described) == IS_STRING)) { + smart_str_append(str, Z_STR(described)); + } + zval_ptr_dtor(&described); + } + smart_str_appendl(str, " (", 2); + { + zend_long certainty = pt_holder_certainty_value(Z_OBJ_P(type_holder)); + if (certainty == PT_TRI_YES) { + smart_str_appendl(str, "Yes", 3); + } else if (certainty == PT_TRI_MAYBE) { + smart_str_appendl(str, "Maybe", 5); + } else { + smart_str_appendl(str, "No", 2); + } + } + smart_str_appendc(str, ')'); + return true; +} + zend_string *pt_ceh_key_build(HashTable *conds, zval *type_holder) { smart_str str = {}; @@ -1412,54 +1458,41 @@ zend_string *pt_ceh_key_build(HashTable *conds, zval *type_holder) bool first = true; ZEND_HASH_FOREACH_KEY_VAL(conds, kidx, key_str, entry) { - zval described; - zval *entry_deref = entry; - ZVAL_DEREF(entry_deref); if (!first) { smart_str_appendl(&str, " && ", 4); } first = false; - if (key_str != NULL) { - smart_str_append(&str, key_str); - } else { - smart_str_append_long(&str, (zend_long) kidx); - } - smart_str_appendc(&str, '='); - if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(entry_deref), PT_ETH_PROP_TYPE), &described))) { + if (UNEXPECTED(!pt_ceh_key_append_condition(&str, key_str, kidx, entry))) { smart_str_free(&str); return NULL; } - if (EXPECTED(Z_TYPE(described) == IS_STRING)) { - smart_str_append(&str, Z_STR(described)); - } - zval_ptr_dtor(&described); } ZEND_HASH_FOREACH_END(); - smart_str_appendl(&str, " => ", 4); - { - zval described; - if (UNEXPECTED(!pt_type_describe_precise(OBJ_PROP_NUM(Z_OBJ_P(type_holder), PT_ETH_PROP_TYPE), &described))) { - smart_str_free(&str); - return NULL; - } - if (EXPECTED(Z_TYPE(described) == IS_STRING)) { - smart_str_append(&str, Z_STR(described)); - } - zval_ptr_dtor(&described); + if (UNEXPECTED(!pt_ceh_key_append_target(&str, type_holder))) { + smart_str_free(&str); + return NULL; } - smart_str_appendl(&str, " (", 2); - { - zend_long certainty = pt_holder_certainty_value(Z_OBJ_P(type_holder)); - if (certainty == PT_TRI_YES) { - smart_str_appendl(&str, "Yes", 3); - } else if (certainty == PT_TRI_MAYBE) { - smart_str_appendl(&str, "Maybe", 5); - } else { - smart_str_appendl(&str, "No", 2); - } + + return smart_str_extract(&str); +} + +zend_string *pt_ceh_key_condition_part(zend_string *key_str, zend_ulong kidx, zval *holder) +{ + smart_str str = {}; + if (UNEXPECTED(!pt_ceh_key_append_condition(&str, key_str, kidx, holder))) { + smart_str_free(&str); + return NULL; } - smart_str_appendc(&str, ')'); + return smart_str_extract(&str); +} +zend_string *pt_ceh_key_target_part(zval *type_holder) +{ + smart_str str = {}; + if (UNEXPECTED(!pt_ceh_key_append_target(&str, type_holder))) { + smart_str_free(&str); + return NULL; + } return smart_str_extract(&str); } diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index c5a651d5ecf..0ebc55a2717 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -992,6 +992,9 @@ bool pt_holder_equal_types(zval *a, zval *b, bool *out); /* ConditionalExpressionHolder::getKey() builder, shared with ScopeOps */ zend_string *pt_ceh_key_build(HashTable *conds, zval *type_holder); +/* the two parts of a single-condition key, which concatenated equal pt_ceh_key_build(): "=" and " => ()" */ +zend_string *pt_ceh_key_condition_part(zend_string *key_str, zend_ulong kidx, zval *holder); +zend_string *pt_ceh_key_target_part(zval *type_holder); /* calls a (possibly private) method on the scope, coercing result to bool */ bool pt_call_scope_bool(zval *scope, const char *lcname, size_t len, uint32_t argc, zval *argv, bool *out);