Skip to content

Commit 28af07b

Browse files
committed
feat(Reflection): Method getAttributesFromCallStack() can scan classes of called methods
1 parent 2c0f790 commit 28af07b

File tree

9 files changed

+330
-41
lines changed

9 files changed

+330
-41
lines changed

src/Application/Config/FinderConfig.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ final class FinderConfig
3636
public array $excludeFiles = [];
3737

3838
/**
39-
* @param Path $include Include directories or files to the scope
40-
* @param Path $exclude Exclude directories or files from the scope
39+
* @param iterable<non-empty-string|Path> $include Include directories or files to the scope
40+
* @param iterable<non-empty-string|Path> $exclude Exclude directories or files from the scope
4141
*
4242
* @note Glob and regex patterns are not supported
4343
*/

src/Common/Reflection.php

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,35 @@ public static function findMethodsWithAttribute(
163163
* Attributes are returned in the order from the deepest call (closest to the point where this method is invoked)
164164
* to the topmost call (root of the call stack). This follows the natural order of {@see debug_backtrace()}.
165165
*
166-
* @param class-string|null $attributeClass If provided, only attributes of this class will be returned.
166+
* @note Attributes may be duplicated in the result if multiple methods in the call stack belong to the same
167+
* class or to classes in the same inheritance hierarchy. For example, if the call stack contains both
168+
* ChildClass::method() and ParentClass::method() and $includeClasses is true with $includeParents set to true,
169+
* attributes from ParentClass will appear twice. This is intentional behavior that reflects the call stack
170+
* structure.
171+
*
172+
* @template T
173+
*
174+
* @param class-string<T>|null $attributeClass If provided, only attributes of this class will be returned.
167175
* @param bool $includePrototypes Whether to include attributes from method prototypes. Only applicable for methods
168176
* in the call stack.
177+
* @param bool $includeClasses Whether to include attributes from the class containing the method. Only applicable
178+
* for methods in the call stack.
179+
* @param bool $includeParents Whether to include attributes from parent classes. Only applicable when
180+
* $includeClasses is true.
181+
* @param bool $includeTraits Whether to include attributes from traits. Only applicable when $includeClasses is
182+
* true.
183+
* @param int<1, max> $limit Maximum number of attributes to return. If reached, the search will stop early.
184+
* Defaults to PHP_INT_MAX (no practical limit).
169185
* @param ReflectionAttribute::* $flags Flags to pass to {@see ReflectionFunctionAbstract::getAttributes()}.
170-
* @return \ReflectionAttribute[]
186+
* @return list<\ReflectionAttribute<T>>
171187
*/
172188
public static function getAttributesFromCallStack(
173189
?string $attributeClass,
174190
bool $includePrototypes = true,
191+
bool $includeClasses = false,
192+
bool $includeParents = true,
193+
bool $includeTraits = true,
194+
int $limit = \PHP_INT_MAX,
175195
int $flags = 0,
176196
): array {
177197
$attributes = [];
@@ -187,18 +207,35 @@ public static function getAttributesFromCallStack(
187207
default => null,
188208
};
189209

210+
if ($reflection === null) {
211+
continue;
212+
}
213+
190214
$functionAttributes = self::fetchFunctionAttributes(
191215
$reflection,
192216
includePrototypes: $includePrototypes,
193217
attributeClass: $attributeClass,
194218
flags: $flags,
195219
);
196220
$attributes = \array_merge($attributes, $functionAttributes);
197-
} catch (\Throwable) {
198-
continue;
199-
}
200221

201-
if ($reflection === null) {
222+
// Include class attributes if requested and the reflection is a method
223+
if ($includeClasses && $reflection instanceof \ReflectionMethod) {
224+
$classAttributes = self::fetchClassAttributes(
225+
$reflection->getDeclaringClass(),
226+
includeParents: $includeParents,
227+
includeTraits: $includeTraits,
228+
attributeClass: $attributeClass,
229+
flags: $flags,
230+
);
231+
$attributes = \array_merge($attributes, $classAttributes);
232+
}
233+
234+
// Early exit if limit is reached
235+
if (\count($attributes) >= $limit) {
236+
return \array_slice($attributes, 0, $limit);
237+
}
238+
} catch (\Throwable) {
202239
continue;
203240
}
204241
}

