• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

daycry / twig / 25524567684

07 May 2026 06:21PM UTC coverage: 60.954% (-18.5%) from 79.433%
25524567684

push

github

daycry
chore(ci): bump toolchain to PHP 8.5; rector 2.x, devkit 1.3, framework 4.7

Brings the toolchain in line with what we actually want to support and what
the audit work needs to live on long-term.

composer.json
- "php": ">=8.2" (CI matrix already drops 8.1; was implicit since the
  codebase relies on enums / readonly / first-class callables).
- "rector/rector": "^2.0" — fixes incompatibility with PHP 8.5 + the
  phpdoc-parser version installed by phpstan.
- "codeigniter4/devkit": "^1.3" — required for rector ^2.0.
- "codeigniter4/framework": "^4.7" — pulls in laminas-escaper 2.18+ which
  supports PHP 8.5 (2.17 capped at 8.4).

rector.php
- Rewritten for the rector 2.x namespace layout:
  * Rector\Core\ValueObject\PhpVersion → Rector\ValueObject\PhpVersion
  * PHPUnitSetList::PHPUNIT_SPECIFIC_METHOD → PHPUNIT_CODE_QUALITY
  * PHPUNIT_80 → PHPUNIT_100 (matches the PHPUnit 10.5 we actually run)
  * Removed rules that no longer exist in 2.x (ForToForeachRector, several
    PSR4 / Php56 rules) and skips for non-existent rules.
- target phpVersion bumped to PHP_82.

phpstan.neon.dist + baseline
- excludePaths marked optional with " (?)" (required by phpstan 2.x).
- baseline migrated from phpstan-baseline.php to phpstan-baseline.neon and
  regenerated against the new rector output and stricter analyser; deleted
  the old .php file.

.github/workflows/
- phpunit / rector matrices bumped to ['8.2', '8.3', '8.4', '8.5'] (8.1
  removed); coveralls publishes from 8.5.
- phpstan matrix likewise bumped (was '7.4', '8.0', '8.1').
- phpcsfixer / deptrac / phpcpd single-version steps moved to '8.5'.
- rector.yml drops "composer global require rector:^0.14" and uses the
  pinned vendor/bin/rector instead.
- phpcpd.yml path fixed: "app/ tests/" → "src/ tests/".
- deptrac.yml path filter fixed: "depfile.yaml" → "deptrac.yaml".

Tests
- declare(strict_types=1) added to every test file by rector (auto-applied
  during the bump). Plus minor cs-fixer formatting touch-ups; n... (continued)

1010 of 1657 relevant lines covered (60.95%)

10.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

61.26
/src/Twig.php
1
<?php
2

3
namespace Daycry\Twig;
4

5
use CodeIgniter\Filters\DebugToolbar;
6
use Config\Services;
7
use Config\Toolbar;
8
use Daycry\Twig\Cache\CICacheAdapter;
9
use Daycry\Twig\Cache\TemplateCacheManager;
10
use Daycry\Twig\Config\CapabilitiesProfile;
11
use Daycry\Twig\Config\Twig as TwigConfig;
12
use Daycry\Twig\Debug\Toolbar\Collectors\Twig as CollectorsTwig;
13
use Daycry\Twig\Discovery\TemplateDiscovery;
14
use Daycry\Twig\Invalidation\TemplateInvalidator;
15
use Daycry\Twig\Logging\TwigLogger;
16
use Daycry\Twig\Profile\RenderProfiler;
17
use Daycry\Twig\Registry\DynamicRegistry;
18
use Daycry\Twig\Support\PersistenceDecoder;
19
use Daycry\Twig\Support\TemplateNameValidator;
20
use FilesystemIterator;
21
// (LoggerInterface import removed — logging now uses global log_message helper only)
22
use InvalidArgumentException;
23
use LogicException;
24
use Psr\Log\LoggerInterface;
25
use RecursiveDirectoryIterator;
26
use RecursiveIteratorIterator;
27
use SplFileInfo;
28
use Throwable;
29
use Twig\Environment;
30
use Twig\Extension\DebugExtension;
31
use Twig\Extension\ExtensionInterface;
32
use Twig\Loader\FilesystemLoader;
33
use Twig\Loader\LoaderInterface;
34
use Twig\TwigFilter;
35
use Twig\TwigFunction;
36

37
/**
38
 * Class General
39
 */
