Skip to content

Commit 0a93aed

Browse files
committed
Better Twig exception handling
1 parent 6da1c36 commit 0a93aed

File tree

4 files changed

+119
-31
lines changed

4 files changed

+119
-31
lines changed

src/Providers/CraftServiceProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use CraftCms\Cms\Section\SectionServiceProvider;
1919
use CraftCms\Cms\Structure\StructureServiceProvider;
2020
use CraftCms\Cms\Translation\TranslationServiceProvider;
21+
use CraftCms\Cms\Twig\TwigServiceProvider;
2122
use CraftCms\Cms\Updates\UpdatesServiceProvider;
2223
use CraftCms\Cms\User\UserServiceProvider;
2324
use Illuminate\Support\AggregateServiceProvider;
@@ -30,6 +31,7 @@ final class CraftServiceProvider extends AggregateServiceProvider
3031
TranslationServiceProvider::class,
3132
DatabaseServiceProvider::class,
3233
ViewServiceProvider::class,
34+
TwigServiceProvider::class,
3335
ProjectConfigServiceProvider::class,
3436
DeprecatorServiceProvider::class,
3537
LicenseServiceProvider::class,

src/Twig/TwigMapper.php

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CraftCms\Cms\Twig;
6+
7+
use Craft;
8+
use Illuminate\Support\Collection;
9+
use ReflectionProperty;
10+
use Throwable;
11+
use Twig\Error\RuntimeError;
12+
use Twig\Template;
13+
14+
final readonly class TwigMapper
15+
{
16+
/**
17+
* Maps an exception and replaces all references to compiled Twig
18+
* templates in the stack trace with references to the original source.
19+
*/
20+
public function map(Throwable $exception): Throwable
21+
{
22+
if ($exception instanceof RuntimeError) {
23+
/**
24+
* When we get a Twig runtime error, we need the previous exception to be shown.
25+
*/
26+
$exception = $exception->getPrevious() ?? $exception;
27+
}
28+
29+
$viewIndex = null;
30+
31+
$trace = collect($exception->getTrace())
32+
->map(function (array $frame, int $index) use (&$viewIndex) {
33+
$templateInfo = $this->resolveTemplatePathAndLine($frame['file'] ?? '', $frame['line']);
34+
35+
if ($templateInfo !== false) {
36+
[$frame['file'], $frame['line']] = $templateInfo;
37+
38+
$viewIndex ??= $index;
39+
}
40+
41+
return $frame;
42+
})
43+
->when(
44+
$viewIndex !== null && str_ends_with($exception->getFile(), '.twig'),
45+
fn (Collection $trace) => $trace->slice($viewIndex + 1) // Remove all traces before the view
46+
)
47+
->all();
48+
49+
$traceProperty = new ReflectionProperty('Exception', 'trace');
50+
$traceProperty->setValue($exception, $trace);
51+
52+
return $exception;
53+
}
54+
55+
/**
56+
* Attempts to resolve a compiled template file path and line number to its source template path and line number.
57+
*
58+
* @param string $path The compiled template path
59+
* @param int|null $line The line number from the compiled template
60+
* @return array|false The resolved template path and line number, or `false` if the path couldn’t be determined.
61+
* If a template path could be determined but not the template line number, the line number will be null.
62+
*/
63+
public function resolveTemplatePathAndLine(string $path, ?int $line): array|false
64+
{
65+
if (! str_contains($path, 'compiled_templates')) {
66+
return false;
67+
}
68+
69+
$contents = file_get_contents($path);
70+
71+
if (! preg_match('/^class (\w+)/m', $contents, $match)) {
72+
return false;
73+
}
74+
75+
$class = $match[1];
76+
if (! class_exists($class, false) || ! is_subclass_of($class, Template::class)) {
77+
return false;
78+
}
79+
80+
$template = new $class(Craft::$app->getView()->getTwig());
81+
$src = $template->getSourceContext();
82+
$templatePath = $src->getPath() ?: null;
83+
$templateLine = null;
84+
85+
if ($line !== null) {
86+
foreach ($template->getDebugInfo() as $codeLine => $thisTemplateLine) {
87+
if ($codeLine <= $line) {
88+
$templateLine = $thisTemplateLine;
89+
break;
90+
}
91+
}
92+
}
93+
94+
return [$templatePath, $templateLine];
95+
}
96+
}

src/Twig/TwigServiceProvider.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CraftCms\Cms\Twig;
6+
7+
use Exception;
8+
use Illuminate\Contracts\Debug\ExceptionHandler;
9+
use Illuminate\Support\ServiceProvider;
10+
11+
final class TwigServiceProvider extends ServiceProvider
12+
{
13+
#[\Override]
14+
public function register(): void
15+
{
16+
$this->app->make(ExceptionHandler::class)->map(Exception::class, fn (Exception $e) => $this->app->make(TwigMapper::class)->map($e));
17+
}
18+
}

yii2-adapter/legacy/helpers/Template.php

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use craft\web\View;
1515
use CraftCms\Cms\Shared\BaseModel;
1616
use CraftCms\Cms\Support\Facades\Entries;
17+
use CraftCms\Cms\Twig\TwigMapper;
1718
use Illuminate\Support\Facades\Auth;
1819
use Stringable;
1920
use Twig\Environment;
@@ -360,40 +361,11 @@ public static function js(string $js, array $options = [], ?string $key = null):
360361
* @return array|false The resolved template path and line number, or `false` if the path couldn’t be determined.
361362
* If a template path could be determined but not the template line number, the line number will be null.
362363
* @since 4.1.5
364+
* @deprecated 6.0.0 use {@see TwigMapper::resolveTemplatePathAndLine()} instead.
363365
*/
364366
public static function resolveTemplatePathAndLine(string $path, ?int $line)
365367
{
366-
if (!str_contains($path, 'compiled_templates')) {
367-
return false;
368-
}
369-
370-
$contents = file_get_contents($path);
371-
372-
if (!preg_match('/^class (\w+)/m', $contents, $match)) {
373-
return false;
374-
}
375-
376-
$class = $match[1];
377-
if (!class_exists($class, false) || !is_subclass_of($class, TwigTemplate::class)) {
378-
return false;
379-
}
380-
381-
/** @var TwigTemplate $template */
382-
$template = new $class(Craft::$app->getView()->getTwig());
383-
$src = $template->getSourceContext();
384-
$templatePath = $src->getPath() ?: null;
385-
$templateLine = null;
386-
387-
if ($line !== null) {
388-
foreach ($template->getDebugInfo() as $codeLine => $thisTemplateLine) {
389-
if ($codeLine <= $line) {
390-
$templateLine = $thisTemplateLine;
391-
break;
392-
}
393-
}
394-
}
395-
396-
return [$templatePath, $templateLine];
368+
return app(TwigMapper::class)->resolveTemplatePathAndLine($path, $line);
397369
}
398370

399371
/**

0 commit comments

Comments
 (0)