Parser::parsePair()   C
last analyzed

Complexity

Conditions 12
Paths 38

Size

Total Lines 79
Code Lines 35

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 156

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 12
eloc 35
nc 38
nop 3
dl 0
loc 79
ccs 0
cts 20
cp 0
crap 156
rs 6.9666
c 1
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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\Exceptions\ViewException;
15
use BlitzPHP\View\Adapters\NativeAdapter;
16
use ParseError;
17
18
/**
19
 * Class for parsing pseudo-vars
20
 */
21
class Parser extends NativeAdapter
22
{
23
    /**
24
     * Left delimiter character for pseudo vars
25
     */
26
    public string $leftDelimiter = '{';
27
28
    /**
29
     * Right delimiter character for pseudo vars
30
     */
31
    public string $rightDelimiter = '}';
32
33
    /**
34
     * Left delimiter characters for conditionals
35
     */
36
    protected string $leftConditionalDelimiter = '{';
37
38
    /**
39
     * Right delimiter characters for conditionals
40
     */
41
    protected string $rightConditionalDelimiter = '}';
42
43
    /**
44
     * Stores extracted noparse blocks.
45
     */
46
    protected array $noparseBlocks = [];
47
48
    /**
49
     * Stores any plugins registered at run-time.
50
     *
51
     * @var array<string, callable|list<string>|string>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, callable|list<string>|string> at position 6 could not be parsed: Expected '>' at position 6, but found 'list'.
Loading history...
52
     */
53
    protected array $plugins = [];
54
55
    /**
56
     * Stores the context for each data element
57
     * when set by `setData` so the context is respected.
58
     */
59
    protected array $dataContexts = [];
60
61
    /**
62
     * {@inheritDoc}
63
     */
64
    public function __construct(protected array $config, $viewPathLocator = null, protected bool $debug = BLITZ_DEBUG)
65
    {
66
        // Ensure user plugins override core plugins.
67
        $this->plugins = $config['plugins'] ?? [];
68
69
        parent::__construct($config, $viewPathLocator, $debug);
70
    }
71
72
    /**
73
     * Parse a template
74
     *
75
     * Parses pseudo-variables contained in the specified template view,
76
     * replacing them with any data that has already been set.
77
     */
78
    public function render(string $view, ?array $options = null, ?bool $saveData = null): string
79
    {
80
        $start = microtime(true);
81
        if ($saveData === null) {
82
            $saveData = $this->saveData;
83
        }
84
85
        if ('' === $ext = pathinfo($view, PATHINFO_EXTENSION)) {
86
            $view .= '.php';
87
            $ext = 'php';
88
        }
89
90
        $cacheName = $options['cache_name'] ?? str_replace('.' . $ext, '', $view);
0 ignored issues
show
Bug introduced by
Are you sure $ext of type array|string can be used in concatenation? ( Ignorable by Annotation )

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

90
        $cacheName = $options['cache_name'] ?? str_replace('.' . /** @scrutinizer ignore-type */ $ext, '', $view);
Loading history...
91
92
        // Was it cached?
93
        if (isset($options['cache']) && ($output = cache($cacheName))) {
94
            $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

94
            $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

94
            $this->logPerformance($start, /** @scrutinizer ignore-type */ microtime(true), $view);
Loading history...
95
96
            return $output;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $output also could return the type BlitzPHP\Cache\Cache|true which is incompatible with the return type mandated by BlitzPHP\View\Adapters\NativeAdapter::render() of string.
Loading history...
97
        }
98
99
        $file = $this->viewPath . $view;
100
101
        if (! is_file($file)) {
102
            $fileOrig = $file;
103
            $file     = $this->locator->locateFile($view, 'Views', $ext);
0 ignored issues
show
Bug introduced by
It seems like $ext can also be of type array; however, parameter $ext of BlitzPHP\Contracts\Autol...Interface::locateFile() does only seem to accept string, 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

103
            $file     = $this->locator->locateFile($view, 'Views', /** @scrutinizer ignore-type */ $ext);
Loading history...
104
105
            // locateFile will return an empty string if the file cannot be found.
106
            if (empty($file)) {
107
                throw ViewException::invalidFile($fileOrig);
108
            }
109
        }
110
111
        if ($this->tempData === null) {
112
            $this->tempData = $this->data;
113
        }
114
115
        $template = file_get_contents($file);
116
        $output   = $this->parse($template, $this->tempData, $options);
117
        $this->logPerformance($start, microtime(true), $view);
118
119
        if ($saveData) {
120
            $this->data = $this->tempData;
121
        }
122
123
        $output = $this->decorate($output);
124
125
        // Should we cache?
126
        if (isset($options['cache'])) {
127
            cache()->save($cacheName, $output, (int) $options['cache']);
128
        }
129
        $this->tempData = null;
130
131
        return $output;
132
    }
133
134
    /**
135
     * Parse a String
136
     *
137
     * Parses pseudo-variables contained in the specified string,
138
     * replacing them with any data that has already been set.
139
     */
140
    public function renderString(string $template, ?array $options = null, ?bool $saveData = null): string
141
    {
142
        $start = microtime(true);
143
        if ($saveData === null) {
144
            $saveData = $this->saveData;
145
        }
146
147
        if ($this->tempData === null) {
148
            $this->tempData = $this->data;
149
        }
150
151
        $output = $this->parse($template, $this->tempData, $options);
152
153
        $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

153
        $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

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

203
    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...
204
    {
205
        if ($template === '') {
206
            return '';
207
        }
208
209
        // Remove any possible PHP tags since we don't support it
210
        // and parseConditionals needs it clean anyway...
211
        $template = str_replace(['<?', '?>'], ['&lt;?', '?&gt;'], $template);
212
213
        $template = $this->parseComments($template);
214
        $template = $this->extractNoparse($template);
215
216
        // Replace any conditional code here so we don't have to parse as much
217
        $template = $this->parseConditionals($template);
218
219
        // Handle any plugins before normal data, so that
220
        // it can potentially modify any template between its tags.
221
        $template = $this->parsePlugins($template);
222
223
        // Parse stack for each parse type (Single and Pairs)
224
        $replaceSingleStack = [];
225
        $replacePairsStack  = [];
226
227
        // loop over the data variables, saving regex and data
228
        // for later replacement.
229
        foreach ($data as $key => $val) {
230
            $escape = true;
231
232
            if (is_array($val)) {
233
                $escape              = false;
234
                $replacePairsStack[] = [
235
                    'replace' => $this->parsePair($key, $val, $template),
236
                    'escape'  => $escape,
237
                ];
238
            } else {
239
                $replaceSingleStack[] = [
240
                    'replace' => $this->parseSingle($key, (string) $val),
241
                    'escape'  => $escape,
242
                ];
243
            }
244
        }
245
246
        // Merge both stacks, pairs first + single stacks
247
        // This allows for nested data with the same key to be replaced properly
248
        $replace = array_merge($replacePairsStack, $replaceSingleStack);
249
250
        // Loop over each replace array item which
251
        // holds all the data to be replaced
252
        foreach ($replace as $replaceItem) {
253
            // Loop over the actual data to be replaced
254
            foreach ($replaceItem['replace'] as $pattern => $content) {
255
                $template = $this->replaceSingle($pattern, $content, $template, $replaceItem['escape']);
256
            }
257
        }
258
259
        return $this->insertNoparse($template);
260
    }
261
262
    /**
263
     * Parse a single key/value, extracting it
264
     */
265
    protected function parseSingle(string $key, string $val): array
266
    {
267
        $pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#')
268
            . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?'
269
            . $this->rightDelimiter . '#ums';
270
271
        return [$pattern => $val];
272
    }
273
274
    /**
275
     * Parse a tag pair
276
     *
277
     * Parses tag pairs: {some_tag} string... {/some_tag}
278
     */
279
    protected function parsePair(string $variable, array $data, string $template): array
280
    {
281
        // Holds the replacement patterns and contents
282
        // that will be used within a preg_replace in parse()
283
        $replace = [];
284
285
        // Find all matches of space-flexible versions of {tag}{/tag} so we
286
        // have something to loop over.
287
        preg_match_all(
288
            '#' . $this->leftDelimiter . '\s*' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '(.+?)' .
289
            $this->leftDelimiter . '\s*/' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '#us',
290
            $template,
291
            $matches,
292
            PREG_SET_ORDER
293
        );
294
295
        /*
296
         * Each match looks like:
297
         *
298
         * $match[0] {tag}...{/tag}
299
         * $match[1] Contents inside the tag
300
         */
301
        foreach ($matches as $match) {
302
            // Loop over each piece of $data, replacing
303
            // its contents so that we know what to replace in parse()
304
            $str = '';  // holds the new contents for this tag pair.
305
306
            foreach ($data as $row) {
307
                // Objects that have a `toArray()` method should be
308
                // converted with that method (i.e. Entities)
309
                if (is_object($row) && method_exists($row, 'toArray')) {
310
                    $row = $row->toArray();
311
                }
312
                // Otherwise, cast as an array and it will grab public properties.
313
                elseif (is_object($row)) {
314
                    $row = (array) $row;
315
                }
316
317
                $temp  = [];
318
                $pairs = [];
319
                $out   = $match[1];
320
321
                foreach ($row as $key => $val) {
322
                    // For nested data, send us back through this method...
323
                    if (is_array($val)) {
324
                        $pair = $this->parsePair($key, $val, $match[1]);
325
326
                        if ($pair !== []) {
327
                            $pairs[array_keys($pair)[0]] = true;
328
329
                            $temp = array_merge($temp, $pair);
330
                        }
331
332
                        continue;
333
                    }
334
335
                    if (is_object($val)) {
336
                        $val = 'Class: ' . $val::class;
337
                    } elseif (is_resource($val)) {
338
                        $val = 'Resource';
339
                    }
340
341
                    $temp['#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#') . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?' . $this->rightDelimiter . '#us'] = $val;
342
                }
343
344
                // Now replace our placeholders with the new content.
345
                foreach ($temp as $pattern => $content) {
346
                    $out = $this->replaceSingle($pattern, $content, $out, ! isset($pairs[$pattern]));
347
                }
348
349
                $str .= $out;
350
            }
351
352
            $escapedMatch = preg_quote($match[0], '#');
353
354
            $replace['#' . $escapedMatch . '#us'] = $str;
355
        }
356
357
        return $replace;
358
    }
359
360
    /**
361
     * Removes any comments from the file. Comments are wrapped in {# #} symbols:
362
     *
363
     *      {# This is a comment #}
364
     */
365
    protected function parseComments(string $template): string
366
    {
367
        return preg_replace('/\{#.*?#\}/us', '', $template);
368
    }
369
370
    /**
371
     * Extracts noparse blocks, inserting a hash in its place so that
372
     * those blocks of the page are not touched by parsing.
373
     */
374
    protected function extractNoparse(string $template): string
375
    {
376
        $pattern = '/\{\s*noparse\s*\}(.*?)\{\s*\/noparse\s*\}/ums';
377
378
        /*
379
         * $matches[][0] is the raw match
380
         * $matches[][1] is the contents
381
         */
382
        if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {
383
            foreach ($matches as $match) {
384
                // Create a hash of the contents to insert in its place.
385
                $hash                       = md5($match[1]);
386
                $this->noparseBlocks[$hash] = $match[1];
387
                $template                   = str_replace($match[0], "noparse_{$hash}", $template);
388
            }
389
        }
390
391
        return $template;
392
    }
393
394
    /**
395
     * Re-inserts the noparsed contents back into the template.
396
     */
397
    public function insertNoparse(string $template): string
398
    {
399
        foreach ($this->noparseBlocks as $hash => $replace) {
400
            $template = str_replace("noparse_{$hash}", $replace, $template);
401
            unset($this->noparseBlocks[$hash]);
402
        }
403
404
        return $template;
405
    }
406
407
    /**
408
     * Parses any conditionals in the code, removing blocks that don't
409
     * pass so we don't try to parse it later.
410
     *
411
     * Valid conditionals:
412
     *  - if
413
     *  - elseif
414
     *  - else
415
     */
416
    protected function parseConditionals(string $template): string
417
    {
418
        $leftDelimiter  = preg_quote($this->leftConditionalDelimiter, '/');
419
        $rightDelimiter = preg_quote($this->rightConditionalDelimiter, '/');
420
421
        $pattern = '/'
422
            . $leftDelimiter
423
            . '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*'
424
            . $rightDelimiter
425
            . '/ums';
426
427
        /*
428
         * For each match:
429
         * [0] = raw match `{if var}`
430
         * [1] = conditional `if`
431
         * [2] = condition `do === true`
432
         * [3] = same as [2]
433
         */
434
        preg_match_all($pattern, $template, $matches, PREG_SET_ORDER);
435
436
        foreach ($matches as $match) {
437
            // Build the string to replace the `if` statement with.
438
            $condition = $match[2];
439
440
            $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>';
441
            $template  = str_replace($match[0], $statement, $template);
442
        }
443
444
        $template = preg_replace(
445
            '/' . $leftDelimiter . '\s*else\s*' . $rightDelimiter . '/ums',
446
            '<?php else: ?>',
447
            $template
448
        );
449
        $template = preg_replace(
450
            '/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums',
451
            '<?php endif; ?>',
452
            $template
453
        );
454
455
        // Parse the PHP itself, or insert an error so they can debug
456
        ob_start();
457
458
        if ($this->tempData === null) {
459
            $this->tempData = $this->data;
460
        }
461
462
        extract($this->tempData);
463
464
        try {
465
            eval('?>' . $template . '<?php ');
0 ignored issues
show
introduced by
The use of eval() is discouraged.
Loading history...
466
        } catch (ParseError) {
0 ignored issues
show
Unused Code introduced by
catch (\ParseError) 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...
467
            ob_end_clean();
468
469
            throw ViewException::tagSyntaxError(str_replace(['?>', '<?php '], '', $template));
470
        }
471
472
        return ob_get_clean();
473
    }
474
475
    /**
476
     * Over-ride the substitution field delimiters.
477
     */
478
    public function setDelimiters(string $leftDelimiter = '{', string $rightDelimiter = '}'): static
479
    {
480
        $this->leftDelimiter  = $leftDelimiter;
481
        $this->rightDelimiter = $rightDelimiter;
482
483
        return $this;
484
    }
485
486
    /**
487
     * Over-ride the substitution conditional delimiters.
488
     */
489
    public function setConditionalDelimiters(string $leftDelimiter = '{', string $rightDelimiter = '}'): static
490
    {
491
        $this->leftConditionalDelimiter  = $leftDelimiter;
492
        $this->rightConditionalDelimiter = $rightDelimiter;
493
494
        return $this;
495
    }
496
497
    /**
498
     * Handles replacing a pseudo-variable with the actual content. Will double-check
499
     * for escaping brackets.
500
     */
501
    protected function replaceSingle(array|string $pattern, string $content, string $template, bool $escape = false): string
502
    {
503
        // Replace the content in the template
504
        return preg_replace_callback($pattern, function ($matches) use ($content, $escape): string {
505
            // Check for {! !} syntax to not escape this one.
506
            if (
507
                str_starts_with($matches[0], $this->leftDelimiter . '!')
508
                && substr($matches[0], -1 - strlen($this->rightDelimiter)) === '!' . $this->rightDelimiter
509
            ) {
510
                $escape = false;
511
            }
512
513
            return $this->prepareReplacement($matches, $content, $escape);
514
        }, $template);
515
    }
516
517
    /**
518
     * Callback used during parse() to apply any filters to the value.
519
     */
520
    protected function prepareReplacement(array $matches, string $replace, bool $escape = true): string
521
    {
522
        $orig = array_shift($matches);
523
524
        // Our regex earlier will leave all chained values on a single line
525
        // so we need to break them apart so we can apply them all.
526
        $filters = ! empty($matches[1]) ? explode('|', $matches[1]) : [];
527
528
        if ($escape && $filters === [] && ($context = $this->shouldAddEscaping($orig))) {
529
            $filters[] = "esc({$context})";
530
        }
531
532
        return $this->applyFilters($replace, $filters);
533
    }
534
535
    /**
536
     * Checks the placeholder the view provided to see if we need to provide any autoescaping.
537
     *
538
     * @return false|string
539
     */
540
    public function shouldAddEscaping(string $key)
541
    {
542
        $escape = false;
543
544
        $key = trim(str_replace(['{', '}'], '', $key));
545
546
        // If the key has a context stored (from setData)
547
        // we need to respect that.
548
        if (array_key_exists($key, $this->dataContexts)) {
549
            if ($this->dataContexts[$key] !== 'raw') {
550
                return $this->dataContexts[$key];
551
            }
552
        }
553
        // No pipes, then we know we need to escape
554
        elseif (! str_contains($key, '|')) {
555
            $escape = 'html';
556
        }
557
        // If there's a `noescape` then we're definitely false.
558
        elseif (str_contains($key, 'noescape')) {
559
            $escape = false;
560
        }
561
        // If no `esc` filter is found, then we'll need to add one.
562
        elseif (! preg_match('/\s+esc/u', $key)) {
563
            $escape = 'html';
564
        }
565
566
        return $escape;
567
    }
568
569
    /**
570
     * Given a set of filters, will apply each of the filters in turn
571
     * to $replace, and return the modified string.
572
     */
573
    protected function applyFilters(string $replace, array $filters): string
574
    {
575
        // Determine the requested filters
576
        foreach ($filters as $filter) {
577
            // Grab any parameter we might need to send
578
            preg_match('/\([\w<>=\/\\\,:.\-\s\+]+\)/u', $filter, $param);
579
580
            // Remove the () and spaces to we have just the parameter left
581
            $param = $param !== [] ? trim($param[0], '() ') : null;
582
583
            // Params can be separated by commas to allow multiple parameters for the filter
584
            if ($param !== null && $param !== '' && $param !== '0') {
585
                $param = explode(',', $param);
586
587
                // Clean it up
588
                foreach ($param as &$p) {
589
                    $p = trim($p, ' "');
590
                }
591
            } else {
592
                $param = [];
593
            }
594
595
            // Get our filter name
596
            $filter = $param !== [] ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter);
597
598
            $this->config['filters'] ??= [];
599
            if (! array_key_exists($filter, $this->config['filters'])) {
600
                continue;
601
            }
602
603
            // Filter it....
604
            $replace = $this->config['filters'][$filter]($replace, ...$param);
605
        }
606
607
        return (string) $replace;
608
    }
609
610
    // Plugins
611
612
    /**
613
     * Scans the template for any parser plugins, and attempts to execute them.
614
     * Plugins are delimited by {+ ... +}
615
     */
616
    protected function parsePlugins(string $template): string
617
    {
618
        foreach ($this->plugins as $plugin => $callable) {
619
            // Paired tags are enclosed in an array in the config array.
620
            $isPair   = is_array($callable);
621
            $callable = $isPair ? array_shift($callable) : $callable;
622
623
            // See https://regex101.com/r/BCBBKB/1
624
            $pattern = $isPair
625
                ? '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}(.+?)\{\+\s*/' . $plugin . '\s*\+\}#uims'
626
                : '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}#uims';
627
628
            /**
629
             * Match tag pairs
630
             *
631
             * Each match is an array:
632
             *   $matches[0] = entire matched string
633
             *   $matches[1] = all parameters string in opening tag
634
             *   $matches[2] = content between the tags to send to the plugin.
635
             */
636
            if (! preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {
637
                continue;
638
            }
639
640
            foreach ($matches as $match) {
641
                $params = [];
642
643
                preg_match_all('/([\w-]+=\"[^"]+\")|([\w-]+=[^\"\s=]+)|(\"[^"]+\")|(\S+)/u', trim($match[1]), $matchesParams);
644
645
                foreach ($matchesParams[0] as $item) {
646
                    $keyVal = explode('=', $item);
647
648
                    if (count($keyVal) === 2) {
649
                        $params[$keyVal[0]] = str_replace('"', '', $keyVal[1]);
650
                    } else {
651
                        $params[] = str_replace('"', '', $item);
652
                    }
653
                }
654
655
                $template = $isPair
656
                    ? str_replace($match[0], $callable($match[2], $params), $template)
657
                    : str_replace($match[0], $callable($params), $template);
658
            }
659
        }
660
661
        return $template;
662
    }
663
664
    /**
665
     * Makes a new plugin available during the parsing of the template.
666
     */
667
    public function addPlugin(string $alias, callable $callback, bool $isPair = false): self
668
    {
669
        $this->plugins[$alias] = $isPair ? [$callback] : $callback;
670
671
        return $this;
672
    }
673
674
    /**
675
     * Removes a plugin from the available plugins.
676
     */
677
    public function removePlugin(string $alias): self
678
    {
679
        unset($this->plugins[$alias]);
680
681
        return $this;
682
    }
683
684
    /**
685
     * Converts an object to an array, respecting any
686
     * toArray() methods on an object.
687
     *
688
     * @param array|bool|float|int|object|string|null $value
689
     *
690
     * @return array|bool|float|int|string|null
691
     */
692
    protected function objectToArray($value)
693
    {
694
        // Objects that have a `toArray()` method should be
695
        // converted with that method (i.e. Entities)
696
        if (is_object($value) && method_exists($value, 'toArray')) {
697
            $value = $value->toArray();
698
        }
699
        // Otherwise, cast as an array and it will grab public properties.
700
        elseif (is_object($value)) {
701
            $value = (array) $value;
702
        }
703
704
        return $value;
705
    }
706
}
707