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

nette / routing / 22834910607

09 Mar 2026 01:42AM UTC coverage: 96.927%. Remained the same
22834910607

push

github

dg
added CLAUDE.md

410 of 423 relevant lines covered (96.93%)

0.97 hits per line

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

98.59
/src/Routing/Route.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Nette\Routing;
9

10
use Nette;
11
use Nette\Utils\Strings;
12
use function array_flip, array_key_exists, array_pop, array_reverse, array_unshift, count, explode, http_build_query, ini_get, ip2long, is_array, is_scalar, is_string, ltrim, preg_match, preg_quote, preg_replace_callback, rawurldecode, rawurlencode, str_starts_with, strlen, strpbrk, strtr, substr, trim;
13

14

15
/**
16
 * Bidirectional route mapping between HTTP requests and parameter arrays using a URL mask.
17
 */
18
class Route implements Router
19
{
20
        /** key used in metadata */
21
        public const
22
                Value = 'value',
23
                Pattern = 'pattern',
24
                FilterIn = 'filterIn',
25
                FilterOut = 'filterOut',
26
                FilterTable = 'filterTable',
27
                FilterStrict = 'filterStrict';
28

29
        /** @deprecated use Route::Value */
30
        public const VALUE = self::Value;
31

32
        /** @deprecated use Route::Pattern */
33
        public const PATTERN = self::Pattern;
34

35
        /** @deprecated use Route::FilterIn */
36
        public const FILTER_IN = self::FilterIn;
37

38
        /** @deprecated use Route::FilterOut */
39
        public const FILTER_OUT = self::FilterOut;
40

41
        /** @deprecated use Route::FilterTable */
42
        public const FILTER_TABLE = self::FilterTable;
43

44
        /** @deprecated use Route::FilterStrict */
45
        public const FILTER_STRICT = self::FilterStrict;
46

47
        /** key used in metadata */
48
        private const
49
                Default = 'defOut',
50
                Fixity = 'fixity',
51
                FilterTableOut = 'filterTO';
52

53
        /** url type */
54
        private const
55
                Host = 1,
56
                Path = 2,
57
                Relative = 3;
58

59
        /** fixity types - has default value and is: */
60
        private const
61
                InQuery = 0,
62
                InPath = 1, // in brackets is default value = null
63
                Constant = 2;
64

65
        /** @var array<string, array<string, mixed>> */
66
        protected array $defaultMeta = [
67
                '#' => [ // default style for path parameters
68
                        self::Pattern => '[^/]+',
69
                        self::FilterOut => [self::class, 'param2path'],
70
                ],
71
        ];
72

73
        private string $mask;
74

75
        /** @var list<string> */
76
        private array $sequence;
77

78
        /** regular expression pattern */
79
        private string $re;
80

81
        /** @var array<string, string> parameter aliases in regular expression */
82
        private array $aliases = [];
83

84
        /** @var array<string, array<string, mixed>> of [value & fixity, filterIn, filterOut] */
85
        private array $metadata = [];
86

87
        /** @var array<string, string> */
88
        private array $xlat = [];
89

90
        /** Host, Path, Relative */
91
        private int $type;
92

93
        /** http | https */
94
        private string $scheme = '';
95

96

97
        /**
98
         * @param string  $mask e.g. '<presenter>/<action>/<id \d{1,3}>'
99
         * @param array<string, mixed>  $metadata default values or metadata
100
         */
101
        public function __construct(string $mask, array $metadata = [])
1✔
102
        {
103
                $this->mask = $mask;
1✔
104
                $this->metadata = $this->normalizeMetadata($metadata);
1✔
105
                $this->parseMask($this->detectMaskType());
1✔
106
        }
1✔
107

108

109
        public function getMask(): string
110
        {
111
                return $this->mask;
1✔
112
        }
113

114

115
        /**
116
         * @internal
117
         * @return array<string, array<string, mixed>>
118
         */
119
        protected function getMetadata(): array
120
        {
121
                return $this->metadata;
×
122
        }
123

124

125
        /** @return array<string, mixed> */
126
        public function getDefaults(): array
127
        {
128
                $defaults = [];
1✔
129
                foreach ($this->metadata as $name => $meta) {
1✔
130
                        if (isset($meta[self::Fixity])) {
1✔
131
                                $defaults[$name] = $meta[self::Value];
1✔
132
                        }
133
                }
134

135
                return $defaults;
1✔
136
        }
137

138

139
        /**
140
         * Returns parameters that must have a specific fixed value for the route to match.
141
         * @internal
142
         * @return array<string, mixed>
143
         */
144
        public function getConstantParameters(): array
145
        {
146
                $res = [];
1✔
147
                foreach ($this->metadata as $name => $meta) {
1✔
148
                        if (isset($meta[self::Fixity]) && $meta[self::Fixity] === self::Constant) {
1✔
149
                                $res[$name] = $meta[self::Value];
1✔
150
                        }
151
                }
152

153
                return $res;
1✔
154
        }
155

156

157
        /**
158
         * Maps HTTP request to an array.
159
         * @return ?array<string, mixed>
160
         */
161
        public function match(Nette\Http\IRequest $httpRequest): ?array
1✔
162
        {
163
                // combine with precedence: mask (params in URL-path), fixity, query, (post,) defaults
164

165
                // 1) URL MASK
166
                $url = $httpRequest->getUrl();
1✔
167
                $re = $this->re;
1✔
168

169
                if ($this->type === self::Host) {
1✔
170
                        $host = $url->getHost();
1✔
171
                        $path = '//' . $host . $url->getPath();
1✔
172
                        $parts = ip2long($host)
1✔
173
                                ? [$host]
1✔
174
                                : array_reverse(explode('.', $host));
1✔
175
                        $re = strtr($re, [
1✔
176
                                '/%basePath%/' => preg_quote($url->getBasePath(), '#'),
1✔
177
                                '%tld%' => preg_quote($parts[0], '#'),
1✔
178
                                '%domain%' => preg_quote(isset($parts[1]) ? "$parts[1].$parts[0]" : $parts[0], '#'),
1✔
179
                                '%sld%' => preg_quote($parts[1] ?? '', '#'),
1✔
180
                                '%host%' => preg_quote($host, '#'),
1✔
181
                        ]);
182

183
                } elseif ($this->type === self::Relative) {
1✔
184
                        $basePath = $url->getBasePath();
1✔
185
                        if (!str_starts_with($url->getPath(), $basePath)) {
1✔
186
                                return null;
×
187
                        }
188

189
                        $path = substr($url->getPath(), strlen($basePath));
1✔
190

191
                } else {
192
                        $path = $url->getPath();
1✔
193
                }
194

195
                $path = rawurldecode($path);
1✔
196
                if ($path !== '' && $path[-1] !== '/') {
1✔
197
                        $path .= '/';
1✔
198
                }
199

200
                if (!$matches = Strings::match($path, $re)) {
1✔
201
                        return null; // stop, not matched
1✔
202
                }
203

204
                // assigns matched values to parameters
205
                $params = [];
1✔
206
                foreach ($matches as $k => $v) {
1✔
207
                        if (is_string($k) && $v !== '') {
1✔
208
                                $params[$this->aliases[$k]] = $v;
1✔
209
                        }
210
                }
211

212
                // 2) CONSTANT FIXITY
213
                foreach ($this->metadata as $name => $meta) {
1✔
214
                        if (!isset($params[$name]) && isset($meta[self::Fixity]) && $meta[self::Fixity] !== self::InQuery) {
1✔
215
                                $params[$name] = null; // cannot be overwriten in 3) and detected by isset() in 4)
1✔
216
                        }
217
                }
218

219
                // 3) QUERY
220
                $params += self::renameKeys($httpRequest->getQuery(), array_flip($this->xlat));
1✔
221

222
                // 4) APPLY FILTERS & FIXITY
223
                foreach ($this->metadata as $name => $meta) {
1✔
224
                        if (isset($params[$name])) {
1✔
225
                                if (!is_scalar($params[$name])) {
1✔
226
                                        // do nothing
227
                                } elseif (isset($meta[self::FilterTable][$params[$name]])) { // applies filterTable only to scalar parameters
1✔
228
                                        $params[$name] = $meta[self::FilterTable][$params[$name]];
1✔
229

230
                                } elseif (isset($meta[self::FilterTable]) && !empty($meta[self::FilterStrict])) {
1✔
231
                                        return null; // rejected by filterTable
1✔
232

233
                                } elseif (isset($meta[self::FilterIn])) { // applies filterIn only to scalar parameters
1✔
234
                                        $params[$name] = $meta[self::FilterIn]((string) $params[$name]);
1✔
235
                                        if ($params[$name] === null && !isset($meta[self::Fixity])) {
1✔
236
                                                return null; // rejected by filter
1✔
237
                                        }
238
                                }
239
                        } elseif (isset($meta[self::Fixity])) {
1✔
240
                                $params[$name] = $meta[self::Value];
1✔
241
                        }
242
                }
243

244
                if (isset($this->metadata[''][self::FilterIn])) {
1✔
245
                        $params = $this->metadata[''][self::FilterIn]($params);
1✔
246
                        if ($params === null) {
1✔
247
                                return null;
1✔
248
                        }
249
                }
250

251
                return $params;
1✔
252
        }
253

254

255
        /**
256
         * Constructs absolute URL from array.
257
         * @param array<string, mixed>  $params
258
         */
259
        public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string
1✔
260
        {
261
                if (!$this->preprocessParams($params)) {
1✔
262
                        return null;
1✔
263
                }
264

265
                $url = $this->compileUrl($params);
1✔
266
                if ($url === null) {
1✔
267
                        return null;
1✔
268
                }
269

270
                // absolutize
271
                if ($this->type === self::Relative) {
1✔
272
                        $url = (($tmp = $refUrl->getAuthority()) ? "//$tmp" : '') . $refUrl->getBasePath() . $url;
1✔
273

274
                } elseif ($this->type === self::Path) {
1✔
275
                        $url = (($tmp = $refUrl->getAuthority()) ? "//$tmp" : '') . $url;
1✔
276

277
                } else {
278
                        $host = $refUrl->getHost();
1✔
279
                        $parts = ip2long($host)
1✔
280
                                ? [$host]
1✔
281
                                : array_reverse(explode('.', $host));
1✔
282
                        $url = strtr($url, [
1✔
283
                                '/%basePath%/' => $refUrl->getBasePath(),
1✔
284
                                '%tld%' => $parts[0],
1✔
285
                                '%domain%' => isset($parts[1]) ? "$parts[1].$parts[0]" : $parts[0],
1✔
286
                                '%sld%' => $parts[1] ?? '',
1✔
287
                                '%host%' => $host,
1✔
288
                        ]);
289
                }
290

291
                $url = ($this->scheme ?: $refUrl->getScheme()) . ':' . $url;
1✔
292

293
                // build query string
294
                $params = self::renameKeys($params, $this->xlat);
1✔
295
                $sep = ini_get('arg_separator.input');
1✔
296
                $query = http_build_query($params, '', $sep ? $sep[0] : '&');
1✔
297
                if ($query !== '') {
1✔
298
                        $url .= '?' . $query;
1✔
299
                }
300

301
                return $url;
1✔
302
        }
303

304

305
        /** @param array<string, mixed>  $params */
306
        private function preprocessParams(array &$params): bool
1✔
307
        {
308
                $filter = $this->metadata[''][self::FilterOut] ?? null;
1✔
309
                if ($filter) {
1✔
310
                        $params = $filter($params);
1✔
311
                        if ($params === null) {
1✔
312
                                return false; // rejected by global filter
1✔
313
                        }
314
                }
315

316
                foreach ($this->metadata as $name => $meta) {
1✔
317
                        $fixity = $meta[self::Fixity] ?? null;
1✔
318

319
                        if (!isset($params[$name])) {
1✔
320
                                continue; // retains null values
1✔
321
                        }
322

323
                        if (is_scalar($params[$name])) {
1✔
324
                                $params[$name] = $params[$name] === false
1✔
325
                                        ? '0'
1✔
326
                                        : (string) $params[$name];
1✔
327
                        }
328

329
                        if ($fixity !== null) {
1✔
330
                                if ($params[$name] === $meta[self::Value]) { // remove default values; null values are retain
1✔
331
                                        unset($params[$name]);
1✔
332
                                        continue;
1✔
333

334
                                } elseif ($fixity === self::Constant) {
1✔
335
                                        return false; // wrong parameter value
1✔
336
                                }
337
                        }
338

339
                        if (is_scalar($params[$name]) && isset($meta[self::FilterTableOut][$params[$name]])) {
1✔
340
                                $params[$name] = $meta[self::FilterTableOut][$params[$name]];
1✔
341

342
                        } elseif (isset($meta[self::FilterTableOut]) && !empty($meta[self::FilterStrict])) {
1✔
343
                                return false;
×
344

345
                        } elseif (isset($meta[self::FilterOut])) {
1✔
346
                                $params[$name] = $meta[self::FilterOut]($params[$name]);
1✔
347
                        }
348

349
                        if (
350
                                isset($meta[self::Pattern])
1✔
351
                                && !preg_match("#(?:{$meta[self::Pattern]})$#DA", rawurldecode((string) $params[$name]))
1✔
352
                        ) {
353
                                return false; // pattern not match
1✔
354
                        }
355
                }
356

357
                return true;
1✔
358
        }
359

360

361
        /** @param array<string, mixed>  $params */
362
        private function compileUrl(array &$params): ?string
1✔
363
        {
364
                $brackets = [];
1✔
365
                $required = null; // null for auto-optional
1✔
366
                $path = '';
1✔
367
                $i = count($this->sequence) - 1;
1✔
368

369
                do {
370
                        $path = $this->sequence[$i] . $path;
1✔
371
                        if ($i === 0) {
1✔
372
                                return $path;
1✔
373
                        }
374

375
                        $i--;
1✔
376

377
                        $name = $this->sequence[$i--]; // parameter name
1✔
378

379
                        if ($name === ']') { // opening optional part
1✔
380
                                $brackets[] = $path;
1✔
381

382
                        } elseif ($name[0] === '[') { // closing optional part
1✔
383
                                $tmp = array_pop($brackets);
1✔
384
                                if ($required < count($brackets) + 1) { // is this level optional?
1✔
385
                                        if ($name !== '[!') { // and not "required"-optional
1✔
386
                                                $path = $tmp;
1✔
387
                                        }
388
                                } else {
389
                                        $required = count($brackets);
1✔
390
                                }
391
                        } elseif ($name[0] === '?') { // "foo" parameter
1✔
392
                                continue;
1✔
393

394
                        } elseif (isset($params[$name]) && $params[$name] !== '') {
1✔
395
                                $required = count($brackets); // make this level required
1✔
396
                                $path = $params[$name] . $path;
1✔
397
                                unset($params[$name]);
1✔
398

399
                        } elseif (isset($this->metadata[$name][self::Fixity])) { // has default value?
1✔
400
                                $path = $required === null && !$brackets // auto-optional
1✔
401
                                        ? ''
1✔
402
                                        : $this->metadata[$name][self::Default] . $path;
1✔
403

404
                        } else {
405
                                return null; // missing parameter '$name'
1✔
406
                        }
407
                } while (true);
1✔
408
        }
409

410

411
        private function detectMaskType(): string
412
        {
413
                // '//host/path' vs. '/abs. path' vs. 'relative path'
414
                if (preg_match('#(?:(https?):)?(//.*)#A', $this->mask, $m)) {
1✔
415
                        $this->type = self::Host;
1✔
416
                        [, $this->scheme, $path] = $m;
1✔
417
                        return $path;
1✔
418

419
                } elseif (str_starts_with($this->mask, '/')) {
1✔
420
                        $this->type = self::Path;
1✔
421

422
                } else {
423
                        $this->type = self::Relative;
1✔
424
                }
425

426
                return $this->mask;
1✔
427
        }
428

429

430
        /**
431
         * @param array<string, mixed>  $metadata
432
         * @return array<string, array<string, mixed>>
433
         */
434
        private function normalizeMetadata(array $metadata): array
1✔
435
        {
436
                foreach ($metadata as $name => $meta) {
1✔
437
                        if (!is_array($meta)) {
1✔
438
                                $metadata[$name] = $meta = [self::Value => $meta];
1✔
439
                        }
440

441
                        if (array_key_exists(self::Value, $meta)) {
1✔
442
                                if (is_scalar($meta[self::Value])) {
1✔
443
                                        $metadata[$name][self::Value] = $meta[self::Value] === false
1✔
444
                                                ? '0'
1✔
445
                                                : (string) $meta[self::Value];
1✔
446
                                }
447

448
                                $metadata[$name]['fixity'] = self::Constant;
1✔
449
                        }
450
                }
451

452
                return $metadata;
1✔
453
        }
454

455

456
        private function parseMask(string $path): void
1✔
457
        {
458
                // <parameter-name[=default] [pattern]> or [ or ] or ?...
459
                $parts = Strings::split($path, '/<([^<>= ]+)(=[^<> ]*)? *([^<>]*)>|(\[!?|\]|\s*\?.*)/');
1✔
460

461
                $i = count($parts) - 1;
1✔
462
                if ($i === 0) {
1✔
463
                        $this->re = '#' . preg_quote($parts[0], '#') . '/?$#DA';
1✔
464
                        $this->sequence = [$parts[0]];
1✔
465
                        return;
1✔
466
                }
467

468
                if ($this->parseQuery($parts)) {
1✔
469
                        $i -= 5;
1✔
470
                }
471

472
                $brackets = 0; // optional level
1✔
473
                $re = '';
1✔
474
                $sequence = [];
1✔
475
                $autoOptional = true;
1✔
476

477
                do {
478
                        $part = $parts[$i]; // part of path
1✔
479
                        if (strpbrk($part, '<>') !== false) {
1✔
480
                                throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'.");
1✔
481
                        }
482

483
                        array_unshift($sequence, $part);
1✔
484
                        $re = preg_quote($part, '#') . $re;
1✔
485
                        if ($i === 0) {
1✔
486
                                break;
1✔
487
                        }
488

489
                        $i--;
1✔
490

491
                        $part = $parts[$i]; // [ or ]
1✔
492
                        if ($part === '[' || $part === ']' || $part === '[!') {
1✔
493
                                $brackets += $part[0] === '[' ? -1 : 1;
1✔
494
                                if ($brackets < 0) {
1✔
495
                                        throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'.");
1✔
496
                                }
497

498
                                array_unshift($sequence, $part);
1✔
499
                                $re = ($part[0] === '[' ? '(?:' : ')?') . $re;
1✔
500
                                $i -= 4;
1✔
501
                                continue;
1✔
502
                        }
503

504
                        $pattern = trim($parts[$i--]); // validation condition (as regexp)
1✔
505
                        $default = $parts[$i--]; // default value
1✔
506
                        $name = $parts[$i--]; // parameter name
1✔
507
                        array_unshift($sequence, $name);
1✔
508

509
                        if ($name[0] === '?') { // "foo" parameter
1✔
510
                                $name = substr($name, 1);
1✔
511
                                $re = $pattern
1✔
512
                                        ? '(?:' . preg_quote($name, '#') . "|$pattern)$re"
1✔
513
                                        : preg_quote($name, '#') . $re;
1✔
514
                                $sequence[1] = $name . $sequence[1];
1✔
515
                                continue;
1✔
516
                        }
517

518
                        // pattern, condition & metadata
519
                        $meta = ($this->metadata[$name] ?? []) + ($this->defaultMeta[$name] ?? $this->defaultMeta['#']);
1✔
520

521
                        if ($pattern === '' && isset($meta[self::Pattern])) {
1✔
522
                                $pattern = $meta[self::Pattern];
1✔
523
                        }
524

525
                        if ($default !== '') {
1✔
526
                                $meta[self::Value] = substr($default, 1);
1✔
527
                                $meta[self::Fixity] = self::InPath;
1✔
528
                        }
529

530
                        $meta[self::FilterTableOut] = empty($meta[self::FilterTable])
1✔
531
                                ? null
1✔
532
                                : array_flip($meta[self::FilterTable]);
1✔
533
                        if (array_key_exists(self::Value, $meta)) {
1✔
534
                                if (isset($meta[self::FilterTableOut][$meta[self::Value]])) {
1✔
535
                                        $meta[self::Default] = $meta[self::FilterTableOut][$meta[self::Value]];
×
536

537
                                } elseif (isset($meta[self::Value], $meta[self::FilterOut])) {
1✔
538
                                        $meta[self::Default] = $meta[self::FilterOut]($meta[self::Value]);
1✔
539

540
                                } else {
541
                                        $meta[self::Default] = $meta[self::Value];
1✔
542
                                }
543
                        }
544

545
                        $meta[self::Pattern] = $pattern;
1✔
546

547
                        // include in expression
548
                        $this->aliases['p' . $i] = $name;
1✔
549
                        $re = '(?P<p' . $i . '>(?U)' . $pattern . ')' . $re;
1✔
550
                        if ($brackets) { // is in brackets?
1✔
551
                                if (!isset($meta[self::Value])) {
1✔
552
                                        $meta[self::Value] = $meta[self::Default] = null;
1✔
553
                                }
554

555
                                $meta[self::Fixity] = self::InPath;
1✔
556

557
                        } elseif (isset($meta[self::Fixity])) {
1✔
558
                                if ($autoOptional) {
1✔
559
                                        $re = '(?:' . $re . ')?';
1✔
560
                                }
561

562
                                $meta[self::Fixity] = self::InPath;
1✔
563

564
                        } else {
565
                                $autoOptional = false;
1✔
566
                        }
567

568
                        $this->metadata[$name] = $meta;
1✔
569
                } while (true);
1✔
570

571
                if ($brackets) {
1✔
572
                        throw new Nette\InvalidArgumentException("Missing '[' in mask '$this->mask'.");
1✔
573
                }
574

575
                $this->re = '#' . $re . '/?$#DA';
1✔
576
                $this->sequence = $sequence;
1✔
577
        }
1✔
578

579

580
        /** @param list<string>  $parts */
581
        private function parseQuery(array $parts): bool
1✔
582
        {
583
                $query = $parts[count($parts) - 2] ?? '';
1✔
584
                if (!str_starts_with(ltrim($query), '?')) {
1✔
585
                        return false;
1✔
586
                }
587

588
                // name=<parameter-name [pattern]>
589
                $matches = Strings::matchAll($query, '/(?:([a-zA-Z0-9_.-]+)=)?<([^> ]+) *([^>]*)>/');
1✔
590

591
                foreach ($matches as [, $param, $name, $pattern]) { // $pattern is not used
1✔
592
                        $meta = ($this->metadata[$name] ?? []) + ($this->defaultMeta['?' . $name] ?? []);
1✔
593

594
                        if (array_key_exists(self::Value, $meta)) {
1✔
595
                                $meta[self::Fixity] = self::InQuery;
1✔
596
                        }
597

598
                        unset($meta[self::Pattern]);
1✔
599
                        $meta[self::FilterTableOut] = empty($meta[self::FilterTable])
1✔
600
                                ? null
1✔
601
                                : array_flip($meta[self::FilterTable]);
1✔
602

603
                        $this->metadata[$name] = $meta;
1✔
604
                        if ($param !== '') {
1✔
605
                                $this->xlat[$name] = $param;
1✔
606
                        }
607
                }
608

609
                return true;
1✔
610
        }
611

612

613
        /********************* Utilities ****************d*g**/
614

615

616
        /**
617
         * Renames keys in array according to the translation table.
618
         * @param array<string, mixed>  $arr
619
         * @param array<string, string>  $xlat
620
         * @return array<string, mixed>
621
         */
622
        private static function renameKeys(array $arr, array $xlat): array
1✔
623
        {
624
                if (!$xlat) {
1✔
625
                        return $arr;
1✔
626
                }
627

628
                $res = [];
1✔
629
                $occupied = array_flip($xlat);
1✔
630
                foreach ($arr as $k => $v) {
1✔
631
                        if (isset($xlat[$k])) {
1✔
632
                                $res[$xlat[$k]] = $v;
1✔
633

634
                        } elseif (!isset($occupied[$k])) {
1✔
635
                                $res[$k] = $v;
1✔
636
                        }
637
                }
638

639
                return $res;
1✔
640
        }
641

642

643
        /**
644
         * Encodes a parameter value for use in a URL path segment, leaving allowed characters unencoded.
645
         */
646
        public static function param2path(string $s): string
1✔
647
        {
648
                // segment + "/", see https://datatracker.ietf.org/doc/html/rfc3986#appendix-A
649
                return (string) preg_replace_callback(
1✔
650
                        '#[^\w.~!$&\'()*+,;=:@"/-]#',
1✔
651
                        fn($m) => rawurlencode($m[0]),
1✔
652
                        $s,
1✔
653
                );
654
        }
655
}
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