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

nette / routing / 22834986678

09 Mar 2026 01:45AM UTC coverage: 96.927%. Remained the same
22834986678

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
        /** @return ?array<string, mixed> */
158
        public function match(Nette\Http\IRequest $httpRequest): ?array
1✔
159
        {
160
                // combine with precedence: mask (params in URL-path), fixity, query, (post,) defaults
161

162
                // 1) URL MASK
163
                $url = $httpRequest->getUrl();
1✔
164
                $re = $this->re;
1✔
165

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

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

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

188
                } else {
189
                        $path = $url->getPath();
1✔
190
                }
191

192
                $path = rawurldecode($path);
1✔
193
                if ($path !== '' && $path[-1] !== '/') {
1✔
194
                        $path .= '/';
1✔
195
                }
196

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

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

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

216
                // 3) QUERY
217
                $params += self::renameKeys($httpRequest->getQuery(), array_flip($this->xlat));
1✔
218

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

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

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

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

248
                return $params;
1✔
249
        }
250

251

252
        /** @param array<string, mixed>  $params */
253
        public function constructUrl(array $params, Nette\Http\UrlScript $refUrl): ?string
1✔
254
        {
255
                if (!$this->preprocessParams($params)) {
1✔
256
                        return null;
1✔
257
                }
258

259
                $url = $this->compileUrl($params);
1✔
260
                if ($url === null) {
1✔
261
                        return null;
1✔
262
                }
263

264
                // absolutize
265
                if ($this->type === self::Relative) {
1✔
266
                        $url = (($tmp = $refUrl->getAuthority()) ? "//$tmp" : '') . $refUrl->getBasePath() . $url;
1✔
267

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

271
                } else {
272
                        $host = $refUrl->getHost();
1✔
273
                        $parts = ip2long($host)
1✔
274
                                ? [$host]
1✔
275
                                : array_reverse(explode('.', $host));
1✔
276
                        $url = strtr($url, [
1✔
277
                                '/%basePath%/' => $refUrl->getBasePath(),
1✔
278
                                '%tld%' => $parts[0],
1✔
279
                                '%domain%' => isset($parts[1]) ? "$parts[1].$parts[0]" : $parts[0],
1✔
280
                                '%sld%' => $parts[1] ?? '',
1✔
281
                                '%host%' => $host,
1✔
282
                        ]);
283
                }
284

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

287
                // build query string
288
                $params = self::renameKeys($params, $this->xlat);
1✔
289
                $sep = ini_get('arg_separator.input');
1✔
290
                $query = http_build_query($params, '', $sep ? $sep[0] : '&');
1✔
291
                if ($query !== '') {
1✔
292
                        $url .= '?' . $query;
1✔
293
                }
294

295
                return $url;
1✔
296
        }
297

298

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

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

313
                        if (!isset($params[$name])) {
1✔
314
                                continue; // retains null values
1✔
315
                        }
316

317
                        if (is_scalar($params[$name])) {
1✔
318
                                $params[$name] = $params[$name] === false
1✔
319
                                        ? '0'
1✔
320
                                        : (string) $params[$name];
1✔
321
                        }
322

323
                        if ($fixity !== null) {
1✔
324
                                if ($params[$name] === $meta[self::Value]) { // remove default values; null values are retain
1✔
325
                                        unset($params[$name]);
1✔
326
                                        continue;
1✔
327

328
                                } elseif ($fixity === self::Constant) {
1✔
329
                                        return false; // wrong parameter value
1✔
330
                                }
331
                        }
332

333
                        if (is_scalar($params[$name]) && isset($meta[self::FilterTableOut][$params[$name]])) {
1✔
334
                                $params[$name] = $meta[self::FilterTableOut][$params[$name]];
1✔
335

336
                        } elseif (isset($meta[self::FilterTableOut]) && !empty($meta[self::FilterStrict])) {
1✔
337
                                return false;
×
338

339
                        } elseif (isset($meta[self::FilterOut])) {
1✔
340
                                $params[$name] = $meta[self::FilterOut]($params[$name]);
1✔
341
                        }
342

343
                        if (
344
                                isset($meta[self::Pattern])
1✔
345
                                && !preg_match("#(?:{$meta[self::Pattern]})$#DA", rawurldecode((string) $params[$name]))
1✔
346
                        ) {
347
                                return false; // pattern not match
1✔
348
                        }
349
                }
350

351
                return true;
1✔
352
        }
353

354

355
        /** @param array<string, mixed>  $params */
356
        private function compileUrl(array &$params): ?string
