Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
UrlPath
100.00% covered (success)
100.00%
58 / 58
100.00% covered (success)
100.00%
14 / 14
35
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 from
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValidElement
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isEmpty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getElements
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
8
 toString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 offsetExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 offsetGet
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 offsetSet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getIterator
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace PeServer\Core\Web;
6
7use ArrayAccess;
8use ArrayIterator;
9use Countable;
10use Iterator;
11use IteratorAggregate;
12use PeServer\Core\Collection\Arr;
13use PeServer\Core\Collection\ArrayAccessHelper;
14use PeServer\Core\Collection\Collections;
15use PeServer\Core\Text;
16use PeServer\Core\Throws\ArgumentException;
17use PeServer\Core\Throws\IndexOutOfRangeException;
18use PeServer\Core\Throws\InvalidOperationException;
19use PeServer\Core\Throws\NotSupportedException;
20use PeServer\Core\TypeUtility;
21use Stringable;
22use TypeError;
23
24/**
25 * URL のパス構成要素。
26 * @implements ArrayAccess<non-negative-int,string>
27 * @implements IteratorAggregate<non-negative-int,string>
28 */
29readonly class UrlPath implements ArrayAccess, Countable, IteratorAggregate, Stringable
30{
31    #region variable
32
33    /**
34     * 構成要素
35     *
36     * `null` の場合はほんとになんもない(ホストの後の `/` もない)
37     * 配列要素数が 0 の場合は `/` のみ
38     *
39     * @var non-empty-string[]|null
40     */
41    private array|null $elements;
42
43    #endregion
44
45    public function __construct(string $path)
46    {
47        if (Text::isNullOrWhiteSpace($path)) {
48            $this->elements = null;
49        } else {
50            /** @phpstan-var non-empty-string[] */
51            $elements = Collections::from(Text::split($path, '/'))
52                ->select(fn ($a) => Text::trim($a, '/'))
53                ->where(fn ($a) => !Text::isNullOrWhiteSpace($a))
54                ->toArray();
55
56            foreach ($elements as $element) {
57                if (!self::isValidElement($element)) {
58                    throw new ArgumentException($path);
59                }
60            }
61
62            $this->elements = $elements;
63        }
64    }
65
66    #region function
67
68    /**
69     * パスの各要素から生成。
70     *
71     * @param string[] $elements
72     * @return self
73     */
74    public static function from(array $elements): self
75    {
76        return new self(Text::join('/', $elements));
77    }
78
79    public static function isValidElement(string $element): bool
80    {
81        $invalids = ['/', '?', '#'];
82
83        foreach ($invalids as $invalid) {
84            if (Text::contains($element, $invalid, false)) {
85                return false;
86            }
87        }
88
89        return true;
90    }
91
92    /**
93     * ルートの `/` すら持たない空のパスか。
94     *
95     * @return bool
96     * @phpstan-assert-if-true null $this->elements
97     * @phpstan-assert-if-false string[] $this->elements
98     */
99    public function isEmpty(): bool
100    {
101        return $this->elements === null;
102    }
103
104    /**
105     * パスの各要素を取得。
106     *
107     * @return non-empty-string[]
108     */
109    public function getElements(): array
110    {
111        if ($this->isEmpty()) {
112            throw new InvalidOperationException('empty');
113        }
114
115        return $this->elements;
116    }
117
118    /**
119     * 終端パスを追加。
120     *
121     * @param string|string[] $element
122     * @phpstan-param string|non-empty-array<non-empty-string> $element
123     * @return self 終端パスの追加された `UrlPath`
124     */
125    public function add(string|array $element): self
126    {
127        if (is_string($element)) {
128            if (Text::isNullOrWhiteSpace($element)) {
129                return $this;
130            }
131            $elements = [$element];
132        } else {
133            if (count($element) === 0) { //@phpstan-ignore-line [DOCTYPE]
134                throw new ArgumentException('$element: empty array');
135            }
136            foreach ($element as $i => $elm) {
137                if (is_string($elm)) { //@phpstan-ignore-line [DOCTYPE]
138                    if (Text::isNullOrWhiteSpace($elm)) { //@phpstan-ignore-line [DOCTYPE]
139                        throw new ArgumentException("\$element[$i]: whitespace string");
140                    }
141                } else {
142                    throw new ArgumentException("\$element[$i]: not string");
143                }
144            }
145            $elements = $element;
146        }
147
148        if ($this->isEmpty()) {
149            return self::from($elements);
150        } else {
151            return self::from([...$this->elements, ...$elements]);
152        }
153    }
154
155    public function toString(bool $trailingSlash): string
156    {
157        if ($this->isEmpty()) {
158            return Text::EMPTY;
159        }
160
161        if (!Arr::getCount($this->elements)) {
162            return '/';
163        }
164
165        return '/' . Text::join('/', $this->elements) . ($trailingSlash ? '/' : Text::EMPTY);
166    }
167
168    #endregion
169
170    #region ArrayAccess
171
172    /**
173     * @param int $offset
174     * @phpstan-param non-negative-int $offset
175     * @return bool
176     * @see ArrayAccess::offsetExists
177     */
178    public function offsetExists(mixed $offset): bool
179    {
180        if (!ArrayAccessHelper::offsetExistsUInt($offset)) { //@phpstan-ignore-line [DOCTYPE] non-negative-int
181            return false;
182        }
183
184        if ($this->isEmpty()) {
185            return false;
186        }
187
188        return isset($this->elements[$offset]);
189    }
190
191    /**
192     * @param int $offset
193     * @phpstan-param non-negative-int $offset
194     * @return string
195     * @throws TypeError
196     * @throws IndexOutOfRangeException
197     * @see ArrayAccess::offsetGet
198     */
199    public function offsetGet(mixed $offset): mixed
200    {
201        ArrayAccessHelper::offsetGetUInt($offset); //@phpstan-ignore-line [DOCTYPE] non-negative-int
202
203        if ($this->isEmpty()) {
204            throw new IndexOutOfRangeException((string)$offset);
205        }
206
207        if (!isset($this->elements[$offset])) {
208            throw new IndexOutOfRangeException((string)$offset);
209        }
210
211        return $this->elements[$offset];
212    }
213
214    /** @throws NotSupportedException */
215    public function offsetSet(mixed $offset, mixed $value): void
216    {
217        throw new NotSupportedException();
218    }
219
220    /** @throws NotSupportedException */
221    public function offsetUnset(mixed $offset): void
222    {
223        throw new NotSupportedException();
224    }
225
226    #endregion
227
228    #region Countable
229
230    /**
231     * Countable::count
232     *
233     * @return int
234     * @phpstan-return non-negative-int
235     */
236    public function count(): int
237    {
238        if ($this->isEmpty()) {
239            return 0;
240        }
241
242        return count($this->elements);
243    }
244
245    #endregion
246
247    #region IteratorAggregate
248
249    public function getIterator(): Iterator
250    {
251        return new ArrayIterator($this->elements ?? []);
252    }
253
254    #endregion
255
256    #region Stringable
257
258    public function __toString(): string
259    {
260        return $this->toString(false);
261    }
262
263    #endregion
264}