• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ICanBoogie / Routing / 11642061502

02 Nov 2024 10:41AM UTC coverage: 97.454% (+0.5%) from 96.984%
11642061502

push

github

olvlvl
Tidy documentation

421 of 432 relevant lines covered (97.45%)

6.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.3
/lib/Pattern.php
1
<?php
2

3
namespace ICanBoogie\Routing;
4

5
use ICanBoogie\Routing\Exception\InvalidPattern;
6

7
use function array_combine;
8
use function array_shift;
9
use function count;
10
use function is_array;
11
use function preg_match;
12
use function preg_quote;
13
use function preg_split;
14
use function str_contains;
15
use function strtr;
16
use function substr;
17
use function trim;
18
use function urlencode;
19

20
use const PREG_SPLIT_DELIM_CAPTURE;
21

22
/**
23
 * Representation of a route pattern.
24
 *
25
 * <pre>
26
 * <?php
27
 *
28
 * use ICanBoogie\Routing\Pattern;
29
 *
30
 * $pattern = Pattern::from("/blog/<year:\d{4}>-<month:\d{2}>-:slug.html");
31
 * echo $pattern;       // "/blog/<year:\d{4}>-<month:\d{2}>-:slug.html"
32
 *
33
 * $pathname = $pattern->format([ 'year' => "2013", 'month' => "07", 'slug' => "test-is-a-test" ]);
34
 * echo $pathname;      // "/blog/2013-07-this-is-a-test.html"
35
 *
36
 * $matching = $pattern->matches($pathname, $captured);
37
 *
38
 * var_dump($matching); // true
39
 * var_dump($captured); // [ 'year' => "2013", 'month' => "07", 'slug' => "test-is-a-test" ]
40
 * </pre>
41
 */
