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

nette / routing / 22292562919

23 Feb 2026 04:05AM UTC coverage: 96.927%. Remained the same
22292562919

push

github

dg
made static analysis mandatory

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
 * The bidirectional route is responsible for mapping
17
 * HTTP request to an array for dispatch and vice-versa.
18
 */
19
class Route implements Router
20
{
21
        /** key used in metadata */
22
        public const
23
                Value = 'value',
24
                Pattern = 'pattern',
25
                FilterIn = 'filterIn',
26
                FilterOut = 'filterOut',
27
                FilterTable = 'filterTable',
28
                FilterStrict = 'filterStrict';
29

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

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

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

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

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

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

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

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

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

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

74
        private string $mask;
75

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

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

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

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

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

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

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

97

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

109

110
        /**
111
         * Returns mask.
112
         */
113
        public function getMask(): string
114
        {
115
                return $this->mask;
1✔
116
        }
117

118

119
        /**
120
         * @internal
121
         * @return array<string, array<string, mixed>>
122
         */
123
        protected function getMetadata(): array
124
        {
125
                return $this->metadata;
×
126
        }
127

128

129
        /**
130
         * Returns default values.
131
         * @return array<string, mixed>
132
         */
133
        public function getDefaults(): array
134
        {
135
                $defaults = [];
1✔
136
                foreach ($this->metadata as $name => $meta) {
1✔
137
                        if (isset($meta[self::Fixity])) {
1✔
138
                                $defaults[$name] = $meta[self::Value];
1✔
139
                        }
140
                }
141

142
                return $defaults;
1✔
143
        }
144

145

146
        /**
147
         * @internal
148
         * @return array<string, mixed>
149
         */
150
        public function getConstantParameters(): array
151
        {
152
                $res = [];
1✔
153
                foreach ($this->metadata as $name => $meta) {
1✔
154
                        if (isset($meta[self::Fixity]) && $meta[self::Fixity] === self::Constant) {
1✔
155
                                $res[$name] = $meta[self::Value];
1✔
156
                        }
157
                }
158

159
                return $res;
1✔
160
        }
161

162

163
        /**
164
         * Maps HTTP request to an array.
165
         * @return ?array<string, mixed>
166
         */
167
        public function match(Nette\Http\IRequest $httpRequest): ?array
1✔
168
        {
169
                // combine with precedence: mask (params in URL-path), fixity, query, (post,) defaults
170

171
                // 1) URL MASK
172
                $url = $httpRequest->getUrl();
1✔
173
                $re = $this->re;
1✔
174

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

189
                } elseif ($this->type === self::Relative) {
1✔
190
                        $basePath = $url->getBasePath();
1✔
191
                        if (!str_starts_with($url->getPath(), $basePath)) {
1✔
192
                                return null;
×
193
                        }
194

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

197
                } else {
198
                        $path = $url->getPath();
1✔
199
                }
200

201
                $path = rawurldecode($path);
1✔
202
                if ($path !== '' && $path[-1] !== '/') {
1✔
203
                        $path .= '/';
1✔
204
                }
205

206
                if (!$matches = Strings::match($path, $re)) {
1✔
207
                        return null; // stop, not matched
1✔
208
                }
209

210
                // assigns matched values to parameters
211
                $params = [];
1✔
212
                foreach ($matches as $k => $v) {
1✔
213
                        if (is_string($k) && $v !== '') {
1✔
214
                                $params[$this->aliases[$k]] = $v;
1✔
215
                        }
216
                }
217

218
                // 2) CONSTANT FIXITY
219
                foreach ($this->metadata as $name => $meta) {
1✔
220
                        if (!isset($params[$name]) && isset($meta[self::Fixity]) && $meta[self::Fixity] !== self::InQuery) {
1✔
221
                                $params[$name] = null; // cannot be overwriten in 3) and detected by isset() in 4)
1✔
222
                        }
223
                }
224

225
                // 3) QUERY
226
                $params += self::renameKeys($httpRequest->getQuery(), array_flip($this->xlat));
1✔
227

228
                // 4) APPLY FILTERS & FIXITY
229
                foreach ($this->metadata as $name => $meta) {
1✔
230
                        if (isset($params[$name])) {
1✔
231
                                if (!is_scalar($params[$name])) {
1✔
232
                                        // do nothing
233
                                } elseif (isset($meta[self::FilterTable][$params[$name]])) { // applies filterTable only to scalar parameters
1✔
234
                                        $params[$name] = $meta[self::FilterTable][$params[$name]];
1✔
235

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

239
                                } elseif (isset($meta[self::FilterIn])) { // applies filterIn only to scalar parameters
1✔
240
                                        $params[$name] = $meta[self::FilterIn]((string) $params[$name]);
1✔
241
                                        if ($params[$name] === null && !isset($meta[self::Fixity])) {
1✔
242
                                                return null; // rejected by filter
1✔
243
                                        }
244
                                }
245
                        } elseif (isset($meta[self::Fixity])) {
1✔
246
                                $params[$name] = $meta[self::Value];
1✔
247
                        }
248
                }
249

250
                if (isset($this->metadata[''][self::FilterIn])) {
1✔
251
                        $params = $this->metadata[''][self::FilterIn]($params);
1✔
252
                        if ($params === null) {
1✔
253
                                return null;
1✔
254
                        }
255
                }
256

257
                return $params;
1✔
258
        }