1✔
357
        {
358
                $brackets = [];
1✔
359
                $required = null; // null for auto-optional
1✔
360
                $path = '';
1✔
361
                $i = count($this->sequence) - 1;
1✔
362

363
                do {
364
                        $path = $this->sequence[$i] . $path;
1✔
365
                        if ($i === 0) {
1✔
366
                                return $path;
1✔
367
                        }
368

369
                        $i--;
1✔
370

371
                        $name = $this->sequence[$i--]; // parameter name
1✔
372

373
                        if ($name === ']') { // opening optional part
1✔
374
                                $brackets[] = $path;
1✔
375

376
                        } elseif ($name[0] === '[') { // closing optional part
1✔
377
                                $tmp = array_pop($brackets);
1✔
378
                                if ($required < count($brackets) + 1) { // is this level optional?
1✔
379
                                        if ($name !== '[!') { // and not "required"-optional
1✔
380
                                                $path = $tmp;
1✔
381
                                        }
382
                                } else {
383
                                        $required = count($brackets);
1✔
384
                                }
385
                        } elseif ($name[0] === '?') { // "foo" parameter
1✔
386
                                continue;
1✔
387

388
                        } elseif (isset($params[$name]) && $params[$name] !== '') {
1✔
389
                                $required = count($brackets); // make this level required
1✔
390
                                $path = $params[$name] . $path;
1✔
391
                                unset($params[$name]);
1✔
392

393
                        } elseif (isset($this->metadata[$name][self::Fixity])) { // has default value?
1✔
394
                                $path = $required === null && !$brackets // auto-optional
1✔
395
                                        ? ''
1✔
396
                                        : $this->metadata[$name][self::Default] . $path;
1✔
397

398
                        } else {
399
                                return null; // missing parameter '$name'
1✔
400
                        }
401
                } while (true);
1✔
402
        }
403

404

405
        private function detectMaskType(): string
406
        {
407
                // '//host/path' vs. '/abs. path' vs. 'relative path'
408
                if (preg_match('#(?:(https?):)?(//.*)#A', $this->mask, $m)) {
1✔
409
                        $this->type = self::Host;
1✔
410
                        [, $this->scheme, $path] = $m;
1✔
411
                        return $path;
1✔
412

413
                } elseif (str_starts_with($this->mask, '/')) {
1✔
414
                        $this->type = self::Path;
1✔
415

416
                } else {
417
                        $this->type = self::Relative;
1✔
418
                }
419

420
                return $this->mask;
1✔
421
        }
422

423

424
        /**
425
         * @param array<string, mixed>  $metadata
426
         * @return array<string, array<string, mixed>>
427
         */
428
        private function normalizeMetadata(array $metadata): array
1✔
429
        {
430
                foreach ($metadata as $name => $meta) {
1✔
431
                        if (!is_array($meta)) {
1✔
432
                                $metadata[$name] = $meta = [self::Value => $meta];
1✔
433
                        }
434

435
                        if (array_key_exists(self::Value, $meta)) {
1✔
436
                                if (is_scalar($meta[self::Value])) {
1✔
437
                                        $metadata[$name][self::Value] = $meta[self::Value] === false
1✔
438
                                                ? '0'
1✔
439
                                                : (string) $meta[self::Value];
1✔
440
                                }
441

442
                                $metadata[$name]['fixity'] = self::Constant;
1✔
443
                        }
444
                }
445

446
                return $metadata;
1✔
447
        }
448

449

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

455
                $i = count($parts) - 1;
1✔
456
                if ($i === 0) {
1✔
457
                        $this->re = '#' . preg_quote($parts[0], '#') . '/?$#DA';
1✔
458
                        $this->sequence = [$parts[0]];
1✔
459
                        return;
1✔
460
                }
461

462
                if ($this->parseQuery($parts)) {
1✔
463
                        $i -= 5;
1✔
464
                }
465

466
                $brackets = 0; // optional level
1✔
467
                $re = '';
1✔
468
                $sequence = [];
1✔
469
                $autoOptional = true;
1✔
470

471
                do {
472
                        $part = $parts[$i]; // part of path
1✔
473
                        if (strpbrk($part, '<>') !== false) {
1✔
474
                                throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'.");
1✔
475
                        }
476

477
                        array_unshift($sequence, $part);
1✔
478
                        $re = preg_quote($part, '#') . $re;
1✔
479
                        if ($i === 0) {
1✔
480
                                break;
1✔
481
                        }
482

483
                        $i--;
1✔
484

485
                        $part = $parts[$i]; // [ or ]
1✔
486
                        if ($part === '[' || $part === ']' || $part === '[!') {
1✔
487
                                $brackets += $part[0] === '[' ? -1 : 1;
1✔
488
                                if ($brackets < 0) {
1✔
489
                                        throw new Nette\InvalidArgumentException("Unexpected '$part' in mask '$this->mask'.");
1✔
490
                                }
491

492
                                array_unshift($sequence, $part);
1✔
493
                                $re = ($part[0] === '[' ? '(?:' : ')?') . $re;
1✔
494
                                $i -= 4;
1✔
495
                                continue;
1✔
496
                        }
497

498
                        $pattern = trim($parts[$i--]); // validation condition (as regexp)
1✔
499
                        $default = $parts[$i--]; // default value
1✔
500
                        $name = $parts[$i--]; // parameter name
1✔
501
                        array_unshift($sequence, $name);
1✔
502

503
                        if ($name[0] === '?') { // "foo" parameter
1✔
504
                                $name = substr($name, 1);
1✔
505
                                $re = $pattern
1✔
506
                                        ? '(?:' . preg_quote($name, '#') . "|$pattern)$re"
1✔
507
                                        : preg_quote($name, '#') . $re;
1✔
508
                                $sequence[1] = $name . $sequence[1];
1✔
509
                                continue;
1✔
510
                        }
511

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

515
                        if ($pattern === '' && isset($meta[self::Pattern])) {
1✔
516
                                $pattern = $meta[self::Pattern];
1✔
517
                        }
518

519
                        if ($default !== '') {
1✔
520
                                $meta[self::Value] = substr($default, 1);
1✔
521
                                $meta[self::Fixity] = self::InPath;
1✔
522
                        }
523

524
                        $meta[self::FilterTableOut] = empty($meta[self::FilterTable])
1✔
525
                                ? null
1✔
526
                                : array_flip($meta[self::FilterTable]);
1✔
527
                        if (array_key_exists(self::Value, $meta)) {
1✔
528
                                if (isset($meta[self::FilterTableOut][$meta[self::Value]])) {
1✔
529
                                        $meta[self::Default] = $meta[self::FilterTableOut][$meta[self::Value]];
×
530

531
                                } elseif (isset($meta[self::Value], $meta[self::FilterOut])) {
1✔
532
                                        $meta[self::Default] = $meta[self::FilterOut]($meta[self::Value]);
1✔
533

534
                                } else {
535
                                        $meta[self::Default] = $meta[self::Value];
1✔
536
                                }
537
                        }
538

539
                        $meta[self::Pattern] = $pattern;
1✔
540

541
                        // include in expression
542
                        $this->aliases['p' . $i] = $name;
1✔
543
                        $re = '(?P<p' . $i . '>(?U)' . $pattern . ')' . $re;
1✔
544
                        if ($brackets) { // is in brackets?
1✔
545
                                if (!isset($meta[self::Value])) {
1✔
546
                                        $meta[self::Value] = $meta[self::Default] = null;
1✔
547
                                }
548

549
                                $meta[self::Fixity] = self::InPath;
1✔
550

551
                        } elseif (isset($meta[self::Fixity])) {
1✔
552
                                if ($autoOptional) {
1✔
553
                                        $re = '(?:' . $re . ')?';
1✔
554
                                }
555

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

558
                        } else {
559
                                $autoOptional = false;
1✔
560
                        }
561

562
                        $this->metadata[$name] = $meta;
1✔
563
                } while (true);
1✔
564

565
                if ($brackets) {
1✔
566
                        throw new Nette\InvalidArgumentException("Missing '[' in mask '$this->mask'.");
1✔
567
                }
568

569
                $this->re = '#' . $re . '/?$#DA';
1✔
570
                $this->sequence = $sequence;
1✔
571
        }
1✔
572

573

574
        /** @param list<string>  $parts */
575
        private function parseQuery(array $parts): bool
1✔
576
        {
577
                $query = $parts[count($parts) - 2] ?? '';
1✔
578
                if (!str_starts_with(ltrim($query), '?')) {
1✔
579
                        return false;
1✔
580
                }
581

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

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

588
                        if (array_key_exists(self::Value, $meta)) {
1✔
589
                                $meta[self::Fixity] = self::InQuery;
1✔
590
                        }
591

592
                        unset($meta[self::Pattern]);
1✔
593
                        $meta[self::FilterTableOut] = empty($meta[self::FilterTable])
1✔
594
                                ? null
1✔
595
                                : array_flip($meta[self::FilterTable]);
1✔
596

597
                        $this->metadata[$name] = $meta;
1✔
598
                        if ($param !== '') {
1✔
599
                                $this->xlat[$name] = $param;
1✔
600
                        }
601
                }
602

603
                return true;
1✔
604
        }
605

606

607
        /********************* Utilities ****************d*g**/
608

609

610
        /**
611
         * Renames keys in array according to the translation table.
612
         * @param array<string, mixed>  $arr
613
         * @param array<string, string>  $xlat
614
         * @return array<string, mixed>
615
         */
616
        private static function renameKeys(array $arr, array $xlat): array
1✔
617
        {
618
                if (!$xlat) {
1✔
619
                        return $arr;
1✔
620
                }
621

622
                $res = [];
1✔
623
                $occupied = array_flip($xlat);
1✔
624
                foreach ($arr as $k => $v) {
1✔
625
                        if (isset($xlat[$k])) {
1✔
626
                                $res[$xlat[$k]] = $v;
1✔
627

628
                        } elseif (!isset($occupied[$k])) {
1✔
629
                                $res[$k] = $v;
1✔
630
                        }
631
                }
632

633
                return $res;
1✔
634
        }
635

636

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