Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
58 / 58 |
|
100.00% |
14 / 14 |
CRAP | |
100.00% |
1 / 1 |
UrlPath | |
100.00% |
58 / 58 |
|
100.00% |
14 / 14 |
35 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
from | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isValidElement | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
isEmpty | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getElements | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
add | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
8 | |||
toString | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
offsetExists | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
offsetGet | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
offsetSet | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
offsetUnset | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
count | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getIterator | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
__toString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace PeServer\Core\Web; |
6 | |
7 | use ArrayAccess; |
8 | use ArrayIterator; |
9 | use Countable; |
10 | use Iterator; |
11 | use IteratorAggregate; |
12 | use PeServer\Core\Collection\Arr; |
13 | use PeServer\Core\Collection\ArrayAccessHelper; |
14 | use PeServer\Core\Collection\Collections; |
15 | use PeServer\Core\Text; |
16 | use PeServer\Core\Throws\ArgumentException; |
17 | use PeServer\Core\Throws\IndexOutOfRangeException; |
18 | use PeServer\Core\Throws\InvalidOperationException; |
19 | use PeServer\Core\Throws\NotSupportedException; |
20 | use PeServer\Core\TypeUtility; |
21 | use Stringable; |
22 | use TypeError; |
23 | |
24 | /** |
25 | * URL のパス構成要素。 |
26 | * @implements ArrayAccess<non-negative-int,string> |
27 | * @implements IteratorAggregate<non-negative-int,string> |
28 | */ |
29 | readonly 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 | } |