Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
20 / 20
CRAP
100.00% covered (success)
100.00%
1 / 1
ArrayProxy
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
20 / 20
44
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 unwrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 in
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getIterator
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 offsetExists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 offsetGet
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 offsetSet
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 offsetUnset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 contains
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 merge
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 map
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reduce
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sorted
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 sort
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
 usort
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 key
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 ArrayAccess;
8use Celema\Boiler\Contract\Wrapper;
9use Celema\Boiler\Exception\OutOfBoundsException;
10use Celema\Boiler\Exception\RuntimeException;
11use Celema\Boiler\Exception\UnexpectedValueException;
12use Countable;
13use Generator;
14use IteratorAggregate;
15use JsonSerializable;
16use Override;
17
18/**
19 * @api
20 *
21 * @psalm-type ArrayCallable = callable(mixed, mixed):int
22 * @psalm-type FilterCallable = callable(mixed):mixed
23 * @psalm-type MapCallable = callable(mixed):mixed
24 * @psalm-type ReduceCallable = callable(mixed, mixed):mixed
25 *
26 * @template-implements ArrayAccess<array-key|StringProxy, mixed>
27 * @template-implements IteratorAggregate<mixed, mixed>
28 * @implements Proxy<array<array-key, mixed>>
29 */
30final class ArrayProxy implements ArrayAccess, IteratorAggregate, Countable, JsonSerializable, Proxy
31{
32    use RefusesJson;
33
34    /**
35     * @param array<array-key, mixed> $array
36     */
37    public function __construct(
38        private array $array,
39        private readonly Wrapper $wrapper,
40    ) {}
41
42    /** @return array<array-key, mixed> */
43    #[Override]
44    public function unwrap(): array
45    {
46        return $this->array;
47    }
48
49    #[Override]
50    public function is(mixed $other): bool
51    {
52        return $this->array === ($other instanceof Proxy ? $other->unwrap() : $other);
53    }
54
55    /** @param self|array<array-key, mixed> $haystack */
56    #[Override]
57    public function in(self|array $haystack): bool
58    {
59        if ($haystack instanceof self) {
60            $haystack = $haystack->unwrap();
61        }
62
63        /** @var mixed $item */
64        foreach ($haystack as $item) {
65            if ($this->is($item)) {
66                return true;
67            }
68        }
69
70        return false;
71    }
72
73    /**
74     * Every loop gets its own generator, so nested loops over the same proxy
75     * do not share a cursor. Keys are wrapped like values: a string key is
76     * template output as much as the value is.
77     *
78     * @return Generator<mixed, mixed>
79     */
80    #[Override]
81    public function getIterator(): Generator
82    {
83        /** @var mixed $value */
84        foreach ($this->array as $key => $value) {
85            yield $this->wrapper->wrap($key) => $this->wrapper->wrap($value);
86        }
87    }
88
89    /**
90     * Backs `isset()` and `??`, so a null value counts as missing, as with a
91     * plain array. Use `exists()` to test for the key alone.
92     *
93     * @param array-key|StringProxy $offset
94     */
95    #[Override]
96    public function offsetExists(mixed $offset): bool
97    {
98        return isset($this->array[self::key($offset)]);
99    }
100
101    /** @param array-key|StringProxy $offset */
102    #[Override]
103    public function offsetGet(mixed $offset): mixed
104    {
105        $offset = self::key($offset);
106
107        if (array_key_exists($offset, $this->array)) {
108            return $this->wrapper->wrap($this->array[$offset]);
109        }
110
111        $key = is_numeric($offset) ? (string) $offset : "'{$offset}'";
112
113        throw new OutOfBoundsException("Undefined array key {$key}");
114    }
115
116    /** @param array-key|StringProxy|null $offset */
117    #[Override]
118    public function offsetSet(mixed $offset, mixed $value): void
119    {
120        if ($offset === null) {
121            $this->array[] = $this->wrapper->unwrap($value);
122        } else {
123            $this->array[self::key($offset)] = $this->wrapper->unwrap($value);
124        }
125    }
126
127    /** @param array-key|StringProxy $offset */
128    #[Override]
129    public function offsetUnset(mixed $offset): void
130    {
131        unset($this->array[self::key($offset)]);
132    }
133
134    #[Override]
135    public function count(): int
136    {
137        return count($this->array);
138    }
139
140    /** @param array-key|StringProxy $key */
141    public function exists(mixed $key): bool
142    {
143        return array_key_exists(self::key($key), $this->array);
144    }
145
146    public function contains(mixed $value): bool
147    {
148        return in_array($value instanceof Proxy ? $value->unwrap() : $value, $this->array, true);
149    }
150
151    /** @param array<array-key, mixed>|self $array */
152    public function merge(array|self $array): self
153    {
154        return new self(array_merge(
155            $this->array,
156            $array instanceof self ? $array->unwrap() : $array,
157        ), $this->wrapper);
158    }
159
160    /** @psalm-param MapCallable $callable */
161    public function map(callable $callable): self
162    {
163        return new self(array_map($callable, $this->array), $this->wrapper);
164    }
165
166    /** @psalm-param FilterCallable $callable */
167    public function filter(callable $callable): self
168    {
169        return new self(array_filter($this->array, $callable), $this->wrapper);
170    }
171
172    /** @psalm-param ReduceCallable $callable */
173    public function reduce(callable $callable, mixed $initial = null): mixed
174    {
175        return $this->wrapper->wrap(array_reduce($this->array, $callable, $initial));
176    }
177
178    /** @psalm-param ArrayCallable $callable */
179    public function sorted(StringProxy|string $mode = '', ?callable $callable = null): self
180    {
181        $mode = strtolower(trim($mode instanceof StringProxy ? $mode->unwrap() : $mode));
182
183        if (str_starts_with($mode, 'u')) {
184            if (!is_callable($callable)) {
185                throw new RuntimeException('No callable provided for user defined sorting');
186            }
187
188            return $this->usort($this->array, $mode, $callable);
189        }
190
191        return $this->sort($this->array, $mode);
192    }
193
194    /** @param array<array-key, mixed> $array */
195    private function sort(array $array, string $mode): self
196    {
197        match ($mode) {
198            '' => sort($array),
199            'ar' => arsort($array),
200            'a' => asort($array),
201            'kr' => krsort($array),
202            'k' => ksort($array),
203            'r' => rsort($array),
204            default => throw new UnexpectedValueException("Sort mode '{$mode}' not supported"),
205        };
206
207        return new self($array, $this->wrapper);
208    }
209
210    /**
211     * @param array<array-key, mixed> $array
212     * @psalm-param ArrayCallable $callable
213     */
214    private function usort(array $array, string $mode, callable $callable): self
215    {
216        match ($mode) {
217            'ua' => uasort($array, $callable),
218            'u' => usort($array, $callable),
219            default => throw new UnexpectedValueException("Sort mode '{$mode}' not supported"),
220        };
221
222        return new self($array, $this->wrapper);
223    }
224
225    /**
226     * Iteration hands out string keys wrapped, so accept them back as offsets.
227     *
228     * @param array-key|StringProxy $offset
229     * @return array-key
230     */
231    private static function key(mixed $offset): mixed
232    {
233        return $offset instanceof StringProxy ? $offset->unwrap() : $offset;
234    }
235}