Skip to content

Commit 32f1af9

Browse files
feat: Enhance PHPStan analysis for Behavior type inference and testing. (#40)
1 parent eec1a67 commit 32f1af9

20 files changed

+985
-33
lines changed

.editorconfig

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,14 @@ ij_php_space_after_type_cast = true
1717
[*.md]
1818
trim_trailing_whitespace = false
1919

20+
[*.neon]
21+
indent_size = 4
22+
2023
[*.yml]
2124
indent_size = 2
25+
26+
[*.xml]
27+
indent_size = 2
28+
29+
[*.xml.dist]
30+
indent_size = 2

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Enh #37: Enhance `DI` container type inference and testing (@terabytesoftw)
88
- Bug #38: Correct exception message formatting in `ServiceMapServiceTest` (@terabytesoftw)
99
- Bug #39: Resolve `Container::get()` type inference for unconfigured classes in config (`ServiceMap`) (@terabytesoftw)
10+
- Enh #40: Enhance `PHPStan` analysis for `Behavior` type inference and testing.
1011

1112
## 0.2.3 June 09, 2025
1213

extension.neon

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ parametersSchema:
1717
)
1818

1919
services:
20+
-
21+
class: yii2\extensions\phpstan\method\BehaviorMethodsClassReflectionExtension
22+
tags: [phpstan.broker.methodsClassReflectionExtension]
2023
-
2124
class: yii2\extensions\phpstan\reflection\ApplicationPropertiesClassReflectionExtension
2225
tags: [phpstan.broker.propertiesClassReflectionExtension]
@@ -32,6 +35,9 @@ services:
3235
-
3336
class: yii2\extensions\phpstan\reflection\UserPropertiesClassReflectionExtension
3437
tags: [phpstan.broker.propertiesClassReflectionExtension]
38+
-
39+
class: yii2\extensions\phpstan\property\BehaviorPropertiesClassReflectionExtension
40+
tags: [phpstan.broker.propertiesClassReflectionExtension]
3541
-
3642
class: yii2\extensions\phpstan\type\ActiveQueryDynamicMethodReturnTypeExtension
3743
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
@@ -42,10 +48,10 @@ services:
4248
class: yii2\extensions\phpstan\type\ActiveRecordDynamicStaticMethodReturnTypeExtension
4349
tags: [phpstan.broker.dynamicStaticMethodReturnTypeExtension]
4450
-
45-
class: yii2\extensions\phpstan\type\HeaderCollectionDynamicMethodReturnTypeExtension
51+
class: yii2\extensions\phpstan\type\ContainerDynamicMethodReturnTypeExtension
4652
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
4753
-
48-
class: yii2\extensions\phpstan\type\ContainerDynamicMethodReturnTypeExtension
54+
class: yii2\extensions\phpstan\type\HeaderCollectionDynamicMethodReturnTypeExtension
4955
tags: [phpstan.broker.dynamicMethodReturnTypeExtension]
5056

5157
- yii2\extensions\phpstan\ServiceMap(%yii2.config_path%)

phpunit.xml.dist

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<phpunit
3-
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4-
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
5-
bootstrap="tests/bootstrap.php"
6-
cacheDirectory=".phpunit.cache"
7-
colors="true"
8-
executionOrder="depends,defects"
9-
failOnRisky="true"
10-
failOnWarning="true"
11-
stopOnFailure="false"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
5+
bootstrap="tests/bootstrap.php"
6+
cacheDirectory=".phpunit.cache"
7+
colors="true"
8+
executionOrder="depends,defects"
9+
failOnRisky="true"
10+
failOnWarning="true"
11+
stopOnFailure="false"
1212
>
13-
<testsuites>
14-
<testsuite name="PHPstan">
15-
<directory>tests</directory>
16-
</testsuite>
17-
</testsuites>
18-
<source>
19-
<include>
20-
<directory suffix=".php">./src</directory>
21-
</include>
22-
<exclude>
23-
<directory suffix=".php">./src/reflection</directory>
24-
<directory suffix=".php">./src/type</directory>
25-
</exclude>
26-
</source>
13+
<testsuites>
14+
<testsuite name="PHPstan">
15+
<directory>tests</directory>
16+
</testsuite>
17+
</testsuites>
18+
<source>
19+
<include>
20+
<directory suffix=".php">./src</directory>
21+
</include>
22+
<exclude>
23+
<directory suffix=".php">./src/method</directory>
24+
<directory suffix=".php">./src/property</directory>
25+
<directory suffix=".php">./src/reflection</directory>
26+
<directory suffix=".php">./src/type</directory>
27+
</exclude>
28+
</source>
2729
</phpunit>

