ci4

Форк
0
/
Parser.php 
749 строк · 23.5 Кб
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;
93

94
        parent::__construct($config, $viewPath, $loader, $debug, $logger);
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);
110
        if ($saveData === null) {
111
            $saveData = $this->config->saveData;
112
        }
113

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

117
        $cacheName = $options['cache_name'] ?? str_replace('.php', '', $view);
118

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

123
            return $output;
124
        }
125

126
        $file = $this->viewPath . $view;
127

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

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

138
        if ($this->tempData === null) {
139
            $this->tempData = $this->data;
140
        }
141

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

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

150
        $output = $this->decorateOutput($output);
151

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

158
        return $output;
159
    }
160

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

178
        if ($this->tempData === null) {
179
            $this->tempData = $this->data;
180
        }
181

182
        $output = $this->parse($template, $this->tempData, $options);
183

184
        $this->logPerformance($start, microtime(true), $this->excerpt($template));
185

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

190
        $this->tempData = null;
191

192
        return $output;
193
    }
194

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

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

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

225
        return $this;
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 === '') {
240
            return '';
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);
246

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

250
        // Replace any conditional code here so we don't have to parse as much
251
        $template = $this->parseConditionals($template);
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);
256

257
        // loop over the data variables, replacing
258
        // the content as we go.
259
        foreach ($data as $key => $val) {
260
            $escape = true;
261

262
            if (is_array($val)) {
263
                $escape  = false;
264
                $replace = $this->parsePair($key, $val, $template);
265
            } else {
266
                $replace = $this->parseSingle($key, (string) $val);
267
            }
268

269
            foreach ($replace as $pattern => $content) {
270
                $template = $this->replaceSingle($pattern, $content, $template, $escape);
271
            }
272
        }
273

274
        return $this->insertNoparse($template);
275
    }
276

277
    /**
278
     * Parse a single key/value, extracting it
279
     *
280
     * @return array<string, string>
281
     */
282
    protected function parseSingle(string $key, string $val): array
283
    {
284
        $pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#')
285
            . '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?'
286
            . $this->rightDelimiter . '#ums';
287

288
        return [$pattern => $val];
289
    }
290

291
    /**
292
     * Parse a tag pair
293
     *
294
     * Parses tag pairs: {some_tag} string... {/some_tag}
295
     *
296
     * @param array<string, mixed> $data
297
     *
298
     * @return array<string, string>
299
     */
300
    protected function parsePair(string $variable, array $data, string $template): array
