1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Templating;
13
14use Symfony\Component\Templating\Helper\HelperInterface;
15use Symfony\Component\Templating\Loader\LoaderInterface;
16use Symfony\Component\Templating\Storage\FileStorage;
17use Symfony\Component\Templating\Storage\Storage;
18use Symfony\Component\Templating\Storage\StringStorage;
19
20/**
21 * PhpEngine is an engine able to render PHP templates.
22 *
23 * @author Fabien Potencier <fabien@symfony.com>
24 */
25class PhpEngine implements EngineInterface, \ArrayAccess
26{
27    protected $loader;
28    protected $current;
29    /**
30     * @var HelperInterface[]
31     */
32    protected $helpers = [];
33    protected $parents = [];
34    protected $stack = [];
35    protected $charset = 'UTF-8';
36    protected $cache = [];
37    protected $escapers = [];
38    protected static $escaperCache = [];
39    protected $globals = [];
40    protected $parser;
41
42    private $evalTemplate;
43    private $evalParameters;
44
45    /**
46     * @param TemplateNameParserInterface $parser  A TemplateNameParserInterface instance
47     * @param LoaderInterface             $loader  A loader instance
48     * @param HelperInterface[]           $helpers An array of helper instances
49     */
50    public function __construct(TemplateNameParserInterface $parser, LoaderInterface $loader, array $helpers = [])
51    {
52        $this->parser = $parser;
53        $this->loader = $loader;
54
55        $this->addHelpers($helpers);
56
57        $this->initializeEscapers();
58        foreach ($this->escapers as $context => $escaper) {
59            $this->setEscaper($context, $escaper);
60        }
61    }
62
63    /**
64     * {@inheritdoc}
65     *
66     * @throws \InvalidArgumentException if the template does not exist
67     */
68    public function render($name, array $parameters = [])
69    {
70        $storage = $this->load($name);
71        $key = hash('sha256', serialize($storage));
72        $this->current = $key;
73        $this->parents[$key] = null;
74
75        // attach the global variables
76        $parameters = array_replace($this->getGlobals(), $parameters);
77        // render
78        if (false === $content = $this->evaluate($storage, $parameters)) {
79            throw new \RuntimeException(sprintf('The template "%s" cannot be rendered.', $this->parser->parse($name)));
80        }
81
82        // decorator
83        if ($this->parents[$key]) {
84            $slots = $this->get('slots');
85            $this->stack[] = $slots->get('_content');
86            $slots->set('_content', $content);
87
88            $content = $this->render($this->parents[$key], $parameters);
89
90            $slots->set('_content', array_pop($this->stack));
91        }
92
93        return $content;
94    }
95
96    /**
97     * {@inheritdoc}
98     */
99    public function exists($name)
100    {
101        try {
102            $this->load($name);
103        } catch (\InvalidArgumentException $e) {
104            return false;
105        }
106
107        return true;
108    }
109
110    /**
111     * {@inheritdoc}
112     */
113    public function supports($name)
114    {
115        $template = $this->parser->parse($name);
116
117        return 'php' === $template->get('engine');
118    }
119
120    /**
121     * Evaluates a template.
122     *
123     * @param Storage $template   The template to render
124     * @param array   $parameters An array of parameters to pass to the template
125     *
126     * @return string|false The evaluated template, or false if the engine is unable to render the template
127     *
128     * @throws \InvalidArgumentException
129     */
130    protected function evaluate(Storage $template, array $parameters = [])
131    {
132        $this->evalTemplate = $template;
133        $this->evalParameters = $parameters;
134        unset($template, $parameters);
135
136        if (isset($this->evalParameters['this'])) {
137            throw new \InvalidArgumentException('Invalid parameter (this).');
138        }
139        if (isset($this->evalParameters['view'])) {
140            throw new \InvalidArgumentException('Invalid parameter (view).');
141        }
142
143        // the view variable is exposed to the require file below
144        $view = $this;
145        if ($this->evalTemplate instanceof FileStorage) {
146            extract($this->evalParameters, \EXTR_SKIP);
147            $this->evalParameters = null;
148
149            ob_start();
150            require $this->evalTemplate;
151
152            $this->evalTemplate = null;
153
154            return ob_get_clean();
155        } elseif ($this->evalTemplate instanceof StringStorage) {
156            extract($this->evalParameters, \EXTR_SKIP);
157            $this->evalParameters = null;
158
159            ob_start();
160            eval('; ?>'.$this->evalTemplate.'<?php ;');
161
162            $this->evalTemplate = null;
163
164            return ob_get_clean();
165        }
166
167        return false;
168    }
169
170    /**
171     * Gets a helper value.
172     *
173     * @param string $name The helper name
174     *
175     * @return HelperInterface The helper value
176     *
177     * @throws \InvalidArgumentException if the helper is not defined
178     */
179    public function offsetGet($name)
180    {
181        return $this->get($name);
182    }
183
184    /**
185     * Returns true if the helper is defined.
186     *
187     * @param string $name The helper name
188     *
189     * @return bool true if the helper is defined, false otherwise
190     */
191    public function offsetExists($name)
192    {
193        return isset($this->helpers[$name]);
194    }
195
196    /**
197     * Sets a helper.
198     *
199     * @param HelperInterface $name  The helper instance
200     * @param string          $value An alias
201     */
202    public function offsetSet($name, $value)
203    {
204        $this->set($name, $value);
205    }
206
207    /**
208     * Removes a helper.
209     *
210     * @param string $name The helper name
211     *
212     * @throws \LogicException
213     */
214    public function offsetUnset($name)
215    {
216        throw new \LogicException(sprintf('You can\'t unset a helper (%s).', $name));
217    }
218
219    /**
220     * Adds some helpers.
221     *
222     * @param HelperInterface[] $helpers An array of helper
223     */
224    public function addHelpers(array $helpers)
225    {
226        foreach ($helpers as $alias => $helper) {
227            $this->set($helper, \is_int($alias) ? null : $alias);
228        }
229    }
230
231    /**
232     * Sets the helpers.
233     *
234     * @param HelperInterface[] $helpers An array of helper
235     */
236    public function setHelpers(array $helpers)
237    {
238        $this->helpers = [];
239        $this->addHelpers($helpers);
240    }
241
242    /**
243     * Sets a helper.
244     *
245     * @param HelperInterface $helper The helper instance
246     * @param string          $alias  An alias
247     */
248    public function set(HelperInterface $helper, $alias = null)
249    {
250        $this->helpers[$helper->getName()] = $helper;
251        if (null !== $alias) {
252            $this->helpers[$alias] = $helper;
253        }
254
255        $helper->setCharset($this->charset);
256    }
257
258    /**
259     * Returns true if the helper if defined.
260     *
261     * @param string $name The helper name
262     *
263     * @return bool true if the helper is defined, false otherwise
264     */
265    public function has($name)
266    {
267        return isset($this->helpers[$name]);
268    }
269
270    /**
271     * Gets a helper value.
272     *
273     * @param string $name The helper name
274     *
275     * @return HelperInterface The helper instance
276     *
277     * @throws \InvalidArgumentException if the helper is not defined
278     */
279    public function get($name)
280    {
281        if (!isset($this->helpers[$name])) {
282            throw new \InvalidArgumentException(sprintf('The helper "%s" is not defined.', $name));
283        }
284
285        return $this->helpers[$name];
286    }
287
288    /**
289     * Decorates the current template with another one.
290     *
291     * @param string $template The decorator logical name
292     */
293    public function extend($template)
294    {
295        $this->parents[$this->current] = $template;
296    }
297
298    /**
299     * Escapes a string by using the current charset.
300     *
301     * @param mixed  $value   A variable to escape
302     * @param string $context The context name
303     *
304     * @return mixed The escaped value
305     */
306    public function escape($value, $context = 'html')
307    {
308        if (is_numeric($value)) {
309            return $value;
310        }
311
312        // If we deal with a scalar value, we can cache the result to increase
313        // the performance when the same value is escaped multiple times (e.g. loops)
314        if (is_scalar($value)) {
315            if (!isset(self::$escaperCache[$context][$value])) {
316                self::$escaperCache[$context][$value] = \call_user_func($this->getEscaper($context), $value);
317            }
318
319            return self::$escaperCache[$context][$value];
320        }
321
322        return \call_user_func($this->getEscaper($context), $value);
323    }
324
325    /**
326     * Sets the charset to use.
327     *
328     * @param string $charset The charset
329     */
330    public function setCharset($charset)
331    {
332        if ('UTF8' === $charset = strtoupper($charset)) {
333            $charset = 'UTF-8'; // iconv on Windows requires "UTF-8" instead of "UTF8"
334        }
335        $this->charset = $charset;
336
337        foreach ($this->helpers as $helper) {
338            $helper->setCharset($this->charset);
339        }
340    }
341
342    /**
343     * Gets the current charset.
344     *
345     * @return string The current charset
346     */
347    public function getCharset()
348    {
349        return $this->charset;
350    }
351
352    /**
353     * Adds an escaper for the given context.
354     *
355     * @param string   $context The escaper context (html, js, ...)
356     * @param callable $escaper A PHP callable
357     */
358    public function setEscaper($context, callable $escaper)
359    {
360        $this->escapers[$context] = $escaper;
361        self::$escaperCache[$context] = [];
362    }
363
364    /**
365     * Gets an escaper for a given context.
366     *
367     * @param string $context The context name
368     *
369     * @return callable A PHP callable
370     *
371     * @throws \InvalidArgumentException
372     */
373    public function getEscaper($context)
374    {
375        if (!isset($this->escapers[$context])) {
376            throw new \InvalidArgumentException(sprintf('No registered escaper for context "%s".', $context));
377        }
378
379        return $this->escapers[$context];
380    }
381
382    /**
383     * @param string $name
384     * @param mixed  $value
385     */
386    public function addGlobal($name, $value)
387    {
388        $this->globals[$name] = $value;
389    }
390
391    /**
392     * Returns the assigned globals.
393     *
394     * @return array
395     */
396    public function getGlobals()
397    {
398        return $this->globals;
399    }
400
401    /**
402     * Initializes the built-in escapers.
403     *
404     * Each function specifies a way for applying a transformation to a string
405     * passed to it. The purpose is for the string to be "escaped" so it is
406     * suitable for the format it is being displayed in.
407     *
408     * For example, the string: "It's required that you enter a username & password.\n"
409     * If this were to be displayed as HTML it would be sensible to turn the
410     * ampersand into '&amp;' and the apostrophe into '&aps;'. However if it were
411     * going to be used as a string in JavaScript to be displayed in an alert box
412     * it would be right to leave the string as-is, but c-escape the apostrophe and
413     * the new line.
414     *
415     * For each function there is a define to avoid problems with strings being
416     * incorrectly specified.
417     */
418    protected function initializeEscapers()
419    {
420        $flags = \ENT_QUOTES | \ENT_SUBSTITUTE;
421
422        $this->escapers = [
423            'html' =>
424                /**
425                 * Runs the PHP function htmlspecialchars on the value passed.
426                 *
427                 * @param string $value The value to escape
428                 *
429                 * @return string the escaped value
430                 */
431                function ($value) use ($flags) {
432                    // Numbers and Boolean values get turned into strings which can cause problems
433                    // with type comparisons (e.g. === or is_int() etc).
434                    return \is_string($value) ? htmlspecialchars($value, $flags, $this->getCharset(), false) : $value;
435                },
436
437            'js' =>
438                /**
439                 * A function that escape all non-alphanumeric characters
440                 * into their \xHH or \uHHHH representations.
441                 *
442                 * @param string $value The value to escape
443                 *
444                 * @return string the escaped value
445                 */
446                function ($value) {
447                    if ('UTF-8' != $this->getCharset()) {
448                        $value = iconv($this->getCharset(), 'UTF-8', $value);
449                    }
450
451                    $callback = function ($matches) {
452                        $char = $matches[0];
453
454                        // \xHH
455                        if (!isset($char[1])) {
456                            return '\\x'.substr('00'.bin2hex($char), -2);
457                        }
458
459                        // \uHHHH
460                        $char = iconv('UTF-8', 'UTF-16BE', $char);
461
462                        return '\\u'.substr('0000'.bin2hex($char), -4);
463                    };
464
465                    if (null === $value = preg_replace_callback('#[^\p{L}\p{N} ]#u', $callback, $value)) {
466                        throw new \InvalidArgumentException('The string to escape is not a valid UTF-8 string.');
467                    }
468
469                    if ('UTF-8' != $this->getCharset()) {
470                        $value = iconv('UTF-8', $this->getCharset(), $value);
471                    }
472
473                    return $value;
474                },
475        ];
476
477        self::$escaperCache = [];
478    }
479
480    /**
481     * Gets the loader associated with this engine.
482     *
483     * @return LoaderInterface A LoaderInterface instance
484     */
485    public function getLoader()
486    {
487        return $this->loader;
488    }
489
490    /**
491     * Loads the given template.
492     *
493     * @param string|TemplateReferenceInterface $name A template name or a TemplateReferenceInterface instance
494     *
495     * @return Storage A Storage instance
496     *
497     * @throws \InvalidArgumentException if the template cannot be found
498     */
499    protected function load($name)
500    {
501        $template = $this->parser->parse($name);
502
503        $key = $template->getLogicalName();
504        if (isset($this->cache[$key])) {
505            return $this->cache[$key];
506        }
507
508        $storage = $this->loader->load($template);
509
510        if (false === $storage) {
511            throw new \InvalidArgumentException(sprintf('The template "%s" does not exist.', $template));
512        }
513
514        return $this->cache[$key] = $storage;
515    }
516}
517