diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index c40bc1066bb..1f2be4721f1 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use Closure; use PHPStan\AnalysedCodeException; use PHPStan\BetterReflection\NodeCompiler\Exception\UnableToCompileNode; use PHPStan\BetterReflection\Reflection\Exception\CircularReference; @@ -38,7 +39,17 @@ public function __construct( { } - public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $debug): FinalizerResult + /** + * @param Closure(string $ruleClass): void|null $preRuleCallback + * @param Closure(): void|null $postRuleCallback called when the rule's processNode() has returned or thrown, before its errors are processed + */ + public function finalize( + AnalyserResult $analyserResult, + bool $onlyFiles, + bool $debug, + ?Closure $preRuleCallback = null, + ?Closure $postRuleCallback = null, + ): FinalizerResult { if (count($analyserResult->getCollectedData()) === 0) { return $this->addUnmatchedIgnoredErrors($this->mergeFilteredPhpErrors($analyserResult), [], []); @@ -57,6 +68,10 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ $tempCollectorErrors = []; $internalErrors = $analyserResult->getInternalErrors(); foreach ($this->ruleRegistry->getRules($nodeType) as $rule) { + if ($preRuleCallback !== null) { + $preRuleCallback(get_class($rule)); + } + try { $ruleErrors = $rule->processNode($node, $scope); } catch (AnalysedCodeException $e) { @@ -96,6 +111,10 @@ public function finalize(AnalyserResult $analyserResult, bool $onlyFiles, bool $ shouldReportBug: true, ); continue; + } finally { + if ($postRuleCallback !== null) { + $postRuleCallback(); + } } foreach ($ruleErrors as $ruleError) { diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index f5868057130..9057fba03b0 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -2,6 +2,7 @@ namespace PHPStan\Command; +use Closure; use PHPStan\Analyser\AnalyserResult; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; @@ -135,10 +136,13 @@ public function analyse( $processedFiles = $intermediateAnalyserResult->getProcessedFiles(); $resultCacheResult = $resultCacheManager->process($intermediateAnalyserResult, $resultCache, $errorOutput, $onlyFiles, true); + [$preRuleCallback, $postRuleCallback] = $this->createRuleCallbacks($debug, $stdOutput); $analyserResult = $this->analyserResultFinalizer->finalize( $this->switchTmpFileInAnalyserResult($resultCacheResult->getAnalyserResult(), $insteadOfFile, $tmpFile), $onlyFiles, $debug, + $preRuleCallback, + $postRuleCallback, )->getAnalyserResult(); $internalErrors = $analyserResult->getInternalErrors(); $errors = array_merge( @@ -291,6 +295,49 @@ private function runAnalyser( return $analyserResult; } + /** + * Prints the rules on CollectedDataNode the way --debug prints the analysed files. They run after + * all files and can take longer than the analysis itself. + * + * @return array{Closure(string $ruleClass): void|null, Closure(): void|null} + */ + private function createRuleCallbacks(bool $debug, Output $stdOutput): array + { + if (!$debug) { + return [ + null, + null, + ]; + } + + $startTime = null; + $preRuleCallback = static function (string $ruleClass) use ($stdOutput, &$startTime): void { + $stdOutput->writeLineFormatted(sprintf('Running %s on the collected data', $ruleClass)); + $startTime = microtime(true); + }; + if (!$stdOutput->isDebug()) { + return [ + $preRuleCallback, + null, + ]; + } + + $previousMemory = memory_get_peak_usage(true); + $postRuleCallback = static function () use ($stdOutput, &$previousMemory, &$startTime): void { + if ($startTime === null) { + throw new ShouldNotHappenException(); + } + $currentTotalMemory = memory_get_peak_usage(true); + $stdOutput->writeLineFormatted(sprintf('--- consumed %s, total %s, took %.2f s', BytesHelper::bytes($currentTotalMemory - $previousMemory), BytesHelper::bytes($currentTotalMemory), microtime(true) - $startTime)); + $previousMemory = $currentTotalMemory; + }; + + return [ + $preRuleCallback, + $postRuleCallback, + ]; + } + private function switchTmpFileInAnalyserResult( AnalyserResult $analyserResult, ?string $insteadOfFile, diff --git a/tests/PHPStan/Analyser/AnalyserResultFinalizerTest.php b/tests/PHPStan/Analyser/AnalyserResultFinalizerTest.php new file mode 100644 index 00000000000..c34fca50257 --- /dev/null +++ b/tests/PHPStan/Analyser/AnalyserResultFinalizerTest.php @@ -0,0 +1,157 @@ +createReportingRule(); + $throwingRule = $this->createThrowingRule(); + + $events = []; + $result = $this->createFinalizer([$reportingRule, $throwingRule])->finalize( + $this->createAnalyserResult(), + false, + false, + static function (string $ruleClass) use (&$events): void { + $events[] = 'pre ' . $ruleClass; + }, + static function () use (&$events): void { + $events[] = 'post'; + }, + )->getAnalyserResult(); + + $this->assertSame([ + 'pre ' . get_class($reportingRule), + 'post', + 'pre ' . get_class($throwingRule), + 'post', + ], $events); + $this->assertCount(1, $result->getErrors()); + $this->assertSame('Reported.', $result->getErrors()[0]->getMessage()); + $this->assertCount(1, $result->getInternalErrors()); + $this->assertSame('Failed.', $result->getInternalErrors()[0]->getMessage()); + } + + public function testPostRuleCallbackRunsWhenDebugRethrows(): void + { + $throwingRule = $this->createThrowingRule(); + + $events = []; + try { + $this->createFinalizer([$throwingRule])->finalize( + $this->createAnalyserResult(), + false, + true, + static function (string $ruleClass) use (&$events): void { + $events[] = 'pre ' . $ruleClass; + }, + static function () use (&$events): void { + $events[] = 'post'; + }, + ); + $this->fail('The exception should have been rethrown in debug mode.'); + } catch (RuntimeException $e) { + $this->assertSame('Failed.', $e->getMessage()); + } + + $this->assertSame([ + 'pre ' . get_class($throwingRule), + 'post', + ], $events); + } + + /** + * @param list> $rules + */ + private function createFinalizer(array $rules): AnalyserResultFinalizer + { + return new AnalyserResultFinalizer( + new DirectRuleRegistry($rules), + new DirectExtensionsCollection([]), + self::getContainer()->getByType(RuleErrorTransformer::class), + self::createScopeFactory( + self::createReflectionProvider(), + self::getContainer()->getService('typeSpecifier'), + ), + new LocalIgnoresProcessor(), + false, + ); + } + + private function createAnalyserResult(): AnalyserResult + { + return new AnalyserResult( + unorderedErrors: [], + filteredPhpErrors: [], + allPhpErrors: [], + locallyIgnoredErrors: [], + linesToIgnore: [], + unmatchedLineIgnores: [], + internalErrors: [], + collectedData: [__FILE__ => [TraitDeclarationCollector::class => ['data']]], + dependencies: null, + usedTraitDependencies: null, + valueDependencies: null, + packageDependencies: null, + exportedNodes: [], + reachedInternalErrorsCountLimit: false, + peakMemoryUsageBytes: 0, + processedFiles: [], + ); + } + + /** + * @return Rule + */ + private function createReportingRule(): Rule + { + return new /** @implements Rule */class implements Rule { + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return [RuleErrorBuilder::message('Reported.')->identifier('tests.reported')->file(__FILE__)->line(1)->build()]; + } + + }; + } + + /** + * @return Rule + */ + private function createThrowingRule(): Rule + { + return new /** @implements Rule */class implements Rule { + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + throw new RuntimeException('Failed.'); + } + + }; + } + +}