From cb4a2a45a4db699e67050a44d459f55b7d9b099f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 20 Mar 2026 17:18:10 +0100 Subject: [PATCH] Allow custom rules to emit collector data for CollectedDataNode --- src/Analyser/CollectedDataEmitter.php | 42 +++++++++++++ src/Analyser/FileAnalyserCallback.php | 8 ++- src/Analyser/MutatingScope.php | 20 ++++++- src/Node/EmitCollectedDataNode.php | 60 +++++++++++++++++++ src/Rules/Methods/OverridingMethodRule.php | 5 +- src/Rules/Playground/PromoteParameterRule.php | 3 +- src/Rules/Rule.php | 3 +- src/Testing/CompositeRule.php | 3 +- src/Testing/DelayedRule.php | 3 +- .../Rules/CollectedDataEmitterRule.php | 33 ++++++++++ .../Rules/CollectedDataEmitterTest.php | 33 ++++++++++ 11 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/Analyser/CollectedDataEmitter.php create mode 100644 src/Node/EmitCollectedDataNode.php create mode 100644 tests/PHPStan/Rules/CollectedDataEmitterRule.php create mode 100644 tests/PHPStan/Rules/CollectedDataEmitterTest.php diff --git a/src/Analyser/CollectedDataEmitter.php b/src/Analyser/CollectedDataEmitter.php new file mode 100644 index 0000000000..1f5be5f4de --- /dev/null +++ b/src/Analyser/CollectedDataEmitter.php @@ -0,0 +1,42 @@ +emitCollectedData(MyCollector::class, ['some', 'data']); + * ``` + * + * @api + */ +interface CollectedDataEmitter +{ + + /** + * @template TCollector of Collector + * @param class-string $collectorType + * @param template-type $data + */ + public function emitCollectedData(string $collectorType, mixed $data): void; + +} diff --git a/src/Analyser/FileAnalyserCallback.php b/src/Analyser/FileAnalyserCallback.php index 304513f7f4..07e5371a04 100644 --- a/src/Analyser/FileAnalyserCallback.php +++ b/src/Analyser/FileAnalyserCallback.php @@ -11,6 +11,7 @@ use PHPStan\Collectors\Registry as CollectorRegistry; use PHPStan\Dependency\DependencyResolver; use PHPStan\Dependency\RootExportedNode; +use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\InClassNode; use PHPStan\Node\InTraitNode; use PHPStan\Parser\Parser; @@ -77,9 +78,14 @@ public function __construct( public function __invoke(Node $node, Scope $scope): void { + if ($node instanceof EmitCollectedDataNode) { + $this->fileCollectedData[$scope->getFile()][$node->getCollectorType()][] = $node->getData(); + return; + } + $parserNodes = $this->parserNodes; - /** @var Scope&NodeCallbackInvoker $scope */ + /** @var Scope&NodeCallbackInvoker&CollectedDataEmitter $scope */ if ($node instanceof Node\Stmt\Trait_) { foreach (array_keys($this->linesToIgnore[$this->file] ?? []) as $lineToIgnore) { if ($lineToIgnore < $node->getStartLine() || $lineToIgnore > $node->getEndLine()) { diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 42fe96957d..8e598d6d7b 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -23,7 +23,9 @@ use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; use PHPStan\Analyser\Traverser\TransformStaticTypeTraverser; +use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\Container; +use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; @@ -133,7 +135,7 @@ use const PHP_INT_MIN; use const PHP_VERSION_ID; -class MutatingScope implements Scope, NodeCallbackInvoker +class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter { public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; @@ -4624,4 +4626,20 @@ public function invokeNodeCallback(Node $node): void $nodeCallback($node, $this); } + /** + * @template TNodeType of Node + * @template TValue + * @param class-string> $collectorType + * @param TValue $data + */ + public function emitCollectedData(string $collectorType, mixed $data): void + { + $nodeCallback = $this->nodeCallback; + if ($nodeCallback === null) { + throw new ShouldNotHappenException('Node callback is not present in this scope'); + } + + $nodeCallback(new EmitCollectedDataNode($collectorType, $data), $this); + } + } diff --git a/src/Node/EmitCollectedDataNode.php b/src/Node/EmitCollectedDataNode.php new file mode 100644 index 0000000000..b8b235adef --- /dev/null +++ b/src/Node/EmitCollectedDataNode.php @@ -0,0 +1,60 @@ +> $collectorType + * @param TValue $data + */ + public function __construct( + private string $collectorType, + private mixed $data, + ) + { + parent::__construct([]); + } + + /** + * @return class-string> + */ + public function getCollectorType(): string + { + return $this->collectorType; + } + + /** + * @return TValue + */ + public function getData(): mixed + { + return $this->data; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_EmitCollectedDataNode'; + } + + /** + * @return list + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Rules/Methods/OverridingMethodRule.php b/src/Rules/Methods/OverridingMethodRule.php index f9023059fd..40f458953b 100644 --- a/src/Rules/Methods/OverridingMethodRule.php +++ b/src/Rules/Methods/OverridingMethodRule.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PhpParser\Node\Attribute; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -49,7 +50,7 @@ public function getNodeType(): string return InClassMethodNode::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $method = $node->getMethodReflection(); $prototypeData = $this->methodPrototypeFinder->findPrototype($node->getClassReflection(), $method->getName()); @@ -329,7 +330,7 @@ private function filterOverrideAttribute(array $attrGroups): array private function addErrors( array $errors, InClassMethodNode $classMethod, - Scope&NodeCallbackInvoker $scope, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, ): array { if (count($errors) > 0) { diff --git a/src/Rules/Playground/PromoteParameterRule.php b/src/Rules/Playground/PromoteParameterRule.php index 8dc1d32916..d01b77c530 100644 --- a/src/Rules/Playground/PromoteParameterRule.php +++ b/src/Rules/Playground/PromoteParameterRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Playground; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\Container; @@ -88,7 +89,7 @@ private function getOriginalRule(): ?Rule return $this->originalRule = $originalRule; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { if ($this->parameterValue) { return []; diff --git a/src/Rules/Rule.php b/src/Rules/Rule.php index 03a5a047b0..fd1d3333e5 100644 --- a/src/Rules/Rule.php +++ b/src/Rules/Rule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -35,6 +36,6 @@ public function getNodeType(): string; * @param TNodeType $node * @return list */ - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array; + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array; } diff --git a/src/Testing/CompositeRule.php b/src/Testing/CompositeRule.php index c83fb047b5..269ed259cc 100644 --- a/src/Testing/CompositeRule.php +++ b/src/Testing/CompositeRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Testing; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -37,7 +38,7 @@ public function getNodeType(): string return Node::class; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $errors = []; diff --git a/src/Testing/DelayedRule.php b/src/Testing/DelayedRule.php index 27b35cb2f4..17909e3721 100644 --- a/src/Testing/DelayedRule.php +++ b/src/Testing/DelayedRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Testing; use PhpParser\Node; +use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; use PHPStan\Rules\DirectRegistry; @@ -43,7 +44,7 @@ public function getDelayedErrors(): array return $this->errors; } - public function processNode(Node $node, Scope&NodeCallbackInvoker $scope): array + public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $nodeType = get_class($node); foreach ($this->registry->getRules($nodeType) as $rule) { diff --git a/tests/PHPStan/Rules/CollectedDataEmitterRule.php b/tests/PHPStan/Rules/CollectedDataEmitterRule.php new file mode 100644 index 0000000000..f8a08ca1b6 --- /dev/null +++ b/tests/PHPStan/Rules/CollectedDataEmitterRule.php @@ -0,0 +1,33 @@ + + */ +final class CollectedDataEmitterRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Expr\MethodCall::class; + } + + public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataEmitter $scope): array + { + // same implementation as DummyCollector, but is actually a rule! + if (!$node->name instanceof Node\Identifier) { + return []; + } + + $scope->emitCollectedData(DummyCollector::class, $node->name->toString()); + + return []; + } + +} diff --git a/tests/PHPStan/Rules/CollectedDataEmitterTest.php b/tests/PHPStan/Rules/CollectedDataEmitterTest.php new file mode 100644 index 0000000000..99c642e457 --- /dev/null +++ b/tests/PHPStan/Rules/CollectedDataEmitterTest.php @@ -0,0 +1,33 @@ + + */ +class CollectedDataEmitterTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + // @phpstan-ignore argument.type + return new CompositeRule([ + new CollectedDataEmitterRule(), + new DummyCollectorRule(), + ]); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/dummy-collector.php'], [ + [ + '2× doFoo, 2× doBar', + 5, + ], + ]); + } + +}