Passed
Push — master ( 800479...48e2e4 )
by Marwan
01:58
created

Engine   B

Complexity

Total Complexity 51

Size/Duplication

Total Lines 577
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 205
c 1
b 0
f 0
dl 0
loc 577
ccs 202
cts 202
cp 1
rs 7.92
wmc 51

22 Methods

Rating   Name   Duplication   Size   Complexity  
A render() 0 5 1
A getCompiledFile() 0 20 5
A __construct() 0 13 1
A clearCache() 0 5 1
A wrapComments() 0 8 2
A setDebug() 0 5 1
A getCompiledContent() 0 16 1
A assertFileExists() 0 6 2
A resolvePath() 0 6 1
A getEvaluatedContent() 0 21 1
A require() 0 12 2
A isDebug() 0 3 1
A wrapControlStructures() 0 19 4
A injectBlocks() 0 24 4
A createCacheDirectory() 0 4 2
A parseBlock() 0 28 4
A importIncludes() 0 27 4
A importDependencies() 0 33 4
A extractBlocks() 0 15 2
A wrapPhp() 0 23 4
A printVariables() 0 17 2
A resolveCachePath() 0 14 2

How to fix   Complexity   

Complex Class

Complex classes like Engine often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Engine, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Frontend\View;
13
14
use MAKS\Velox\Frontend\Path;
15
use MAKS\Velox\Helper\Misc;
16
17
/**
18
 * A class that serves as a templating engine for view files.
19
 * This templating engine is based on regular expression.
20
 *
21
 * Templating tags:
22
 * - Create a block `{! @block name !}` ... `{! @endblock !}`, use `{! @super !}` to inherit parent block.
23
 * - Print a block `{! @block(name) !}`.
24
 * - Extend a file `{! @extends 'theme/layouts/file' !}`, blocks of this file will be inherited.
25
 * - Include a file `{! @include 'theme/includes/file' !}`, this will get rendered before inclusion.
26
 * - Embed a file `{! @embed 'theme/components/file' !}`, this will be included as is.
27
 * - Control structures `{! @if ($var) !}` ... `{! @endif !}`, `{! @foreach($vars as $var) !}` ... `{! @endforeach !}`.
28
 * - Variable assignments `{! $var = '' !}`, content can be a variable or any valid PHP expression.
29
 * - Print a variable `{{ $var }}`, content can be a variable or any PHP expression that can be casted to a string.
30
 * - Print a variable without escaping `{{{ $var }}}`, content can be a variable or any PHP expression that can be casted to a string.
31
 * - Comment something `{# This is a comment #}`, this will be a PHP comment (will not be available in final HTML).
32
 *
33
 * @internal
34
 * @since 1.3.0
35
 */