301
    {
302
        // Holds the replacement patterns and contents
303
        // that will be used within a preg_replace in parse()
304
        $replace = [];
305

306
        // Find all matches of space-flexible versions of {tag}{/tag} so we
307
        // have something to loop over.
308
        preg_match_all(
309
            '#' . $this->leftDelimiter . '\s*' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '(.+?)' .
310
            $this->leftDelimiter . '\s*/' . preg_quote($variable, '#') . '\s*' . $this->rightDelimiter . '#us',
311
            $template,
312
            $matches,
313
            PREG_SET_ORDER
314
        );
315

316
        /*
317
         * Each match looks like:
318
         *
319
         * $match[0] {tag}...{/tag}
320
         * $match[1] Contents inside the tag
321
         */
322
        foreach ($matches as $match) {
323
            // Loop over each piece of $data, replacing
324
            // its contents so that we know what to replace in parse()
325
            $str = '';  // holds the new contents for this tag pair.
326

327
            foreach ($data as $row) {
328
                // Objects that have a `toArray()` method should be
329
                // converted with that method (i.e. Entities)
330
                if (is_object($row) && method_exists($row, 'toArray')) {
331
                    $row = $row->toArray();
332
                }
333
                // Otherwise, cast as an array and it will grab public properties.
334
                elseif (is_object($row)) {
335
                    $row = (array) $row;
336
                }
337

338
                $temp  = [];
339
                $pairs = [];
340
                $out   = $match[1];
341

342
                foreach ($row as $key => $val) {
343
                    // For nested data, send us back through this method...
344
                    if (is_array($val)) {
345
                        $pair = $this->parsePair($key, $val, $match[1]);
346

347
                        if ($pair !== []) {
348
                            $pairs[array_keys($pair)[0]] = true;
349

350
                            $temp = array_merge($temp, $pair);
351
                        }
352

353
                        continue;
354
                    }
355

356
                    if (is_object($val)) {
357
                        $val = 'Class: ' . $val::class;
358
                    } elseif (is_resource($val)) {
359
                        $val = 'Resource';
360
                    }
361

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

365
                // Now replace our placeholders with the new content.
366
                foreach ($temp as $pattern => $content) {
367
                    $out = $this->replaceSingle($pattern, $content, $out, ! isset($pairs[$pattern]));
368
                }
369

370
                $str .= $out;
371
            }
372

373
            $escapedMatch = preg_quote($match[0], '#');
374

375
            $replace['#' . $escapedMatch . '#us'] = $str;
376
        }
377

378
        return $replace;
379
    }
380

381
    /**
382
     * Removes any comments from the file. Comments are wrapped in {# #} symbols:
383
     *
384
     *      {# This is a comment #}
385
     */
386
    protected function parseComments(string $template): string
387
    {
388
        return preg_replace('/\{#.*?#\}/us', '', $template);
389
    }
390

391
    /**
392
     * Extracts noparse blocks, inserting a hash in its place so that
393
     * those blocks of the page are not touched by parsing.
394
     */
395
    protected function extractNoparse(string $template): string
396
    {
397
        $pattern = '/\{\s*noparse\s*\}(.*?)\{\s*\/noparse\s*\}/ums';
398

399
        /*
400
         * $matches[][0] is the raw match
401
         * $matches[][1] is the contents
402
         */
403
        if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {
404
            foreach ($matches as $match) {
405
                // Create a hash of the contents to insert in its place.
406
                $hash                       = md5($match[1]);
407
                $this->noparseBlocks[$hash] = $match[1];
408
                $template                   = str_replace($match[0], "noparse_{$hash}", $template);
409
            }
410
        }
411

412
        return $template;
413
    }
414

415
    /**
416
     * Re-inserts the noparsed contents back into the template.
417
     */
418
    public function insertNoparse(string $template): string
419
    {
420
        foreach ($this->noparseBlocks as $hash => $replace) {
421
            $template = str_replace("noparse_{$hash}", $replace, $template);
422
            unset($this->noparseBlocks[$hash]);
423
        }
424

425
        return $template;
426
    }
427

428
    /**
429
     * Parses any conditionals in the code, removing blocks that don't
430
     * pass so we don't try to parse it later.
431
     *
432
     * Valid conditionals:
433
     *  - if
434
     *  - elseif
435
     *  - else
436
     */
437
    protected function parseConditionals(string $template): string
438
    {
439
        $leftDelimiter  = preg_quote($this->leftConditionalDelimiter, '/');
440
        $rightDelimiter = preg_quote($this->rightConditionalDelimiter, '/');
441

442
        $pattern = '/'
443
            . $leftDelimiter
444
            . '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*'
445
            . $rightDelimiter
446
            . '/ums';
447

448
        /*
449
         * For each match:
450
         * [0] = raw match `{if var}`
451
         * [1] = conditional `if`
452
         * [2] = condition `do === true`
453
         * [3] = same as [2]
454
         */
455
        preg_match_all($pattern, $template, $matches, PREG_SET_ORDER);
456

457
        foreach ($matches as $match) {
458
            // Build the string to replace the `if` statement with.
459
            $condition = $match[2];
460

461
            $statement = $match[1] === 'elseif' ? '<?php elseif (' . $condition . '): ?>' : '<?php if (' . $condition . '): ?>';
462
            $template  = str_replace($match[0], $statement, $template);
463
        }
464

465
        $template = preg_replace(
466
            '/' . $leftDelimiter . '\s*else\s*' . $rightDelimiter . '/ums',
467
            '<?php else: ?>',
468
            $template
469
        );
470
        $template = preg_replace(
471
            '/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums',
472
            '<?php endif; ?>',
473
            $template
474
        );
475

476
        // Parse the PHP itself, or insert an error so they can debug
477
        ob_start();
478

479
        if ($this->tempData === null) {
480
            $this->tempData = $this->data;
481
        }
482

483
        extract($this->tempData);
484

485
        try {
486
            eval('?>' . $template . '<?php ');
487
        } catch (ParseError) {
488
            ob_end_clean();
489

490
            throw ViewException::forTagSyntaxError(str_replace(['?>', '<?php '], '', $template));
491
        }
492

493
        return ob_get_clean();
494
    }
495

496
    /**
497
     * Over-ride the substitution field delimiters.
498
     *
499
     * @param string $leftDelimiter
500
     * @param string $rightDelimiter
501
     */
502
    public function setDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface
503
    {
504
        $this->leftDelimiter  = $leftDelimiter;
505
        $this->rightDelimiter = $rightDelimiter;
506

507
        return $this;
508
    }
509

510
    /**
511
     * Over-ride the substitution conditional delimiters.
512
     *
513
     * @param string $leftDelimiter
514
     * @param string $rightDelimiter
515
     */
516
    public function setConditionalDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface
517
    {
518
        $this->leftConditionalDelimiter  = $leftDelimiter;
519
        $this->rightConditionalDelimiter = $rightDelimiter;
520

521
        return $this;
522
    }
523

524
    /**
525
     * Handles replacing a pseudo-variable with the actual content. Will double-check
526
     * for escaping brackets.
527
     *
528
     * @param array|string $pattern
529
     * @param string       $content
530
     * @param string       $template
531
     */
532
    protected function replaceSingle($pattern, $content, $template, bool $escape = false): string
533
    {
534
        $content = (string) $content;
535

536
        // Replace the content in the template
537
        return preg_replace_callback($pattern, function ($matches) use ($content, $escape) {
538
            // Check for {! !} syntax to not escape this one.
539
            if (
540
                str_starts_with($matches[0], $this->leftDelimiter . '!')
541
                && substr($matches[0], -1 - strlen($this->rightDelimiter)) === '!' . $this->rightDelimiter
542
            ) {
543
                $escape = false;
544
            }
545

546
            return $this->prepareReplacement($matches, $content, $escape);
547
        }, (string) $template);
548
    }
549

550
    /**
551
     * Callback used during parse() to apply any filters to the value.
552
     *
553
     * @param list<string> $matches
554
     */
555
    protected function prepareReplacement(array $matches, string $replace, bool $escape = true): string
556
    {
557
        $orig = array_shift($matches);
558

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

563
        if ($escape && $filters === [] && ($context = $this->shouldAddEscaping($orig))) {
564
            $filters[] = "esc({$context})";
565
        }
566

567
        return $this->applyFilters($replace, $filters);
568
    }
569

570
    /**
571
     * Checks the placeholder the view provided to see if we need to provide any autoescaping.
572
     *
573
     * @return false|string
574
     */
575
    public function shouldAddEscaping(string $key)
576
    {
577
        $escape = false;
578

579
        $key = trim(str_replace(['{', '}'], '', $key));
580

581
        // If the key has a context stored (from setData)
582
        // we need to respect that.
583
        if (array_key_exists($key, $this->dataContexts)) {
584
            if ($this->dataContexts[$key] !== 'raw') {
585
                return $this->dataContexts[$key];
586
            }
587
        }
588
        // No pipes, then we know we need to escape
589
        elseif (! str_contains($key, '|')) {
590
            $escape = 'html';
591
        }
592
        // If there's a `noescape` then we're definitely false.
593
        elseif (str_contains($key, 'noescape')) {
594
            $escape = false;
595
        }
596
        // If no `esc` filter is found, then we'll need to add one.
597
        elseif (! preg_match('/\s+esc/u', $key)) {
598
            $escape = 'html';
599
        }
600

601
        return $escape;
602
    }
603

604
    /**
605
     * Given a set of filters, will apply each of the filters in turn
606
     * to $replace, and return the modified string.
607
     *
608
     * @param list<string> $filters
609
     */
610
    protected function applyFilters(string $replace, array $filters): string
611
    {
612
        // Determine the requested filters
613
        foreach ($filters as $filter) {
614
            // Grab any parameter we might need to send
615
            preg_match('/\([\w<>=\/\\\,:.\-\s\+]+\)/u', $filter, $param);
616

617
            // Remove the () and spaces to we have just the parameter left
618
            $param = ($param !== []) ? trim($param[0], '() ') : null;
619

620
            // Params can be separated by commas to allow multiple parameters for the filter
621
            if ($param !== null && $param !== '') {
622
                $param = explode(',', $param);
623

624
                // Clean it up
625
                foreach ($param as &$p) {
626
                    $p = trim($p, ' "');
627
                }
628
            } else {
629
                $param = [];
630
            }
631

632
            // Get our filter name
633
            $filter = $param !== [] ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter);
634

635
            if (! array_key_exists($filter, $this->config->filters)) {
636
                continue;
637
            }
638

639
            // Filter it....
640
            // We can't know correct param types, so can't set `declare(strict_types=1)`.
641
            $replace = $this->config->filters[$filter]($replace, ...$param);
642
        }
643

644
        return (string) $replace;
645
    }
