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

codeigniter4 / CodeIgniter4 / 15238498328

25 May 2025 01:42PM UTC coverage: 84.165% (+0.002%) from 84.163%
15238498328

Pull #9578

github

web-flow
Merge 1f78f8122 into 9b14ad94f
Pull Request #9578: refactor: fix non-booleans in if conditions

6 of 6 new or added lines in 2 files covered. (100.0%)

2 existing lines in 1 file now uncovered.

20771 of 24679 relevant lines covered (84.16%)

191.01 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
 *
26
 * @see \CodeIgniter\View\ParserTest
27
 */
28
class Parser extends View
29
{
30
    use ViewDecoratorTrait;
31

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

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

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

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

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

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

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

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

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

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

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

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

120
        // Was it cached?
121
        if (isset($options['cache']) && is_string($output) && $output !== '') {
8✔
122
            $this->logPerformance($start, microtime(true), $view);
1✔
123

124
            return $output;
1✔
125
        }
126

127
        $file = $this->viewPath . $view;
8✔
128

129
        if (! is_file($file)) {
8✔
130
            $fileOrig = $file;
1✔
131
            $file     = $this->loader->locateFile($view, 'Views');
1✔
132

133
            // locateFile() will return false if the file cannot be found.
134
            if ($file === false) {
1✔
135
                throw ViewException::forInvalidFile($fileOrig);
1✔
136
            }
137
        }
138

139
        if ($this->tempData === null) {
7✔
UNCOV
140
            $this->tempData = $this->data;
×
141
        }
142

143
        $template = file_get_contents($file);
7✔
144
        $output   = $this->parse($template, $this->tempData, $options);
7✔
145
        $this->logPerformance($start, microtime(true), $view);
7✔
146

147
        if ($saveData) {
7✔
148
            $this->data = $this->tempData;
7✔
149
        }
150

151
        $output = $this->decorateOutput($output);
7✔
152

153
        // Should we cache?
154
        if (isset($options['cache'])) {
7✔
155
            cache()->save($cacheName, $output, (int) $options['cache']);
1✔
156
        }
157
        $this->tempData = null;
7✔
158

159
        return $output;
7✔
160
    }
161

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

179
        if ($this->tempData === null) {
93✔
180
            $this->tempData = $this->data;
22✔
181
        }
182

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

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

187
        if ($saveData) {
92✔
188
            $this->data = $this->tempData;
92✔
189
        }
190

191
        $this->tempData = null;
92✔
192

193
        return $output;
92✔
194
    }
195

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

218
                $this->dataContexts[$key] = $context;
13✔
219
            }
220
        }
221

222
        $this->tempData ??= $this->data;
77✔
223
        $this->tempData = array_merge($this->tempData, $data);
77✔
224

225
        return $this;
77✔
226
    }
227

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

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

247
        $template = $this->parseComments($template);
99✔
248
        $template = $this->extractNoparse($template);
99✔
249

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

253
        // Handle any plugins before normal data, so that
254
        // it can potentially modify any template between its tags.
255
        $template = $this->parsePlugins($template);
98✔
256

257
        // Parse stack for each parse type (Single and Pairs)
258
        $replaceSingleStack = [];
98✔
259
        $replacePairsStack  = [];
98✔
260

261
        // loop over the data variables, saving regex and data
262
        // for later replacement.
263
        foreach ($data as $key => $val) {
98✔
264
            $escape = true;
77✔
265

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

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

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

293
        return $this->insertNoparse($template);
98✔
294
    }
295

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

307
        return [$pattern => $val];
73✔
308
    }
309

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

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

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

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

357
                $temp  = [];
17✔
358
                $pairs = [];
17✔
359
                $out   = $match[1];
17✔
360

361
                foreach ($row as $key => $val) {
17✔
362
                    // For nested data, send us back through this method...
363
                    if (is_array($val)) {
17✔
364
                        $pair = $this->parsePair($key, $val, $match[1]);
6✔
365

366
                        if ($pair !== []) {
6✔
367
                            $pairs[array_keys($pair)[0]] = true;
5✔
368

369
                            $temp = array_merge($temp, $pair);
5✔
370
                        }
371

372
                        continue;
6✔
373
                    }
374

375
                    if (is_object($val)) {
17✔
376
                        $val = 'Class: ' . $val::class;
2✔
377
                    } elseif (is_resource($val)) {
17✔
378
                        $val = 'Resource';
1✔
379
                    }
380

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

384
                // Now replace our placeholders with the new content.
385
                foreach ($temp as $pattern => $content) {
17✔
386
                    $out = $this->replaceSingle($pattern, $content, $out, ! isset($pairs[$pattern]));
17✔
387
                }
388

389
                $str .= $out;
17✔
390
            }
391

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

394
            $replace['#' . $escapedMatch . '#us'] = $str;
17✔
395
        }
396

397
        return $replace;
20✔
398
    }
399

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

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

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

431
        return $template;
99✔
432
    }
433

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

444
        return $template;
98✔
445
    }
446

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

461
        $pattern = '/'
99✔
462
            . $leftDelimiter
99✔
463
            . '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*'
99✔
464
            . $rightDelimiter
99✔
465
            . '/ums';
99✔
466

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

476
        foreach ($matches as $match) {
99✔
477
            // Build the string to replace the `if` statement with.
478
            $condition = $match[2];
6✔
479

480
            $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>';
6✔
481
            $template  = str_replace($match[0], $statement, $template);
6✔
482
        }
483

484
        $template = preg_replace(
99✔
485
            '/' . $leftDelimiter . '\s*else\s*' . $rightDelimiter . '/ums',
99✔
486
            '<?php else: ?>',
99✔
487
            $template,
99✔
488
        );
99✔
489
        $template = preg_replace(
99✔
490
            '/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums',
99✔
491
            '<?php endif; ?>',
99✔
492
            $template,
99✔
493
        );
99✔
494

495
        // Parse the PHP itself, or insert an error so they can debug
496
        ob_start();
99✔
497

498
        if ($this->tempData === null) {
99✔
UNCOV
499
            $this->tempData = $this->data;
×
500
        }
501

502
        extract($this->tempData);
99✔
503

504
        try {
505
            eval('?>' . $template . '<?php ');
99✔
506
        } catch (ParseError) {
1✔
507
            ob_end_clean();
1✔
508

509
            throw ViewException::forTagSyntaxError(str_replace(['?>', '<?php '], '', $template));
1✔
510
        }
511

512
        return ob_get_clean();
98✔
513
    }
514

515
    /**
516
     * Over-ride the substitution field delimiters.
517
     *
518
     * @param string $leftDelimiter
519
     * @param string $rightDelimiter
520
     */
521
    public function setDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface
522
    {
523
        $this->leftDelimiter  = $leftDelimiter;
2✔
524
        $this->rightDelimiter = $rightDelimiter;
2✔
525

526
        return $this;
2✔
527
    }
528

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

540
        return $this;
2✔
541
    }
542

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

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

565
            return $this->prepareReplacement($matches, $content, $escape);
71✔
566
        }, (string) $template);
77✔
567
    }
568

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

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

582
        if ($escape && $filters === [] && ($context = $this->shouldAddEscaping($orig))) {
71✔
583
            $filters[] = "esc({$context})";
38✔
584
        }
585

586
        return $this->applyFilters($replace, $filters);
71✔
587
    }
588

589
    /**
590
     * Checks the placeholder the view provided to see if we need to provide any autoescaping.
591
     *
592
     * @return false|string
593
     */
594
    public function shouldAddEscaping(string $key)
595
    {
596
        $escape = false;
42✔
597

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

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

620
        return $escape;
36✔
621
    }
622

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

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

639
            // Params can be separated by commas to allow multiple parameters for the filter
640
            if ($param !== null && $param !== '') {
67✔
641
                $param = explode(',', $param);
55✔
642

643
                // Clean it up
644
                foreach ($param as &$p) {
55✔
645
                    $p = trim($p, ' "');
55✔
646
                }
647
            } else {
648
                $param = [];
13✔
649
            }
650

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

654
            if (! array_key_exists($filter, $this->config->filters)) {
67✔
655
                continue;
1✔
656
            }
657

658
            // Filter it....
659
            // We can't know correct param types, so can't set `declare(strict_types=1)`.
660
            $replace = $this->config->filters[$filter]($replace, ...$param);
66✔
661
        }
662

663
        return (string) $replace;
71✔
664
    }
665

666
    // Plugins
667

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

681
            // See https://regex101.com/r/BCBBKB/1
682
            $pattern = $isPair
98✔
683
                ? '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}(.+?)\{\+\s*/' . $plugin . '\s*\+\}#uims'
2✔
684
                : '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}#uims';
98✔
685

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

698
            foreach ($matches as $match) {
19✔
699
                $params = [];
19✔
700

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

703
                foreach ($matchesParams[0] as $item) {
19✔
704
                    $keyVal = explode('=', $item);
13✔
705

706
                    if (count($keyVal) === 2) {
13✔
707
                        $params[$keyVal[0]] = str_replace('"', '', $keyVal[1]);
7✔
708
                    } else {
709
                        $params[] = str_replace('"', '', $item);
6✔
710
                    }
711
                }
712

713
                $template = $isPair
19✔
714
                    ? str_replace($match[0], $callable($match[2], $params), $template)
2✔
715
                    : str_replace($match[0], $callable($params), $template);
17✔
716
            }
717
        }
718

719
        return $template;
98✔
720
    }
721

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

731
        return $this;
8✔
732
    }
733

734
    /**
735
     * Removes a plugin from the available plugins.
736
     *
737
     * @return $this
738
     */
739
    public function removePlugin(string $alias)
740
    {
741
        unset($this->plugins[$alias]);
1✔
742

743
        return $this;
1✔
744
    }
745

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

766
        return $value;
13✔
767
    }
768
}
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