tests/Common/Self/ReflectionTest.php

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,122 @@ function testGetAttributesFromCallStackWithNullAttributeClass(): void
216216
// Should return all attributes, not just CallStackAttribute
217217
Assert::true(\count($attributes) >= 1);
218218
}
219+
220+
function testGetAttributesFromCallStackIncludesClassAttributes(): void
221+
{
222+
$obj = new CallStackTestClass();
223+
$attributes = $obj->methodA(CallStackAttribute::class, true, true);
224+
225+
$labels = \array_map(
226+
static fn(\ReflectionAttribute $attr) => $attr->newInstance()->label,
227+
$attributes,
228+
);
229+
230+
// Should find both method attribute and class attribute
231+
Assert::true(\in_array('methodA', $labels, true));
232+
Assert::true(\in_array('classAttribute', $labels, true));
233+
}
234+
235+
function testGetAttributesFromCallStackWithoutClassAttributes(): void
236+
{
237+
$obj = new CallStackTestClass();
238+
$attributes = $obj->methodA(CallStackAttribute::class, true, false);
239+
240+
$labels = \array_map(
241+
static fn(\ReflectionAttribute $attr) => $attr->newInstance()->label,
242+
$attributes,
243+
);
244+
245+
// Should find only method attribute, not class attribute
246+
Assert::true(\in_array('methodA', $labels, true));
247+
Assert::false(\in_array('classAttribute', $labels, true));
248+
}
249+
250+
function testGetAttributesFromCallStackIncludesParentClassAttributes(): void
251+
{
252+
$obj = new CallStackChildClass();
253+
$attributes = $obj->childMethod(CallStackAttribute::class, true, true, true);
254+
255+
$labels = \array_map(
256+
static fn(\ReflectionAttribute $attr) => $attr->newInstance()->label,
257+
$attributes,
258+
);
259+
260+
// Should find child class and parent class attributes
261+
Assert::true(\in_array('childClassAttribute', $labels, true));
262+
Assert::true(\in_array('baseClassAttribute', $labels, true));
263+
}
264+
265+
function testGetAttributesFromCallStackWithoutParentClassAttributes(): void
266+
{
267+
$obj = new CallStackChildClass();
268+
$attributes = $obj->childMethod(CallStackAttribute::class, true, true, false);
269+
270+
$labels = \array_map(
271+
static fn(\ReflectionAttribute $attr) => $attr->newInstance()->label,
272+
$attributes,
273+
);
274+
275+
// Should find only child class attribute, not parent
276+
Assert::true(\in_array('childClassAttribute', $labels, true));
277+
Assert::false(\in_array('baseClassAttribute', $labels, true));
278+
}
279+
280+
function testGetAttributesFromCallStackWithLimit(): void
281+
{
282+
$obj = new CallStackTestClass();
283+
$attributes = $obj->methodB(CallStackAttribute::class, true, false, true, true, 1);
284+
285+
// Should return only 1 attribute due to limit
286+
Assert::same(1, \count($attributes));
287+
}
288+
289+
function testGetAttributesFromCallStackWithLimitGreaterThanResults(): void
290+
{
291+
$attributes = topLevelFunction(CallStackAttribute::class, true, false, true, true, 100);
292+
293+
// Should return all available attributes (less than limit)
294+
Assert::same(1, \count($attributes));
295+
}
296+
297+
function testGetAttributesFromCallStackWithLimitAndNestedCalls(): void
298+
{
299+
$obj = new CallStackTestClass();
300+
$attributes = $obj->methodB(CallStackAttribute::class, true, false, true, true, 2);
301+
302+
// Should return exactly 2 attributes
303+
Assert::same(2, \count($attributes));
304+
305+
$labels = \array_map(
306+
static fn(\ReflectionAttribute $attr) => $attr->newInstance()->label,
307+
$attributes,
308+
);
309+
310+
// Should find the first two attributes from the call stack
311+
Assert::true(\in_array('methodA', $labels, true));
312+
Assert::true(\in_array('methodB', $labels, true));
313+
}
314+
315+
function testGetAttributesFromCallStackDuplicatesClassAttributesFromHierarchy(): void
316+
{
317+
$obj = new CallStackChildClass();
318+
// Call stack: childMethod() -> overriddenMethod()
319+
// With includeClasses=true and includeParents=true:
320+
// - overriddenMethod scans CallStackChildClass + CallStackBaseClass
321+
// - childMethod scans CallStackChildClass + CallStackBaseClass
322+
// Result: childClassAttribute appears twice, baseClassAttribute appears twice
323+
$attributes = $obj->childMethod(CallStackAttribute::class, true, true, true);
324+
325+
$labels = \array_map(
326+
static fn(\ReflectionAttribute $attr) => $attr->newInstance()->label,
327+
$attributes,
328+
);
329+
330+
// Count occurrences of each label
331+
$childClassCount = \count(\array_filter($labels, static fn($l) => $l === 'childClassAttribute'));
332+
$baseClassCount = \count(\array_filter($labels, static fn($l) => $l === 'baseClassAttribute'));
333+
334+
// Both class attributes should appear twice (once per method in call stack)
335+
Assert::same(2, $childClassCount);
336+
Assert::same(2, $baseClassCount);
337+
}