42
final class Pattern
43
{
44
    private const EXTENDED_CHARACTER_CLASSES = [
45

46
        '{:uuid:}' => '[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}',
47
        '{:sha1:}' => '[a-f0-9]{40}',
48

49
    ];
50

51
    /**
52
     * Parses a route pattern and returns an array of interleaved paths and parameters, the
53
     * parameter names and the regular expression for the specified pattern.
54
     *
55
     * @phpstan-return array{ 0: string, 1: string[]|string[], 2: array<int, int|string>, 3: string }
56
     */
57
    private static function parse(string $pattern): array
58
    {
59
        $catchall = false;
24✔
60

61
        if ($pattern[-1] == '*') {
24✔
62
            $catchall = true;
1✔
63
            $pattern = substr($pattern, 0, -1);
1✔
64
        }
65

66
        $pattern_extended = strtr($pattern, self::EXTENDED_CHARACTER_CLASSES);
24✔
67
        $parts = preg_split('#(:\w+|<(\w+:)?([^>]+)>)#', $pattern_extended, -1, PREG_SPLIT_DELIM_CAPTURE);
24✔
68

69
        if ($parts === false) {
24✔
70
            throw new InvalidPattern("Unable to parse pattern: $pattern.");
×
71
        }
72

73
        [ $interleaved, $params, $regex ] = self::parse_parts($parts);
24✔
74

75
        if ($catchall) {
24✔
76
            $regex .= '(.*)';
1✔
77
            $params[] = 'all';
1✔
78
        }
79

80
        $regex .= '$#';
24✔
81

82
        return [ $pattern, $interleaved, $params, $regex ]; // @phpstan-ignore-line
24✔
83
    }
84

85
    /**
86
     * Parses pattern parts.
87
     *
88
     * @param string[] $parts
89
     *
90
     * @return array{ 0: string|array{ 0: string, 1: string }, 1: string[], 2: string }
91
     */
92
    private static function parse_parts(array $parts): array
93
    {
94
        $regex = '#^';
24✔
95
        $interleaved = [];
24✔
96
        $params = [];
24✔
97
        $n = 0;
24✔
98

99
        for ($i = 0, $j = count($parts); $i < $j;) {
24✔
100
            $part = $parts[$i++];
24✔
101

102
            $regex .= preg_quote($part, '#');
24✔
103
            $interleaved[] = $part;
24✔
104

105
            if ($i == $j) {
24✔
106
                break;
24✔
107
            }
108

109
            $part = $parts[$i++];
17✔
110

111
            if ($part[0] == ':') {
17✔
112
                $identifier = substr($part, 1);
9✔
113
                $separator = $parts[$i];
9✔
114
                $selector = $separator ? '[^/\\' . $separator[0] . ']+' : '[^/]+';
9✔
115
            } else {
116
                $identifier = substr($parts[$i++], 0, -1);
9✔
117

118
                if (!$identifier) {
9✔
119
                    $identifier = $n++;
4✔
120
                }
121

122
                $selector = $parts[$i++];
9✔
123
            }
124

125
            $regex .= '(' . $selector . ')';
17✔
126
            $interleaved[] = [ $identifier, $selector ];
17✔
127
            $params[] = $identifier;
17✔
128
        }
129

130
        return [ $interleaved, $params, $regex ]; // @phpstan-ignore-line
24✔
131
    }
132

133
    /**
134
     * Reads an offset from an array.
135
     *
136
     * @param array<string, mixed> $container
137
     *
138
     * @return mixed
139
     */
140
    private static function read_value_from_array(array $container, string $key): mixed
141
    {
142
        return $container[$key];
3✔
143
    }
144

145
    /**
146
     * Reads a property from an object.
147
     */
148
    private static function read_value_from_object(object $container, string $key): mixed
149
    {
150
        return $container->$key;
1✔
151
    }
152

153
    /**
154
     * Checks if the given string is a route pattern.
155
     */
156
    public static function is_pattern(string $pattern): bool
157
    {
158
        return str_contains($pattern, '<') || str_contains($pattern, ':') || str_contains($pattern, '*');
1✔
159
    }
160

161
    /**
162
     * @var array<string, self>
163
     */
164
    private static array $instances = [];
165

166
    /**
167
     * Creates a {@link Pattern} instance from the specified pattern.
168
     */
169
    public static function from(string|self $pattern): self
170
    {
171
        if ($pattern instanceof self) {
90✔
172
            return $pattern;
4✔
173
        }
174

175
        if (!trim($pattern)) {
90✔
176
            throw new InvalidPattern("Pattern cannot be blank.");
×
177
        }
178

179
        return self::$instances[$pattern] ??= new self(...self::parse($pattern));
90✔
180
    }
181

182
    /**
183
     * @param array{
184
     *     'pattern': string,
185
     *     'interleaved': string[]|string[][],
186
     *     'params': array<int, int|string>,
187
     *     'regex': string
188
     *     } $an_array
189
     */
190
    public static function __set_state(array $an_array): self
191
    {
192
        return new self(
4✔
193
            $an_array['pattern'],
4✔
194
            $an_array['interleaved'],
4✔
195
            $an_array['params'],
4✔
196
            $an_array['regex']
4✔
197
        );
4✔
198
    }
199

200
    /*
201
     * INSTANCE
202
     */
203

204
    /**
205
     * @param string $pattern
206
     * @param string[]|string[][] $interleaved Interleaved pattern.
207
     * @param array<int, int|string> $params Params of the pattern.
208
     * @param string $regex Regex of the pattern.
209
     */
210
    private function __construct(
211
        public readonly string $pattern,
212
        public readonly array $interleaved,
213
        public readonly array $params,
214
        public readonly string $regex
215
    ) {
216
    }
28✔
217

218
    /**
219
     * Returns the route pattern specified during construct.
220
     */
221
    public function __toString(): string
222
    {
223
        return $this->pattern;
3✔
224
    }
225

226
    /**
227
     * Formats a pattern with the specified values.
228
     *
229
     * @param array<string|int, mixed>|object|null $values The values to format the pattern, either as an array or an
230
     * object. If value is an instance of {@link ToSlug} the `to_slug()` method is used to
231
     * transform the instance into a URL component.
232
     *
233
     * @throws PatternRequiresValues in attempt to format a pattern requiring values without
234
     * providing any.
235
     */
236
    public function format(array|object|null $values = null): string
237
    {
238
        if (!$this->params) {
6✔
239
            return $this->pattern;
2✔
240
        }
241

242
        if (!$values) {
5✔
243
            throw new PatternRequiresValues($this);
1✔
244
        }
245

246
        return $this->format_parts($values);
4✔
247
    }
248

249
    /**
250
     * Formats pattern parts.
251
     *
252
     * @param array<int|string, mixed>|object $container
253
     *
254
     * @uses read_value_from_array
255
     * @uses read_value_from_object
256
     */
257
    private function format_parts(array|object $container): string
258
    {
259
        $url = '';
4✔
260
        $method = 'read_value_from_' . (is_array($container) ? 'array' : 'object');
4✔
261

262
        foreach ($this->interleaved as $i => $value) {
4✔
263
            $url .= $i % 2 ? $this->format_part(self::$method($container, $value[0])) : $value;
4✔
264
        }
265

266
        return $url;
4✔
267
    }
268

269
    /**
270
     * Formats pattern part.
271
     */
272
    private function format_part(string|ToSlug $value): string
273
    {
274
        if ($value instanceof ToSlug) {
4✔
275
            $value = $value->to_slug();
1✔
276
        }
277

278
        return urlencode($value);
4✔
279
    }
280

281
    /**
282
     * Checks if a pathname matches the pattern.
283
     *
284
     * @param array<string, string> $captured The parameters captured from the pathname.
285
     */
286
    public function matches(string $pathname, ?array &$captured = null): bool
287
    {
288
        $captured = [];
20✔
289

290
        #
291
        # `params` is empty if the pattern is a plain string, thus we can simply compare strings.
292
        #
293

294
        if (!$this->params) {
20✔
295
            return $pathname === $this->pattern;
2✔
296
        }
297

298
        if (!preg_match($this->regex, $pathname, $matches)) {
19✔
299
            return false;
3✔
300
        }
301

302
        array_shift($matches);
19✔
303

304
        $captured = array_combine($this->params, $matches);
19✔
305

306
        return true;
19✔
307
    }
308
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc