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

codeigniter4 / CodeIgniter4 / 24303870580

12 Apr 2026 09:49AM UTC coverage: 88.21%. Remained the same
24303870580

Pull #10101

github

web-flow
Merge 0cc9d8476 into df914e228
Pull Request #10101: fix: preserve null values in Validation::getValidated()

2 of 2 new or added lines in 1 file covered. (100.0%)

128 existing lines in 8 files now uncovered.

22101 of 25055 relevant lines covered (88.21%)

209.48 hits per line

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

99.14
/system/View/Parser.php
1
<?php
2

3
/**
4
 * This file is part of CodeIgniter 4 framework.
5
 *
6
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
7
 *
8
 * For the full copyright and license information, please view
9
 * the LICENSE file that was distributed with this source code.
10
 */
11

12
namespace CodeIgniter\View;
13

14
use CodeIgniter\Autoloader\FileLocatorInterface;
15
use CodeIgniter\View\Exceptions\ViewException;
16
use Config\View as ViewConfig;
17
use ParseError;
18
use Psr\Log\LoggerInterface;
19

20
/**
21
 * Class for parsing pseudo-vars
22
 *
23
 * @phpstan-type parser_callable (callable(mixed): mixed)
24
 * @phpstan-type parser_callable_string (callable(mixed): mixed)&string
25
 * @psalm-type parser_callable = callable(mixed): mixed
26
 * @psalm-type parser_callable_string = callable-string
27
 *
28
 * @see \CodeIgniter\View\ParserTest
29
 */
30
class Parser extends View
31
{
32
    use ViewDecoratorTrait;
33

34
    /**
35
     * Left delimiter character for pseudo vars
36
     *
37
     * @var string
38
     */
39
    public $leftDelimiter = '{';
40

41
    /**
42
     * Right delimiter character for pseudo vars
43
     *
44
     * @var string
45
     */
46
    public $rightDelimiter = '}';
47

48
    /**
49
     * Left delimiter characters for conditionals
50
     */
51
    protected string $leftConditionalDelimiter = '{';
52

53
    /**
54
     * Right delimiter characters for conditionals
55
     */
56
    protected string $rightConditionalDelimiter = '}';
57

58
    /**
59
     * Stores extracted noparse blocks.
60
     *
61
     * @var list<string>
62
     */
63
    protected $noparseBlocks = [];
64

65
    /**
66
     * Stores any plugins registered at run-time.
67
     *
68
     * @var         array<string, callable|list<string>|string>
69
     * @phpstan-var array<string, list<parser_callable_string>|parser_callable|parser_callable_string>
70
     */
71
    protected $plugins = [];
72

73
    /**
74
     * Stores the context for each data element
75
     * when set by `setData` so the context is respected.
76
     *
77
     * @var array<string, mixed>
78
     */
79
    protected $dataContexts = [];
80

81
    /**
82
     * Constructor
83
     *
84
     * @param FileLocatorInterface|null $loader
85
     */
86
    public function __construct(
87
        ViewConfig $config,
88
        ?string $viewPath = null,
89
        $loader = null,
90
        ?bool $debug = null,
91
        ?LoggerInterface $logger = null,
92
    ) {
93
        // Ensure user plugins override core plugins.
94
        $this->plugins = $config->plugins;
115✔
95

96
        parent::__construct($config, $viewPath, $loader, $debug, $logger);
115✔
97
    }
98

99
    /**
100
     * Parse a template
101
     *
102
     * Parses pseudo-variables contained in the specified template view,
103
     * replacing them with any data that has already been set.
104
     *
105
     * @param array<string, mixed>|null $options Reserved for 3rd-party uses since
106
     *                                           it might be needed to pass additional info
107
     *                                           to other template engines.
108
     */
109
    public function render(string $view, ?array $options = null, ?bool $saveData = null): string
110
    {
111
        $start = microtime(true);
8✔
112
        if ($saveData === null) {
8✔
113
            $saveData = $this->config->saveData;
7✔
114
        }
115

116
        $fileExt = pathinfo($view, PATHINFO_EXTENSION);
8✔
117
        $view    = ($fileExt === '') ? $view . '.php' : $view; // allow Views as .html, .tpl, etc (from CI3)
8✔
118

119
        $cacheName = $options['cache_name'] ?? str_replace('.php', '', $view);
8✔
120

121
        // Was it cached?
122
        if (isset($options['cache'])) {
8✔
123
            $output = cache($cacheName);
1✔
124

125
            if (is_string($output) && $output !== '') {
1✔
126
                $this->logPerformance($start, microtime(true), $view);
1✔
127

128
                return $output;
1✔
129
            }
130
        }
131

132
        $file = $this->viewPath . $view;
8✔
133

134
        if (! is_file($file)) {
8✔
135
            $fileOrig = $file;
1✔
136
            $file     = $this->loader->locateFile($view, 'Views');
1✔
137

138
            // locateFile() will return false if the file cannot be found.
139
            if ($file === false) {
1✔
140
                throw ViewException::forInvalidFile($fileOrig);
1✔
141
            }
142
        }
143

144
        if ($this->tempData === null) {
7✔
UNCOV
145
            $this->tempData = $this->data;
×
146
        }
147

148
        $template = file_get_contents($file);
7✔
149
        $output   = $this->parse($template, $this->tempData, $options);
7✔
150
        $this->logPerformance($start, microtime(true), $view);
7✔
151

152
        if ($saveData) {
7✔
153
            $this->data = $this->tempData;
7✔
154
        }
155

156
        $output = $this->decorateOutput($output);
7✔
157

158
        // Should we cache?
159
        if (isset($options['cache'])) {
7✔
160
            cache()->save($cacheName, $output, (int) $options['cache']);
1✔
161
        }
162
        $this->tempData = null;
7✔
163

164
        return $output;
7✔
165
    }
166

167
    /**
168
     * Parse a String
169
     *
170
     * Parses pseudo-variables contained in the specified string,
171
     * replacing them with any data that has already been set.
172
     *
173
     * @param array<string, mixed>|null $options Reserved for 3rd-party uses since
174
     *                                           it might be needed to pass additional info
175
     *                                           to other template engines.
176
     */
177
    public function renderString(string $template, ?array $options = null, ?bool $saveData = null): string
178
    {
179
        $start = microtime(true);
93✔
180
        if ($saveData === null) {
93✔
181
            $saveData = $this->config->saveData;
92✔
182
        }
183

184
        if ($this->tempData === null) {
93✔
185
            $this->tempData = $this->data;
22✔
186
        }
187

188
        $output = $this->parse($template, $this->tempData, $options);
93✔
189

190
        $this->logPerformance($start, microtime(true), $this->excerpt($template));
92✔
191

192
        if ($saveData) {
92✔
193
            $this->data = $this->tempData;
92✔
194
        }
195

196
        $this->tempData = null;
92✔
197

198
        return $output;
92✔
199
    }
200

201
    /**
202
     * Sets several pieces of view data at once.
203
     * In the Parser, we need to store the context here
204
     * so that the variable is correctly handled within the
205
     * parsing itself, and contexts (including raw) are respected.
206
     *
207
     * @param array<string, mixed>                      $data
208
     * @param 'attr'|'css'|'html'|'js'|'raw'|'url'|null $context The context to escape it for.
209
     *                                                           If 'raw', no escaping will happen.
210
     */
211
    public function setData(array $data = [], ?string $context = null): RendererInterface
212
    {
213
        if ($context !== null && $context !== '') {
77✔
214
            foreach ($data as $key => &$value) {
13✔
215
                if (is_array($value)) {
13✔
216
                    foreach ($value as &$obj) {
2✔
217
                        $obj = $this->objectToArray($obj);
2✔
218
                    }
219
                } else {
220
                    $value = $this->objectToArray($value);
13✔
221
                }
222

223
                $this->dataContexts[$key] = $context;
13✔
224
            }
225
        }
226

227
        $this->tempData ??= $this->data;
77✔
228
        $this->tempData = array_merge($this->tempData, $data);
77✔
229

230
        return $this;
77✔
231
    }
232

233
    /**
234
     * Parse a template
235
     *
236
     * Parses pseudo-variables contained in the specified template,
237
     * replacing them with the data in the second param
238
     *
239
     * @param array<string, mixed> $data
240
     * @param array<string, mixed> $options Future options
241
     */
242
    protected function parse(string $template, array $data = [], ?array $options = null): string
243
    {
244
        if ($template === '') {
100✔
245
            return '';
1✔
246
        }
247

248
        // Remove any possible PHP tags since we don't support it
249
        // and parseConditionals needs it clean anyway...
250
        $template = str_replace(['<?', '?>'], ['&lt;?', '?&gt;'], $template);
99✔
251

252
        $template = $this->parseComments($template);
99✔
253
        $template = $this->extractNoparse($template);
99✔
254

255
        // Replace any conditional code here so we don't have to parse as much
256
        $template = $this->parseConditionals($template);
99✔
257

258
        // Handle any plugins before normal data, so that
259
        // it can potentially modify any template between its tags.
260
        $template = $this->parsePlugins($template);
98✔
261

262
        // Parse stack for each parse type (Single and Pairs)
263
        $replaceSingleStack = [];
98✔
264
        $replacePairsStack  = [];
98✔
265

266
        // loop over the data variables, saving regex and data
267
        // for later replacement.
268
        foreach ($data as $key => $val) {
98✔
269
            $escape = true;
77✔
270

271
            if (is_array($val)) {
77✔
272
                $escape              = false;
20✔
273
                $replacePairsStack[] = [
20✔
274
                    'replace' => $this->parsePair($key, $val, $template),
20✔
275
                    'escape'  => $escape,
20✔
276
                ];
20✔
277
            } else {
278
                $replaceSingleStack[] = [
73✔
279
                    'replace' => $this->parseSingle($key, (string) $val),
73✔
280
                    'escape'  => $escape,
73✔
281
                ];
73✔
282
            }
283
        }
284

285
        // Merge both stacks, pairs first + single stacks
286
        // This allows for nested data with the same key to be replaced properly
287
        $replace = array_merge($replacePairsStack, $replaceSingleStack);
98✔
288

289
        // Loop over each replace array item which
290
        // holds all the data to be replaced
291
        foreach ($replace as $replaceItem) {
98✔
292
            // Loop over the actual data to be replaced
293
            foreach ($replaceItem['replace'] as $pattern => $content) {
77✔
294
                $template = $this->replaceSingle($pattern, $content, $template, $replaceItem['escape']);
77✔
295
            }
296
        }
297

298
        return $this->insertNoparse($template);
98✔
299
    }
300

301
    /**
302
     * Parse a single key/value, extracting it
303
     *
304
     * @return array<string, string>
305
     */
306
    protected function parseSingle(string $key, string $val): array
307
    {
308
        $pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#')
73✔
309
            . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?'
73✔
310
            . $this->rightDelimiter . '#ums';
73✔
311

312
        return [$pattern => $val];
73✔
313
    }
314

315
    /**
316
     * Parse a tag pair
317
     *
318
     * Parses tag pairs: {some_tag} string... {/some_tag}
319
     *
320
     * @param array<string, mixed> $data
321
     *
322
     * @return array<string, string>
323
     */
324
    protected function parsePair(string $variable, array $data, string $template): array
325
    {
326
        // Holds the replacement patterns and contents
327
        // that will be used within a preg_replace in parse()
328
        $replace = [];
20✔
329

330
        // Find all matches of space-flexible versions of {tag}{/tag} so we
331
        // have something to loop over.
332
        preg_match_all(
20✔
333
            '#' . $this->leftDelimiter . '\s*' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '(.+?)' .
20✔
334
            $this->leftDelimiter . '\s*/' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '#us',
20✔
335
            $template,
20✔
336
            $matches,
20✔
337
            PREG_SET_ORDER,
20✔
338
        );
20✔
339

340
        /*
341
         * Each match looks like:
342
         *
343
         * $match[0] {tag}...{/tag}
344
         * $match[1] Contents inside the tag
345
         */
346
        foreach ($matches as $match) {
20✔
347
            // Loop over each piece of $data, replacing
348
            // its contents so that we know what to replace in parse()
349
            $str = '';  // holds the new contents for this tag pair.
17✔
350

351
            foreach ($data as $row) {
17✔
352
                // Objects that have a `toArray()` method should be
353
                // converted with that method (i.e. Entities)
354
                if (is_object($row) && method_exists($row, 'toArray')) {
17✔
355
                    $row = $row->toArray();
1✔
356
                }
357
                // Otherwise, cast as an array and it will grab public properties.
358
                elseif (is_object($row)) {
17✔
359
                    $row = (array) $row;
1✔
360
                }
361

362
                $temp  = [];
17✔
363
                $pairs = [];
17✔
364
                $out   = $match[1];
17✔
365

366
                foreach ($row as $key => $val) {
17✔
367
                    // For nested data, send us back through this method...
368
                    if (is_array($val)) {
17✔
369
                        $pair = $this->parsePair($key, $val, $match[1]);
6✔
370

371
                        if ($pair !== []) {
6✔
372
                            $pairs[array_keys($pair)[0]] = true;
5✔
373

374
                            $temp = array_merge($temp, $pair);
5✔
375
                        }
376

377
                        continue;
6✔
378
                    }
379

380
                    if (is_object($val)) {
17✔
381
                        $val = 'Class: ' . $val::class;
2✔
382
                    } elseif (is_resource($val)) {
17✔
383
                        $val = 'Resource';
1✔
384
                    }
385

386
                    $temp['#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#') . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?' . $this->rightDelimiter . '#us'] = $val;
17✔
387
                }
388

389
                // Now replace our placeholders with the new content.
390
                foreach ($temp as $pattern => $content) {
17✔
391
                    $out = $this->replaceSingle($pattern, $content, $out, ! isset($pairs[$pattern]));
17✔
392
                }
393

394
                $str .= $out;
17✔
395
            }
396

397
            $escapedMatch = preg_quote($match[0], '#');
17✔
398

399
            $replace['#' . $escapedMatch . '#us'] = $str;
17✔
400
        }
401

402
        return $replace;
20✔
403
    }
404

405
    /**
406
     * Removes any comments from the file. Comments are wrapped in {# #} symbols:
407
     *
408
     *      {# This is a comment #}
409
     */
410
    protected function parseComments(string $template): string
411
    {
412
        return preg_replace('/\{#.*?#\}/us', '', $template);
99✔
413
    }
414

415
    /**
416
     * Extracts noparse blocks, inserting a hash in its place so that
417
     * those blocks of the page are not touched by parsing.
418
     */
419
    protected function extractNoparse(string $template): string
420
    {
421
        $pattern = '/\{\s*noparse\s*\}(.*?)\{\s*\/noparse\s*\}/ums';
99✔
422

423
        /*
424
         * $matches[][0] is the raw match
425
         * $matches[][1] is the contents
426
         */
427
        if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER) >= 1) {
99✔
428
            foreach ($matches as $match) {
1✔
429
                // Create a hash of the contents to insert in its place.
430
                $hash                       = md5($match[1]);
1✔
431
                $this->noparseBlocks[$hash] = $match[1];
1✔
432
                $template                   = str_replace($match[0], "noparse_{$hash}", $template);
1✔
433
            }
434
        }
435

436
        return $template;
99✔
437
    }
438

439
    /**
440
     * Re-inserts the noparsed contents back into the template.
441
     */
442
    public function insertNoparse(string $template): string
443
    {
444
        foreach ($this->noparseBlocks as $hash => $replace) {
98✔
445
            $template = str_replace("noparse_{$hash}", $replace, $template);
1✔
446
            unset($this->noparseBlocks[$hash]);
1✔
447
        }
448

449
        return $template;
98✔
450
    }
451

452
    /**
453
     * Parses any conditionals in the code, removing blocks that don't
454
     * pass so we don't try to parse it later.
455
     *
456
     * Valid conditionals:
457
     *  - if
458
     *  - elseif
459
     *  - else
460
     */
461
    protected function parseConditionals(string $template): string
462
    {
463
        $leftDelimiter  = preg_quote($this->leftConditionalDelimiter, '/');
99✔
464
        $rightDelimiter = preg_quote($this->rightConditionalDelimiter, '/');
99✔
465

466
        $pattern = '/'
99✔
467
            . $leftDelimiter
99✔
468
            . '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*'
99✔
469
            . $rightDelimiter
99✔
470
            . '/ums';
99✔
471

472
        /*
473
         * For each match:
474
         * [0] = raw match `{if var}`
475
         * [1] = conditional `if`
476
         * [2] = condition `do === true`
477
         * [3] = same as [2]
478
         */
479
        preg_match_all($pattern, $template, $matches, PREG_SET_ORDER);
99✔
480

481
        foreach ($matches as $match) {
99✔
482
            // Build the string to replace the `if` statement with.
483
            $condition = $match[2];
6✔
484

485
            $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>';
6✔
486
            $template  = str_replace($match[0], $statement, $template);
6✔
487
        }
488

489
        $template = preg_replace(
99✔
490
            '/' . $leftDelimiter . '\s*else\s*' . $rightDelimiter . '/ums',
99✔
491
            '<?php else: ?>',
99✔
492
            $template,
99✔
493
        );
99✔
494
        $template = preg_replace(
99✔
495
            '/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums',
99✔
496
            '<?php endif; ?>',
99✔
497
            $template,
99✔
498
        );
99✔
499

500
        // Parse the PHP itself, or insert an error so they can debug
501
        ob_start();
99✔
502

503
        if ($this->tempData === null) {
99✔
UNCOV
504
            $this->tempData = $this->data;
×
505
        }
506

507
        extract($this->tempData);
99✔
508

509
        try {
510
            eval('?>' . $template . '<?php ');
99✔
511
        } catch (ParseError) {
1✔
512
            ob_end_clean();
1✔
513

514
            throw ViewException::forTagSyntaxError(str_replace(['?>', '<?php '], '', $template));
1✔
515
        }
516

517
        return ob_get_clean();
98✔
518
    }
519

520
    /**
521
     * Over-ride the substitution field delimiters.
522
     *
523
     * @param string $leftDelimiter
524
     * @param string $rightDelimiter
525
     */
526
    public function setDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface
527
    {
528
        $this->leftDelimiter  = $leftDelimiter;
2✔
529
        $this->rightDelimiter = $rightDelimiter;
2✔
530

531
        return $this;
2✔
532
    }
533

534
    /**
535
     * Over-ride the substitution conditional delimiters.
536
     *
537
     * @param string $leftDelimiter
538
     * @param string $rightDelimiter
539
     */
540
    public function setConditionalDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface
541
    {
542
        $this->leftConditionalDelimiter  = $leftDelimiter;
2✔
543
        $this->rightConditionalDelimiter = $rightDelimiter;
2✔
544

545
        return $this;
2✔
546
    }
547

548
    /**
549
     * Handles replacing a pseudo-variable with the actual content. Will double-check
550
     * for escaping brackets.
551
     *
552
     * @param array|string $pattern
553
     * @param string       $content
554
     * @param string       $template
555
     */
556
    protected function replaceSingle($pattern, $content, $template, bool $escape = false): string
557
    {
558
        $content = (string) $content;
77✔
559

560
        // Replace the content in the template
561
        return preg_replace_callback($pattern, function ($matches) use ($content, $escape): string {
77✔
562
            // Check for {! !} syntax to not escape this one.
563
            if (
564
                str_starts_with($matches[0], $this->leftDelimiter . '!')
71✔
565
                && substr($matches[0], -1 - strlen($this->rightDelimiter)) === '!' . $this->rightDelimiter
71✔
566
            ) {
567
                $escape = false;
2✔
568
            }
569

570
            return $this->prepareReplacement($matches, $content, $escape);
71✔
571
        }, (string) $template);
77✔
572
    }