40
class Twig
41
{
42
    /**
43
     * Saved Data.
44
     */
45
    protected array $data = [];
46

47
    protected string $extension = '.twig';
48

49
    /**
50
     * @var array Paths to Twig templates
51
     */
52
    private array $paths = [APPPATH . 'Views'];
53

54
    /**
55
     * @var array Functions to add to Twig
56
     */
57
    private array $functions_asis = ['base_url', 'site_url'];
58

59
    /**
60
     * @var array Functions with `is_safe` option
61
     *
62
     * @see http://twig.sensiolabs.org/doc/advanced.html#automatic-escaping
63
     */
64
    private array $functions_safe = [
65
        'form_open', 'form_close', 'form_error', 'form_hidden', 'set_value',
66
    ];
67

68
    /**
69
     * @var array<string,array<mixed,string>|string> filters
70
     */
71
    private array $filters = [];
72

73
    /**
74
     * @var array Twig Environment Options
75
     *
76
     * @see http://twig.sensiolabs.org/doc/api.html#environment-options
77
     */
78
    private array $config = [];
79

80
    /**
81
     * https://twig.symfony.com/doc/3.x/advanced.html
82
     */
83
    private array $extensions = [];
84

85
    /**
86
     * Extensions queued before the Environment is created or manually registered after creation.
87
     *
88
     * @var list<class-string<ExtensionInterface>>
89
     */
90
    private array $pendingExtensions = [];
91

92
    /**
93
     * @var bool Whether functions are added or not
94
     */
95
    private bool $functions_added = false;
96

97
    private ?Environment $twig = null;
98

99
    /**
100
     * @class \Twig\Loader\FilesystemLoader
101
     */
102
    private ?LoaderInterface $loader = null;
103

104
    protected array $performanceData = [];
105
    protected bool $debug            = false;
106
    protected bool $saveData         = true;
107
    protected ?array $tempData       = null;
108
    protected int $viewsCount        = 0;
109

110
    // Diagnostics / instrumentation counters
111
    private int $renderCount           = 0;
112
    private int $environmentResets     = 0;
113
    private float $totalRenderTime     = 0.0;
114
    private ?array $lastWarmup         = null; // ['summary'=>array,'all'=>bool,'timestamp'=>float]
115
    private ?array $lastInvalidation   = null; // ['type'=>string,'removed'=>int,'reinit'=>bool,'timestamp'=>float]
116
    private int $cumulativeInvalidated = 0;
117

118
    /**
119
     * Capability profile derivado de configuración (leanMode + overrides).
120
     * Claves:
121
     *  - discoverySnapshot
122
     *  - warmupSummary
123
     *  - invalidationHistory
124
     *  - dynamicMetrics
125
     *  - extendedDiagnostics
126
     */
127
    private array $capabilities = [
128
        'discoverySnapshot'   => false,
129
        'warmupSummary'       => true,
130
        'invalidationHistory' => true,
131
        'dynamicMetrics'      => true,
132
        'extendedDiagnostics' => true,
133
    ];
134

135
    // (No internal logger instance retained)
136
    /**
137
     * Cache manager for compiled templates & index
138
     */
139
    private TemplateCacheManager $cacheManager;
140

141
    /**
142
     * Dynamic registry extracted (Stage3)
143
     */
144
    private readonly DynamicRegistry $dynamicRegistry;
145

146
    /**
147
     * @var array<string,bool|string> namespace specific autoescape strategies (namespace without @)
148
     */
149
    private array $autoescapeNamespaceMap = [];
150

151
    /**
152
     * In-process cache of discovered logical template names.
153
     */
154
    // Removed: handled by TemplateDiscovery service
155
    private readonly TemplateDiscovery $discovery;
156
    private readonly TemplateInvalidator $invalidator;
157

158
    // Cache backend: always attempt service('cache'). If handler is File* treat as filesystem;
159
    // otherwise wrap with CICacheAdapter. Legacy string backend replaced by boolean flag.
160
    private bool $usingCacheService = false; // true when non-file cache handler in use
161
    private ?string $cachePrefix    = null;  // resolved during initialize
162
    private int $cacheTtl           = 0;
163
    private readonly TwigLogger $log;
164
    private readonly RenderProfiler $renderProfiler;
165

166
    public function __construct(?TwigConfig $config = null, ?LoggerInterface $logger = null)
167
    {
168
        $this->log             = new TwigLogger($logger);
62✔
169
        $this->renderProfiler  = new RenderProfiler();
62✔
170
        $this->discovery       = new TemplateDiscovery();
62✔
171
        $this->cacheManager    = new TemplateCacheManager($this->extension);
62✔
172
        $this->dynamicRegistry = new DynamicRegistry();
62✔
173
        $this->invalidator     = new TemplateInvalidator($this->cacheManager, $this->discovery, $this->extension);
62✔
174
        // Set persistence path for discovery stats
175
        $persistDir = WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'twig';
62✔
176
        if (! is_dir($persistDir)) {
62✔
177
            @mkdir($persistDir, 0775, true);
1✔
178
        }
179
        $this->discovery->setPersistPath($persistDir . DIRECTORY_SEPARATOR . 'discovery-stats.json');
62✔
180
        $this->initialize($config);
62✔
181
        // After initialize decide discovery persistence medium (remote cache vs filesystem)
182
        if ($this->usingCacheService) {
62✔
183
            try {
184
                $ciCache = Services::cache();
62✔
185
                if ($ciCache && method_exists($this->discovery, 'useCiCache')) {
62✔
186
                    $this->discovery->useCiCache($ciCache, $this->cachePrefix ?? 'twig_', $this->cacheTtl ?? 0);
62✔
187
                }
188
            } catch (Throwable) { // ignore
×
189
            }
190
        }
191
        $this->discovery->loadPersisted();
62✔
192
    }
193

194
    public function initialize(?TwigConfig $config = null): Twig
195
    {
196
        if (empty($config)) {
62✔
197
            /** @var TwigConfig $config */
198
            $config = config('Twig');
2✔
199
        }
200

201
        $this->debug = ENVIRONMENT !== 'production';
62✔
202

203
        $this->extensions = $this->unique_matrix($config->extensions);
62✔
204

205
        // Logging now relies solely on the framework helper log_message(); no internal logger stored.
206

207
        if (isset($config->extension) && $config->extension !== '') {
62✔
208
            $this->extension    = $config->extension;
62✔
209
            $this->cacheManager = new TemplateCacheManager($this->extension);
62✔
210
        }
211

212
        if (isset($config->functions_asis)) {
62✔
213
            $this->functions_asis = $this->unique_matrix(array_merge($this->functions_asis, $config->functions_asis));
62✔
214
        }
215

216
        if (isset($config->functions_safe)) {
62✔
217
            $this->functions_safe = $this->unique_matrix(array_merge($this->functions_safe, $config->functions_safe));
62✔
218
        }
219

220
        if (isset($config->paths)) {
62✔
221
            $this->paths = $this->unique_matrix(array_merge($this->paths, $config->paths));
62✔
222
        }
223

224
        $this->filters = $this->unique_matrix(array_merge($this->filters, $config->filters));
62✔
225

226
        // default Twig config (allow overriding cache path via config property)
227
        $cachePath = $config->cachePath ?? (WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'twig');
62✔
228
        // Cache may be replaced later with adapter if a non-file cache service is active
229
        $this->config = [
62✔
230
            'cache'            => $cachePath,
62✔
231
            'debug'            => $this->debug,
62✔
232
            'autoescape'       => 'html',
62✔
233
            'strict_variables' => $config->strictVariables ?? false,
62✔
234
        ];
62✔
235

236
        // Backend auto-detection: always try service('cache'). Non-file handler => remote cache service.
237
        try {
238
            $svc = Services::cache();
62✔
239
            if ($svc) {
62✔
240
                $handlerClass            = $svc::class;
62✔
241
                $isFile                  = str_contains(strtolower($handlerClass), strtolower('File')); // heuristic for FileHandler
62✔
242
                $this->usingCacheService = ! $isFile;
62✔
243
            } else {
244
                $this->usingCacheService = false;
62✔
245
            }
246
        } catch (Throwable) {
×
247
            $this->usingCacheService = false;
×
248
        }
249
        $this->cachePrefix = $this->deriveCachePrefix();
62✔
250
        $this->cacheTtl    = $config->cacheTtl ?? 0;
62✔
251

252
        if (isset($config->saveData)) {
62✔
253
            $this->saveData = $config->saveData;
62✔
254
        }
255

256
        // Configure discovery performance options (introduced advanced flags)
257
        if (method_exists($this->discovery, 'configure')) {
62✔
258
            // Recompute capabilities; snapshot decision derived from lean/override only now.
259
            $this->computeCapabilities($config);
62✔
260
            $persistSnapshot = $this->capabilities['discoverySnapshot'];
62✔
261
            // Auto strategy: preload & APCu usage enabled when snapshot persistence active (cheap heuristics)
262
            $enablePreload = $persistSnapshot; // simplifies config surface
62✔
263
            $useAPCu       = $persistSnapshot && (function_exists('apcu_enabled') && apcu_enabled());
62✔
264
            $mtimeDepth    = $persistSnapshot ? 0 : 0; // depth currently fixed; retained parameter for API stability
62✔
265
            $this->discovery->configure(
62✔
266
                $persistSnapshot,
62✔
267
                $enablePreload,
62✔
268
                $useAPCu,
62✔
269
                $mtimeDepth,
62✔
270
            );
62✔
271
        }
272

273
        return $this;
62✔
274
    }
275

276
    /**
277
     * Derive final cache prefix.
278
     * New simplified rule (requested):
279
     *  - If global Config\Cache::$prefix ends with '_' (after trimming whitespace) => return '_twig_'
280
     *  - Otherwise => return 'twig_'
281
     * Rationale: avoid incorporating variable project prefixes to prevent accidental duplication
282
     * and keep key size minimal while allowing a separator when a global underscore already exists.
283
     */
284
    private function deriveCachePrefix(): string
285
    {
286
        $globalPrefix = '';
62✔
287

288
        try {
289
            $cacheCfg = config('Cache');
62✔
290
            if ($cacheCfg && property_exists($cacheCfg, 'prefix') && is_string($cacheCfg->prefix)) {
62✔
291
                $globalPrefix = trim($cacheCfg->prefix);
62✔
292
            }
293
        } catch (Throwable) { // ignore
×
294
        }
295
        if ($globalPrefix !== '' && str_ends_with($globalPrefix, '_')) {
62✔
296
            return '_twig_';
2✔
297
        }
298

299
        return 'twig_';
60✔
300
    }
301

302
    /**
303
     * Resolve final capability matrix from leanMode + nullable overrides.
304
     * Delegates to {@see \Daycry\Twig\Config\CapabilitiesProfile} so the
305
     * resolution rules can be tested independently of the Twig facade.
306
     */
307
    private function computeCapabilities(TwigConfig $config): void
308
    {
309
        $this->capabilities = CapabilitiesProfile::fromConfig($config)->toArray();
62✔
310
    }
311

312
    public function resetTwig(): void
313
    {
314
        $this->twig = null;
×
315
        if (function_exists('log_message')) {
×
316
            log_message('debug', 'event=twig.reset');
×
317
        }
318
        // Invalidate discovery cache on reset via service
319
        $this->discovery->invalidate();
×
320
        $this->createTwig();
×
321
        $this->environmentResets++;
×
322
    }
323

324
    /**
325
     * @param string $uri
326
     * @param string $title
327
     * @param array  $attributes [changed] only array is acceptable
328
     */
329
    public function safe_anchor($uri = '', $title = '', $attributes = []): string
330
    {
331
        $uri   = esc($uri, 'url');
1✔
332
        $title = esc($title);
1✔
333

334
        $new_attr = [];
1✔
335

336
        foreach ($attributes as $key => $val) {
1✔
337
            $new_attr[esc($key)] = $val;
1✔
338
        }
339

340
        return anchor($uri, $title, $new_attr);
1✔
341
    }
342

343
    /**
344
     * @codeCoverageIgnore
345
     */
346
    public function validation_list_errors(): string
347
    {
348
        return Services::validation()->listErrors();
×
349
    }
350

351
    public function getTwig(): Environment
352
    {
353
        $this->createTwig();
9✔
354

355
        return $this->twig;
9✔
356
    }
357

358
    public function getPaths(): array
359
    {
360
        return $this->paths;
6✔
361
    }
362

363
    /**
364
     * Renders Twig Template and Set Output
365
     *
366
     * @param string $view   Template filename without `.twig`
367
     * @param array  $params Array of parameters to pass to the template
368
     */
369
    public function display(string $view, array $params = [])
370
    {
371
        echo $this->render($view, $params);
1✔
372
    }
373

374
    /**
375
     * Renders Twig Template and Returns as String
376
     *
377
     * @param string $view   Template filename without `.twig`
378
     * @param array  $params Array of parameters to pass to the template
379
     */
380
    public function render(string $view, array $params = []): string
381
    {
382
        $start = microtime(true);
38✔
383
        $data  = esc($params, 'raw');
38✔
384
        $this->tempData ??= $this->data;
38✔
385
        $this->tempData = array_merge($this->tempData, $data);
38✔
386

387
        // Make our view data available to the view.
388
        $this->prepareTemplateData($this->saveData);
38✔
389

390
        $this->createTwig();
38✔
391
        // We call addFunctions() here, because we must call addFunctions()
392
        // after loading CodeIgniter functions in a controller.
393
        $this->addFunctions();
38✔
394

395
        $view .= $this->extension;
38✔
396

397
        $output = $this->twig->render($view, $params);
38✔
398
        // Update render instrumentation
399
        $end     = microtime(true);
37✔
400
        $elapsed = $end - $start;
37✔
401
        $this->renderCount++;
37✔
402
        $this->totalRenderTime += $elapsed;
37✔
403
        if ($this->capabilities['extendedDiagnostics']) {
37✔
404
            $this->renderProfiler->record($view, $elapsed);
37✔
405
        }
406

407
        // Check if DebugToolbar is enabled.
408
        $filters              = service('filters');
37✔
409
        $requiredAfterFilters = $filters->getRequiredFilters('after')[0];
37✔
410

411
        if (in_array('toolbar', $requiredAfterFilters, true)) {
37✔
412
            $debugBarEnabled = true;
37✔
413
        } else {
414
            $afterFilters    = $filters->getFiltersClass()['after'];
×
415
            $debugBarEnabled = in_array(DebugToolbar::class, $afterFilters, true);
×
416
        }
417

418
        if ($this->debug && $debugBarEnabled) {
37✔
419
            $this->logPerformance($start, microtime(true), $view);
37✔
420

421
            $toolbarCollectors = config(Toolbar::class)->collectors;
37✔
422

423
            if (in_array(CollectorsTwig::class, $toolbarCollectors, true)) {
37✔
424
                $output = '<!-- DEBUG-VIEW START ' . $view . ' -->' . PHP_EOL
×
425
                    . $output . PHP_EOL
×
426
                    . '<!-- DEBUG-VIEW ENDED ' . $view . ' -->' . PHP_EOL;
×
427
            }
428
        }
429
        $this->tempData = null;
37✔
430

431
        return $output;
37✔
432
    }
433

434
    public function createTemplate(string $template, array $params = [], bool $display = false)
435
    {
436
        $this->createTwig();
4✔
437
        $this->addFunctions();
4✔
438
        $template = $this->twig->createTemplate($template);
4✔
439
        if (! $display) {
4✔
440
            return $template->render($params);
3✔
441
        }
442
        echo $template->render($params);
1✔
443
    }
444

445
    public function getPerformanceData(): array
446
    {
447
        return $this->performanceData;
×
448
    }
449

450
    public function getData(): array
451
    {
452
        return $this->tempData ?? $this->data;
×
453
    }
454

455
    protected function createTwig(): void
456
    {
457
        if ($this->twig !== null) {
57✔
458
            return;
20✔
459
        }
460
        if ($this->loader === null) {
55✔
461
            $this->loader = new FilesystemLoader();
33✔
462
        }
463
        if ($this->loader instanceof FilesystemLoader) {
55✔
464
            $fsLoader = $this->loader; // local typed alias
33✔
465

466
            foreach ($this->paths as $path) {
33✔
467
                if (is_array($path)) {
33✔
468
                    $p = is_string($path[0]) && is_dir($path[0]) ? (realpath($path[0]) ?: $path[0]) : $path[0];
×
469
                    $fsLoader->addPath($p, $path[1]);
×
470
                } else {
471
                    $p = is_string($path) && is_dir($path) ? (realpath($path) ?: $path) : $path;
33✔
472
                    $fsLoader->addPath($p);
33✔
473
                }
474
            }
475
        }
476
        // Swap cache option with adapter when using non-file cache service.
477
        if ($this->usingCacheService) {
55✔
478
            try {
479
                $ciCache = Services::cache();
55✔
480
                if ($ciCache) {
55✔
481
                    $adapter               = new CICacheAdapter($ciCache, $this->cachePrefix ?? 'twig_', $this->cacheTtl ?? 0);
55✔
482
                    $this->config['cache'] = $adapter; // Twig accepts CacheInterface
55✔
483
                }
484
            } catch (Throwable $e) {
×
485
                $this->logSwallowed('cache.adapter.swap_failed', $e);
×
486
            }
487
        }
488
        $twig = new Environment($this->loader, $this->config);
55✔
489
        if ($this->debug) {
55✔
490
            $twig->addExtension(new DebugExtension());
55✔
491
        }
492

493
        foreach ($this->extensions as $extension) {
55✔
494
            $twig->addExtension(new $extension());
12✔
495
        }
496

497
        foreach ($this->pendingExtensions as $ext) {
55✔
498
            $twig->addExtension(new $ext());
3✔
499
        }
500
        $this->twig = $twig;
55✔
501
        if ($this->autoescapeNamespaceMap !== []) {
55✔
502
            $this->applyAutoescapeStrategy();
1✔
503
        }
504
    }
505

506
    protected function setLoader($loader)
507
    {
508
        $this->loader = $loader;
×
509
    }
510

511
    /**
512
     * Public fluent API to replace the internal Loader.
513
     * Resets the current Twig Environment so that subsequent renders
514
     * use the new loader. Existing configuration (filters/functions/extensions)
515
     * will be re-applied lazily on next render.
516
     */
517
    public function withLoader(LoaderInterface $loader): self
518
    {
519
        $this->loader          = $loader;
22✔
520
        $this->twig            = null; // force re-create
22✔
521
        $this->functions_added = false; // ensure functions re-added for new environment
22✔
522
        // Invalidate discovery cache because loader changed
523
        $this->discovery->invalidate();
22✔
524
        if (function_exists('log_message')) {
22✔
525
            log_message('debug', 'event=twig.loader.replaced loader=' . $loader::class);
22✔
526
        }
527

528
        return $this;
22✔
529
    }
530

531
    /**
532
     * Registers a Global
533
     *
534
     * @param string $name  The global name
535
     * @param mixed  $value The global value
536
     */
537
    public function addGlobal($name, $value): void
538
    {
539
        $this->createTwig();
1✔
540
        $this->twig->addGlobal($name, $value);
1✔
541
    }
542

543
    protected function addFunctions(): void
544
    {
545
        // Runs only once
546
        if ($this->functions_added) {
46✔
547
            return;
7✔
548
        }
549
        if (function_exists('log_message')) {
46✔
550
            log_message('debug', 'event=twig.functions.start');
46✔
551
        }
552

553
        // Attempt to autoload helpers if configured functions not yet defined.
554
        $maybeMissing = array_merge($this->functions_asis, $this->functions_safe);
46✔
555
        $needHelpers  = [];
46✔
556

557
        foreach ($maybeMissing as $fn) {
46✔
558
            if (! function_exists($fn)) {
46✔
559
                $needHelpers[$fn] = true;
46✔
560
            }
561
        }
562
        if ($needHelpers !== []) {
46✔
563
            // Common CodeIgniter helpers that define many of these
564
            $candidateHelpers = ['form', 'security', 'url', 'text'];
46✔
565

566
            foreach ($candidateHelpers as $h) {
46✔
567
                if (function_exists('helper')) {
46✔
568
                    helper($h);
46✔
569
                }
570
            }
571
            // Recheck; custom minifier/lang might come from project-specific helpers
572
            if (function_exists('helper')) {
46✔
573
                if (isset($needHelpers['minifier'])) {
46✔
574
                    @helper('minifier');
×
575
                }
576
                if (isset($needHelpers['lang'])) {
46✔
577
                    @helper('language');
×
578
                }
579
            }
580
        }
581

582
        // as-is functions (register only those that now exist)
583
        foreach ($this->functions_asis as $function) {
46✔
584
            if (function_exists($function)) {
46✔
585
                $this->twig->addFunction(new TwigFunction($function, $function));
46✔
586
            }
587
        }
588

589
        // safe functions
590
        foreach ($this->functions_safe as $function) {
46✔
591
            if (function_exists($function)) {
46✔
592
                $this->twig->addFunction(new TwigFunction($function, $function, ['is_safe' => ['html']]));
46✔
593
            }
594
        }
595

596
        // static filters from config (always safe html here to preserve previous behavior)
597
        foreach ($this->filters as $name => $filter) {
46✔
598
            $this->twig->addFilter(new TwigFilter($name, $filter, ['is_variadic' => true, 'is_safe' => ['html']]));
11✔
599
        }
600
        // apply dynamic filters/functions via registry (includes queued & persisted)
601
        $this->dynamicRegistry->apply($this->twig);
46✔
602

603
        // customized functions
604
        if (function_exists('anchor')) {
46✔
605
            $this->twig->addFunction(new TwigFunction('anchor', $this->safe_anchor(...), ['is_safe' => ['html']]));
46✔
606
        }
607

608
        $this->twig->addFunction(new TwigFunction('validation_list_errors', $this->validation_list_errors(...), ['is_safe' => ['html']]));
46✔
609
        // dynamic registry apply manages its own internal queues
610

611
        $this->functions_added = true;
46✔
612
        if (function_exists('log_message')) {
46✔
613
            log_message('debug', 'event=twig.functions.ready');
46✔
614
        }
615
    }
616

617
    /**
618
     * Register a Twig function dynamically (available in subsequent renders).
619
     *
620
     * @param mixed $options
621
     */
622
    public function registerFunction(string $name, callable $callable, $options = []): self
623
    {
624
        // Backward compatibility: boolean indicates safe html
625
        if (is_bool($options)) {
9✔
626
            $options = $options ? ['is_safe' => ['html']] : [];
2✔
627
        }
628
        if (! is_array($options)) {
9✔
629
            throw new InvalidArgumentException('Function options must be array or bool.');
×
630
        }
631
        $this->dynamicRegistry->registerFunction($name, $callable, $options, $this->twig, $this->functions_added);
9✔
632

633
        return $this;
9✔
634
    }
635

636
    /**
637
     * Register a Twig filter dynamically.
638
     *
639
     * @param mixed $options
640
     */
641
    public function registerFilter(string $name, callable $callable, $options = ['is_safe' => ['html']]): self
642
    {
643
        if (is_bool($options)) { // backward compatibility bool parameter
8✔
644
            $options = $options ? ['is_safe' => ['html']] : [];
3✔
645
        }
646
        if (! is_array($options)) {
8✔
647
            throw new InvalidArgumentException('Filter options must be array or bool.');
×
648
        }
649
        $this->dynamicRegistry->registerFilter($name, $callable, $options, $this->twig, $this->functions_added);
8✔
650

651
        return $this;
8✔
652
    }
653

654
    /**
655
     * Logs performance data for rendering a view.
656
     */
657
    protected function logPerformance(float $start, float $end, string $view): void
658
    {
659
        $this->performanceData[] = [
37✔
660
            'start' => $start,
37✔
661
            'end'   => $end,
37✔
662
            'view'  => $view,
37✔
663
        ];
37✔
664
    }
665

666
    protected function prepareTemplateData(bool $saveData): void
667
    {
668
        $this->tempData ??= $this->data;
38✔
669

670
        if ($saveData) {
38✔
671
            $this->data = $this->tempData;
38✔
672
        }
673
    }
674

675
    private function unique_matrix(array $matrix): array
676
    {
677
        // Preserve keys for associative arrays (e.g. filters => callable) and
678
        // perform value-based deduplication for sequential (indexed) arrays.
679

680
        $isAssoc = array_keys($matrix) !== range(0, count($matrix) - 1);
62✔
681

682
        if ($isAssoc) {
62✔
683
            // Keep first occurrence of each key only.
684
            $result = [];
62✔
685

686
            foreach ($matrix as $k => $v) {
62✔
687
                if (! array_key_exists($k, $result)) {
17✔
688
                    $result[$k] = $v;
17✔
689
                }
690
            }
691

692
            return $result;
62✔
693
        }
694

695
        // Indexed array branch: linear de-dup preserving order.
696
        $seen   = [];
62✔
697
        $result = [];
62✔
698

699
        foreach ($matrix as $item) {
62✔
700
            $key = is_array($item)
62✔
701
                ? 'a:' . json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
×
702
                : 's:' . $item;
62✔
703
            if (isset($seen[$key])) {
62✔
704
                continue;
62✔
705
            }
706
            $seen[$key] = true;
62✔
707
            $result[]   = $item;
62✔
708
        }
709

710
        return $result;
62✔
711
    }
712

713
    /**
714
     * Returns the configured Twig cache directory path (ensures it exists).
715
     */
716
    public function getCachePath(): string
717
    {
718
        $this->createTwig();
13✔
719
        if ($this->usingCacheService) {
13✔
720
            return ''; // no filesystem path when using remote cache service
13✔
721
        }
722
        $cache = $this->config['cache'] ?? '';
×
723
        if (is_string($cache) && $cache !== '' && ! is_dir($cache)) {
×
724
            @mkdir($cache, 0775, true);
×
725
        }
726

727
        return is_string($cache) ? $cache : '';
×
728
    }
729

730
    /**
731
     * Clears compiled Twig templates. If $reinitialize is true, resets
732
     * the Twig Environment so new templates will be recompiled lazily.
733
     * Returns number of removed files.
734
     */
735
    public function clearCache(bool $reinitialize = false): int
736
    {
737
        if ($this->usingCacheService) {
2✔
738
            $this->createTwig();
2✔
739
            $removed = 0; // we cannot count precisely without scanning index again; adapter handles clear
2✔
740

741
            try {
742
                $cacheObj = $this->config['cache'];
2✔
743
                if ($cacheObj instanceof CICacheAdapter) {
2✔
744
                    $cacheObj->clear();
2✔
745
                }
746

747
                // Also remove persisted artifact keys stored in CI cache
748
                try {
749
                    $ciCache = Services::cache();
2✔
750
                    if ($ciCache) {
2✔
751
                        $prefix = $this->cachePrefix ?? 'twig_';
2✔
752
                        $keys   = [
2✔
753
                            $prefix . 'disc.stats',
2✔
754
                            $prefix . 'disc.list',
2✔
755
                            $prefix . 'warmup.summary',
2✔
756
                            $prefix . 'compile.index',
2✔
757
                            $prefix . 'invalidations',
2✔
758
                        ];
2✔
759

760
                        foreach ($keys as $k) {
2✔
761
                            $ciCache->delete($k);
2✔
762
                        }
763
                    }
764
                } catch (Throwable $e) {
×
765
                    $this->logSwallowed('cache.clear.ci.delete', $e);
×
766
                }
767
                // Reset in-memory tracking
768
                $this->lastWarmup            = null;
2✔
769
                $this->lastInvalidation      = null;
2✔
770
                $this->cumulativeInvalidated = 0;
2✔
771
                $this->discovery->invalidate(); // reset discovery cache stats (will persist fresh on next use)
2✔
772
            } catch (Throwable $e) {
×
773
                $this->logSwallowed('cache.clear.ci.outer', $e);
×
774
            }
775
            if ($reinitialize) {
2✔
776
                $this->resetTwig();
×
777
            }
778
            if (function_exists('log_message')) {
2✔
779
                log_message('info', 'event=twig.cache.cleared backend=ci');
2✔
780
            }
781

782
            return $removed;
2✔
783
        }
784
        $cachePath = $this->getCachePath();
×
785
        if ($cachePath === '' || ! is_dir($cachePath)) {
×
786
            return 0;
×
787
        }
788
        $removed  = 0;
×
789
        $iterator = new RecursiveIteratorIterator(
×
790
            new RecursiveDirectoryIterator($cachePath, FilesystemIterator::SKIP_DOTS),
×
791
            RecursiveIteratorIterator::CHILD_FIRST,
×
792
        );
×
793

794
        /** @var SplFileInfo $file */
795
        foreach ($iterator as $file) {
×
796
            $path = $file->getPathname();
×
797
            if ($file->isFile()) {
×
798
                if (@unlink($path)) {
×
799
                    $removed++;
×
800
                }
801
            }
802
        }
803
        if ($reinitialize) {
×
804
            $this->resetTwig();
×
805
        }
806
        if (function_exists('log_message')) {
×
807
            log_message('info', 'event=twig.cache.cleared removed=' . $removed . ' reinit=' . (int) $reinitialize);
×
808
        }
809

810
        return $removed;
×
811
    }
812

813
    /**
814
     * Attempts to invalidate a single template's compiled cache file(s).
815
     * Best-effort: Twig's default compiled filenames are hashes of the logical name,
816
     * so we search for files containing the md5 of the logical template (with extension).
817
     * Returns number of files removed.
818
     */
819
    public function invalidateTemplate(string $logicalName, bool $reinitialize = false): int
820
    {
821
        $logicalName = TemplateNameValidator::assertValid($logicalName);
2✔
822
        $this->createTwig();
2✔
823
        $cacheDir = $this->getCachePath();
2✔
824
        $removed  = $this->invalidator->invalidateOne($logicalName, $cacheDir, $reinitialize, fn () => $this->resetTwig(), static function ($level, $msg) { if (function_exists('log_message')) { log_message($level, $msg); } });
2✔
825
        if ($removed > 0) {
2✔
826
            $this->saveCompileIndex();
×
827
            $this->cumulativeInvalidated += $removed;
×
828
            $this->lastInvalidation = ['type' => 'single', 'removed' => $removed, 'reinit' => $reinitialize, 'timestamp' => microtime(true)];
×
829
            $this->saveInvalidationsState();
×
830
        }
831

832
        return $removed;
2✔
833
    }
834

835
    /**
836
     * Precompiles (warms) a list of logical template names (without extension).
837
     * Skips templates whose compiled cache already exists unless $force = true.
838
     * Returns array with keys: compiled (int), skipped (int), errors (int).
839
     *
840
     * @param list<string> $templates
841
     */
842
    public function warmup(array $templates, bool $force = false): array
843
    {
844
        $this->createTwig();
4✔
845
        // Ensure functions/filters/extensions are applied so templates referencing them compile.
846
        $this->addFunctions();
4✔
847
        $cacheDir     = $this->getCachePath();
4✔
848
        $compiled     = $skipped = $errors = 0;
4✔
849
        $errorDetails = [];
4✔
850
        $this->loadCompileIndex();
4✔
851
        // Pre-build a single hash->present index so the per-template lookup is O(1)
852
        // instead of O(files) for each call to templateIsCompiled().
853
        $compiledHashIndex = $force ? [] : $this->buildCompiledHashIndex($cacheDir);
4✔
854

855
        foreach ($templates as $logical) {
4✔
856
            $logical = trim($logical);
4✔
857
            if ($logical === '') {
4✔
858
                continue;
×
859
            }
860
            $name = $logical . $this->extension;
4✔
861
            $hash = md5($name);
4✔
862
            // Use cache manager state or pre-built hash index (avoids repeated recursive scans).
863
            $already = $this->cacheManager->isCompiled($logical) || isset($compiledHashIndex[$hash]);
4✔
864
            if ($already && ! $force) {
4✔
865
                $skipped++;
1✔
866

867
                continue;
1✔
868
            }
869

870
            try {
871
                // loadTemplate triggers compilation; discard returned template
872
                $this->twig->load($name);
4✔
873
                $this->cacheManager->markCompiled($logical);
4✔
874
                $compiled++;
4✔
875
                if (function_exists('log_message')) {
4✔
876
                    log_message('info', 'event=twig.warmup.compiled template=' . $logical);
4✔
877
                }
878
            } catch (Throwable $e) {
×
879
                $errors++;
×
880
                $msg = str_replace(["\n", "\r"], ' ', $e->getMessage());
×
881
                if (function_exists('log_message')) {
×
882
                    log_message('error', 'event=twig.warmup.error template=' . $logical . ' message=' . $msg);
×
883
                }
884
                // Collect details when verbose diagnostic env flag set
885
                if (getenv('TWIG_WARMUP_VERBOSE')) {
×
886
                    $errorDetails[] = ['template' => $logical, 'error' => $msg];
×
887
                }
888
            }
889
        }
890
        if ($compiled > 0) {
4✔
891
            $this->saveCompileIndex();
4✔
892
        }
893
        $summary = ['compiled' => $compiled, 'skipped' => $skipped, 'errors' => $errors];
4✔
894
        if ($errorDetails !== []) {
4✔
895
            $summary['error_details'] = $errorDetails;
×
896
        }
897
        $this->lastWarmup = ['summary' => $summary, 'all' => false, 'timestamp' => microtime(true)];
4✔
898
        $this->saveWarmupSummary();
4✔
899
        // Dispatch post-warmup event (subset) for external cache invalidation hooks
900
        if (function_exists('event')) {
4✔
901
            try {
902
                @event('twig:warmup:after', $summary + ['mode' => 'subset']);
×
903
            } catch (Throwable $e) {
×
904
                $this->logSwallowed('warmup.event.subset', $e);
×
905
            }
906
        }
907

908
        return $summary;
4✔
909
    }
910

911
    /**
912
     * Attempts to warm all templates discovered in configured loader paths.
913
     * Only works for FilesystemLoader. Non-recursive by namespace; recursively scans directories.
914
     */
915
    public function warmupAll(bool $force = false): array
916
    {
917
        if (! $this->loader instanceof FilesystemLoader) {
1✔
918
            return ['compiled' => 0, 'skipped' => 0, 'errors' => 0];
1✔
919
        }
920
        // Reuse discovery (benefits from in-process cache)
921
        $templates = $this->listAllLogicalTemplates();
×
922
        $result    = $this->warmup($templates, $force);
×
923
        // Override lastWarmup flag to indicate full warmup
924
        if ($this->lastWarmup !== null) {
×
925
            $this->lastWarmup['all'] = true;
×
926
            $this->saveWarmupSummary();
×
927
        }
928
        // Explicit full warmup event (include list size only to avoid large payload)
929
        if (function_exists('event')) {
×
930
            $payload = $result + ['mode' => 'all', 'template_count' => count($templates)];
×
931

932
            try {
933
                @event('twig:warmup:after', $payload);
×
934
            } catch (Throwable $e) {
×
935
                $this->logSwallowed('warmup.event.all', $e);
×
936
            }
937
        }
938

939
        return $result;
×
940
    }
941

942
    /**
943
     * Scan the compiled-template directory once and return a `hash => true` map
944
     * for fast batch lookup. Twig stores compiled classes as `<2 hex>/<32 hex>.php`
945
     * (or as `<32 hex>.php` directly), so the hex prefix of the filename is the
946
     * full md5 of `<logical>.<extension>`.
947
     *
948
     * @return array<string,bool>
949
     */
950
    private function buildCompiledHashIndex(string $cacheDir): array
951
    {
952
        if ($cacheDir === '' || ! is_dir($cacheDir)) {
4✔
953
            return [];
4✔
954
        }
955
        $index = [];
×
956

957
        try {
958
            $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS));
×
959

960
            /** @var SplFileInfo $fi */
961
            foreach ($it as $fi) {
×
962
                if (! $fi->isFile()) {
×
963
                    continue;
×
964
                }
965
                $name = $fi->getFilename();
×
966
                // Twig compiled filenames begin with the md5 of the logical name+extension.
967
                if (preg_match('/^([a-f0-9]{32})/', $name, $m) === 1) {
×
968
                    $index[$m[1]] = true;
×
969
                }
970
            }
971
        } catch (Throwable $e) {
×
972
            $this->logSwallowed('warmup.scan_failed', $e);
×
973
        }