259

260

261
        /**
262
         * Constructs absolute URL from array.
263
         * @param array<string, mixed>  $params
264
         */
265
        public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string
1✔
266
        {
267
                if (!$this->preprocessParams($params)) {
1✔
268
                        return null;
1✔
269
                }
270

271
                $url = $this->compileUrl($params);
1✔
272
                if ($url === null) {
1✔
273
                        return null;
1✔
274
                }
275

276
                // absolutize
277
                if ($this->type === self::Relative) {
1✔
278
                        $url = (($tmp = $refUrl->getAuthority()) ? "//$tmp" : '') . $refUrl->getBasePath() . $url;
1✔
279

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

283
                } else {
284
                        $host = $refUrl->getHost();
1✔
285
                        $parts = ip2long($host)
1✔
286
                                ? [$host]
1✔
287
                                : array_reverse(explode('.', $host));
1✔
288
                        $url = strtr($url, [
1✔
289
                                '/%basePath%/' => $refUrl->getBasePath(),
1✔
290
                                '%tld%' => $parts[0],
1✔
291
                                '%domain%' => isset($parts[1]) ? "$parts[1].$parts[0]" : $parts[0],
1✔
292
                                '%sld%' => $parts[1] ?? '',
1✔
293
                                '%host%' => $host,
1✔
294
                        ]);
295
                }
296

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

299
                // build query string
300
                $params = self::renameKeys($params, $this->xlat);
1✔
301
                $sep = ini_get('arg_separator.input');
1✔
302
                $query = http_build_query($params, '', $sep ? $sep[0] : '&');
1✔
303
                if ($query !== '') {
1✔
304
                        $url .= '?' . $query;
1✔
305
                }
306

307
                return $url;
1✔
308
        }
309

310

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

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

325
                        if (!isset($params[$name])) {
1✔
326
                                continue; // retains null values
1✔
327
                        }
328

329
                        if (is_scalar($params[$name])) {
1✔
330
                                $params[$name] = $params[$name] === false
1✔
331
                                        ? '0'
1✔
332
                                        : (string) $params[$name];
1✔
333
                        }
334

335
                        if ($fixity !== null) {
1✔
336
                                if ($params[$name] === $meta[self::Value]) { // remove default values; null values are retain
1✔
337
                                        unset($params[$name]);
1✔
338
                                        continue;
1✔
339

340
                                } elseif ($fixity === self::Constant) {
1✔
341
                                        return false; // wrong parameter value
1✔
342
                                }
343
                        }
