Passed
Push — main ( beb9e1...43b6a1 )
by Dimitri
25:37 queued 20:48
created

Parser::setDelimiters()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
c 2
b 0
f 0
nc 1
nop 2
dl 0
loc 6
ccs 3
cts 3
cp 1
crap 1
rs 10
1
<?php
2
3
/**
4
 * This file is part of Blitz PHP framework.
5
 *
6
 * (c) 2022 Dimitri Sitchet Tomkeu <[email protected]>
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 BlitzPHP\View;
13
14
use BlitzPHP\Container\Services;
15
use BlitzPHP\Exceptions\ViewException;
16
use BlitzPHP\View\Adapters\NativeAdapter;
17
use ParseError;
18
19
/**
20
 * Class for parsing pseudo-vars
21
 */
22
class Parser extends NativeAdapter
23
{
24
    /**
25
     * Left delimiter character for pseudo vars
26
     */
27
    public string $leftDelimiter = '{';
28
29
    /**
30
     * Right delimiter character for pseudo vars
31
     */
32
    public string $rightDelimiter = '}';
33
34
    /**
35
     * Left delimiter characters for conditionals
36
     */
37
    protected string $leftConditionalDelimiter = '{';
38
39
    /**
40
     * Right delimiter characters for conditionals
41
     */
42
    protected string $rightConditionalDelimiter = '}';
43
44
    /**
45
     * Stores extracted noparse blocks.
46
     */
47
    protected array $noparseBlocks = [];
48
49
    /**
50
     * Stores any plugins registered at run-time.
51
     *
52
     * @var array<string, array<string>|callable|string>
53
     */
54
    protected array $plugins = [];
55
56
    /**
57
     * Stores the context for each data element
58
     * when set by `setData` so the context is respected.
59
     */
60
    protected array $dataContexts = [];
61
62
    /**
63
     * {@inheritDoc}
64
     */
65
    public function __construct(protected array $config, $viewPathLocator = null, protected bool $debug = BLITZ_DEBUG)
66
    {
67
        // Ensure user plugins override core plugins.
68 4
        $this->plugins = $config['plugins'] ?? [];
69
70 4
        parent::__construct($config, $viewPathLocator, $debug);
71
    }
72
73
    /**
74
     * Parse a template
75
     *
76
     * Parses pseudo-variables contained in the specified template view,
77
     * replacing them with any data that has already been set.
78
     */
79
    public function render(string $view, ?array $options = null, ?bool $saveData = null): string
80
    {
81 4
        $start = microtime(true);
82
        if ($saveData === null) {
83 4
            $saveData = $this->saveData;
84
        }
85
86 4
        $fileExt = pathinfo($view, PATHINFO_EXTENSION);
87 4
        $view    = empty($fileExt) ? $view . '.php' : $view; // allow Views as .html, .tpl, etc (from CI3)
88
89 4
        $cacheName = $options['cache_name'] ?? str_replace('.php', '', $view);
90
91
        // Was it cached?
92
        if (isset($options['cache']) && ($output = cache($cacheName))) {
93 4
            $this->logPerformance($start, microtime(true), $view);
0 ignored issues
show
Bug introduced by
It seems like $start can also be of type string; however, parameter $start of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

93
            $this->logPerformance(/** @scrutinizer ignore-type */ $start, microtime(true), $view);
Loading history...
Bug introduced by
It seems like microtime(true) can also be of type string; however, parameter $end of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

93
            $this->logPerformance($start, /** @scrutinizer ignore-type */ microtime(true), $view);
Loading history...
94
95
            return $output;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $output returns the type BlitzPHP\Cache\Cache|true which is incompatible with the type-hinted return string.
Loading history...
96
        }
97
98 4
        $file = $this->viewPath . $view;
99
100
        if (! is_file($file)) {
101 4
            $fileOrig = $file;
102 4
            $file     = ($this->locator ?: Services::locator())->locateFile($view, 'Views');
103
104
            // locateFile will return an empty string if the file cannot be found.
105
            if (empty($file)) {
106 4
                throw ViewException::invalidFile($fileOrig);
107
            }
108
        }
109
110
        if ($this->tempData === null) {
111
            $this->tempData = $this->data;
112
        }
113
114 4
        $template = file_get_contents($file);
115 4
        $output   = $this->parse($template, $this->tempData, $options);
116 4
        $this->logPerformance($start, microtime(true), $view);
117
118
        if ($saveData) {
119 4
            $this->data = $this->tempData;
120
        }
121
122 4
        $output = $this->decorate($output);
123
124
        // Should we cache?
125
        if (isset($options['cache'])) {
126 4
            cache()->save($cacheName, $output, (int) $options['cache']);
127
        }
128 4
        $this->tempData = null;
129
130 4
        return $output;
131
    }
132
133
    /**
134
     * Parse a String
135
     *
136
     * Parses pseudo-variables contained in the specified string,
137
     * replacing them with any data that has already been set.
138
     */
139
    public function renderString(string $template, ?array $options = null, ?bool $saveData = null): string
140
    {
141 2
        $start = microtime(true);
142
        if ($saveData === null) {
143 2
            $saveData = $this->saveData;
144
        }
145
146
        if ($this->tempData === null) {
147
            $this->tempData = $this->data;
148
        }
149
150 2
        $output = $this->parse($template, $this->tempData, $options);
151
152 2
        $this->logPerformance($start, microtime(true), $this->excerpt($template));
0 ignored issues
show
Bug introduced by
It seems like microtime(true) can also be of type string; however, parameter $end of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

152
        $this->logPerformance($start, /** @scrutinizer ignore-type */ microtime(true), $this->excerpt($template));
Loading history...
Bug introduced by
It seems like $start can also be of type string; however, parameter $start of BlitzPHP\View\Adapters\A...apter::logPerformance() does only seem to accept double, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

152
        $this->logPerformance(/** @scrutinizer ignore-type */ $start, microtime(true), $this->excerpt($template));
Loading history...
153
154
        if ($saveData) {
155 2
            $this->data = $this->tempData;
156
        }
157
158 2
        $this->tempData = null;
159
160 2
        return $output;
161
    }
162
163
    /**
164
     * Sets several pieces of view data at once.
165
     * In the Parser, we need to store the context here
166
     * so that the variable is correctly handled within the
167
     * parsing itself, and contexts (including raw) are respected.
168
     *
169
     * @param string|null $context The context to escape it for: html, css, js, url, raw
170
     *                             If 'raw', no escaping will happen
171
     */
172
    public function setData(array $data = [], ?string $context = null): static
173
    {
174
        if (! empty($context)) {
175
            foreach ($data as $key => &$value) {
176
                if (is_array($value)) {
177
                    foreach ($value as &$obj) {
178
                        $obj = $this->objectToArray($obj);
179
                    }
180
                } else {
181
                    $value = $this->objectToArray($value);
182
                }
183
184
                $this->dataContexts[$key] = $context;
185
            }
186
        }
187
188 2
        $this->tempData ??= $this->data;
189 2
        $this->tempData = array_merge($this->tempData, $data);
190
191 2
        return $this;
192
    }
193
194
    /**
195
     * Parse a template
196
     *
197
     * Parses pseudo-variables contained in the specified template,
198
     * replacing them with the data in the second param
199
     *
200
     * @param array $options Future options
201
     */
202
    protected function parse(string $template, array $data = [], ?array $options = null): string
0 ignored issues
show
Unused Code introduced by
The parameter $options is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

202
    protected function parse(string $template, array $data = [], /** @scrutinizer ignore-unused */ ?array $options = null): string

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
203
    {
204
        if ($template === '') {
205 2
            return '';
206
        }
207
208
        // Remove any possible PHP tags since we don't support it
209
        // and parseConditionals needs it clean anyway...
210 4
        $template = str_replace(['<?', '?>'], ['&lt;?', '?&gt;'], $template);
211
212 4
        $template = $this->parseComments($template);
213 4
        $template = $this->extractNoparse($template);
214
215
        // Replace any conditional code here so we don't have to parse as much
216 4
        $template = $this->parseConditionals($template);
217
218
        // Handle any plugins before normal data, so that
219
        // it can potentially modify any template between its tags.
220 4
        $template = $this->parsePlugins($template);
221
222
        // loop over the data variables, replacing
223
        // the content as we go.
224
        foreach ($data as $key => $val) {
225 4
            $escape = true;
226
227
            if (is_array($val)) {
228 2
                $escape  = false;
229 2
                $replace = $this->parsePair($key, $val, $template);
230
            } else {
231 4
                $replace = $this->parseSingle($key, (string) $val);
232
            }
233
234
            foreach ($replace as $pattern => $content) {
235 4
                $template = $this->replaceSingle($pattern, $content, $template, $escape);
236
            }
237
        }
238
239 4
        return $this->insertNoparse($template);
240
    }
241
242
    /**
243
     * Parse a single key/value, extracting it
244
     */
245
    protected function parseSingle(string $key, string $val): array
246
    {
247
        $pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#')
248
            . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?'
249 4
            . $this->rightDelimiter . '#ums';
250
251 4
        return [$pattern => $val];
252
    }
253
254
    /**
255
     * Parse a tag pair
256
     *
257
     * Parses tag pairs: {some_tag} string... {/some_tag}
258
     */
259
    protected function parsePair(string $variable, array $data, string $template): array
260
    {
261
        // Holds the replacement patterns and contents
262
        // that will be used within a preg_replace in parse()
263 2
        $replace = [];
264
265
        // Find all matches of space-flexible versions of {tag}{/tag} so we
266
        // have something to loop over.
267
        preg_match_all(
268
            '#' . $this->leftDelimiter . '\s*' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '(.+?)' .
269
            $this->leftDelimiter . '\s*/' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '#us',
270
            $template,
271
            $matches,
272
            PREG_SET_ORDER
273 2
        );
274
275
        /*
276
         * Each match looks like:
277
         *
278
         * $match[0] {tag}...{/tag}
279
         * $match[1] Contents inside the tag
280
         */
281
        foreach ($matches as $match) {
282
            // Loop over each piece of $data, replacing
283
            // its contents so that we know what to replace in parse()
284 2
            $str = '';  // holds the new contents for this tag pair.
285
286
            foreach ($data as $row) {
287
                // Objects that have a `toArray()` method should be
288
                // converted with that method (i.e. Entities)
289
                if (is_object($row) && method_exists($row, 'toArray')) {
290 2
                    $row = $row->toArray();
291
                }
292
                // Otherwise, cast as an array and it will grab public properties.
293
                elseif (is_object($row)) {
294 2
                    $row = (array) $row;
295
                }
296
297 2
                $temp  = [];
298 2
                $pairs = [];
299 2
                $out   = $match[1];
300
301
                foreach ($row as $key => $val) {
302
                    // For nested data, send us back through this method...
303
                    if (is_array($val)) {
304 2
                        $pair = $this->parsePair($key, $val, $match[1]);
305
306
                        if (! empty($pair)) {
307 2
                            $pairs[array_keys($pair)[0]] = true;
308
309 2
                            $temp = array_merge($temp, $pair);
310
                        }
311
312 2
                        continue;
313
                    }
314
315
                    if (is_object($val)) {
316 2
                        $val = 'Class: ' . get_class($val);
317
                    } elseif (is_resource($val)) {
318 2
                        $val = 'Resource';
319
                    }
320
321 2
                    $temp['#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#') . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?' . $this->rightDelimiter . '#us'] = $val;
322
                }
323
324
                // Now replace our placeholders with the new content.
325
                foreach ($temp as $pattern => $content) {
326 2
                    $out = $this->replaceSingle($pattern, $content, $out, ! isset($pairs[$pattern]));
327
                }
328
329 2
                $str .= $out;
330
            }
331
332 2
            $escapedMatch = preg_quote($match[0], '#');
333
334 2
            $replace['#' . $escapedMatch . '#us'] = $str;
335
        }
336
337 2
        return $replace;
338
    }
339
340
    /**
341
     * Removes any comments from the file. Comments are wrapped in {# #} symbols:
342
     *
343
     *      {# This is a comment #}
344
     */
345
    protected function parseComments(string $template): string
346
    {
347 4
        return preg_replace('/\{#.*?#\}/us', '', $template);
348
    }
349
350
    /**
351
     * Extracts noparse blocks, inserting a hash in its place so that
352
     * those blocks of the page are not touched by parsing.
353
     */
354
    protected function extractNoparse(string $template): string
355
    {
356 4
        $pattern = '/\{\s*noparse\s*\}(.*?)\{\s*\/noparse\s*\}/ums';
357
358
        /*
359
         * $matches[][0] is the raw match
360
         * $matches[][1] is the contents
361
         */
362
        if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {
363
            foreach ($matches as $match) {
364
                // Create a hash of the contents to insert in its place.
365
                $hash                       = md5($match[1]);
366
                $this->noparseBlocks[$hash] = $match[1];
367
                $template                   = str_replace($match[0], "noparse_{$hash}", $template);
368
            }
369
        }
370
371 4
        return $template;
372
    }
373
374
    /**
375
     * Re-inserts the noparsed contents back into the template.
376
     */
377
    public function insertNoparse(string $template): string
378
    {
379
        foreach ($this->noparseBlocks as $hash => $replace) {
380 4
            $template = str_replace("noparse_{$hash}", $replace, $template);
381
            unset($this->noparseBlocks[$hash]);
382
        }
383
384 4
        return $template;
385
    }
386
387
    /**
388
     * Parses any conditionals in the code, removing blocks that don't
389
     * pass so we don't try to parse it later.
390
     *
391
     * Valid conditionals:
392
     *  - if
393
     *  - elseif
394
     *  - else
395
     */
396
    protected function parseConditionals(string $template): string
397
    {
398 4
        $leftDelimiter  = preg_quote($this->leftConditionalDelimiter, '/');
399 4
        $rightDelimiter = preg_quote($this->rightConditionalDelimiter, '/');
400
401
        $pattern = '/'
402
            . $leftDelimiter
403
            . '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*'
404
            . $rightDelimiter
405 4
            . '/ums';
406
407
        /*
408
         * For each match:
409
         * [0] = raw match `{if var}`
410
         * [1] = conditional `if`
411
         * [2] = condition `do === true`
412
         * [3] = same as [2]
413
         */
414 4
        preg_match_all($pattern, $template, $matches, PREG_SET_ORDER);
415
416
        foreach ($matches as $match) {
417
            // Build the string to replace the `if` statement with.
418
            $condition = $match[2];
419
420
            $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>';
421
            $template  = str_replace($match[0], $statement, $template);
422
        }
423
424
        $template = preg_replace(
425
            '/' . $leftDelimiter . '\s*else\s*' . $rightDelimiter . '/ums',
426
            '<?php else: ?>',
427
            $template
428 4
        );
429
        $template = preg_replace(
430
            '/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums',
431
            '<?php endif; ?>',
432
            $template
433 4
        );
434
435
        // Parse the PHP itself, or insert an error so they can debug
436 4
        ob_start();
437
438
        if ($this->tempData === null) {
439
            $this->tempData = $this->data;
440
        }
441
442 4
        extract($this->tempData);
443
444
        try {
445 4
            eval('?>' . $template . '<?php ');
0 ignored issues
show
introduced by
The use of eval() is discouraged.
Loading history...
446
        } catch (ParseError $e) {
0 ignored issues
show
Unused Code introduced by
catch (\ParseError $e) is not reachable.

This check looks for unreachable code. It uses sophisticated control flow analysis techniques to find statements which will never be executed.

Unreachable code is most often the result of return, die or exit statements that have been added for debug purposes.

function fx() {
    try {
        doSomething();
        return true;
    }
    catch (\Exception $e) {
        return false;
    }

    return false;
}

In the above example, the last return false will never be executed, because a return statement has already been met in every possible execution path.

Loading history...
447
            ob_end_clean();
448
449
            throw ViewException::tagSyntaxError(str_replace(['?>', '<?php '], '', $template));
450
        }
451
452 4
        return ob_get_clean();
453
    }
454
455
    /**
456
     * Over-ride the substitution field delimiters.
457
     */
458
    public function setDelimiters(string $leftDelimiter = '{', string $rightDelimiter = '}'): static
459
    {
460 2
        $this->leftDelimiter  = $leftDelimiter;
461 2
        $this->rightDelimiter = $rightDelimiter;
462
463 2
        return $this;
464
    }
465
466
    /**
467
     * Over-ride the substitution conditional delimiters.
468
     */
469
    public function setConditionalDelimiters(string $leftDelimiter = '{', string $rightDelimiter = '}'): static
470
    {
471
        $this->leftConditionalDelimiter  = $leftDelimiter;
472
        $this->rightConditionalDelimiter = $rightDelimiter;
473
474
        return $this;
475
    }
476
477
    /**
478
     * Handles replacing a pseudo-variable with the actual content. Will double-check
479
     * for escaping brackets.
480
     */
481
    protected function replaceSingle(array|string $pattern, string $content, string $template, bool $escape = false): string
482
    {
483
        // Replace the content in the template
484
        return preg_replace_callback($pattern, function ($matches) use ($content, $escape) {
485
            // Check for {! !} syntax to not escape this one.
486
            if (
487
                str_starts_with($matches[0], $this->leftDelimiter . '!')
488
                && substr($matches[0], -1 - strlen($this->rightDelimiter)) === '!' . $this->rightDelimiter
489
            ) {
490
                $escape = false;
491
            }
492
493 4
            return $this->prepareReplacement($matches, $content, $escape);
494 4
        }, (string) $template);
495
    }
496
497
    /**
498
     * Callback used during parse() to apply any filters to the value.
499
     */
500
    protected function prepareReplacement(array $matches, string $replace, bool $escape = true): string
501
    {
502 4
        $orig = array_shift($matches);
503
504
        // Our regex earlier will leave all chained values on a single line
505
        // so we need to break them apart so we can apply them all.
506 4
        $filters = ! empty($matches[1]) ? explode('|', $matches[1]) : [];
507
508
        if ($escape && empty($filters) && ($context = $this->shouldAddEscaping($orig))) {
509 4
            $filters[] = "esc({$context})";
510
        }
511
512 4
        return $this->applyFilters($replace, $filters);
513
    }
514
515
    /**
516
     * Checks the placeholder the view provided to see if we need to provide any autoescaping.
517
     *
518
     * @return false|string
519
     */
520
    public function shouldAddEscaping(string $key)
521
    {
522 4
        $escape = false;
523
524 4
        $key = trim(str_replace(['{', '}'], '', $key));
525
526
        // If the key has a context stored (from setData)
527
        // we need to respect that.
528
        if (array_key_exists($key, $this->dataContexts)) {
529
            if ($this->dataContexts[$key] !== 'raw') {
530
                return $this->dataContexts[$key];
531
            }
532
        }
533
        // No pipes, then we know we need to escape
534
        elseif (! str_contains($key, '|')) {
535 4
            $escape = 'html';
536
        }
537
        // If there's a `noescape` then we're definitely false.
538
        elseif (str_contains($key, 'noescape')) {
539
            $escape = false;
540
        }
541
        // If no `esc` filter is found, then we'll need to add one.
542
        elseif (! preg_match('/\s+esc/u', $key)) {
543
            $escape = 'html';
544
        }
545
546 4
        return $escape;
547
    }
548
549
    /**
550
     * Given a set of filters, will apply each of the filters in turn
551
     * to $replace, and return the modified string.
552
     */
553
    protected function applyFilters(string $replace, array $filters): string
554
    {
555
        // Determine the requested filters
556
        foreach ($filters as $filter) {
557
            // Grab any parameter we might need to send
558 4
            preg_match('/\([\w<>=\/\\\,:.\-\s\+]+\)/u', $filter, $param);
559
560
            // Remove the () and spaces to we have just the parameter left
561 4
            $param = ! empty($param) ? trim($param[0], '() ') : null;
562
563
            // Params can be separated by commas to allow multiple parameters for the filter
564
            if (! empty($param)) {
565 4
                $param = explode(',', $param);
566
567
                // Clean it up
568
                foreach ($param as &$p) {
569 4
                    $p = trim($p, ' "');
570
                }
571
            } else {
572
                $param = [];
573
            }
574
575
            // Get our filter name
576 4
            $filter = ! empty($param) ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter);
577
578 4
            $this->config['filters'] ??= [];
579
            if (! array_key_exists($filter, $this->config['filters'])) {
580 4
                continue;
581
            }
582
583
            // Filter it....
584
            $replace = $this->config['filters'][$filter]($replace, ...$param);
585
        }
586
587 4
        return (string) $replace;
588
    }
589
590
    // Plugins
591
592
    /**
593
     * Scans the template for any parser plugins, and attempts to execute them.
594
     * Plugins are delimited by {+ ... +}
595
     */
596
    protected function parsePlugins(string $template): string
597
    {
598
        foreach ($this->plugins as $plugin => $callable) {
599
            // Paired tags are enclosed in an array in the config array.
600
            $isPair   = is_array($callable);
601
            $callable = $isPair ? array_shift($callable) : $callable;
602
603
            // See https://regex101.com/r/BCBBKB/1
604
            $pattern = $isPair
605
                ? '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}(.+?)\{\+\s*/' . $plugin . '\s*\+\}#uims'
606
                : '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}#uims';
607
608
            /**
609
             * Match tag pairs
610
             *
611
             * Each match is an array:
612
             *   $matches[0] = entire matched string
613
             *   $matches[1] = all parameters string in opening tag
614
             *   $matches[2] = content between the tags to send to the plugin.
615
             */
616
            if (! preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {
617
                continue;
618
            }
619
620
            foreach ($matches as $match) {
621
                $params = [];
622
623
                preg_match_all('/([\w-]+=\"[^"]+\")|([\w-]+=[^\"\s=]+)|(\"[^"]+\")|(\S+)/u', trim($match[1]), $matchesParams);
624
625
                foreach ($matchesParams[0] as $item) {
626
                    $keyVal = explode('=', $item);
627
628
                    if (count($keyVal) === 2) {
629
                        $params[$keyVal[0]] = str_replace('"', '', $keyVal[1]);
630
                    } else {
631
                        $params[] = str_replace('"', '', $item);
632
                    }
633
                }
634
635
                $template = $isPair
636
                    ? str_replace($match[0], $callable($match[2], $params), $template)
637
                    : str_replace($match[0], $callable($params), $template);
638
            }
639
        }
640
641 4
        return $template;
642
    }
643
644
    /**
645
     * Makes a new plugin available during the parsing of the template.
646
     */
647
    public function addPlugin(string $alias, callable $callback, bool $isPair = false): self
648
    {
649
        $this->plugins[$alias] = $isPair ? [$callback] : $callback;
650
651
        return $this;
652
    }
653
654
    /**
655
     * Removes a plugin from the available plugins.
656
     */
657
    public function removePlugin(string $alias): self
658
    {
659
        unset($this->plugins[$alias]);
660
661
        return $this;
662
    }
663
664
    /**
665
     * Converts an object to an array, respecting any
666
     * toArray() methods on an object.
667
     *
668
     * @param array|bool|float|int|object|string|null $value
669
     *
670
     * @return array|bool|float|int|string|null
671
     */
672
    protected function objectToArray($value)
673
    {
674
        // Objects that have a `toArray()` method should be
675
        // converted with that method (i.e. Entities)
676
        if (is_object($value) && method_exists($value, 'toArray')) {
677
            $value = $value->toArray();
678
        }
679
        // Otherwise, cast as an array and it will grab public properties.
680
        elseif (is_object($value)) {
681
            $value = (array) $value;
682
        }
683
684
        return $value;
685
    }
686
}
687