646

647
    // Plugins
648

649
    /**
650
     * Scans the template for any parser plugins, and attempts to execute them.
651
     * Plugins are delimited by {+ ... +}
652
     *
653
     * @return string
654
     */
655
    protected function parsePlugins(string $template)
656
    {
657
        foreach ($this->plugins as $plugin => $callable) {
658
            // Paired tags are enclosed in an array in the config array.
659
            $isPair   = is_array($callable);
660
            $callable = $isPair ? array_shift($callable) : $callable;
661

662
            // See https://regex101.com/r/BCBBKB/1
663
            $pattern = $isPair
664
                ? '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}(.+?)\{\+\s*/' . $plugin . '\s*\+\}#uims'
665
                : '#\{\+\s*' . $plugin . '([\w=\-_:\+\s\(\)/"@.]*)?\s*\+\}#uims';
666

667
            /**
668
             * Match tag pairs
669
             *
670
             * Each match is an array:
671
             *   $matches[0] = entire matched string
672
             *   $matches[1] = all parameters string in opening tag
673
             *   $matches[2] = content between the tags to send to the plugin.
674
             */
675
            if (! preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {
676
                continue;
677
            }
678

679
            foreach ($matches as $match) {
680
                $params = [];
681

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

684
                foreach ($matchesParams[0] as $item) {
685
                    $keyVal = explode('=', $item);
686

687
                    if (count($keyVal) === 2) {
688
                        $params[$keyVal[0]] = str_replace('"', '', $keyVal[1]);
689
                    } else {
690
                        $params[] = str_replace('"', '', $item);
691
                    }
692
                }
693

694
                $template = $isPair
695
                    ? str_replace($match[0], $callable($match[2], $params), $template)
696
                    : str_replace($match[0], $callable($params), $template);
697
            }
698
        }
699

700
        return $template;
701
    }
702

703
    /**
704
     * Makes a new plugin available during the parsing of the template.
705
     *
706
     * @return $this
707
     */
708
    public function addPlugin(string $alias, callable $callback, bool $isPair = false)
709
    {
710
        $this->plugins[$alias] = $isPair ? [$callback] : $callback;
711

712
        return $this;
713
    }
714

715
    /**
716
     * Removes a plugin from the available plugins.
717
     *
718
     * @return $this
719
     */
720
    public function removePlugin(string $alias)
721
    {
722
        unset($this->plugins[$alias]);
723

724
        return $this;
725
    }
726

727
    /**
728
     * Converts an object to an array, respecting any
729
     * toArray() methods on an object.
730
     *
731
     * @param array<string, mixed>|bool|float|int|object|string|null $value
732
     *
733
     * @return array<string, mixed>|bool|float|int|string|null
734
     */
735
    protected function objectToArray($value)
736
    {
737
        // Objects that have a `toArray()` method should be
738
        // converted with that method (i.e. Entities)
739
        if (is_object($value) && method_exists($value, 'toArray')) {
740
            $value = $value->toArray();
741
        }
742
        // Otherwise, cast as an array and it will grab public properties.
743
        elseif (is_object($value)) {
744
            $value = (array) $value;
745
        }
746

747
        return $value;
748
    }
749
}
750

Использование cookies

Мы используем файлы cookie в соответствии с Политикой конфиденциальности и Политикой использования cookies.

Нажимая кнопку «Принимаю», Вы даете АО «СберТех» согласие на обработку Ваших персональных данных в целях совершенствования нашего веб-сайта и Сервиса GitVerse, а также повышения удобства их использования.

Запретить использование cookies Вы можете самостоятельно в настройках Вашего браузера.