573

574
    /**
575
     * Callback used during parse() to apply any filters to the value.
576
     *
577
     * @param list<string> $matches
578
     */
579
    protected function prepareReplacement(array $matches, string $replace, bool $escape = true): string
580
    {
581
        $orig = array_shift($matches);
71✔
582

583
        // Our regex earlier will leave all chained values on a single line
584
        // so we need to break them apart so we can apply them all.
585
        $filters = (isset($matches[1]) && $matches[1] !== '') ? explode('|', $matches[1]) : [];
71✔
586

587
        if ($escape && $filters === [] && ($context = $this->shouldAddEscaping($orig))) {
71✔
588
            $filters[] = "esc({$context})";
38✔
589
        }
590

591
        return $this->applyFilters($replace, $filters);
71✔
592
    }
593

594
    /**
595
     * Checks the placeholder the view provided to see if we need to provide any autoescaping.
596
     *
597
     * @return false|string
598
     */
599
    public function shouldAddEscaping(string $key)
600
    {
601
        $escape = false;
42✔
602

603
        $key = trim(str_replace(['{', '}'], '', $key));
42✔
604

605
        // If the key has a context stored (from setData)
606
        // we need to respect that.
607
        if (array_key_exists($key, $this->dataContexts)) {
42✔
608
            if ($this->dataContexts[$key] !== 'raw') {
10✔
609
                return $this->dataContexts[$key];
8✔
610
            }
611
        }
612
        // No pipes, then we know we need to escape
613
        elseif (! str_contains($key, '|')) {
34✔
614
            $escape = 'html';
32✔
615
        }
616
        // If there's a `noescape` then we're definitely false.
617
        elseif (str_contains($key, 'noescape')) {
2✔
618
            $escape = false;
1✔
619
        }
620
        // If no `esc` filter is found, then we'll need to add one.
621
        elseif (preg_match('/\s+esc/u', $key) !== 1) {
1✔
622
            $escape = 'html';
1✔
623
        }
624

625
        return $escape;
36✔
626
    }