344

345
                        if (is_scalar($params[$name]) && isset($meta[self::FilterTableOut][$params[$name]])) {
1✔
346
                                $params[$name] = $meta[self::FilterTableOut][$params[$name]];
1✔
347

348
                        } elseif (isset($meta[self::FilterTableOut]) && !empty($meta[self::FilterStrict])) {
1✔
349
                                return false;
×
350

351
                        } elseif (isset($meta[self::FilterOut])) {
1✔
352
                                $params[$name] = $meta[self::FilterOut]($params[$name]);
1✔
353
                        }
354

355
                        if (
356
                                isset($meta[self::Pattern])
1✔
357
                                && !preg_match("#(?:{$meta[self::Pattern]})$#DA", rawurldecode((string) $params[$name]))
1✔
358
                        ) {
359
                                return false; // pattern not match
1✔
360
                        }
361
                }
362

363
                return true;
1✔
364
        }
365

366

367
        /** @param array<string, mixed>  $params */
368
        private function compileUrl(array &$params): ?string
1✔
369
        {
370
                $brackets = [];
1✔
371
                $required = null; // null for auto-optional
1✔
372
                $path = '';
1✔
373
                $i = count($this->sequence) - 1;
1✔
374

375
                do {
376
                        $path = $this->sequence[$i] . $path;
1✔
377
                        if ($i === 0) {
1✔
378
                                return $path;
1✔
379
                        }
380

381
                        $i--;
1✔
382

383
                        $name = $this->sequence[$i--]; // parameter name
1✔
384

385
                        if ($name === ']') { // opening optional part
1✔
386
                                $brackets[] = $path;
1✔
387

388
                        } elseif ($name[0] === '[') { // closing optional part
1✔
389
                                $tmp = array_pop($brackets);
1✔
390
                                if ($required < count($brackets) + 1) { // is this level optional?
1✔
391
                                        if ($name !== '[!') { // and not "required"-optional
1✔
392
                                                $path = $tmp;
1✔
393
                                        }
394
                                } else {
395
                                        $required = count($brackets);
1✔
396
                                }
397
                        } elseif ($name[0] === '?') { // "foo" parameter
1✔
398
                                continue;
1✔
399

400
                        } elseif (isset($params[$name]) && $params[$name] !== '') {
1✔
401
                                $required = count($brackets); // make this level required
1✔
402
                                $path = $params[$name] . $path;
1✔
403
                                unset($params[$name]);
1✔
404

405
                        } elseif (isset($this->metadata[$name][self::Fixity])) { // has default value?
1✔
406
                                $path = $required === null && !$brackets // auto-optional
1✔
407
                                        ? ''
1✔
408
                                        : $this->metadata[$name][self::Default] . $path;
1✔
409

410
                        } else {
411
                                return null; // missing parameter '$name'
1✔
412
                        }
413
                } while (true);
1✔
414
        }
415

416

417
        private function detectMaskType(): string
418
        {
419
                // '//host/path' vs. '/abs. path' vs. 'relative path'
420
                if (preg_match('#(?:(https?):)?(//.*)#A', $this->mask, $m)) {
1✔
421
                        $this->type = self::Host;
1✔
422
                        [, $this->scheme, $path] = $m;
1✔
423
                        return $path;
1✔
424

425
                } elseif (str_starts_with($this->mask, '/')) {
1✔
426
                        $this->type = self::Path;
1✔
427

428
                } else {
429
                        $this->type = self::Relative;
1✔
430
                }
431

432
                return $this->mask;
1✔
433
        }
434

435

436
        /**
437
         * @param array<string, mixed>  $metadata
438
         * @return array<string, array<string, mixed>>
439
         */
440
        private function normalizeMetadata(array $metadata): array
