Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
ObjectAccess
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
10 / 10
20
100.00% covered (success)
100.00%
1 / 1
 __toString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 __get
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 __isset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __set
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 __call
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 __invoke
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 object
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPublicProperty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 declaredProperties
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 unwrapArgs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Celema\Boiler\Proxy;
6
7use Celema\Boiler\Exception\RuntimeException;
8use ReflectionClass;
9use ReflectionProperty;
10use Stringable;
11
12/**
13 * Template access to a wrapped object: properties, methods, invocation, and
14 * string conversion, each wrapping what it returns. Shared by the proxies of
15 * plain and traversable objects, so a collection or a menu item with children
16 * offers the same access as any other object.
17 *
18 * Requires the using class to hold the object in `$value`, its wrapper in
19 * `$wrapper`, and whether its string conversion skips escaping in `$safe`.
20 *
21 * @internal
22 */
23trait ObjectAccess
24{
25    /** @var array<class-string, array<string, ReflectionProperty>> public instance properties by class */
26    private static array $declared = [];
27
28    public function __toString(): string
29    {
30        if (!$this->value instanceof Stringable) {
31            throw new RuntimeException('Wrapped object is not stringable');
32        }
33
34        $string = (string) $this->value;
35
36        return $this->safe ? $string : $this->wrapper->escape($string);
37    }
38
39    public function __get(string $name): mixed
40    {
41        // Reflection spares the full lookup, which copies all properties and runs
42        // their get hooks. Dynamic properties and lazy proxies still need it.
43        $declared = self::$declared[$this->value::class] ?? $this->declaredProperties();
44        $readable = isset($declared[$name]) && $declared[$name]->isInitialized($this->value);
45
46        // An object with `__get()` answers for the properties it does not expose,
47        // as it does outside a template.
48        if ($readable || method_exists($this->value, '__get') || $this->hasPublicProperty($name)) {
49            return $this->wrapper->wrap($this->value->{$name});
50        }
51
52        throw new RuntimeException('No such property');
53    }
54
55    /**
56     * Backs `isset()`, `empty()`, and `??` on properties. Delegates to the
57     * object, so its own `__isset()` applies and null counts as missing.
58     */
59    public function __isset(string $name): bool
60    {
61        return isset($this->value->{$name});
62    }
63
64    public function __set(string $name, mixed $value): void
65    {
66        if ($this->hasPublicProperty($name)) {
67            $this->value->{$name} = $this->wrapper->unwrap($value);
68
69            return;
70        }
71
72        throw new RuntimeException('No such property');
73    }
74
75    /** @param array<array-key, mixed> $args */
76    public function __call(string $name, array $args): mixed
77    {
78        if (is_callable([$this->value, $name])) {
79            return $this->wrapper->wrap($this->value->{$name}(...$this->unwrapArgs($args)));
80        }
81
82        throw new RuntimeException('No such method');
83    }
84
85    public function __invoke(mixed ...$args): mixed
86    {
87        $object = $this->object();
88
89        if (is_callable($object)) {
90            return $this->wrapper->wrap($object(...$this->unwrapArgs($args)));
91        }
92
93        throw new RuntimeException('No such method');
94    }
95
96    /**
97     * The wrapped object, typed loosely: Psalm does not consider that a
98     * traversable class may define `__invoke()`.
99     */
100    private function object(): object
101    {
102        return $this->value;
103    }
104
105    private function hasPublicProperty(string $name): bool
106    {
107        return array_key_exists($name, get_object_vars($this->value));
108    }
109
110    /** @return array<string, ReflectionProperty> */
111    private function declaredProperties(): array
112    {
113        return self::$declared[$this->value::class] = array_column(
114            array_filter(
115                new ReflectionClass($this->value)->getProperties(ReflectionProperty::IS_PUBLIC),
116                static fn(ReflectionProperty $property): bool => !$property->isStatic(),
117            ),
118            null,
119            'name',
120        );
121    }
122
123    /**
124     * @param array<array-key, mixed> $args
125     * @return array<array-key, mixed>
126     */
127    private function unwrapArgs(array $args): array
128    {
129        // Spares the common call without arguments the pass through the wrapper.
130        return $args === [] ? $args : $this->wrapper->unwrap($args);
131    }
132}