627

628
    /**
629
     * Given a set of filters, will apply each of the filters in turn
630
     * to $replace, and return the modified string.
631
     *
632
     * @param list<string> $filters
633
     */
634
    protected function applyFilters(string $replace, array $filters): string
635
    {
636
        // Determine the requested filters
637
        foreach ($filters as $filter) {
71✔
638
            // Grab any parameter we might need to send
639
            preg_match('/\([\w<>=\/\\\,:.\-\s\+]+\)/u', $filter, $param);
67✔
640

641
            // Remove the () and spaces to we have just the parameter left
642
            $param = ($param !== []) ? trim($param[0], '() ') : null;
67✔
643

644
            // Params can be separated by commas to allow multiple parameters for the filter
645
            if ($param !== null && $param !== '') {
67✔
646
                $param = explode(',', $param);
55✔
647

648
                // Clean it up
649
                foreach ($param as &$p) {
55✔
650
                    $p = trim($p, ' "');
55✔
651
                }
652
            } else {
653
                $param = [];
13✔
654
            }
655

656
            // Get our filter name
657
            $filter = $param !== [] ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter);
67✔
658

659
            if (! array_key_exists($filter, $this->config->filters)) {
67✔
660
                continue;
1✔
661
            }
662

663
            // Filter it....
664
            // We can't know correct param types, so can't set `declare(strict_types=1)`.
665
            $replace = $this->config->filters[$filter]($replace, ...$param);
66✔
666
        }