974

975
        return $index;
×
976
    }
977

978
    /**
979
     * Registers a Twig Extension dynamically. If the Environment already exists
980
     * the extension is added immediately, otherwise it is queued.
981
     *
982
     * @param class-string<ExtensionInterface> $extensionFqcn
983
     */
984
    public function registerExtension(string $extensionFqcn): self
985
    {
986
        if (! in_array($extensionFqcn, $this->pendingExtensions, true) && ! in_array($extensionFqcn, $this->extensions, true)) {
3✔
987
            if ($this->twig !== null) {
3✔
988
                try {
989
                    $this->twig->addExtension(new $extensionFqcn());
2✔
990
                    if (function_exists('log_message')) {
1✔
991
                        log_message('info', 'event=twig.extension.registered extension=' . $extensionFqcn);
1✔
992
                    }
993
                } catch (LogicException) {
1✔
994
                    // Extensions already initialized: queue and force recreation
995
                    $this->pendingExtensions[] = $extensionFqcn;
1✔
996
                    $this->twig                = null;
1✔
997
                    $this->functions_added     = false;
1✔
998
                    if (function_exists('log_message')) {
1✔
999
                        log_message('info', 'event=twig.extension.queued_recreate extension=' . $extensionFqcn);
1✔
1000
                    }
1001
                }
1002
            } else {
1003
                $this->pendingExtensions[] = $extensionFqcn;
2✔
1004
                if (function_exists('log_message')) {
2✔
1005
                    log_message('info', 'event=twig.extension.queued extension=' . $extensionFqcn);
2✔
1006
                }
1007
            }
1008
        }
1009

1010
        return $this;
3✔
1011
    }