1✔
441
        {
442
                foreach ($metadata as $name => $meta) {
1✔
443
                        if (!is_array($meta)) {
1✔
444
                                $metadata[$name] = $meta = [self::Value => $meta];
1✔
445
                        }
446

447
                        if (array_key_exists(self::Value, $meta)) {
1✔
448
                                if (is_scalar($meta[self::Value])) {
1✔
449
                                        $metadata[$name][self::Value] = $meta[self::Value] === false
1✔
450
                                                ? '0'
1✔
451
                                                : (string) $meta[self::Value];
1✔
452
                                }
453

454
                                $metadata[$name]['fixity'] = self::Constant;
1✔
455
                        }
456
                }
457

458
                return $metadata;
1✔
459
        }
460

461

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

467
                $i = count($parts) - 1;
1✔
468
                if ($i === 0) {
1✔
469
                        $this->re = '#' . preg_quote($parts[0], '#') . '/?$#DA';
1✔
470
                        $this->sequence = [$parts[0]];
1✔
471
                        return;
1✔
472
                }
473

474
                if ($this->parseQuery($parts)) {
1✔
475
                        $i -= 5;
1✔
476
                }
477

478
                $brackets = 0; // optional level
1✔
479
                $re = '';
1✔
480
                $sequence = [];
1✔
481
                $autoOptional = true;
1✔
482

483
                do {
484
                        $part = $parts[$i]; // part of path
1✔
485
                        if (strpbrk($part, '<>') !== false) {
1✔
486
                                throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'.");
1✔
487
                        }
488

489
                        array_unshift($sequence, $part);
1✔
490
                        $re = preg_quote($part, '#') . $re;
1✔
491
                        if ($i === 0) {
1✔
492
                                break;
1✔
493
                        }
494

495
                        $i--;
1✔
496

497
                        $part = $parts[$i]; // [ or ]
1✔
498
                        if ($part === '[' || $part === ']' || $part === '[!') {
1✔
499
                                $brackets += $part[0] === '[' ? -1 : 1;
1✔
500
                                if ($brackets < 0) {
1✔
501
                                        throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'.");
1✔
502
                                }
503

504
                                array_unshift($sequence, $part);
1✔
505
                                $re = ($part[0] === '[' ? '(?:' : ')?') . $re;
1✔
506
                                $i -= 4;
1✔
507
                                continue;
1✔
508
                        }
509

510
                        $pattern = trim($parts[$i--]); // validation condition (as regexp)
1✔
511
                        $default = $parts[$i--]; // default value
1✔
512
                        $name = $parts[$i--]; // parameter name
1✔
513
                        array_unshift($sequence, $name);
1✔
514

515
                        if ($name[0] === '?') { // "foo" parameter
1✔
516
                                $name = substr($name, 1);
1✔
517
                                $re = $pattern
1✔
518
                                        ? '(?:' . preg_quote($name, '#') . "|$pattern)$re"
1✔
519
                                        : preg_quote($name, '#') . $re;
1✔
520
                                $sequence[1] = $name . $sequence[1];
1✔
521
                                continue;
1✔
522
                        }
523

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

527
                        if ($pattern === '' && isset($meta[self::Pattern])) {
1✔
528
                                $pattern = $meta[self::Pattern];
1✔
529
                        }
530

531
                        if ($default !== '') {
1✔
532
                                $meta[self::Value] = substr($default, 1);
1✔
533
                                $meta[self::Fixity] = self::InPath;
1✔
534
                        }
535

536
                        $meta[self::FilterTableOut] = empty($meta[self::FilterTable])
1✔
537
                                ? null
1✔
538
                                : array_flip($meta[self::FilterTable]);
1✔
539
                        if (array_key_exists(self::Value, $meta)) {
1✔
540
                                if (isset($meta[self::FilterTableOut][$meta[self::Value]])) {
1✔
541
                                        $meta[self::Default] = $meta[self::FilterTableOut][$meta[self::Value]];
×
542

543
                                } elseif (isset($meta[self::Value], $meta[self::FilterOut])) {
1✔
544
                                        $meta[self::Default] = $meta[self::FilterOut]($meta[self::Value]);
1✔
545

546
                                } else {
547
                                        $meta[self::Default] = $meta[self::Value];
1✔
548
                                }
549
                        }
550

551
                        $meta[self::Pattern] = $pattern;
1✔
552

553
                        // include in expression
554
                        $this->aliases['p' . $i] = $name;
1✔
555
                        $re = '(?P<p' . $i . '>(?U)' . $pattern . ')' . $re;
1✔
556
                        if ($brackets) { // is in brackets?
1✔
557
                                if (!isset($meta[self::Value])) {
1✔
558
                                        $meta[self::Value] = $meta[self::Default] = null;
1✔
559
                                }
560

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

563
                        } elseif (isset($meta[self::Fixity])) {
1✔
564
                                if ($autoOptional) {
1✔
565
                                        $re = '(?:' . $re . ')?';
1✔
566
                                }
567

568
                                $meta[self::Fixity] = self::InPath;
1✔
569

570
                        } else {
571
                                $autoOptional = false;
1✔
572
                        }
573

574
                        $this->metadata[$name] = $meta;
1✔
575
                } while (true);