tests/Common/Stub/CallStack/CallStackAttribute.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
namespace Tests\Common\Stub\CallStack;
66

7-
#[\Attribute(\Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
7+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_FUNCTION | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
88
final class CallStackAttribute
99
{
1010
public function __construct(

tests/Common/Stub/CallStack/CallStackBaseClass.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,44 @@
66

77
use Testo\Common\Reflection;
88

9+
#[CallStackAttribute('baseClassAttribute')]
910
abstract class CallStackBaseClass
1011
{
1112
#[CallStackAttribute('baseMethod')]
12-
public function baseMethod(?string $attributeClass = null, bool $includePrototypes = true): array
13-
{
14-
return Reflection::getAttributesFromCallStack($attributeClass, $includePrototypes);
13+
public function baseMethod(
14+
?string $attributeClass = null,
15+
bool $includePrototypes = true,
16+
bool $includeClasses = false,
17+
bool $includeParents = true,
18+
bool $includeTraits = true,
19+
int $limit = \PHP_INT_MAX,
20+
): array {
21+
return Reflection::getAttributesFromCallStack(
22+
$attributeClass,
23+
$includePrototypes,
24+
$includeClasses,
25+
$includeParents,
26+
$includeTraits,
27+
$limit,
28+
);
1529
}
1630

1731
#[CallStackAttribute('overridden')]
18-
public function overriddenMethod(?string $attributeClass = null, bool $includePrototypes = true): array
19-
{
20-
return Reflection::getAttributesFromCallStack($attributeClass, $includePrototypes);
32+
public function overriddenMethod(
33+
?string $attributeClass = null,
34+
bool $includePrototypes = true,
35+
bool $includeClasses = false,
36+
bool $includeParents = true,
37+
bool $includeTraits = true,
38+
int $limit = \PHP_INT_MAX,
39+
): array {
40+
return Reflection::getAttributesFromCallStack(
41+
$attributeClass,
42+
$includePrototypes,
43+
$includeClasses,
44+
$includeParents,
45+
$includeTraits,
46+
$limit,
47+
);
2148
}
2249
}

tests/Common/Stub/CallStack/CallStackChildClass.php

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,43 @@
66

77
use Testo\Common\Reflection;
88

9+
#[CallStackAttribute('childClassAttribute')]
910
final class CallStackChildClass extends CallStackBaseClass
1011
{
11-
public function overriddenMethod(?string $attributeClass = null, bool $includePrototypes = true): array
12-
{
13-
return Reflection::getAttributesFromCallStack($attributeClass, $includePrototypes);
12+
public function overriddenMethod(
13+
?string $attributeClass = null,
14+
bool $includePrototypes = true,
15+
bool $includeClasses = false,
16+
bool $includeParents = true,
17+
bool $includeTraits = true,
18+
int $limit = \PHP_INT_MAX,
19+
): array {
20+
return Reflection::getAttributesFromCallStack(
21+
$attributeClass,
22+
$includePrototypes,
23+
$includeClasses,
24+
$includeParents,
25+
$includeTraits,
26+
$limit,
27+
);
1428
}
1529

1630
#[CallStackAttribute('childMethod')]
17-
public function childMethod(?string $attributeClass = null, bool $includePrototypes = true): array
18-
{
19-
return $this->overriddenMethod($attributeClass, $includePrototypes);
31+
public function childMethod(
32+
?string $attributeClass = null,
33+
bool $includePrototypes = true,
34+
bool $includeClasses = false,
35+
bool $includeParents = true,
36+
bool $includeTraits = true,
37+
int $limit = \PHP_INT_MAX,
38+
): array {
39+
return $this->overriddenMethod(
40+
$attributeClass,
41+
$includePrototypes,
42+
$includeClasses,
43+
$includeParents,
44+
$includeTraits,
45+
$limit,
46+
);
2047
}
2148
}

tests/Common/Stub/CallStack/CallStackHelpers.php

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,57 @@
77
use Testo\Common\Reflection;
88

99
#[CallStackAttribute('topFunction')]
10-
function topLevelFunction(?string $attributeClass = null, bool $includePrototypes = true): array
11-
{
12-
return Reflection::getAttributesFromCallStack($attributeClass, $includePrototypes);
10+
function topLevelFunction(
11+
?string $attributeClass = null,
12+
bool $includePrototypes = true,
13+
bool $includeClasses = false,
14+
bool $includeParents = true,
15+
bool $includeTraits = true,
16+
int $limit = \PHP_INT_MAX,
17+
): array {
18+
return Reflection::getAttributesFromCallStack(
19+
$attributeClass,
20+
$includePrototypes,
21+
$includeClasses,
22+
$includeParents,
23+
$includeTraits,
24+
$limit,
25+
);
1326
}
1427

1528
#[CallStackAttribute('nestedFunction')]
16-
function nestedFunction(?string $attributeClass = null, bool $includePrototypes = true): array
17-
{
18-
return topLevelFunction($attributeClass, $includePrototypes);
29+
function nestedFunction(
30+
?string $attributeClass = null,
31+
bool $includePrototypes = true,
32+
bool $includeClasses = false,
33+
bool $includeParents = true,
34+
bool $includeTraits = true,
35+
int $limit = \PHP_INT_MAX,
36+
): array {
37+
return topLevelFunction(
38+
$attributeClass,
39+
$includePrototypes,
40+
$includeClasses,
41+
$includeParents,
42+
$includeTraits,
43+
$limit,
44+
);
1945
}
2046

21-
function unmarkedFunction(?string $attributeClass = null, bool $includePrototypes = true): array
22-
{
23-
return Reflection::getAttributesFromCallStack($attributeClass, $includePrototypes);
47+
function unmarkedFunction(
48+
?string $attributeClass = null,
49+
bool $includePrototypes = true,
50+
bool $includeClasses = false,
51+
bool $includeParents = true,
52+
bool $includeTraits = true,
53+
int $limit = \PHP_INT_MAX,
54+
): array {
55+
return Reflection::getAttributesFromCallStack(
56+
$attributeClass,
57+
$includePrototypes,
58+
$includeClasses,
59+
$includeParents,
60+
$includeTraits,
61+
$limit,
62+
);
2463
}

0 commit comments

Comments
 (0)