1<?php
2declare(strict_types=1);
3/**
4 * PHPTAL templating engine
5 *
6 * @category HTML
7 * @package  PHPTAL
8 * @author   Laurent Bedubourg <lbedubourg@motion-twin.com>
9 * @author   Kornel Lesiński <kornel@aardvarkmedia.co.uk>
10 * @license  http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
11 * @link     http://phptal.org/
12 */
13
14namespace PhpTal;
15
16use PhpTal\Php\TalesInternal;
17use RuntimeException;
18use stdClass;
19use Throwable;
20
21/**
22 * PHPTAL template entry point.
23 *
24 * @category HTML
25 * @package  PHPTAL
26 * @author   Laurent Bedubourg <lbedubourg@motion-twin.com>
27 * @author   Kornel Lesiński <kornel@aardvarkmedia.co.uk>
28 * @license  http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
29 * @link     http://phptal.org/
30 */
31class PHPTAL implements PhpTalInterface
32{
33
34    public const PHPTAL_VERSION = '3_0_2';
35
36    /**
37     * constants for output mode
38     * @see setOutputMode()
39     */
40    public const XHTML = 11;
41    public const XML   = 22;
42    public const HTML5 = 55;
43
44    /**
45     * @see getPreFilters()
46     *
47     * @var FilterInterface[]
48     */
49    protected $prefilters = [];
50
51    /**
52     * The postfilter which will get called on every run
53     *
54     * @var FilterInterface
55     */
56    protected $postfilter;
57
58    /**
59     *  list of template source repositories given to file source resolver
60     *
61     * @var string[]
62     */
63    protected $repositories = [];
64
65    /**
66     *  template path (path that has been set, not necessarily loaded)
67     *
68     * @var string|null
69     */
70    protected $path;
71
72    /**
73     *  template source resolvers (classes that search for templates by name)
74     *
75     *  @var SourceResolverInterface[]
76     */
77    protected $resolvers = [];
78
79    /**
80     *  template source (only set when not working with file)
81     *
82     * @var StringSource|null
83     */
84    protected $source;
85
86    /**
87     * destination of PHP intermediate file
88     *
89     * @var string
90     */
91    protected $codeFile;
92
93    /**
94     * php function generated for the template
95     *
96     * @var string
97     */
98    protected $functionName;
99
100    /**
101     * set to true when template is ready for execution
102     *
103     * @var bool
104     */
105    protected $prepared = false;
106
107    /**
108     * associative array of phptal:id => \PhpTal\TriggerInterface
109     *
110     * @var TriggerInterface[]
111     */
112    protected $triggers = [];
113
114    /**
115     * i18n translator
116     *
117     * @var TranslationServiceInterface|null
118     */
119    protected $translator;
120
121    /**
122     * global execution context
123     *
124     * @var stdClass
125     */
126    protected $globalContext;
127
128    /**
129     * current execution context
130     *
131     * @var Context
132     */
133    protected $context;
134
135    /**
136     * list of on-error caught exceptions
137     *
138     * @var \Exception[]
139     */
140    protected $errors = [];
141
142    /**
143     * encoding used throughout
144     *
145     * @var string
146     */
147    protected $encoding = 'UTF-8';
148
149    /**
150     * type of syntax used in generated templates
151     *
152     * @var int
153     */
154    protected $outputMode = self::XHTML;
155
156    // configuration properties
157
158    /**
159     * don't use code cache
160     *
161     * @var bool
162     */
163    protected $forceReparse = false;
164
165    /**
166     * directory where code cache is
167     *
168     * @var string
169     */
170    private $phpCodeDestination;
171
172    /**
173     * @var string
174     */
175    private $phpCodeExtension = 'php';
176
177    /**
178     * number of days
179     *
180     * @var float
181     */
182    private $cacheLifetime = 30.;
183
184    /**
185     * 1/x
186     *
187     * @var int
188     */
189    private $cachePurgeFrequency = 30;
190
191    /**
192     * speeds up calls to external templates
193     *
194     * @var PhpTalInterface[]
195     */
196    private $externalMacroTemplatesCache = [];
197
198    /**
199     * @var int
200     */
201    private $subpathRecursionLevel = 0;
202
203    /**
204     * @param string $path Template file path.
205     */
206    public function __construct(?string $path = null)
207    {
208        $this->path = $path;
209        $this->globalContext = new stdClass();
210        $this->context = new Context();
211        $this->context->setGlobal($this->globalContext);
212
213        $this->setPhpCodeDestination(sys_get_temp_dir());
214    }
215
216    /**
217     * Clone template state and context.
218     *
219     * @return void
220     */
221    public function __clone()
222    {
223        $this->context = $this->context->pushContext();
224    }
225
226    /**
227     * Set template from file path.
228     *
229     * @param string $path filesystem path,
230     *                     or any path that will be accepted by source resolver
231     *
232     * @return $this
233     */
234    public function setTemplate(?string $path): PhpTalInterface
235    {
236        $this->prepared = false;
237        $this->functionName = null;
238        $this->codeFile = null;
239        $this->path = $path;
240        $this->source = null;
241        $this->context->_docType = null;
242        $this->context->_xmlDeclaration = null;
243        return $this;
244    }
245
246    /**
247     * Set template from source.
248     *
249     * Should be used only with temporary template sources.
250     * Use setTemplate() or addSourceResolver() whenever possible.
251     *
252     * @param string $src The phptal template source.
253     * @param string $path Fake and 'unique' template path.
254     *
255     * @return $this
256     */
257    public function setSource(string $src, ?string $path = null): PhpTalInterface
258    {
259        $this->prepared = false;
260        $this->functionName = null;
261        $this->codeFile = null;
262        $this->source = new StringSource($src, $path);
263        $this->path = $this->source->getRealPath();
264        $this->context->_docType = null;
265        $this->context->_xmlDeclaration = null;
266        return $this;
267    }
268
269    /**
270     * Specify where to look for templates.
271     *
272     * @param mixed $rep string or Array of repositories
273     *
274     * @return $this
275     */
276    public function setTemplateRepository($rep): PhpTalInterface
277    {
278        if (is_array($rep)) {
279            $this->repositories = $rep;
280        } else {
281            $this->repositories[] = $rep;
282        }
283        return $this;
284    }
285
286    /**
287     * Get template repositories.
288     *
289     * @return array
290     */
291    public function getTemplateRepositories(): array
292    {
293        return $this->repositories;
294    }
295
296    /**
297     * Clears the template repositories.
298     *
299     * @return $this
300     */
301    public function clearTemplateRepositories(): PhpTalInterface
302    {
303        $this->repositories = [];
304        return $this;
305    }
306
307    /**
308     * Specify how to look for templates.
309     *
310     * @param SourceResolverInterface $resolver instance of resolver
311     *
312     * @return $this
313     */
314    public function addSourceResolver(SourceResolverInterface $resolver): PhpTalInterface
315    {
316        $this->resolvers[] = $resolver;
317        return $this;
318    }
319
320    /**
321     * Ignore XML/XHTML comments on parsing.
322     * Comments starting with <!--! are always stripped.
323     *
324     * @param bool $bool if true all comments are stripped during parse
325     *
326     * @return $this
327     */
328    public function stripComments(bool $bool): PhpTalInterface
329    {
330        $this->resetPrepared();
331
332        if ($bool) {
333            $this->prefilters['_phptal_strip_comments_'] = new PreFilter\StripComments();
334        } else {
335            unset($this->prefilters['_phptal_strip_comments_']);
336        }
337        return $this;
338    }
339
340    /**
341     * Set output mode
342     * XHTML output mode will force elements like <link/>, <meta/> and <img/>, etc.
343     * to be empty and threats attributes like selected, checked to be
344     * boolean attributes.
345     *
346     * XML output mode outputs XML without such modifications
347     * and is neccessary to generate RSS feeds properly.
348     *
349     * @param int $mode (\PhpTal\PHPTAL::XML, \PhpTal\PHPTAL::XHTML or \PhpTal\PHPTAL::HTML5).
350     *
351     * @return $this
352     * @throws Exception\ConfigurationException
353     */
354    public function setOutputMode(int $mode): PhpTalInterface
355    {
356        $this->resetPrepared();
357
358        if (!in_array($mode, [static::XHTML, static::XML, static::HTML5], true)) {
359            throw new Exception\ConfigurationException('Unsupported output mode ' . $mode);
360        }
361        $this->outputMode = $mode;
362        return $this;
363    }
364
365    /**
366     * Get output mode
367     * @see setOutputMode()
368     *
369     * @return int output mode constant
370     */
371    public function getOutputMode(): int
372    {
373        return $this->outputMode;
374    }
375
376    /**
377     * Set input and ouput encoding. Encoding is case-insensitive.
378     *
379     * @param string $enc example: 'UTF-8'
380     *
381     * @return $this
382     */
383    public function setEncoding(string $enc): PhpTalInterface
384    {
385        $enc = strtoupper($enc);
386        if ($enc !== $this->encoding) {
387            $this->encoding = $enc;
388            if ($this->translator) {
389                $this->translator->setEncoding($enc);
390            }
391
392            $this->resetPrepared();
393        }
394        return $this;
395    }
396
397    /**
398     * Get input and ouput encoding.
399     *
400     * @return string
401     */
402    public function getEncoding(): string
403    {
404        return $this->encoding;
405    }
406
407    /**
408     * Set the storage location for intermediate PHP files.
409     * The path cannot contain characters that would be interpreted by glob() (e.g. *[]?)
410     *
411     * @param string $path Intermediate file path.
412     *
413     * @return void
414     */
415    public function setPhpCodeDestination(string $path): void
416    {
417        $this->phpCodeDestination = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
418        $this->resetPrepared();
419    }
420
421    /**
422     * Get the storage location for intermediate PHP files.
423     *
424     * @return string
425     */
426    public function getPhpCodeDestination(): string
427    {
428        return $this->phpCodeDestination;
429    }
430
431    /**
432     * Set the file extension for intermediate PHP files.
433     *
434     * @param string $extension The file extension.
435     *
436     * @return $this
437     */
438    public function setPhpCodeExtension(string $extension): PhpTalInterface
439    {
440        $this->phpCodeExtension = $extension;
441        $this->resetPrepared();
442        return $this;
443    }
444
445    /**
446     * Get the file extension for intermediate PHP files.
447     */
448    public function getPhpCodeExtension(): string
449    {
450        return $this->phpCodeExtension;
451    }
452
453    /**
454     * Flags whether to ignore intermediate php files and to
455     * reparse templates every time (if set to true).
456     *
457     * DON'T USE IN PRODUCTION - this makes PHPTAL many times slower.
458     *
459     * @param bool $bool Forced reparse state.
460     *
461     * @return $this
462     */
463    public function setForceReparse(bool $bool): PhpTalInterface
464    {
465        $this->forceReparse = $bool;
466        return $this;
467    }
468
469    /**
470     * Get the value of the force reparse state.
471     *
472     * @return bool
473     */
474    public function getForceReparse(): bool
475    {
476        return $this->forceReparse;
477    }
478
479    /**
480     * Set I18N translator.
481     *
482     * This sets encoding used by the translator, so be sure to use encoding-dependent
483     * features of the translator (e.g. addDomain) _after_ calling setTranslator.
484     *
485     * @param TranslationServiceInterface $t instance
486     *
487     * @return $this
488     */
489    public function setTranslator(TranslationServiceInterface $t): PhpTalInterface
490    {
491        $this->translator = $t;
492        $t->setEncoding($this->getEncoding());
493        return $this;
494    }
495
496    /**
497     * Add new prefilter to filter chain.
498     * Prefilters are called only once template is compiled.
499     *
500     * PreFilters must inherit PreFilter class.
501     * (in future this method will allow string with filter name instead of object)
502     *
503     * @param PreFilter $filter PreFilter object or name of prefilter to add
504     *
505     * @return $this
506     */
507    final public function addPreFilter(PreFilter $filter): PhpTalInterface
508    {
509        $this->resetPrepared();
510        $this->prefilters[] = $filter;
511        return $this;
512    }
513
514    /**
515     * Sets the level of recursion for template cache directories
516     *
517     * @param int $recursion_level
518     *
519     * @return self
520     */
521    public function setSubpathRecursionLevel(int $recursion_level): PhpTalInterface
522    {
523        $this->subpathRecursionLevel = $recursion_level;
524        return $this;
525    }
526
527    /**
528     * Array with all prefilter objects *or strings* that are names of prefilter classes.
529     * (the latter is not implemented in 1.2.1)
530     *
531     * Array keys may be non-numeric!
532     *
533     * @return FilterInterface[]
534     */
535    protected function getPreFilters(): array
536    {
537        return $this->prefilters;
538    }
539
540    /**
541     * Returns string that is unique for every different configuration of prefilters.
542     * Result of prefilters may be cached until this string changes.
543     *
544     * You can override this function.
545     *
546     * @return string
547     */
548    private function getPreFiltersCacheId(): string
549    {
550        $cacheid = '';
551        foreach ($this->getPreFilters() as $key => $prefilter) {
552            if ($prefilter instanceof PreFilter) {
553                $cacheid .= $key . $prefilter->getCacheId();
554            } else {
555                $cacheid .= $key . get_class($prefilter);
556            }
557        }
558        return $cacheid;
559    }
560
561    /**
562     * Instantiate prefilters
563     *
564     * @return FilterInterface[]
565     */
566    private function getPreFilterInstances(): array
567    {
568        $prefilters = $this->getPreFilters();
569
570        foreach ($prefilters as $prefilter) {
571            if ($prefilter instanceof PreFilter) {
572                $prefilter->setPHPTAL($this);
573            }
574        }
575        return $prefilters;
576    }
577
578    /**
579     * Set template post filter.
580     * It will be called every time after template generates output.
581     *
582     * See PHPTAL_PostFilter class.
583     *
584     * @param FilterInterface $filter filter instance
585     *
586     * @return $this
587     */
588    public function setPostFilter(FilterInterface $filter): PhpTalInterface
589    {
590        $this->postfilter = $filter;
591        return $this;
592    }
593
594    /**
595     * Register a trigger for specified phptal:id.
596     *
597     * @param string $id phptal:id to look for
598     * @param TriggerInterface $trigger
599     *
600     * @return $this
601     */
602    public function addTrigger(string $id, TriggerInterface $trigger): PhpTalInterface
603    {
604        $this->triggers[$id] = $trigger;
605        return $this;
606    }
607
608    /**
609     * Returns trigger for specified phptal:id.
610     *
611     * @param string $id phptal:id
612     *
613     * @return TriggerInterface|null
614     */
615    public function getTrigger(string $id): ?TriggerInterface
616    {
617        return $this->triggers[$id] ?? null;
618    }
619
620    /**
621     * Set a context variable.
622     * Use it by setting properties on PHPTAL object.
623     *
624     * @param string $varname
625     * @param mixed $value
626     *
627     * @return void
628     * @throws Exception\InvalidVariableNameException
629     */
630    public function __set($varname, $value)
631    {
632        $this->context->set($varname, $value);
633    }
634
635    /**
636     * Set a context variable.
637     *
638     * @see \PhpTal\PHPTAL::__set()
639     * @param string $varname name of the variable
640     * @param mixed $value value of the variable
641     *
642     * @return $this
643     * @throws Exception\InvalidVariableNameException
644     */
645    public function set(string $varname, $value): PhpTalInterface
646    {
647        $this->context->set($varname, $value);
648        return $this;
649    }
650
651    /**
652     * Execute the template code and return generated markup.
653     *
654     * @return string
655     * @throws Exception\TemplateException
656     * @throws Throwable
657     */
658    public function execute(): string
659    {
660        $res = '';
661
662        try {
663            if (!$this->prepared) {
664                // includes generated template PHP code
665                $this->prepare();
666            }
667            $this->context->echoDeclarations(false);
668
669            $templateFunction = $this->getFunctionName();
670
671            try {
672                ob_start();
673                $templateFunction($this, $this->context);
674                $res = ob_get_clean();
675            } catch (Throwable $e) {
676                ob_end_clean();
677                throw $e;
678            }
679
680            // unshift doctype
681            if ($this->context->_docType) {
682                $res = $this->context->_docType . $res;
683            }
684
685            // unshift xml declaration
686            if ($this->context->_xmlDeclaration) {
687                $res = $this->context->_xmlDeclaration . "\n" . $res;
688            }
689
690            if ($this->postfilter !== null) {
691                return $this->postfilter->filter($res);
692            }
693        } catch (Throwable $e) {
694            ExceptionHandler::handleException($e, $this->getEncoding());
695        }
696
697        return $res;
698    }
699
700    /**
701     * Execute and echo template without buffering of the output.
702     * This function does not allow postfilters nor DOCTYPE/XML declaration.
703     *
704     * @return void
705     * @throws Exception\TemplateException
706     * @throws Throwable
707     */
708    public function echoExecute(): void
709    {
710        try {
711            if (!$this->prepared) {
712                // includes generated template PHP code
713                $this->prepare();
714            }
715
716            if ($this->postfilter !== null) {
717                throw new Exception\ConfigurationException('echoExecute() does not support postfilters');
718            }
719
720            $this->context->echoDeclarations(true);
721
722            $templateFunction = $this->getFunctionName();
723            $templateFunction($this, $this->context);
724        } catch (Throwable $e) {
725            ExceptionHandler::handleException($e, $this->getEncoding());
726        }
727    }
728
729    /**
730     * This is PHPTAL's internal function that handles
731     * execution of macros from templates.
732     *
733     * $this is caller's context (the file where execution had originally started)
734     *
735     * @param string $path
736     * @param PhpTalInterface $local_tpl is PHPTAL instance of the file in which macro is defined
737     *                          (it will be different from $this if it's external macro call)
738     *
739     * @throws Exception\IOException
740     * @throws Exception\MacroMissingException
741     * @throws Exception\TemplateException
742     * @throws Throwable
743     */
744    final public function executeMacroOfTemplate(string $path, PhpTalInterface $local_tpl): void
745    {
746        // extract macro source file from macro name, if macro path does not
747        // contain filename, then the macro is assumed to be local
748
749        if (preg_match('/^(.*?)\/([a-z0-9_-]*)$/i', $path, $m)) {
750            [, $file, $macroName] = $m;
751
752            if (isset($this->externalMacroTemplatesCache[$file])) {
753                $tpl = $this->externalMacroTemplatesCache[$file];
754            } else {
755                $tpl = clone $this;
756                array_unshift($tpl->repositories, dirname($this->source->getRealPath()));
757                $tpl->setTemplate($file);
758                $tpl->prepare();
759
760                // keep it small (typically only 1 or 2 external files are used)
761                if (count($this->externalMacroTemplatesCache) > 10) {
762                    $this->externalMacroTemplatesCache = [];
763                }
764                $this->externalMacroTemplatesCache[$file] = $tpl;
765            }
766
767            $fun = $tpl->getFunctionName() . '_' . str_replace('-', '_', $macroName);
768            if (!function_exists($fun)) {
769                throw new Exception\MacroMissingException(
770                    "Macro '$macroName' is not defined in $file",
771                    $this->getSource()->getRealPath()
772                );
773            }
774
775            $fun($tpl, $this);
776        } else {
777            // call local macro
778            $fun = $local_tpl->getFunctionName() . '_' . str_replace('-', '_', $path);
779            if (!function_exists($fun)) {
780                throw new Exception\MacroMissingException(
781                    "Macro '$path' is not defined",
782                    $local_tpl->getSource()->getRealPath()
783                );
784            }
785            $fun($local_tpl, $this);
786        }
787    }
788
789    /**
790     * ensure that getCodePath will return up-to-date path
791     *
792     * @return void
793     * @throws Exception\ConfigurationException
794     * @throws Exception\IOException
795     */
796    private function setCodeFile(): void
797    {
798        $this->findTemplate();
799        $this->codeFile = $this->getPhpCodeDestination() . $this->getSubPath() . '/'  . $this->getFunctionName()
800            . '.' . $this->getPhpCodeExtension();
801    }
802
803    /**
804     * Generate a subpath structure depending on the config
805     *
806     * @return string
807     */
808    private function getSubPath(): string
809    {
810        $real_path = md5($this->getFunctionName());
811        $path = '';
812        for ($i = 0; $i < $this->subpathRecursionLevel; $i++) {
813            $path .= '/' . $real_path[$i];
814        }
815        if (!file_exists($this->getPhpCodeDestination() . $path) &&
816            !mkdir($concurrentDirectory = $this->getPhpCodeDestination() . $path, 0777, true) &&
817            !is_dir($concurrentDirectory)) {
818            throw new RuntimeException(sprintf('Directory "%s" was not created', $concurrentDirectory));
819        }
820        return $path;
821    }
822
823    /**
824     * @return void
825     */
826    protected function resetPrepared(): void
827    {
828        $this->prepared = false;
829        $this->functionName = null;
830        $this->codeFile = null;
831    }
832
833    /**
834     * Prepare template without executing it.
835     *
836     * @return self
837     * @throws Exception\ConfigurationException
838     * @throws Exception\IOException
839     * @throws Exception\TemplateException
840     * @throws Throwable
841     */
842    public function prepare(): PhpTalInterface
843    {
844        // clear just in case settings changed and cache is out of date
845        $this->externalMacroTemplatesCache = [];
846
847        // find the template source file and update function name
848        $this->setCodeFile();
849
850        if (!function_exists($this->getFunctionName())) {
851            // parse template if php generated code does not exists or template
852            // source file modified since last generation or force reparse is set
853            if ($this->getForceReparse() || !file_exists($this->getCodePath())) {
854                // i'm not sure where that belongs, but not in normal path of execution
855                // because some sites have _a lot_ of files in temp
856                if ($this->getCachePurgeFrequency() && mt_rand() % $this->getCachePurgeFrequency() === 0) {
857                    $this->cleanUpGarbage();
858                }
859
860                $result = $this->parse();
861
862                if (!file_put_contents($this->getCodePath(), $result)) {
863                    throw new Exception\IOException('Unable to open '.$this->getCodePath().' for writing');
864                }
865
866                // the awesome thing about eval() is that parse errors don't stop PHP.
867                // when PHP dies during eval, fatal error is printed and
868                // can be captured with output buffering
869                ob_start();
870                try {
871                    eval("?>\n".$result);
872                } catch (Throwable $e) {
873                    ob_end_clean();
874                    throw $e;
875                }
876
877                if (!function_exists($this->getFunctionName())) {
878                    $msg = str_replace('eval()\'d code', $this->getCodePath(), ob_get_clean());
879
880                    // greedy .* ensures last match
881                    $line = preg_match('/.*on line (\d+)$/m', $msg, $m) ? $m[1] : 0;
882                    throw new Exception\TemplateException(trim($msg), $this->getCodePath(), $line);
883                }
884                ob_end_clean();
885            } else {
886                // eval trick is used only on first run,
887                // just in case it causes any problems with opcode accelerators
888                require $this->getCodePath();
889            }
890        }
891
892        $this->prepared = true;
893        return $this;
894    }
895
896    /**
897     * get how long compiled templates and phptal:cache files are kept, in days
898     *
899     * @return float
900     */
901    private function getCacheLifetime(): float
902    {
903        return $this->cacheLifetime;
904    }
905
906    /**
907     * set how long compiled templates and phptal:cache files are kept
908     *
909     * @param float $days number of days
910     *
911     * @return $this
912     */
913    public function setCacheLifetime(float $days): PhpTalInterface
914    {
915        $this->cacheLifetime = max(0.5, $days);
916        return $this;
917    }
918
919    /**
920     * PHPTAL will scan cache and remove old files on every nth compile
921     * Set to 0 to disable cleanups
922     *
923     * @param int $n
924     *
925     * @return $this
926     */
927    public function setCachePurgeFrequency(int $n): PhpTalInterface
928    {
929        $this->cachePurgeFrequency = $n;
930        return $this;
931    }
932
933    /**
934     * how likely cache cleaning can happen
935     * @see self::setCachePurgeFrequency()
936     *
937     * @return int
938     */
939    private function getCachePurgeFrequency(): int
940    {
941        return $this->cachePurgeFrequency;
942    }
943
944
945    /**
946     * Removes all compiled templates from cache that
947     * are older than getCacheLifetime() days
948     *
949     * @return void
950     */
951    public function cleanUpGarbage(): void
952    {
953        $cacheFilesExpire = (int) (time() - $this->getCacheLifetime() * 3600 * 24);
954
955        // relies on templates sorting order being related to their modification dates
956        $upperLimit = $this->getPhpCodeDestination() . $this->getFunctionNamePrefix($cacheFilesExpire) . '_';
957        $lowerLimit = $this->getPhpCodeDestination() . $this->getFunctionNamePrefix();
958
959        // last * gets phptal:cache
960        $cacheFiles = glob(sprintf(
961            '%s%stpl_????????_*.%s*',
962            $this->getPhpCodeDestination(),
963            str_repeat('*/', $this->subpathRecursionLevel),
964            $this->getPhpCodeExtension()
965        ), GLOB_NOSORT);
966
967        if ($cacheFiles) {
968            foreach ($cacheFiles as $index => $file) {
969                // comparison here skips filenames that are certainly too new
970                if (strcmp($file, $upperLimit) <= 0 || strpos($file, $lowerLimit) === 0) {
971                    $time = filemtime($file);
972                    if ($time && $time < $cacheFilesExpire) {
973                        @unlink($file);
974                    }
975                }
976            }
977        }
978    }
979
980    /**
981     * Removes content cached with phptal:cache for currently set template
982     * Must be called after setSource/setTemplate.
983     *
984     * @return void
985     * @throws Exception\ConfigurationException
986     * @throws Exception\IOException
987     */
988    public function cleanUpCache(): void
989    {
990        $filename = $this->getCodePath();
991        $cacheFiles = glob($filename . '?*', GLOB_NOSORT);
992        if ($cacheFiles) {
993            foreach ($cacheFiles as $file) {
994                if (strpos($file, $filename) !== 0) {
995                    continue;
996                } // safety net
997                @unlink($file);
998            }
999        }
1000        $this->prepared = false;
1001    }
1002
1003    /**
1004     * Returns the path of the intermediate PHP code file.
1005     *
1006     * The returned file may be used to cleanup (unlink) temporary files
1007     * generated by temporary templates or more simply for debug.
1008     *
1009     * @return string
1010     * @throws Exception\ConfigurationException
1011     * @throws Exception\IOException
1012     */
1013    public function getCodePath(): string
1014    {
1015        if (!$this->codeFile) {
1016            $this->setCodeFile();
1017        }
1018        return $this->codeFile;
1019    }
1020
1021    /**
1022     * Returns the generated template function name.
1023     *
1024     * @return string
1025     */
1026    public function getFunctionName(): string
1027    {
1028       // function name is used as base for caching, so it must be unique for
1029       // every combination of settings that changes code in compiled template
1030
1031        if (!$this->functionName) {
1032            // just to make tempalte name recognizable
1033            $basename = preg_replace('/\.[a-z]{3,5}$/', '', basename($this->source->getRealPath()));
1034            $basename = substr(trim(preg_replace('/[^a-zA-Z0-9]+/', '_', $basename), '_'), 0, 20);
1035
1036            $hash = md5(
1037                static::PHPTAL_VERSION . PHP_VERSION
1038                . $this->source->getRealPath()
1039                . $this->getEncoding()
1040                . $this->getPreFiltersCacheId()
1041                . $this->getOutputMode(),
1042                true
1043            );
1044
1045            // uses base64 rather than hex to make filename shorter.
1046            // there is loss of some bits due to name constraints and case-insensivity,
1047            // but that's still over 110 bits in addition to basename and timestamp.
1048            $hash = strtr(rtrim(base64_encode($hash), '='), '+/=', '_A_');
1049
1050            $this->functionName = $this->getFunctionNamePrefix($this->source->getLastModifiedTime()) .
1051                                   $basename . '__' . $hash;
1052        }
1053        return $this->functionName;
1054    }
1055
1056    /**
1057     * Returns prefix used for function name.
1058     * Function name is also base name for the template.
1059     *
1060     * @param int|null $timestamp unix timestamp with template modification date
1061     *
1062     * @return string
1063     */
1064    private function getFunctionNamePrefix(?int $timestamp = null): string
1065    {
1066        // tpl_ prefix and last modified time must not be changed,
1067        // because cache cleanup relies on that
1068        return 'tpl_' . sprintf('%08x', $timestamp ?? 0) . '_';
1069    }
1070
1071    /**
1072     * Returns template translator.
1073     *
1074     * @return TranslationServiceInterface|null
1075     */
1076    public function getTranslator(): ?TranslationServiceInterface
1077    {
1078        return $this->translator;
1079    }
1080
1081    /**
1082     * Returns array of exceptions caught by tal:on-error attribute.
1083     *
1084     * @return \Exception[]
1085     */
1086    public function getErrors(): array
1087    {
1088        return $this->errors;
1089    }
1090
1091    /**
1092     * Public for phptal templates, private for user.
1093     *
1094     * @param \Exception $error
1095     *
1096     * @return void
1097     */
1098    public function addError(\Exception $error): void
1099    {
1100        $this->errors[] = $error;
1101    }
1102
1103    /**
1104     * Returns current context object.
1105     * Use only in Triggers.
1106     *
1107     * @return Context
1108     */
1109    public function getContext(): Context
1110    {
1111        return $this->context;
1112    }
1113
1114    /**
1115     * only for use in generated template code
1116     *
1117     * @return stdClass
1118     */
1119    public function getGlobalContext(): stdClass
1120    {
1121        return $this->globalContext;
1122    }
1123
1124    /**
1125     * only for use in generated template code
1126     *
1127     * @return Context
1128     */
1129    final public function pushContext(): Context
1130    {
1131        $this->context = $this->context->pushContext();
1132        return $this->context;
1133    }
1134
1135    /**
1136     * only for use in generated template code
1137     *
1138     * @return Context
1139     */
1140    final public function popContext(): Context
1141    {
1142        $this->context = $this->context->popContext();
1143        return $this->context;
1144    }
1145
1146    /**
1147     * Parse currently set template, prefilter and generate PHP code.
1148     *
1149     * @return string (compiled PHP code)
1150     * @throws Exception\ConfigurationException
1151     * @throws Exception\ParserException
1152     * @throws Exception\TemplateException
1153     * @throws Exception\PhpTalException
1154     */
1155    protected function parse(): string
1156    {
1157        $data = $this->source->getData();
1158
1159        $prefilters = $this->getPreFilterInstances();
1160        foreach ($prefilters as $prefilter) {
1161            $data = $prefilter->filter($data);
1162        }
1163
1164        $realpath = $this->source->getRealPath();
1165        $parser = new Dom\SaxXmlParser($this->encoding);
1166
1167        $builder = new Dom\PHPTALDocumentBuilder();
1168        $tree = $parser->parseString($builder, $data, $realpath)->getResult();
1169
1170        foreach ($prefilters as $prefilter) {
1171            if ($prefilter instanceof PreFilter) {
1172                $prefilter->filterDOM($tree);
1173            }
1174        }
1175
1176        $state = new Php\State($this);
1177
1178        $codewriter = new Php\CodeWriter($state);
1179        $codewriter->doTemplateFile($this->getFunctionName(), $tree);
1180
1181        return $codewriter->getResult();
1182    }
1183
1184    /**
1185     * Search template source location.
1186     *
1187     * @return void
1188     * @throws Exception\ConfigurationException
1189     * @throws Exception\IOException
1190     */
1191    protected function findTemplate(): void
1192    {
1193        if ($this->path === null) {
1194            throw new Exception\ConfigurationException('No template file specified');
1195        }
1196
1197        if ($this->source !== null) {
1198            return;
1199        }
1200
1201        if ($this->resolvers === [] && !$this->repositories) {
1202            $this->source = new FileSource($this->path);
1203        } else {
1204            foreach ($this->resolvers as $resolver) {
1205                $source = $resolver->resolve($this->path);
1206                if ($source !== null) {
1207                    $this->source = $source;
1208                    return;
1209                }
1210            }
1211
1212            $resolver = new FileSourceResolver($this->repositories);
1213            $this->source = $resolver->resolve($this->path);
1214        }
1215
1216        if (!$this->source) {
1217            throw new Exception\IOException('Unable to locate template file '.$this->path);
1218        }
1219    }
1220
1221    /**
1222     * @return SourceInterface
1223     */
1224    public function getSource(): SourceInterface
1225    {
1226        return $this->source;
1227    }
1228
1229    /**
1230     * @return PHPTAL
1231     */
1232    public function allowPhpModifier(): PhpTalInterface
1233    {
1234        TalesInternal::setPhpModifierAllowed(true);
1235        return $this;
1236    }
1237
1238    /**
1239     * @return PHPTAL
1240     */
1241    public function disallowPhpModifier(): PhpTalInterface
1242    {
1243        TalesInternal::setPhpModifierAllowed(false);
1244        return $this;
1245    }
1246}
1247