1012

1013
    /**
1014
     * Unregister a previously registered Twig Extension (dynamic only, not those from config).
1015
     */
1016
    public function unregisterExtension(string $extensionFqcn): bool
1017
    {
1018
        $removed = false;
1✔
1019
        // remove from pending first
1020
        $idx = array_search($extensionFqcn, $this->pendingExtensions, true);
1✔
1021
        if ($idx !== false) {
1✔
1022
            array_splice($this->pendingExtensions, $idx, 1);
1✔
1023
            $removed = true;
1✔
1024
        }
1025
        // extensions loaded at construction are in $this->extensions (config) – do not remove those
1026
        if ($removed && $this->twig !== null) {
1✔
1027
            // rebuild environment without extension
1028
            $this->twig            = null;
1✔
1029
            $this->functions_added = false;
1✔
1030
        } elseif (! $removed && $this->twig !== null) {
×
1031
            // If the extension was added dynamically after creation we cannot introspect easily; force rebuild and skip re-adding
1032
            if (in_array($extensionFqcn, $this->extensions, true)) {
×
1033
                // cannot remove config extension
1034
                return false;
×
1035
            }
1036
            // There's a chance it's a dynamically added one not in pending (already applied). We rebuild and mark removed by preventing requeue.
1037
            $removed               = true; // treat as removed for caller
×
1038
            $this->twig            = null;
×
1039
            $this->functions_added = false;
×
1040
        }
1041
        if ($removed && function_exists('log_message')) {
1✔
1042
            log_message('info', 'event=twig.extension.unregistered extension=' . $extensionFqcn);
1✔
1043
        }
1044

1045
        return $removed;
1✔
1046
    }
