ci4
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
12namespace CodeIgniter\View;13
14use CodeIgniter\Autoloader\FileLocatorInterface;15use CodeIgniter\View\Exceptions\ViewException;16use Config\View as ViewConfig;17use ParseError;18use 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*/
28class Parser extends View29{
30use ViewDecoratorTrait;31
32/**33* Left delimiter character for pseudo vars
34*
35* @var string
36*/
37public $leftDelimiter = '{';38
39/**40* Right delimiter character for pseudo vars
41*
42* @var string
43*/
44public $rightDelimiter = '}';45
46/**47* Left delimiter characters for conditionals
48*/
49protected string $leftConditionalDelimiter = '{';50
51/**52* Right delimiter characters for conditionals
53*/
54protected string $rightConditionalDelimiter = '}';55
56/**57* Stores extracted noparse blocks.
58*
59* @var list<string>
60*/
61protected $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*/
69protected $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*/
77protected $dataContexts = [];78
79/**80* Constructor
81*
82* @param FileLocatorInterface|null $loader
83*/
84public function __construct(85ViewConfig $config,86?string $viewPath = null,87$loader = null,88?bool $debug = null,89?LoggerInterface $logger = null90) {91// Ensure user plugins override core plugins.92$this->plugins = $config->plugins;93
94parent::__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*/
107public function render(string $view, ?array $options = null, ?bool $saveData = null): string108{109$start = microtime(true);110if ($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?120if (isset($options['cache']) && ($output = cache($cacheName))) {121$this->logPerformance($start, microtime(true), $view);122
123return $output;124}125
126$file = $this->viewPath . $view;127
128if (! 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.133if ($file === false) {134throw ViewException::forInvalidFile($fileOrig);135}136}137
138if ($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
146if ($saveData) {147$this->data = $this->tempData;148}149
150$output = $this->decorateOutput($output);151
152// Should we cache?153if (isset($options['cache'])) {154cache()->save($cacheName, $output, (int) $options['cache']);155}156$this->tempData = null;157
158return $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*/
171public function renderString(string $template, ?array $options = null, ?bool $saveData = null): string172{173$start = microtime(true);174if ($saveData === null) {175$saveData = $this->config->saveData;176}177
178if ($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
186if ($saveData) {187$this->data = $this->tempData;188}189
190$this->tempData = null;191
192return $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*/
206public function setData(array $data = [], ?string $context = null): RendererInterface207{208if ($context !== null && $context !== '') {209foreach ($data as $key => &$value) {210if (is_array($value)) {211foreach ($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
225return $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*/
237protected function parse(string $template, array $data = [], ?array $options = null): string238{239if ($template === '') {240return '';241}242
243// Remove any possible PHP tags since we don't support it244// and parseConditionals needs it clean anyway...245$template = str_replace(['<?', '?>'], ['<?', '?>'], $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 much251$template = $this->parseConditionals($template);252
253// Handle any plugins before normal data, so that254// it can potentially modify any template between its tags.255$template = $this->parsePlugins($template);256
257// loop over the data variables, replacing258// the content as we go.259foreach ($data as $key => $val) {260$escape = true;261
262if (is_array($val)) {263$escape = false;264$replace = $this->parsePair($key, $val, $template);265} else {266$replace = $this->parseSingle($key, (string) $val);267}268
269foreach ($replace as $pattern => $content) {270$template = $this->replaceSingle($pattern, $content, $template, $escape);271}272}273
274return $this->insertNoparse($template);275}276
277/**278* Parse a single key/value, extracting it
279*
280* @return array<string, string>
281*/
282protected function parseSingle(string $key, string $val): array283{284$pattern = '#' . $this->leftDelimiter . '!?\s*' . preg_quote($key, '#')285. '(?(?=\s*\|\s*)(\s*\|*\s*([|\w<>=\(\),:.\-\s\+\\\\/]+)*\s*))(\s*)!?'286. $this->rightDelimiter . '#ums';287
288return [$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*/
300protected function parsePair(string $variable, array $data, string $template): array301{302// Holds the replacement patterns and contents303// 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 we307// have something to loop over.308preg_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,313PREG_SET_ORDER314);315
316/*317* Each match looks like:
318*
319* $match[0] {tag}...{/tag}
320* $match[1] Contents inside the tag
321*/
322foreach ($matches as $match) {323// Loop over each piece of $data, replacing324// its contents so that we know what to replace in parse()325$str = ''; // holds the new contents for this tag pair.326
327foreach ($data as $row) {328// Objects that have a `toArray()` method should be329// converted with that method (i.e. Entities)330if (is_object($row) && method_exists($row, 'toArray')) {331$row = $row->toArray();332}333// Otherwise, cast as an array and it will grab public properties.334elseif (is_object($row)) {335$row = (array) $row;336}337
338$temp = [];339$pairs = [];340$out = $match[1];341
342foreach ($row as $key => $val) {343// For nested data, send us back through this method...344if (is_array($val)) {345$pair = $this->parsePair($key, $val, $match[1]);346
347if ($pair !== []) {348$pairs[array_keys($pair)[0]] = true;349
350$temp = array_merge($temp, $pair);351}352
353continue;354}355
356if (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.366foreach ($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
378return $replace;379}380
381/**382* Removes any comments from the file. Comments are wrapped in {# #} symbols:
383*
384* {# This is a comment #}
385*/
386protected function parseComments(string $template): string387{388return 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*/
395protected function extractNoparse(string $template): string396{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*/
403if (preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {404foreach ($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
412return $template;413}414
415/**416* Re-inserts the noparsed contents back into the template.
417*/
418public function insertNoparse(string $template): string419{420foreach ($this->noparseBlocks as $hash => $replace) {421$template = str_replace("noparse_{$hash}", $replace, $template);422unset($this->noparseBlocks[$hash]);423}424
425return $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*/
437protected function parseConditionals(string $template): string438{439$leftDelimiter = preg_quote($this->leftConditionalDelimiter, '/');440$rightDelimiter = preg_quote($this->rightConditionalDelimiter, '/');441
442$pattern = '/'443. $leftDelimiter444. '\s*(if|elseif)\s*((?:\()?(.*?)(?:\))?)\s*'445. $rightDelimiter446. '/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*/
455preg_match_all($pattern, $template, $matches, PREG_SET_ORDER);456
457foreach ($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$template469);470$template = preg_replace(471'/' . $leftDelimiter . '\s*endif\s*' . $rightDelimiter . '/ums',472'<?php endif; ?>',473$template474);475
476// Parse the PHP itself, or insert an error so they can debug477ob_start();478
479if ($this->tempData === null) {480$this->tempData = $this->data;481}482
483extract($this->tempData);484
485try {486eval('?>' . $template . '<?php ');487} catch (ParseError) {488ob_end_clean();489
490throw ViewException::forTagSyntaxError(str_replace(['?>', '<?php '], '', $template));491}492
493return ob_get_clean();494}495
496/**497* Over-ride the substitution field delimiters.
498*
499* @param string $leftDelimiter
500* @param string $rightDelimiter
501*/
502public function setDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface503{504$this->leftDelimiter = $leftDelimiter;505$this->rightDelimiter = $rightDelimiter;506
507return $this;508}509
510/**511* Over-ride the substitution conditional delimiters.
512*
513* @param string $leftDelimiter
514* @param string $rightDelimiter
515*/
516public function setConditionalDelimiters($leftDelimiter = '{', $rightDelimiter = '}'): RendererInterface517{518$this->leftConditionalDelimiter = $leftDelimiter;519$this->rightConditionalDelimiter = $rightDelimiter;520
521return $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*/
532protected function replaceSingle($pattern, $content, $template, bool $escape = false): string533{534$content = (string) $content;535
536// Replace the content in the template537return preg_replace_callback($pattern, function ($matches) use ($content, $escape) {538// Check for {! !} syntax to not escape this one.539if (540str_starts_with($matches[0], $this->leftDelimiter . '!')541&& substr($matches[0], -1 - strlen($this->rightDelimiter)) === '!' . $this->rightDelimiter542) {543$escape = false;544}545
546return $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*/
555protected function prepareReplacement(array $matches, string $replace, bool $escape = true): string556{557$orig = array_shift($matches);558
559// Our regex earlier will leave all chained values on a single line560// so we need to break them apart so we can apply them all.561$filters = (isset($matches[1]) && $matches[1] !== '') ? explode('|', $matches[1]) : [];562
563if ($escape && $filters === [] && ($context = $this->shouldAddEscaping($orig))) {564$filters[] = "esc({$context})";565}566
567return $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*/
575public 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.583if (array_key_exists($key, $this->dataContexts)) {584if ($this->dataContexts[$key] !== 'raw') {585return $this->dataContexts[$key];586}587}588// No pipes, then we know we need to escape589elseif (! str_contains($key, '|')) {590$escape = 'html';591}592// If there's a `noescape` then we're definitely false.593elseif (str_contains($key, 'noescape')) {594$escape = false;595}596// If no `esc` filter is found, then we'll need to add one.597elseif (! preg_match('/\s+esc/u', $key)) {598$escape = 'html';599}600
601return $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*/
610protected function applyFilters(string $replace, array $filters): string611{612// Determine the requested filters613foreach ($filters as $filter) {614// Grab any parameter we might need to send615preg_match('/\([\w<>=\/\\\,:.\-\s\+]+\)/u', $filter, $param);616
617// Remove the () and spaces to we have just the parameter left618$param = ($param !== []) ? trim($param[0], '() ') : null;619
620// Params can be separated by commas to allow multiple parameters for the filter621if ($param !== null && $param !== '') {622$param = explode(',', $param);623
624// Clean it up625foreach ($param as &$p) {626$p = trim($p, ' "');627}628} else {629$param = [];630}631
632// Get our filter name633$filter = $param !== [] ? trim(strtolower(substr($filter, 0, strpos($filter, '(')))) : trim($filter);634
635if (! array_key_exists($filter, $this->config->filters)) {636continue;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
644return (string) $replace;645}646
647// Plugins648
649/**650* Scans the template for any parser plugins, and attempts to execute them.
651* Plugins are delimited by {+ ... +}
652*
653* @return string
654*/
655protected function parsePlugins(string $template)656{657foreach ($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/1663$pattern = $isPair664? '#\{\+\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*/
675if (! preg_match_all($pattern, $template, $matches, PREG_SET_ORDER)) {676continue;677}678
679foreach ($matches as $match) {680$params = [];681
682preg_match_all('/([\w-]+=\"[^"]+\")|([\w-]+=[^\"\s=]+)|(\"[^"]+\")|(\S+)/u', trim($match[1]), $matchesParams);683
684foreach ($matchesParams[0] as $item) {685$keyVal = explode('=', $item);686
687if (count($keyVal) === 2) {688$params[$keyVal[0]] = str_replace('"', '', $keyVal[1]);689} else {690$params[] = str_replace('"', '', $item);691}692}693
694$template = $isPair695? str_replace($match[0], $callable($match[2], $params), $template)696: str_replace($match[0], $callable($params), $template);697}698}699
700return $template;701}702
703/**704* Makes a new plugin available during the parsing of the template.
705*
706* @return $this
707*/
708public function addPlugin(string $alias, callable $callback, bool $isPair = false)709{710$this->plugins[$alias] = $isPair ? [$callback] : $callback;711
712return $this;713}714
715/**716* Removes a plugin from the available plugins.
717*
718* @return $this
719*/
720public function removePlugin(string $alias)721{722unset($this->plugins[$alias]);723
724return $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*/
735protected function objectToArray($value)736{737// Objects that have a `toArray()` method should be738// converted with that method (i.e. Entities)739if (is_object($value) && method_exists($value, 'toArray')) {740$value = $value->toArray();741}742// Otherwise, cast as an array and it will grab public properties.743elseif (is_object($value)) {744$value = (array) $value;745}746
747return $value;748}749}
750