src/ServiceMap.php

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
*
4747
* @phpstan-type DefinitionType = array{class?: mixed}|array{array{class?: mixed}}|object|string
4848
* @phpstan-type ServiceType = array{
49+
* behaviors?: array<array-key, mixed>,
4950
* components?: array<array-key, array<array-key, mixed>|object>,
5051
* container?: array{
5152
* definitions?: array<array-key, DefinitionType>,
@@ -58,6 +59,13 @@
5859
*/
5960
final class ServiceMap
6061
{
62+
/**
63+
* Behavior definitions map for Yii application analysis.
64+
*
65+
* @phpstan-var array<string, string[]>
66+
*/
67+
private array $behaviors = [];
68+
6169
/**
6270
* Component definitions map for Yii application analysis.
6371
*
@@ -111,11 +119,32 @@ public function __construct(string $configPath = '')
111119

112120
$config = $this->loadConfig($configPath);
113121

122+
$this->processBehaviors($config);
114123
$this->processComponents($config);
115124
$this->processDefinition($config);
116125
$this->processSingletons($config);
117126
}
118127

128+
/**
129+
* Retrieves the behavior class names associated with the specified class.
130+
*
131+
* Looks up the internal behavior definitions map for the provided fully qualified class name and return an array
132+
* of associated behavior class names.
133+
*
134+
* This method enables static analysis tools and IDEs to infer attached behaviors for Yii application classes,
135+
* supporting accurate type inference and property reflection.
136+
*
137+
* @param string $class Fully qualified class name for which to retrieve behavior class names.
138+
*
139+
* @return string[] Array of behavior class names, or an empty array if none are defined.
140+
*
141+
* @phpstan-return string[]
142+
*/
143+
public function getBehaviorsByClassName(string $class): array
144+
{
145+
return $this->behaviors[$class] ?? [];
146+
}
147+
119148
/**
120149
* Retrieves the fully qualified class name of a Yii application component by its identifier.
121150
*
@@ -145,15 +174,15 @@ public function getComponentClassById(string $id): string|null
145174
*
146175
* @param string $id Component identifier to look up in the component definitions map.
147176
*
148-
* @return array|null Component definition array with configuration options, or `null` if not found.
177+
* @return array Component definition array with configuration options, or empty array if not found.
149178
*
150-
* @phpstan-return array<array-key, mixed>|null
179+
* @phpstan-return array<array-key, mixed>
151180
*/
152-
public function getComponentDefinitionById(string $id): array|null
181+
public function getComponentDefinitionById(string $id): array
153182
{
154183
$definition = $this->componentsDefinitions[$id] ?? null;
155184

156-
return is_array($definition) ? $definition : null;
185+
return is_array($definition) ? $definition : [];
157186
}
158187

159188
/**
@@ -231,6 +260,10 @@ private function loadConfig(string $configPath): array
231260
throw new RuntimeException(sprintf("Configuration file '%s' must return an array.", $configPath));
232261
}
233262

263+
if (isset($config['behaviors']) && is_array($config['behaviors']) === false) {
264+
$this->throwErrorWhenConfigFileIsNotArray($configPath, 'behaviors');
265+
}
266+
234267
if (isset($config['components']) && is_array($config['components']) === false) {
235268
$this->throwErrorWhenConfigFileIsNotArray($configPath, 'components');
236269
}
@@ -305,6 +338,33 @@ private function normalizeDefinition(string $id, array|int|object|string $defini
305338
$this->throwErrorWhenUnsupportedDefinition($id);
306339
}
307340

341+
/**
342+
* @param array $config Yii application configuration array containing behavior definitions.
343+
*
344+
* @phpstan-import-type ServiceType from ServiceMap
345+
* @phpstan-param ServiceType $config
346+
*/
347+
private function processBehaviors(array $config): void
348+
{
349+
if ($config !== []) {
350+
$behaviors = $config['behaviors'] ?? [];
351+
352+
foreach ($behaviors as $id => $definition) {
353+
if (is_string($id) === false) {
354+
$this->throwErrorWhenIdIsNotString('Behavior class', gettype($id));
355+
}
356+
357+
if (is_array($definition) === false) {
358+
throw new RuntimeException(
359+
sprintf("Behavior definition for '%s' must be an array.", $id),
360+
);
361+
}
362+
363+
$this->behaviors[$id] = array_values(array_filter($definition, 'is_string'));
364+
}
365+
}
366+
}
367+
308368
/**
309369
* Processes component definitions from the Yii application configuration array.
310370
*
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace yii2\extensions\phpstan\method;
6+
7+
use PHPStan\Analyser\OutOfClassScope;
8+
use PHPStan\Reflection\{
9+
ClassReflection,
10+
MethodReflection,
11+
MethodsClassReflectionExtension,
12+
ReflectionProvider,
13+
};
14+
use yii\base\Component;
15+
use yii2\extensions\phpstan\ServiceMap;
16+
17+
/**
18+
* Provides method reflection for Yii Behavior in PHPStan analysis.
19+
*
20+
* Integrates Yii Behavior with PHPStan method reflection extension, enabling detection and resolution of methods
21+
* provided by attached behaviors on {@see Component} subclasses during static analysis.
22+
*
23+
* This extension inspects the behaviors attached to a given class and determines if any of them provide the requested
24+
* method, allowing PHPStan to recognize available methods as if they were natively declared.
25+
*
26+
* Key features.
27+
* - Delegates to the native {@see Component} method if not found in behaviors.
28+
* - Detects methods provided by behaviors attached to {@see Component} subclasses.
29+
* - Ensures compatibility with PHPStan strict static analysis and autocompletion.
30+
* - Integrates with {@see ServiceMap} for efficient behavior lookup by class name.
31+
*
32+
* @see Component for Yii Component class.
33+
* @see MethodsClassReflectionExtension for custom methods class reflection extension contract.
34+
*
35+
* @copyright Copyright (C) 2023 Terabytesoftw.
36+
* @license https://opensource.org/license/bsd-3-clause BSD 3-Clause License.
37+
*/
38+
final class BehaviorMethodsClassReflectionExtension implements MethodsClassReflectionExtension
39+
{
40+
/**
41+
* Creates a new instance of the {@see BehaviorMethodsClassReflectionExtension} class.
42+
*
43+
* @param ReflectionProvider $reflectionProvider Reflection provider for class and property lookups.
44+
* @param ServiceMap $serviceMap Service map for resolving component classes by ID.
45+
*/
46+
public function __construct(
47+
private readonly ReflectionProvider $reflectionProvider,
48+
private readonly ServiceMap $serviceMap,
49+
) {}
50+
51+
/**
52+
* Retrieves the method reflection for a given method name, including those provided by attached behaviors.
53+
*
54+
* Resolves the {@see MethodReflection} for the specified method name on the given class, searching first among
55+
* methods provided by behaviors attached to the class. If the method is not found in any behavior, it delegates
56+
* to the native {@see Component} method resolution.
57+
*
58+
* This enables PHPStan to recognize available methods from behaviors as if they were natively declared on the
59+
* component class, supporting accurate static analysis and autocompletion.
60+
*
61+
* @param ClassReflection $classReflection Reflection of the class being analyzed.
62+
* @param string $methodName Name of the method to resolve.
63+
*
64+
* @return MethodReflection Reflection instance for the resolved method.
65+
*/
66+
public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection
67+
{
68+
$behaviorMethod = $this->findMethodInBehaviors($classReflection, $methodName);
69+
70+
assert($behaviorMethod !== null);
71+
72+
return $behaviorMethod;
73+
}
74+
75+
/**
76+
* Determines whether the specified method exists on the given class, including methods provided by attached
77+
* behaviors.
78+
*
79+
* Checks if the class is a subclass of {@see Component} and doesn't already declare the method natively. If so,
80+
* inspect all behaviors attached to the class to determine if any provide the requested method.
81+
*
82+
* This enables PHPStan to recognize available methods from behaviors as if they were natively declared on the
83+
* component class, supporting accurate static analysis and autocompletion.
84+
*
85+
* @param ClassReflection $classReflection Reflection of the class being analyzed.
86+
* @param string $methodName Name of the method to check for existence.
87+
*
88+
* @return bool `true` if the method exists on the class via an attached behavior; `false` otherwise.
89+
*/
90+
public function hasMethod(ClassReflection $classReflection, string $methodName): bool
91+
{
92+
if ($classReflection->isSubclassOfClass($this->reflectionProvider->getClass(Component::class)) === false) {
93+
return false;
94+
}
95+
96+
if ($classReflection->hasNativeMethod($methodName)) {
97+
return false;
98+
}
99+
100+
return $this->findMethodInBehaviors($classReflection, $methodName) !== null;
101+
}
102+
103+
/**
104+
* Searches for a method provided by behaviors attached to the specified class.
105+
*
106+
* Iterates over all behaviors attached to the given class and checks if any of them declare the requested method.
107+
*
108+
* This enables method resolution for behaviors in PHPStan static analysis, allowing detection of methods that
109+
* aren't natively declared on the component class but are available via attached behaviors.
110+
*
111+
* @param ClassReflection $classReflection Reflection of the class being analyzed.
112+
* @param string $methodName Name of the method to search for in attached behaviors.
113+
*
114+
* @return MethodReflection|null Reflection instance for the resolved method if found in a behavior; {@see null}
115+
* otherwise.
116+
*/
117+
private function findMethodInBehaviors(ClassReflection $classReflection, string $methodName): MethodReflection|null
118+
{
119+
$behaviors = $this->serviceMap->getBehaviorsByClassName($classReflection->getName());
120+
121+
foreach ($behaviors as $behaviorClass) {
122+
if ($this->reflectionProvider->hasClass($behaviorClass)) {
123+
$behaviorReflection = $this->reflectionProvider->getClass($behaviorClass);
124+
125+
if ($behaviorReflection->hasMethod($methodName)) {
126+
return $behaviorReflection->getMethod($methodName, new OutOfClassScope());
127+
}
128+
}
129+
}
130+
131+
return null;
132+
}
133+
}

0 commit comments

Comments
 (0)