1047

1048
    /**
1049
     * Unregister a dynamically registered function.
1050
     */
1051
    public function unregisterFunction(string $name): bool
1052
    {
1053
        $removed = $this->dynamicRegistry->unregisterFunction($name);
1✔
1054
        if ($removed) {
1✔
1055
            $this->twig            = null;
1✔
1056
            $this->functions_added = false;
1✔
1057
        }
1058

1059
        return $removed;
1✔
1060
    }
1061

1062
    /**
1063
     * Unregister a dynamically registered filter.
1064
     */
1065
    public function unregisterFilter(string $name): bool
1066
    {
1067
        $removed = $this->dynamicRegistry->unregisterFilter($name);
1✔
1068
        if ($removed) {
1✔
1069
            $this->twig            = null;
1✔
1070
            $this->functions_added = false;
1✔
1071
        }
1072

1073
        return $removed;
1✔
1074
    }
1075

1076
    /**
1077
     * Disable template cache (optionally deleting existing compiled templates).
1078
     */
1079
    public function disableCache(bool $deleteExisting = false): void
1080
    {
1081
        if ($deleteExisting) {
×
1082
            $this->clearCache(false);
×
1083
        }
1084
        $this->config['cache'] = false;
×
1085
        $this->twig            = null;
×
1086
        $this->functions_added = false;
×
1087
        if (function_exists('log_message')) {
×
1088
            log_message('info', 'event=twig.cache.disabled');
×
1089
        }
1090
    }
1091

1092
    /**
1093
     * Enable template cache (optionally with custom path).
1094
     */
1095
    public function enableCache(?string $path = null): void
1096
    {
1097
        if ($path !== null) {
×
1098
            $this->config['cache'] = $path;
×
1099
        } elseif (! isset($this->config['cache']) || $this->config['cache'] === false) {
×
1100
            // default path
1101
            $this->config['cache'] = WRITEPATH . 'cache' . DIRECTORY_SEPARATOR . 'twig';
×
1102
        }
1103
        $this->getCachePath(); // ensure exists
×
1104
        $this->twig            = null;
×
1105
        $this->functions_added = false;
×
1106
        if (function_exists('log_message')) {
×
1107
            log_message('info', 'event=twig.cache.enabled path=' . $this->config['cache']);
×
1108
        }
1109
    }
1110

1111
    public function isCacheEnabled(): bool
1112
    {
1113
        return $this->usingCacheService || ! empty($this->config['cache']);
7✔
1114
    }
1115

1116
    /** Set autoescape strategy for a namespace (namespace without leading @). */
1117
    /**
1118
     * Set autoescape strategy for a namespace (pass string strategy like 'html','js','css' or false to disable).
1119
     *
1120
     * @param mixed $strategy string strategy or false
1121
     */
1122
    public function setAutoescapeForNamespace(string $namespace, $strategy): self
1123
    {
1124
        $namespace                                = ltrim($namespace, '@');
1✔
1125
        $this->autoescapeNamespaceMap[$namespace] = $strategy;
1✔
1126
        if ($this->twig !== null) {
1✔
1127
            $this->applyAutoescapeStrategy();
×
1128
        }
1129
        if (function_exists('log_message')) {
1✔
1130
            $strategyStr = is_bool($strategy) ? ($strategy ? 'true' : 'false') : (string) $strategy;
1✔
1131
            log_message('info', 'event=twig.autoescape.namespace.set namespace=' . $namespace . ' strategy=' . $strategyStr);
1✔
1132
        }
1133

1134
        return $this;
1✔
1135
    }
1136

1137
    public function removeAutoescapeForNamespace(string $namespace): bool
1138
    {
1139
        $namespace = ltrim($namespace, '@');
×
1140
        if (! isset($this->autoescapeNamespaceMap[$namespace])) {
×
1141
            return false;
×
1142
        }
1143
        unset($this->autoescapeNamespaceMap[$namespace]);
×
1144
        if ($this->twig !== null) {
×
1145
            $this->applyAutoescapeStrategy();
×
1146
        }
1147
        if (function_exists('log_message')) {
×
1148
            log_message('info', 'event=twig.autoescape.namespace.removed namespace=' . $namespace);
×
1149
        }
1150

1151
        return true;
×
1152
    }
1153

1154
    /**
1155
     * Dynamically add a template path at runtime (optionally with a namespace).
1156
     * If the Twig Environment is already created, the loader is updated immediately
1157
     * and discovery cache invalidated so subsequent warmup/list operations see it.
1158
     *
1159
     * Examples:
1160
     *   $twig->addPath(APPPATH.'Modules/Admin/Cookies/Views', 'corporateCookies');
1161
     *   $twig->addPath(APPPATH.'Another/Views'); // main namespace
1162
     */
1163
    public function addPath(string $path, ?string $namespace = null): self
1164
    {
1165
        if ($path === '' || str_contains($path, "\0")) {
×
1166
            throw new InvalidArgumentException('Twig::addPath: path must be a non-empty string without null bytes.');
×
1167
        }
1168
        if ($namespace !== null) {
×
1169
            $namespace = TemplateNameValidator::assertValidNamespace($namespace);
×
1170
            $namespace = is_string($namespace) ? ltrim($namespace, '@') : null;
×
1171
        }
1172
        $entry         = $namespace ? [$path, $namespace] : $path;
×
1173
        $this->paths[] = $entry;
×
1174
        // Update loader if already instantiated
1175
        if ($this->loader instanceof FilesystemLoader) {
×
1176
            if ($namespace) {
×
1177
                $this->loader->addPath($path, $namespace);
×
1178
            } else {
1179
                $this->loader->addPath($path);
×
1180
            }
1181
        }
1182
        // Invalidate discovery so new path is included
1183
        $this->discovery->invalidate();
×
1184
        if (function_exists('log_message')) {
×
1185
            log_message('info', 'event=twig.path.added path=' . $path . ' namespace=' . $namespace);
×
1186
        }
1187

1188
        return $this;
×
1189
    }
1190

1191
    /**
1192
     * Apply current autoescape strategy map to the EscaperExtension.
1193
     */
1194
    private function applyAutoescapeStrategy(): void
1195
    {
1196
        if ($this->twig === null) {
1✔
1197
            return;
×
1198
        }
1199

1200
        try {
1201
            $ext = $this->twig->getExtension('Twig\\Extension\\EscaperExtension');
1✔
1202
            if (method_exists($ext, 'setDefaultStrategy')) {
1✔
1203
                $map      = $this->autoescapeNamespaceMap;
1✔
1204
                $default  = 'html';
1✔
1205
                $callable = static function (string $name) use ($map, $default): string|bool {
1✔
1206
                    // Determine namespace from template name (@ns/...) pattern
1207
                    if (isset($name[0]) && $name[0] === '@') {
1✔
1208
                        $pos = strpos($name, '/');
×
1209
                        if ($pos !== false) {
×
1210
                            $ns = substr($name, 1, $pos - 1);
×
1211
                            if (array_key_exists($ns, $map)) {
×
1212
                                return $map[$ns];
×
1213
                            }
1214
                        }
1215
                    }
1216

1217
                    return $default; // fallback
1✔
1218
                };
1✔
1219
                $ext->setDefaultStrategy($map === [] ? 'html' : $callable);
1✔
1220
            }
1221
        } catch (Throwable) {
×
1222
            // ignore; escaper extension not available yet
1223
        }
1224
    }