667

668
        return (string) $replace;
71✔
669
    }
670

671
    // Plugins
672

673
    /**
674
     * Scans the template for any parser plugins, and attempts to execute them.
675
     * Plugins are delimited by {+ ... +}
676
     *
677
     * @return string
678
     */
679
    protected function parsePlugins(string $template)
680
    {
681
        foreach ($this->plugins as $plugin => $callable) {
98✔
682
            // Paired tags are enclosed in an array in the config array.
683
            $isPair   = is_array($callable);
98✔
684
            $callable = $isPair ? array_shift($callable) : $callable;
98✔
685

686
            // See https://regex101.com/r/BCBBKB/1
687
            $pattern = $isPair
98✔
688
                ? '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}(.+?)\{\+\s*/' . $plugin . '\s*\+\}#uims'
2✔
689
                : '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}#uims';
98✔
690

691
            /**
692
             * Match tag pairs
693
             *
694
             * Each match is an array:
695
             *   $matches[0] = entire matched string
696
             *   $matches[1] = all parameters string in opening tag
697
             *   $matches[2] = content between the tags to send to the plugin.
698
             */
699
            if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER) < 1) {
98✔
700
                continue;
98✔
701
            }
702

703
            foreach ($matches as $match) {
19✔
704
                $params = [];
19✔
705

706
                preg_match_all('/([\w-]+=\"[^"]+\")|([\w-]+=[^\"\s=]+)|(\"[^"]+\")|(\S+)/u', trim($match[1]), $matchesParams);
19✔
707

708
                foreach ($matchesParams[0] as $item) {
19✔
709
                    $keyVal = explode('=', $item);
13✔
710

711
                    if (count($keyVal) === 2) {
13✔
712
                        $params[$keyVal[0]] = str_replace('"', '', $keyVal[1]);
7✔
713
                    } else {
714
                        $params[] = str_replace('"', '', $item);
6✔
715
                    }
716
                }
717

718
                $template = $isPair
19✔
719
                    ? str_replace($match[0], $callable($match[2], $params), $template)
2✔
720
                    : str_replace($match[0], $callable($params), $template);
17✔
721
            }
722
        }
723

724
        return $template;
98✔
725
    }
726

727
    /**
728
     * Makes a new plugin available during the parsing of the template.
729
     *
730
     * @return $this
731
     */
732
    public function addPlugin(string $alias, callable $callback, bool $isPair = false)
733
    {
734
        $this->plugins[$alias] = $isPair ? [$callback] : $callback;
8✔
735

736
        return $this;
8✔
737
    }
738

739
    /**
740
     * Removes a plugin from the available plugins.
741
     *
742
     * @return $this
743
     */
744
    public function removePlugin(string $alias)
745
    {
746
        unset($this->plugins[$alias]);
1✔
747

748
        return $this;
1✔
749
    }
750

751
    /**
752
     * Converts an object to an array, respecting any
753
     * toArray() methods on an object.
754
     *
755
     * @param array<string, mixed>|bool|float|int|object|string|null $value
756
     *
757
     * @return array<string, mixed>|bool|float|int|string|null
758
     */
759
    protected function objectToArray($value)
760
    {
761
        // Objects that have a `toArray()` method should be
762
        // converted with that method (i.e. Entities)
763
        if (is_object($value) && method_exists($value, 'toArray')) {
13✔
764
            $value = $value->toArray();
1✔
765
        }
766
        // Otherwise, cast as an array and it will grab public properties.
767
        elseif (is_object($value)) {
13✔
768
            $value = (array) $value;
1✔
769
        }
770

771
        return $value;
13✔
772
    }
773
}
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