36
class Engine
37
{
38
    /**
39
     * Regular expressions for syntax tokens.
40
     */
41
    public const REGEX = [
42
        'dependency'             => '/{!\s*@(extends|embed)\s+[\'"]?(.*?)[\'"]?\s*!}/s',
43
        'include'                => '/{!\s*@include\s+[\'"]?(.*?)[\'"]?\s*!}/s',
44
        'block'                  => '/{!\s*@block\s+(.*?)\s*!}(.*?){!\s*@endblock\s*!}(?!.+{!\s*@endblock\s*!})/s',
45
        'block.child'            => '/{!\s*@block\s+.*?\s*!}.*({!\s*@block\s+(.*?)\s*!}(.*?){!\s*@endblock\s*!}).*{!\s*@endblock\s*!}/s',
46
        'block.print'            => '/{!\s*@block\((.*?)\)\s*!}/s',
47
        'block.super'            => '/{!\s*@super\s*!}/s',
48
        'php'                    => '/{!\s*(.+?)\s*!}/s',
49
        'comment'                => '/{#\s*(.+?)\s*#}/s',
50
        'controlStructure'       => '/@(if|else|elseif|endif|do|while|endwhile|for|endfor|foreach|endforeach|continue|switch|endswitch|break|return|require|include)/s',
51
        'controlStructure.start' => '/^@(if|else|elseif|while|for|foreach|switch)/s',
52
        'controlStructure.end'   => '/@(endif|endwhile|endfor|endforeach|endswitch)$/s',
53
        'print'                  => '/{{\s*(.+?)\s*}}/s',
54
        'print.unescaped'        => '/{{{\s*(.+?)\s*}}}/s',
55
    ];
56
57
58
    /**
59
     * Currently captured template blocks.
60
     */
61
    private array $blocks = [];
62
63
    /**
64
     * Template files directory.
65
     */
66
    protected string $templatesDirectory;
67
68
    /**
69
     * Template files file extension.
70
     */
71
    protected string $templatesFileExtension;
72
73
    /**
74
     * Template files cache directory.
75
     */
76
    protected string $cacheDirectory;
77
78
    /**
79
     * Whether or not to cache compiled template files.
80
     */
81
    protected bool $cache;
82
83
84
    /**
85
     * Whether or not to add debugging info to the compiled template.
86
     */
87
    public static bool $debug = false;
88
89
90
    /**
91
     * Class constructor.
92
     */
93 8
    public function __construct(
94
        string $templatesDirectory     = './templates',
95
        string $templatesFileExtension = '.phtml',
96
        string $cacheDirectory         = './cache/',
97
        bool $cache                    = true,
98
        bool $debug                    = false
99
    ) {
100 8
        $this->templatesDirectory     = $templatesDirectory;
101 8
        $this->templatesFileExtension = $templatesFileExtension;
102 8
        $this->cacheDirectory         = $cacheDirectory;
103 8
        $this->cache                  = $cache;
104
105 8
        $this->setDebug($debug);
106 8
    }
107
108
109
    /**
110
     * Renders a template file and pass the passed variables to it.
111
     *
112
     * @param string $file A relative path to template file from templates directory.
113
     * @param array $variables The variables to pass to the template.
114
     *
115
     * @return void
116
     */
117 3
    public function render(string $file, array $variables = []): void
118
    {
119 3
        $this->require(
120 3
            $this->getCompiledFile($file),
121
            $variables
122
        );
123 3
    }
124
125 1
    public function clearCache(): void
126
    {
127 1
        $files = glob(rtrim($this->cacheDirectory, '/') . '/*.php');
128
129 1
        array_map('unlink', $files);
130 1
    }
131
132
    /**
133
     * Compiles a template file and returns the path to the compiled template file from cache directory.
134
     *
135
     * @param string $file A relative path to template file from templates directory.
136
     *
137
     * @return string
138
     *
139
     * @throws \Exception If file does not exist.
140
     */
141 14
    public function getCompiledFile(string $file): string
142
    {
143 14
        $this->createCacheDirectory();
144
145 14
        $templateFile = $this->resolvePath($file);
146 14
        $cachedFile   = $this->resolveCachePath($file);
147
148 14
        $isCompiled = file_exists($cachedFile) && filemtime($cachedFile) > filemtime($templateFile);
149
150 14
        if (!$this->cache || !$isCompiled) {
151 11
            $content = vsprintf('<?php %s class_exists(\'%s\') or exit; ?> %s', [
152 11
                $this->cache ? '/* ' . $file . ' */' : 'unlink(__FILE__);',
153 11
                static::class,
154 11
                PHP_EOL . PHP_EOL . $this->getCompiledContent($file),
155
            ]);
156
157 11
            file_put_contents($cachedFile, $content);
158
        }
159
160 14
        return $cachedFile;
161
    }
162
163
    /**
164
     * Compiles a template file and returns the result after compilation.
165
     *
166
     * @param string $file A relative path to template file from templates directory.
167
     *
168
     * @return string
169
     *
170
     * @throws \Exception If file does not exist.
171
     */
172 13
    public function getCompiledContent(string $file): string
173
    {
174 13
        $file = $this->resolvePath($file);
175
176 13
        $this->assertFileExists($file);
177
178
        // execution order matters
179 12
        $code = $this->importDependencies($file);
180 12
        $code = $this->importIncludes($code);
181 12
        $code = $this->extractBlocks($code);
182 12
        $code = $this->injectBlocks($code);
183 12
        $code = $this->printVariables($code);
184 12
        $code = $this->wrapPhp($code);
185 12
        $code = $this->wrapComments($code);
186
187 12
        return $code;
188
    }
189
190
    /**
191
     * Evaluates a template file after compiling it in a temporary file and returns the result after evaluation.
192
     *
193
     * @param string $file A relative path to template file from templates directory.
194
     * @param array $variables The variables to pass to the template.
195
     *
196
     * @return string
197
     *
198
     * @throws \Exception If file does not exist.
199
     */
200 6
    public function getEvaluatedContent(string $file, array $variables = []): string
201
    {
202 6
        $this->createCacheDirectory();
203
204 6
        $content = $this->getCompiledContent($file);
205
206
        // an actual file is used here because 'require' does not work with 'php://temp'
207 6
        $file = tempnam($this->cacheDirectory, 'EVL');
208
209 6
        $temp = fopen($file, 'w');
210 6
        fwrite($temp, $content);
211 6
        fclose($temp);
212
213 6
        ob_start();
214 6
        $this->require($file, $variables);
215 6
        $content = ob_get_contents();
216 6
        ob_get_clean();
217
218 6
        unlink($file);
219
220 6
        return $content;
221
    }
222
223
    /**
224
     * Creates cache directory if it does not exist.
225
     *
226
     * @return void
227
     */
228 15
    private function createCacheDirectory(): void
229
    {
230 15
        if (!file_exists($this->cacheDirectory)) {
231 7
            mkdir($this->cacheDirectory, 0744, true);
232
        }
233 15
    }
234
235
    /**
236
     * Asserts that a file exists.
237
     *
238
     * @param string $file An absolute path to a file.
239
     *
240
     * @return void
241
     *
242
     * @throws \Exception If file does not exist.
243
     */
244 13
    private function assertFileExists(string $file): void
245
    {
246 13
        if (!file_exists($file)) {
247 1
            throw new \Exception(
248 1
                'Template file "' . $file . '" does not exist. ' .
249 1
                'The path is wrong. Hint: a parent directory may be missing'
250
            );
251
        }
252 12
    }
253
254
    /**
255
     * Requires a PHP file and pass it the passed variables.
256
     *
257
     * @param string $file An absolute path to the file that should be compiled.
258
     * @param array|null $variables [optional] An associative array of the variables to pass.
259
     *
260
     * @return void
261
     */
262 6
    protected static function require(string $file, ?array $variables = null): void
263
    {
264 6
        $_file = $file;
265 6
        unset($file);
266
267 6
        if ($variables !== null) {
268 6
            extract($variables, EXTR_OVERWRITE);
269 6
            unset($variables);
270
        }
271
272 6
        require($_file);
273 6
        unset($_file);
274 6
    }
275
276
    /**
277
     * Resolves a template file path.
278
     *
279
     * @param string $file The file path to resolve.
280
     *
281
     * @return string
282
     */
283 16
    protected function resolvePath(string $file): string
284
    {
285 16
        return Path::normalize(
286 16
            $this->templatesDirectory,
287 16
            $file,
288 16
            $this->templatesFileExtension
289
        );
290
    }
291
292
    /**
293
     * Resolves a template file path from cache directory.
294
     *
295
     * @param string $file The file path to resolve.
296
     *
297
     * @return string
298
     */
299 14
    protected function resolveCachePath(string $file): string
300
    {
301 14
        if (!$this->cache) {
302 2
            return Path::normalize($this->cacheDirectory, md5('temporary'), '.tmp');
303
        }
304
305 12
        $templatePath = strtr($file, [
306 12
            $this->templatesDirectory => '',
307 12
            $this->templatesFileExtension => '',
308
        ]);
309 12
        $templateName = Misc::transform($templatePath, 'snake');
310 12
        $cacheName    = $templateName . '_' . md5($file);
311
312 12
        return Path::normalize($this->cacheDirectory, $cacheName, '.php');
313
    }
314
315
    /**
316
     * Imports template dependencies.
317
     *
318
     * @param string $file The template file.
319
     *
320
     * @return string
321
     *
322
     * @throws \Exception If file does not exist.
323
     */
324 12
    final protected function importDependencies(string $file): string
325
    {
326 12
        $this->assertFileExists($file);
327
328 12
        $code = file_get_contents($file);
329
330 12
        $count = preg_match_all(
331 12
            static::REGEX['dependency'],
332
            $code,
333
            $matches,
334 12
            PREG_SET_ORDER
335 12
        ) ?: 0;
336
337 12
        for ($i = 0; $i < $count; $i++) {
338 2
            $match = $matches[$i][0];
339 2
            $type  = $matches[$i][1];
340 2
            $path  = $matches[$i][2];
341 2
            $path  = $this->resolvePath($path);
342
343 2
            $startComment = sprintf('<!-- %s::(\'%s\') [START] -->', $type, $path) . PHP_EOL;
344 2
            $endComment   = sprintf('<!-- %s::(\'%s\') [END] -->', $type, $path) . PHP_EOL;
345 2
            $content      = $this->importDependencies($path);
346 2
            $requirement  = vsprintf(
347 2
                '%s%s%s',
348 2
                $this->isDebug() ? [$startComment, $content, $endComment] : ['', $content, '']
349
            );
350
351 2
            $code = str_replace($match, $requirement, $code);
352
        }
353
354 12
        $code = preg_replace(static::REGEX['dependency'], '', $code);
355
356 12
        return $code;
357
    }
358
359
    /**
360
     * Imports template includes.
361
     *
362
     * @param string $code The template code.
363
     *
364
     * @return string
365
     */
366 12
    final protected function importIncludes(string $code): string
367
    {
368 12
        $count = preg_match_all(
369 12
            static::REGEX['include'],
370
            $code,
371
            $matches,
372 12
            PREG_SET_ORDER
373 12
        ) ?: 0;
374
375 12
        for ($i = 0; $i < $count; $i++) {
376 6
            $match = $matches[$i][0];
377 6
            $path  = $matches[$i][1];
378
379 6
            $startComment = sprintf('<!-- include::(\'%s\') [START] -->', $path) . PHP_EOL;
380 6
            $endComment   = sprintf('<!-- include::(\'%s\') [END] -->', $path) . PHP_EOL;
381 6
            $content      = $this->getEvaluatedContent($path);
382 6
            $requirement  = vsprintf(
383 6
                '%s%s%s',
384 6
                $this->isDebug() ? [$startComment, $content, $endComment] : ['', $content, '']
385
            );
386
387 6
            $code = str_replace($match, $requirement, $code);
388
        }
389
390 12
        $code = preg_replace(static::REGEX['include'], '', $code);
391
392 12
        return $code;
393
    }
394
395
    /**
396
     * Parses a template block, extract data from it, and updates class internal state.
397
     *
398
     * @param string $code The template block.
399
     *
400
     * @return string
401
     */
402 6
    final protected function parseBlock(string $code): string
403
    {
404 6
        preg_match(static::REGEX['block'], $code, $matches);
405 6
        $name  = $matches[1];
406 6
        $value = $matches[2];
407
408 6
        $comment = '';
409
410 6
        if (preg_match(static::REGEX['block.super'], $value)) {
411 2
            $value = preg_replace(
412 2
                static::REGEX['block.super'],
413 2
                sprintf('{! @block(%s) !}', $name . 'Super'),
414
                $value
415
            );
416
        }
417
418 6
        if (isset($this->blocks[$name])) {
419 2
            $this->blocks[$name . 'Super'] = $value;
420
421 2
            $comment = sprintf('<!-- block::(\'%s\') [INHERIT] -->', $name);
422
        } else {
423 6
            $this->blocks[$name] = $value;
424
425 6
            $comment = sprintf('<!-- block::(\'%s\') [ASSIGN] -->', $name);
426
        }
427
428
429 6
        return $this->isDebug() ? $comment : '';
430
    }
431
432
    /**
433
     * Extract blocks data from template code.
434
     *
435
     * @param string $code
436
     *
437
     * @return string
438
     */
439 12
    final protected function extractBlocks(string $code): string
440
    {
441 12
        $opening = '{! @block pseudo-' . md5($code) . ' !}';
442 12
        $closing = '{! @endblock !}';
443 12
        $code    = sprintf('%s%s%s', $opening, $code, $closing);
444
445 12
        while (preg_match(static::REGEX['block.child'], $code, $matches)) {
446 6
            $block = $matches[1];
447
448 6
            $code = str_replace($block, $this->parseBlock($block), $code);
449
        }
450
451 12
        $code = str_replace([$opening, $closing], ['', ''], $code);
452
453 12
        return $code;
454
    }
455
456
    /**
457
     * Injects blocks data in template code.
458
     *
459
     * @param string $code
460
     *
461
     * @return string
462
     */
463 12
    final protected function injectBlocks(string $code): string
464
    {
465 12
        while (preg_match(static::REGEX['block.print'], $code, $matches)) {
466 6
            $match = $matches[0];
467 6
            $block = $matches[1];
468
469 6
            $requirement = $this->blocks[$block] ?? '';
470
471 6
            if ($this->isDebug()) {
472 1
                $startComment     = sprintf('<!-- print::(\'%s\') [START] -->', $block) . PHP_EOL;
473 1
                $endComment       = sprintf('<!-- print::(\'%s\') [END] -->', $block) . PHP_EOL;
474 1
                $undefinedComment = sprintf('<!-- print::(\'$1\') [UNDEFINED] -->', $block);
475 1
                $requirement      = vsprintf(
476 1
                    '%s%s%s',
477 1
                    isset($this->blocks[$block]) ? [$startComment, $requirement, $endComment] : ['', $undefinedComment, '']
478
                );
479
            }
480
481 6
            $code = str_replace($match, $requirement, $code);
482
        }
483
484 12
        $this->blocks = [];
485
486 12
        return $code;
487
    }
488
489
    /**
490
     * Echos escaped and unescaped variables in template code.
491
     *
492
     * @param string $code
493
     *
494
     * @return string
495
     */
496 12
    final protected function printVariables(string $code): string
497
    {
498 12
        $comment = $this->isDebug() ? '<!-- %s::(\'$1\') [ECHO] -->' : '';
499
500 12
        $code = preg_replace(
501 12
            static::REGEX['print.unescaped'],
502 12
            sprintf($comment, 'unescapedVariable') . '<?php echo (string)($1); ?>',
503
            $code
504
        );
505
506 12
        $code = preg_replace(
507 12
            static::REGEX['print'],
508 12
            sprintf($comment, 'escapedVariable') . '<?php echo htmlentities((string)($1), ENT_QUOTES, \'UTF-8\'); ?>',
509
            $code
510
        );
511
512 12
        return $code;
513
    }
514
515
    /**
516
     * Wraps PHP in template code.
517
     *
518
     * @param string $code
519
     *
520
     * @return string
521
     */
522 12
    final protected function wrapPhp(string $code): string
523
    {
524 12
        $count = preg_match_all(
525 12
            static::REGEX['php'],
526
            $code,
527
            $matches,
528 12
            PREG_SET_ORDER
529 12
        ) ?: 0;
530
531 12
        for ($i = 0; $i < $count; $i++) {
532 2
            $match = trim($matches[$i][0]);
533 2
            $php   = trim($matches[$i][1]);
534
535 2
            $php = $this->wrapControlStructures($php);
536
537 2
            $comment     = sprintf('<!-- php::(\'%s\') [PHP] -->', $php) . PHP_EOL;
538 2
            $content     = sprintf('<?php %s ?>', $php);
539 2
            $requirement = vsprintf('%s%s', $this->isDebug() ? [$comment, $content] : ['', $content]);
540
541 2
            $code = str_replace($match, $requirement, $code);
542
        }
543
544 12
        return $code;
545
    }
546
547
    /**
548
     * Wraps control structures and PHP code in template code.
549
     *
550
     * @param string $code
551
     *
552
     * @return string
553
     */
554 2
    final protected function wrapControlStructures(string $code): string
555
    {
556 2
        if (preg_match(static::REGEX['controlStructure.start'], $code)) {
557
            // if code starts with an opening control structure
558
            // check if it ends with ':' and add ':' if it does not
559 2
            if (substr($code, 0, 1) !== ':') {
560 2
                $code = ltrim($code, '@ ') . ':';
561
            }
562
563 2
            return $code;
564
        }
565
566
        // if code ends with a closing control structure or anything else
567
        // check if it ends ';' and add ';' if it does not
568 2
        if (substr($code, -1) !== ';') {
569 2
            $code = ltrim($code, '@ ') . ';';
570
        }
571
572 2
        return $code;
573
    }
574
575
    /**
576
     * Wraps comments in template code.
577
     *
578
     * @param string $code
579
     *
580
     * @return string
581
     */
582 12
    final protected function wrapComments(string $code): string
583
    {
584 12
        $comment = $this->isDebug() ? '<!-- comment::(\'$1\') [COMMENT] -->' : '';
585
586 12
        return preg_replace(
587 12
            static::REGEX['comment'],
588 12
            $comment . '<?php /* $1 */ ?>',
589
            $code
590
        );
591
    }
592
593
    /**
594
     * Get the value of `static::$debug`.
595
     *
596
     * @return bool
597
     */
598 12
    public function isDebug(): bool
599
    {
600 12
        return self::$debug;
601
    }
602
603
    /**
604
     * Set the value of `static::$debug`
605
     *
606
     * @return $this
607
     */
608 8
    public function setDebug(bool $debug)
609
    {
610 8
        self::$debug = $debug;
611
612 8
        return $this;
613
    }
614
}
615