1225

1226
    /**
1227
     * Get path to compile index file.
1228
     */
1229
    public function getCompileIndexPath(): string
1230
    {
1231
        if ($this->usingCacheService) {
×
1232
            return '';
×
1233
        }
1234

1235
        return rtrim($this->getCachePath(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'compile-index.json';
×
1236
    }
1237

1238
    private function loadCompileIndex(): void
1239
    {
1240
        static $loadedByPath = [];
9✔
1241
        if ($this->usingCacheService) {
9✔
1242
            $key = ($this->cachePrefix ?? 'twig_') . 'compile.index';
9✔
1243
            if (isset($loadedByPath[$key])) {
9✔
1244
                return;
9✔
1245
            }
1246

1247
            try {
1248
                $cache = Services::cache();
1✔
1249
                if ($cache) {
1✔
1250
                    $raw = $cache->get($key);
1✔
1251
                    if (is_string($raw)) {
1✔
1252
                        $data = json_decode($raw, true);
×
1253
                        if (is_array($data)) {
×
1254
                            $names = [];
×
1255

1256
                            foreach ($data as $k => $v) {
×
1257
                                if (is_string($k) && ($v === true || $v === 1)) {
×
1258
                                    $names[] = $k;
×
1259
                                }
1260
                            }
1261
                            if ($names !== []) {
×
1262
                                $this->cacheManager->seedCompiled($names);
1✔
1263
                            }
1264
                        }
1265
                    }
1266
                }
1267
            } catch (Throwable) { // ignore
×
1268
            }
1269
            $loadedByPath[$key] = true;
1✔
1270

1271
            return;
1✔
1272
        }
1273
        $path = $this->getCompileIndexPath();
×
1274
        if (isset($loadedByPath[$path])) {
×
1275
            return;
×
1276
        }
1277
        $this->cacheManager->loadIndex($path);
×
1278
        $loadedByPath[$path] = true;
×
1279
    }
1280

1281
    /**
1282
     * Heuristic fallback: if the compile index is empty but cache directory contains compiled Twig
1283
     * PHP classes (common after upgrade or manual cache copy), we reconstruct a synthetic index so
1284
     * diagnostics show a realistic compiled_templates count. We cannot map back to logical template
1285
     * names without embedded metadata, so we generate placeholder logical IDs (unknown_N). This only
1286
     * runs once per request and only when count=0.
1287
     */
1288
    private function rebuildCompileIndexIfEmpty(): void
1289
    {
1290
        if ($this->usingCacheService) {
7✔
1291
            // CI backend already persisted index as JSON; skip heuristic.
1292
            return;
7✔
1293
        }
1294
        $indexPath = $this->getCompileIndexPath();
×
1295
        if ($indexPath === '') {
×
1296
            return;
×
1297
        }
1298
        // If we already have entries, nothing to do.
1299
        if (count($this->cacheManager->getCompiledTemplates()) > 0) {
×
1300
            return;
×
1301
        }
1302
        $cacheDir = $this->getCachePath();
×
1303
        if ($cacheDir === '' || ! is_dir($cacheDir)) {
×
1304
            return;
×
1305
        }
1306
        $compiledFiles = [];
×
1307

1308
        try {
1309
            $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cacheDir, FilesystemIterator::SKIP_DOTS));
×
1310

1311
            /** @var SplFileInfo $fi */
1312
            foreach ($it as $fi) {
×
1313
                if (! $fi->isFile()) {
×
1314
                    continue;
×
1315
                }
1316
                // Quick reject: only PHP files
1317
                if (! str_ends_with($fi->getFilename(), '.php')) {
×
1318
                    continue;
×
1319
                }
1320
                // Cheap content scan (first 2KB) for Twig template class marker
1321
                $chunk = @file_get_contents($fi->getPathname(), false, null, 0, 2048);
×
1322
                if (is_string($chunk) && str_contains($chunk, 'class __TwigTemplate_')) {
×
1323
                    $compiledFiles[] = $fi->getPathname();
×
1324
                }
1325
            }
1326
        } catch (Throwable) { // ignore scan errors
×
1327
        }
1328
        if ($compiledFiles === []) {
×
1329
            return; // nothing to rebuild
×
1330
        }
1331
        // Seed synthetic logical names
1332
        $i = 1;
×
1333

1334
        foreach ($compiledFiles as $_) {
×
1335
            $this->cacheManager->markCompiled('unknown_' . $i++);
×
1336
        }
1337
        // Persist synthetic index (best-effort)
1338
        $this->saveCompileIndex();
×
1339
        // Annotate that we reconstructed so UI/diagnostics can clarify
1340
        $this->reconstructedIndex = true; // dynamic property; minimal impact
×
1341
    }
1342

1343
    private function saveCompileIndex(): void
1344
    {
1345
        if ($this->usingCacheService) {
4✔
1346
            try {
1347
                $cache = Services::cache();
4✔
1348
                if ($cache) {
4✔
1349
                    $key = ($this->cachePrefix ?? 'twig_') . 'compile.index';
4✔
1350
                    $cache->save($key, json_encode($this->cacheManager->getCompiledTemplates(), JSON_UNESCAPED_SLASHES), $this->cacheTtl ?? 0);
4✔
1351
                }
1352
            } catch (Throwable) { // ignore
×
1353
            }
1354

1355
            return;
4✔
1356
        }
1357
        $path = $this->getCompileIndexPath();
×
1358
        if ($path === '' || ! is_dir(dirname($path))) {
×
1359
            return;
×
1360
        }
1361
        $this->cacheManager->saveIndex($path);
×
1362
    }
1363

1364
    /**
1365
     * Public listing API.
1366
     * Parameters:
1367
     *  - $withStatus: include compiled status if true
1368
     *  - $namespace: filter by namespace (accepts with or without leading @). If null, returns all.
1369
     *  - $pattern: optional glob-like filter applied to the logical template (after namespace), supports * and ?
1370
     *      Examples: 'admin/*', 'emails/user_*', '*partial', 'dash??/index'
1371
     *      If $namespace provided, pattern does not need to repeat namespace (applies inside namespace root).
1372
     */
1373
    public function listTemplates(bool $withStatus = false, ?string $namespace = null, ?string $pattern = null): array
1374
    {
1375
        $names = $this->listAllLogicalTemplates();
5✔
1376
        // Normalize namespace
1377
        $nsFilter = null;
5✔
1378
        if ($namespace !== null && $namespace !== '') {
5✔
1379
            $nsFilter = ltrim($namespace, '@');
×
1380
        }
1381
        $filtered = [];
5✔
1382
        if ($nsFilter === null && $pattern === null) {
5✔
1383
            $filtered = $names; // fast path no filtering
5✔
1384
        } else {
1385
            // Pre-build regex if pattern has wildcards
1386
            $regex   = null;
×
1387
            $hasWild = false;
×
1388
            if ($pattern !== null && $pattern !== '') {
×
1389
                $hasWild = strpbrk($pattern, '*?') !== false;
×
1390
                if ($hasWild) {
×
1391
                    // escape regex delimiters then replace wildcards
1392
                    $rx    = preg_quote($pattern, '/');
×
1393
                    $rx    = str_replace(['\\*', '\\?'], ['.*', '.?'], $rx);
×
1394
                    $regex = '/^' . $rx . '$/i';
×
1395
                }
1396
            }
1397

1398
            foreach ($names as $logical) {
×
1399
                $logicalNs        = null;
×
1400
                $logicalRemainder = $logical;
×
1401
                if (isset($logical[0]) && $logical[0] === '@') {
×
1402
                    $pos = strpos($logical, '/');
×
1403
                    if ($pos !== false) {
×
1404
                        $logicalNs        = substr($logical, 1, $pos - 1);
×
1405
                        $logicalRemainder = substr($logical, $pos + 1);
×
1406
                    } else {
1407
                        $logicalNs        = substr($logical, 1); // template directly under namespace
×
1408
                        $logicalRemainder = '';
×
1409
                    }
1410
                }
1411
                if ($nsFilter !== null) {
×
1412
                    if ($logicalNs !== $nsFilter) {
×
1413
                        continue;
×
1414
                    }
1415
                }
1416
                // Determine target name to match pattern against
1417
                $candidate = $nsFilter !== null ? $logicalRemainder : $logical;
×
1418
                if ($pattern === null || $pattern === '') {
×
1419
                    $filtered[] = $logical;
×
1420

1421
                    continue;
×
1422
                }
1423
                if (! $hasWild) {
×
1424
                    // simple case-insensitive prefix match if pattern ends with * or exact match otherwise
1425
                    if ($pattern[strlen($pattern) - 1] === '*') {
×
1426
                        $prefix = substr($pattern, 0, -1);
×
1427
                        if (str_starts_with(strtolower((string) $candidate), strtolower($prefix))) {
×
1428
                            $filtered[] = $logical;
×
1429
                        }
1430
                    } else {
1431
                        if (strcasecmp((string) $candidate, $pattern) === 0) {
×
1432
                            $filtered[] = $logical;
×
1433
                        }
1434
                    }
1435

1436
                    continue;
×
1437
                }
1438
                if ($regex && preg_match($regex, (string) $candidate) === 1) {
×
1439
                    $filtered[] = $logical;
×
1440
                }
1441
            }
1442
        }
1443
        $this->loadCompileIndex();
5✔
1444
        if (! $withStatus) {
5✔
1445
            return $filtered;
4✔
1446
        }
1447
        $out = [];
1✔
1448

1449
        foreach ($filtered as $n) {
1✔
1450
            $out[] = ['name' => $n, 'compiled' => $this->cacheManager->isCompiled($n)];
×
1451
        }
1452

1453
        return $out;
1✔
1454
    }
1455

1456
    /**
1457
     * Invalidate multiple logical templates at once.
1458
     *
1459
     * @param list<string> $logicalNames (without extension)
1460
     */
1461
    public function invalidateTemplates(array $logicalNames, bool $reinitialize = false): array