1✔
576

577
                if ($brackets) {
1✔
578
                        throw new Nette\InvalidArgumentException("Missing '[' in mask '$this->mask'.");
1✔
579
                }
580

581
                $this->re = '#' . $re . '/?$#DA';
1✔
582
                $this->sequence = $sequence;
1✔
583
        }
1✔
584

585

586
        /** @param list<string>  $parts */
587
        private function parseQuery(array $parts): bool
1✔
588
        {
589
                $query = $parts[count($parts) - 2] ?? '';
1✔
590
                if (!str_starts_with(ltrim($query), '?')) {
1✔
591
                        return false;
1✔
592
                }
593

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

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

600
                        if (array_key_exists(self::Value, $meta)) {
1✔
601
                                $meta[self::Fixity] = self::InQuery;
1✔
602
                        }
603

604
                        unset($meta[self::Pattern]);
1✔
605
                        $meta[self::FilterTableOut] = empty($meta[self::FilterTable])
1✔
606
                                ? null
1✔
607
                                : array_flip($meta[self::FilterTable]);
1✔
608

609
                        $this->metadata[$name] = $meta;
1✔
610
                        if ($param !== '') {
1✔
611
                                $this->xlat[$name] = $param;
1✔
612
                        }
613
                }
614

615
                return true;
1✔
616
        }
617

618

619
        /********************* Utilities ****************d*g**/
620

621

622
        /**
623
         * Rename keys in array.
624
         * @param array<string, mixed>  $arr
625
         * @param array<string, string>  $xlat
626
         * @return array<string, mixed>
627
         */
628
        private static function renameKeys(array $arr, array $xlat): array
1✔
629
        {
630
                if (!$xlat) {
1✔
631
                        return $arr;
1✔
632
                }
633

634
                $res = [];
1✔
635
                $occupied = array_flip($xlat);
1✔
636
                foreach ($arr as $k => $v) {
1✔
637
                        if (isset($xlat[$k])) {
1✔
638
                                $res[$xlat[$k]] = $v;
1✔
639

640
                        } elseif (!isset($occupied[$k])) {
1✔
641
                                $res[$k] = $v;
1✔
642
                        }
643
                }
644

645
                return $res;
1✔
646
        }
647

648

649
        /**
650
         * Url encode.
651
         */
652
        public static function param2path(string $s): string
1✔
653
        {
654
                // segment + "/", see https://datatracker.ietf.org/doc/html/rfc3986#appendix-A
655
                return (string) preg_replace_callback(
1✔
656
                        '#[^\w.~!$&\'()*+,;=:@"/-]#',
1✔
657
                        fn($m) => rawurlencode($m[0]),
1✔
658
                        $s,
1✔
659
                );
660
        }
661
}
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