1462
    {
1463
        $logicalNames = TemplateNameValidator::filterValid($logicalNames, static function (string $raw, string $err): void {
2✔
1464
            if (function_exists('log_message')) {
×
1465
                log_message('warning', 'event=twig.invalidate.skipped raw=' . $raw . ' reason=' . $err);
×
1466
            }
1467
        });
2✔
1468
        $this->createTwig();
2✔
1469
        $cacheDir = $this->getCachePath();
2✔
1470
        $result   = $this->invalidator->invalidateMany($logicalNames, $cacheDir, $reinitialize, fn () => $this->resetTwig(), static function ($level, $msg) {
2✔
1471
            if (function_exists('log_message')) {
×
1472
                log_message($level, $msg);
×
1473
            }
1474
        });
2✔
1475
        if ($result['removed'] > 0) {
2✔
1476
            $this->saveCompileIndex();
×
1477
            $this->cumulativeInvalidated += $result['removed'];
×
1478
            $this->lastInvalidation = ['type' => 'batch', 'removed' => $result['removed'], 'reinit' => $reinitialize, 'timestamp' => microtime(true)];
×
1479
            $this->saveInvalidationsState();
×
1480
        }
1481

1482
        return $result;
2✔
1483
    }
1484

1485
    /**
1486
     * Invalidate all templates in a given namespace (e.g. "@admin") or root if null.
1487
     * Namespace should include leading '@'.
1488
     */
1489
    public function invalidateNamespace(?string $namespace, bool $reinitialize = false): array
1490
    {
1491
        $namespace = TemplateNameValidator::assertValidNamespace($namespace);
1✔
1492
        $this->createTwig();
1✔
1493
        if (! $this->loader instanceof FilesystemLoader) {
1✔
1494
            return ['removed' => 0, 'templates' => [], 'reinit' => false];
×
1495
        }
1496
        $cacheDir = $this->getCachePath();
1✔
1497
        $result   = $this->invalidator->invalidateNamespace($namespace, $cacheDir, $reinitialize, $this->loader, fn () => $this->resetTwig(), static function ($level, $msg) {
1✔
1498
            if (function_exists('log_message')) {
×
1499
                log_message($level, $msg);
×
1500
            }
1501
        });
1✔
1502
        if ($result['removed'] > 0) {
1✔
1503
            $this->saveCompileIndex();
×
1504
            $this->cumulativeInvalidated += $result['removed'];
×
1505
            $this->lastInvalidation = ['type' => 'namespace', 'removed' => $result['removed'], 'reinit' => $reinitialize, 'timestamp' => microtime(true)];
×
1506
            $this->saveInvalidationsState();
×
1507
        }
1508

1509
        return $result;
1✔
1510
    }
1511

1512
    /**
1513
     * Aggregate diagnostics for the debug toolbar and external observability.
1514
     * Sections are conditionally included based on the active capability
1515
     * profile (Lean vs Full + nullable overrides). See
1516
     * `docs/DIAGNOSTICS_REFERENCE.md` for the full schema.
1517
     *
1518
     * @return array<string,mixed>
1519
     */
1520
    public function getDiagnostics(): array
1521
    {
1522
        // Ensure compile index loaded so compiled templates count is accurate when called early in request.
1523
        if (method_exists($this, 'loadCompileIndex')) {
7✔
1524
            try {
1525
                $this->loadCompileIndex();
7✔
1526
                $this->rebuildCompileIndexIfEmpty();
7✔
1527
            } catch (Throwable $e) {
×
1528
                $this->logSwallowed('diagnostics.compile_index.load', $e);
×
1529
            }
1530
        }
1531
        // Load persisted warmup summary if not already present (e.g. CLI warmup in previous request)
1532
        if ($this->lastWarmup === null) {
7✔
1533
            $this->loadWarmupSummary();
6✔
1534
        }
1535
        // Load persisted invalidations state if not present
1536
        if ($this->lastInvalidation === null && $this->cumulativeInvalidated === 0) {
7✔
1537
            $this->loadInvalidationsState();
7✔
1538
        }
1539
        // reload persisted discovery stats if available (non-destructive)
1540
        $this->discovery->loadPersisted();
7✔
1541
        $discoveryStats = $this->discovery->getStats();
7✔
1542
        // We no longer force an eager discovery scan here. First-request toolbar will
1543
        // show persistedCount (if available) or defer full list enumeration until
1544
        // explicitly requested (e.g., warmup or manual listing) to minimize latency.
1545
        $fnCounts     = $this->dynamicRegistry->getFunctionCounts();
7✔
1546
        $filterCounts = $this->dynamicRegistry->getFilterCounts();
7✔
1547
        // Static (configured) functions & filters: show how many are configured regardless of dynamic registry usage.
1548
        // We purposely do NOT call addFunctions() here to avoid premature helper loading; these are configured counts.
1549
        $staticFnConfigured     = count($this->functions_asis) + count($this->functions_safe);
7✔
1550
        $staticFilterConfigured = count($this->filters);
7✔
1551
        // Collect name lists (static & dynamic) for richer diagnostics (not persisted). Truncate if large.
1552
        $dynamicFunctionNames = method_exists($this->dynamicRegistry, 'listFunctionNames') ? $this->dynamicRegistry->listFunctionNames() : [];
7✔
1553
        $dynamicFilterNames   = method_exists($this->dynamicRegistry, 'listFilterNames') ? $this->dynamicRegistry->listFilterNames() : [];
7✔
1554
        $truncateList         = static function (array $items, int $limit = 50): array {
7✔
1555
            if (count($items) <= $limit) {
5✔
1556
                return $items;
5✔
1557
            }
1558

1559
            return array_slice($items, 0, $limit);
×
1560
        };
7✔
1561
        $staticFunctionNames    = array_merge($this->functions_asis, $this->functions_safe);
7✔
1562
        $staticFilterNames      = array_keys($this->filters);
7✔
1563
        $compiledTemplatesCount = method_exists($this->cacheManager, 'getCompiledTemplates') ? count($this->cacheManager->getCompiledTemplates()) : null;
7✔
1564
        $avgRender              = $this->renderCount ? $this->totalRenderTime / $this->renderCount : 0.0;
7✔
1565
        $lastView               = null;
7✔
1566
        if (! empty($this->performanceData)) {
7✔
1567
            $lastRow  = $this->performanceData[count($this->performanceData) - 1];
1✔
1568
            $lastView = $lastRow['view'] ?? null;
1✔
1569
        }
1570

1571
        // Base always-present sections
1572
        $serviceClass = null;
7✔
1573
        if ($this->usingCacheService) {
7✔
1574
            try {
1575
                $c            = Services::cache();
7✔
1576
                $serviceClass = $c ? $c::class : null;
7✔
1577
            } catch (Throwable) {
×
1578
                $serviceClass = null;
×
1579
            }
1580
        }
1581
        $diag = [
7✔
1582
            'renders'            => $this->renderCount,
7✔
1583
            'last_render_view'   => $lastView,
7✔
1584
            'environment_resets' => $this->environmentResets,
7✔
1585
            'cache'              => [
7✔
1586
                'enabled'             => $this->isCacheEnabled(),
7✔
1587
                'path'                => $this->isCacheEnabled() ? $this->getCachePath() : null,
7✔
1588
                'mode'                => $this->usingCacheService ? 'service' : 'filesystem',
7✔
1589
                'service_class'       => $serviceClass,
7✔
1590
                'prefix'              => $this->cachePrefix,
7✔
1591
                'ttl'                 => $this->cacheTtl,
7✔
1592
                'compiled_templates'  => $compiledTemplatesCount,
7✔
1593
                'reconstructed_index' => property_exists($this, 'reconstructedIndex'),
7✔
1594
            ],
7✔
1595
            'performance' => [
7✔
1596
                'total_render_time_ms' => round($this->totalRenderTime * 1000, 2),
7✔
1597
                'avg_render_time_ms'   => round($avgRender * 1000, 2),
7✔
1598
            ],
7✔
1599
            'capabilities' => $this->capabilities,
7✔
1600
        ];
7✔
1601

1602
        // Only include extended sections if capability enabled to keep lean truly minimal.
1603
        if ($this->capabilities['dynamicMetrics']) {
7✔
1604
            $diag['dynamic_functions'] = $fnCounts;
5✔
1605
            $diag['dynamic_filters']   = $filterCounts;
5✔
1606
        }
1607

1608
        // Static counts useful for debugging, keep only if extendedDiagnostics enabled to reduce size.
1609
        if ($this->capabilities['extendedDiagnostics']) {
7✔
1610
            $diag['static_functions'] = ['configured' => $staticFnConfigured];
5✔
1611
            $diag['static_filters']   = ['configured' => $staticFilterConfigured];
5✔
1612
            $diag['extensions']       = [
5✔
1613
                'configured' => count($this->extensions),
5✔
1614
                'pending'    => count($this->pendingExtensions),
5✔
1615
            ];
5✔
1616
            $diag['performance']['per_template']  = $this->renderProfiler->snapshot();
5✔
1617
            $diag['performance']['top_templates'] = $this->renderProfiler->topByTotal(10);
5✔
1618
        }
1619

1620
        if ($this->capabilities['extendedDiagnostics']) {
7✔
1621
            $diag['names'] = [
5✔
1622
                'static_functions'  => $truncateList($staticFunctionNames),
5✔
1623
                'dynamic_functions' => $truncateList($dynamicFunctionNames),
5✔
1624
                'static_filters'    => $truncateList($staticFilterNames),
5✔
1625
                'dynamic_filters'   => $truncateList($dynamicFilterNames),
5✔
1626
            ];
5✔
1627
        }
1628

1629
        if ($this->capabilities['warmupSummary']) {
7✔
1630
            $diag['warmup'] = $this->lastWarmup;
6✔
1631
        }
1632
        if ($this->capabilities['invalidationHistory']) {
7✔
1633
            $diag['invalidations'] = [
5✔
1634
                'last'               => $this->lastInvalidation,
5✔
1635
                'cumulative_removed' => $this->cumulativeInvalidated,
5✔
1636
            ];
5✔
1637
        }
1638
        if ($this->capabilities['discoverySnapshot']) {
7✔
1639
            $diag['discovery'] = $discoveryStats + [
5✔
1640
                'persistence_medium' => method_exists($this->discovery, 'getPersistenceMedium') ? $this->discovery->getPersistenceMedium() : 'file',
5✔
1641
            ];
5✔
1642
        }
1643
        // Persistence mediums only relevant if any persistence features are on; always show compile_index medium for transparency.
1644
        $diag['persistence'] = [
7✔
1645
            'compile_index' => ['medium' => $this->usingCacheService ? 'ci' : 'file'],
7✔
1646
        ];
7✔
1647
        if ($this->capabilities['discoverySnapshot']) {
7✔
1648
            $diag['persistence']['discovery_snapshot'] = ['medium' => method_exists($this->discovery, 'getPersistenceMedium') ? $this->discovery->getPersistenceMedium() : ($this->usingCacheService ? 'ci' : 'file')];
5✔
1649
        }
1650
        if ($this->capabilities['warmupSummary']) {
7✔
1651
            $diag['persistence']['warmup'] = ['medium' => $this->usingCacheService ? 'ci' : 'file'];
6✔
1652
        }
1653
        if ($this->capabilities['invalidationHistory']) {
7✔
1654
            $diag['persistence']['invalidations'] = ['medium' => $this->usingCacheService ? 'ci' : 'file'];
5✔
1655
        }
1656

1657
        return $diag;
7✔
1658
    }
1659

1660
    /**
1661
     * Path to persisted warmup summary JSON file.
1662
     */
1663
    private function getWarmupSummaryPath(): string
1664
    {
1665
        return rtrim($this->getCachePath(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'warmup-summary.json';
×
1666
    }
1667

1668
    /**
1669
     * Path to invalidations state file (file backend).
1670
     */
1671
    private function getInvalidationsStatePath(): string
1672
    {
1673
        return rtrim($this->getCachePath(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'invalidations.json';
×
1674
    }
1675

1676
    /**
1677
     * Persist invalidations state (last + cumulative)
1678
     */
1679
    private function saveInvalidationsState(): void
1680
    {
1681
        if (! $this->capabilities['invalidationHistory']) {
×
1682
            return;
×
1683
        }
1684
        $payload = [
×
1685
            'last'       => $this->lastInvalidation,
×
1686
            'cumulative' => $this->cumulativeInvalidated,
×
1687
            'version'    => 1,
×
1688
        ];
×
1689
        if ($this->usingCacheService) {
×
1690
            try {
1691
                $cache = Services::cache();
×
1692
                if ($cache) {
×
1693
                    $cache->save(($this->cachePrefix ?? 'twig_') . 'invalidations', json_encode($payload, JSON_UNESCAPED_SLASHES), $this->cacheTtl ?? 0);
×
1694
                }
1695
            } catch (Throwable $e) {
×
1696
                $this->logSwallowed('invalidations.save.ci', $e);
×
1697
            }
1698

1699
            return;
×
1700
        }
1701
        $path = $this->getInvalidationsStatePath();
×
1702
        if ($path === '' || ! is_dir(dirname($path))) {
×
1703
            return;
×
1704
        }
1705

1706
        try {
1707
            @file_put_contents($path, json_encode($payload, JSON_UNESCAPED_SLASHES));
×
1708
        } catch (Throwable $e) {
×
1709
            $this->logSwallowed('invalidations.save.file', $e);
×
1710
        }
1711
    }
1712

1713
    /**
1714
     * Load invalidations state if available.
1715
     */
1716
    private function loadInvalidationsState(): void
1717
    {
1718
        if (! $this->capabilities['invalidationHistory']) {
7✔
1719
            return;
2✔
1720
        }
1721
        if ($this->usingCacheService) {
5✔
1722
            try {
1723
                $cache = Services::cache();
5✔
1724
                if ($cache) {
5✔
1725
                    $raw  = $cache->get(($this->cachePrefix ?? 'twig_') . 'invalidations');
5✔
1726
                    $data = PersistenceDecoder::decode(is_string($raw) ? $raw : null);
5✔
1727
                    if ($data !== null) {
5✔
1728
                        if (isset($data['cumulative']) && (is_int($data['cumulative']) || is_numeric($data['cumulative']))) {
×
1729
                            $this->cumulativeInvalidated = (int) $data['cumulative'];
×
1730
                        }
1731
                        if (isset($data['last']) && is_array($data['last'])) {
×
1732
                            $this->lastInvalidation = $data['last'];
5✔
1733
                        }
1734
                    }
1735
                }
1736
            } catch (Throwable $e) {
×
1737
                $this->logSwallowed('invalidations.load.ci', $e);
×
1738
            }
1739

1740
            return;
5✔
1741
        }
1742
        $path = $this->getInvalidationsStatePath();
×
1743
        if (! is_file($path)) {
×
1744
            return;
×
1745
        }
1746

1747
        try {
1748
            $raw  = @file_get_contents($path);
×
1749
            $data = PersistenceDecoder::decode($raw === false ? null : $raw);
×
1750
            if ($data === null) {
×
1751
                return;
×
1752
            }
1753
            if (isset($data['cumulative']) && (is_int($data['cumulative']) || is_numeric($data['cumulative']))) {
×
1754
                $this->cumulativeInvalidated = (int) $data['cumulative'];
×
1755
            }
1756
            if (isset($data['last']) && is_array($data['last'])) {
×
1757
                $this->lastInvalidation = $data['last'];
×
1758
            }
1759
        } catch (Throwable $e) {
×
1760
            $this->logSwallowed('invalidations.load.file', $e);
×
1761
        }
1762
    }
1763

1764
    /**
1765
     * Persist last warmup summary (best-effort).
1766
     */
1767
    private function saveWarmupSummary(): void
1768
    {
1769
        if ($this->lastWarmup === null) {
4✔
1770
            return;
×
1771
        }
1772
        if (! $this->capabilities['warmupSummary']) {
4✔
1773
            return;
×
1774
        }
1775
        $payload = $this->lastWarmup + ['version' => 1];
4✔
1776
        if ($this->usingCacheService) {
4✔
1777
            try {
1778
                $cache = Services::cache();
4✔
1779
                if ($cache) {
4✔
1780
                    $cache->save(($this->cachePrefix ?? 'twig_') . 'warmup.summary', json_encode($payload, JSON_UNESCAPED_SLASHES), $this->cacheTtl ?? 0);
4✔
1781
                }
1782
            } catch (Throwable $e) {
×
1783
                $this->logSwallowed('warmup.summary.save.ci', $e);
×
1784
            }
1785

1786
            return;
4✔
1787
        }
1788
        $path = $this->getWarmupSummaryPath();
×
1789

1790
        try {
1791
            @file_put_contents($path, json_encode($payload, JSON_UNESCAPED_SLASHES));
×
1792
        } catch (Throwable $e) {
×
1793
            $this->logSwallowed('warmup.summary.save.file', $e);
×
1794
        }
1795
    }
1796

1797
    /**
1798
     * Load persisted warmup summary if exists.
1799
     */
1800
    private function loadWarmupSummary(): void
1801
    {
1802
        if (! $this->capabilities['warmupSummary']) {
6✔
1803
            return;
1✔
1804
        }
1805
        if ($this->usingCacheService) {
5✔
1806
            try {
1807
                $cache = Services::cache();
5✔
1808
                if ($cache) {
5✔
1809
                    $json = $cache->get(($this->cachePrefix ?? 'twig_') . 'warmup.summary');
5✔
1810
                    $data = PersistenceDecoder::decode(is_string($json) ? $json : null);
5✔
1811
                    if ($data !== null && isset($data['summary']) && is_array($data['summary'])) {
5✔
1812
                        $this->lastWarmup = [
5✔
1813
                            'summary'   => $data['summary'],
5✔
1814
                            'all'       => (bool) ($data['all'] ?? false),
5✔
1815
                            'timestamp' => isset($data['timestamp']) ? (float) $data['timestamp'] : microtime(true),
5✔
1816
                        ];
5✔
1817
                    }
1818
                }
1819
            } catch (Throwable $e) {
×
1820
                $this->logSwallowed('warmup.summary.load.ci', $e);
×
1821
            }
1822

1823
            return;
5✔
1824
        }
1825
        $path = $this->getWarmupSummaryPath();
×
1826
        if (! is_file($path)) {
×
1827
            return;
×
1828
        }
1829

1830
        try {
1831
            $json = @file_get_contents($path);
×
1832
            $data = PersistenceDecoder::decode($json === false ? null : $json);
×
1833
            if ($data === null || ! isset($data['summary']) || ! is_array($data['summary'])) {
×
1834
                return;
×
1835
            }
1836
            $this->lastWarmup = [
×
1837
                'summary'   => $data['summary'],
×
1838
                'all'       => (bool) ($data['all'] ?? false),
×
1839
                'timestamp' => isset($data['timestamp']) ? (float) $data['timestamp'] : microtime(true),
×
1840
            ];
×
1841
        } catch (Throwable $e) {
×
1842
            $this->logSwallowed('warmup.summary.load.file', $e);
×
1843
        }
1844
    }
1845

1846
    private function listAllLogicalTemplates(): array
1847
    {
1848
        $this->createTwig();
5✔
1849
        if (! $this->loader instanceof FilesystemLoader) {
5✔
1850
            return [];
×
1851
        }
1852

1853
        return $this->discovery->listAll($this->loader, $this->extension);
5✔
1854
    }
1855

1856
    // compiledHash moved to TemplateInvalidator (centralized)
1857

1858
    /**
1859
     * Centralized logger for swallowed exceptions: keeps catch(Throwable){} blocks
1860
     * from hiding real production failures while preserving best-effort semantics.
1861
     */
1862
    private function logSwallowed(string $eventSuffix, Throwable $e): void
1863
    {
1864
        $this->log->debug('event=twig.' . $eventSuffix . ' msg=' . $e->getMessage());
×
1865
    }
1866

1867
    /**
1868
     * Inject (or replace) a PSR-3 logger. When unset, the library falls back to
1869
     * CodeIgniter's `log_message()` helper.
1870
     */
1871
    public function setLogger(?LoggerInterface $logger): self
1872
    {
1873
        $this->log->setLogger($logger);
×
1874

1875
        return $this;
×
1876
    }
1877

1878
    public function getLogger(): ?LoggerInterface
1879
    {
1880
        return $this->log->getLogger();
×
1881
    }
1882

1883
    // End of class
1884
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc